Files
manus-dashboard/frontend/src/pages/InventairePage.jsx
Manus 3c000c37af
All checks were successful
Validation Dashboard / Tests backend et build frontend (push) Successful in 42s
fix: référencer les portails nommés par entité
2026-10-24 13:44:44 +00:00

459 lines
17 KiB
JavaScript

import React, { useState, useEffect } from 'react';
import { Table, RefreshCw, GitBranch, CheckCircle, XCircle, ExternalLink, Tag, AlertCircle, ArrowUp, ArrowDown, Minus, AlertTriangle } from 'lucide-react';
import api from '../utils/api';
const APPS_CONFIG = [
{
id: 'itinova-contacts',
name: 'Itinova Contacts',
repoName: 'itinova-contacts',
urlRecette: 'https://contacts.recette.santinova-soft.org',
urlProd: 'https://contacts.santinova-soft.org',
},
{
id: 'itinova-podcasts',
name: 'Itinova Podcasts',
repoName: 'itinova-podcasts',
urlRecette: 'https://podcasts.recette.santinova-soft.org',
urlProd: 'https://podcasts.santinova-soft.org',
},
{
id: 'veille-reglementaire',
name: 'Veille Réglementaire',
repoName: 'veille-reglementaire',
urlRecette: 'https://veille.recette.santinova-soft.org',
urlProd: 'https://veille.santinova-soft.org',
},
{
id: 'itinova-vehicle-exchange',
name: 'Itinova Gestion de Flotte',
repoName: 'itinova-vehicle-exchange',
urlRecette: 'https://flotte.recette.santinova-soft.org',
urlProd: 'https://flotte.santinova-soft.org',
},
{
id: 'sonum',
name: 'SONUM',
repoName: 'sonum',
urlRecette: 'https://sonum.recette.santinova-soft.org',
urlProd: 'https://sonum.santinova-soft.org',
},
{
id: 'demat-facturation-dsi',
name: 'Démat. Facturation DSI',
repoName: 'demat-facturation-dsi',
urlRecette: 'https://demat-facturation.recette.santinova-soft.org',
urlProd: 'https://demat-facturation.santinova-soft.org',
},
{
id: 'facturation-santinova',
name: 'Facturation Santinova',
repoName: 'facturation-santinova',
urlRecette: 'https://facturation.recette.santinova-soft.org',
urlProd: null,
},
{
id: 'formation-manager-itinova',
name: 'Formation Manager',
repoName: 'formation-manager-itinova',
urlRecette: 'https://formation.recette.santinova-soft.org',
urlProd: 'https://formations.itinova.org',
},
{
id: 'pilotage-masse-salariale',
name: 'Pilotage Masse Salariale',
repoName: 'pilotage-masse-salariale',
urlRecette: 'https://pilotage-ms.recette.santinova-soft.org',
urlProd: null,
},
{
id: 'itinova-budget-si',
name: 'Gestion Budget Informatique',
repoName: 'itinova-budget-si',
urlRecette: 'https://budget-si.recette.santinova-soft.org',
urlProd: null,
},
{
id: 'falc-generator',
name: 'FALC Generator',
repoName: 'falc-generator',
urlRecette: 'https://falc.recette.santinova-soft.org',
urlProd: 'https://falc.santinova-soft.org',
},
{
id: 'portail-santinova',
name: 'Portail Applicatif',
repoName: 'portail-santinova',
urlRecette: 'https://portail-santinova.recette.santinova-soft.org',
urlProd: 'https://portail-santinova.santinova-soft.org',
},
{
id: 'manus-dashboard',
name: 'Dashboard Recette',
repoName: 'manus-dashboard',
urlRecette: 'https://dashboard.recette.santinova-soft.org',
urlProd: 'https://dashboard.santinova-soft.org',
},
];
function SyncBadge({ versionRecette, versionProd }) {
// Extraire le SHA (7 premiers caractères avant l'espace)
const shaRec = versionRecette ? versionRecette.split(' ')[0] : null;
const shaProd = versionProd ? versionProd.split(' ')[0] : null;
// Extraire la date entre parenthèses pour comparer
const dateRec = versionRecette ? versionRecette.match(/\((.+?)\)/)?.[1] : null;
const dateProd = versionProd ? versionProd.match(/\((.+?)\)/)?.[1] : null;
if (!shaRec && !shaProd) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-800 text-gray-500">
<Minus className="w-3 h-3" />
N/A
</span>
);
}
if (!shaRec) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-green-900/40 text-green-300 border border-green-700/40" title="Uniquement en production">
<ArrowDown className="w-3 h-3" />
Prod en avance
</span>
);
}
if (!shaProd) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-orange-900/40 text-orange-300 border border-orange-700/40" title="Uniquement en recette">
<ArrowUp className="w-3 h-3" />
Recette en avance
</span>
);
}
if (shaRec === shaProd) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-emerald-900/40 text-emerald-300 border border-emerald-700/40" title="Versions identiques">
<CheckCircle className="w-3 h-3" />
Synchronisé
</span>
);
}
// Comparer les dates pour savoir qui est en avance
// Format fr-FR : dd/mm/yyyy
const parseDate = (s) => {
if (!s) return null;
const parts = s.split('/');
if (parts.length === 3) return new Date(`${parts[2]}-${parts[1]}-${parts[0]}`);
return new Date(s);
};
const dRec = parseDate(dateRec);
const dProd = parseDate(dateProd);
if (dRec && dProd) {
if (dRec > dProd) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-orange-900/40 text-orange-300 border border-orange-700/40" title={`Recette : ${versionRecette} | Prod : ${versionProd}`}>
<ArrowUp className="w-3 h-3" />
Recette en avance
</span>
);
} else if (dProd > dRec) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-green-900/40 text-green-300 border border-green-700/40" title={`Recette : ${versionRecette} | Prod : ${versionProd}`}>
<ArrowDown className="w-3 h-3" />
Prod en avance
</span>
);
}
}
// SHA différents mais dates non comparables
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-yellow-900/40 text-yellow-300 border border-yellow-700/40" title={`Recette : ${versionRecette} | Prod : ${versionProd}`}>
<AlertTriangle className="w-3 h-3" />
Divergent
</span>
);
}
function VersionBadge({ version, loading }) {
if (loading) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-700 text-gray-400 animate-pulse">
<Tag className="w-3 h-3" />
Chargement...
</span>
);
}
if (!version) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-gray-800 text-gray-500">
<AlertCircle className="w-3 h-3" />
N/A
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-blue-900/40 text-blue-300 border border-blue-700/40">
<Tag className="w-3 h-3" />
{version}
</span>
);
}
function RepoBadge({ present, url, label }) {
if (!present) {
return (
<div className="flex items-center gap-1.5">
<XCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
<span className="text-gray-500 text-xs">Absent</span>
</div>
);
}
return (
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1.5">
<CheckCircle className="w-4 h-4 text-green-500 flex-shrink-0" />
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary-400 hover:text-primary-300 hover:underline flex items-center gap-1 truncate max-w-[180px]"
title={url}
>
{label}
<ExternalLink className="w-3 h-3 flex-shrink-0" />
</a>
</div>
</div>
);
}
export default function InventairePage() {
const [inventory, setInventory] = useState([]);
const [loading, setLoading] = useState(true);
const [lastUpdate, setLastUpdate] = useState(null);
const fetchInventory = async () => {
setLoading(true);
try {
const res = await api.get('/inventory');
setInventory(res.data);
setLastUpdate(new Date());
} catch (err) {
console.error('Erreur chargement inventaire:', err);
// Fallback : construire l'inventaire depuis la config statique
const fallback = APPS_CONFIG.map((app) => ({
...app,
repoRecette: { present: false, url: null, version: null },
repoProd: { present: false, url: null, version: null },
}));
setInventory(fallback);
} finally {
setLoading(false);
}
};
// Auto-refresh toutes les 5 minutes
useEffect(() => {
fetchInventory();
const interval = setInterval(fetchInventory, 5 * 60 * 1000);
return () => clearInterval(interval);
}, []);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-3">
<Table className="w-7 h-7 text-emerald-400" />
Inventaire des Applications
</h2>
<p className="text-gray-400 mt-1">
État des dépôts Git et versions déployées par environnement
</p>
</div>
<div className="flex items-center gap-3">
{lastUpdate && (
<span className="text-xs text-gray-500">
Mis à jour : {lastUpdate.toLocaleTimeString('fr-FR')}
</span>
)}
<button
onClick={fetchInventory}
disabled={loading}
className="btn-secondary flex items-center gap-2"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Rafraîchir
</button>
</div>
</div>
{/* Stats */}
{!loading && inventory.length > 0 && (
<div className="grid grid-cols-5 gap-4">
<div className="card p-4 text-center border border-dark-700">
<div className="text-2xl font-bold text-white">{inventory.length}</div>
<div className="text-xs text-gray-400 mt-1">Applications totales</div>
</div>
<div className="card p-4 text-center border border-orange-700/30">
<div className="text-2xl font-bold text-orange-400">
{inventory.filter((a) => a.repoRecette?.present).length}
</div>
<div className="text-xs text-gray-400 mt-1">Dépôts Recette</div>
</div>
<div className="card p-4 text-center border border-green-700/30">
<div className="text-2xl font-bold text-green-400">
{inventory.filter((a) => a.repoProd?.present).length}
</div>
<div className="text-xs text-gray-400 mt-1">Dépôts Production</div>
</div>
<div className="card p-4 text-center border border-emerald-700/30">
<div className="text-2xl font-bold text-emerald-400">
{inventory.filter((a) => {
const shaRec = a.repoRecette?.version?.split(' ')[0];
const shaProd = a.repoProd?.version?.split(' ')[0];
return shaRec && shaProd && shaRec === shaProd;
}).length}
</div>
<div className="text-xs text-gray-400 mt-1">Synchronisés</div>
</div>
<div className="card p-4 text-center border border-blue-700/30">
<div className="text-2xl font-bold text-blue-400">
{inventory.filter((a) => a.repoRecette?.present && a.repoProd?.present).length}
</div>
<div className="text-xs text-gray-400 mt-1">Déployés sur les 2 envs</div>
</div>
</div>
)}
{/* Légende */}
<div className="flex items-center gap-6 text-xs text-gray-400">
<div className="flex items-center gap-1.5">
<CheckCircle className="w-4 h-4 text-green-500" />
Dépôt présent
</div>
<div className="flex items-center gap-1.5">
<XCircle className="w-4 h-4 text-red-500" />
Dépôt absent
</div>
<div className="flex items-center gap-1.5">
<Tag className="w-4 h-4 text-blue-400" />
Version (dernier commit)
</div>
</div>
{/* Tableau */}
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-dark-700">
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase tracking-wider w-48">
Application
</th>
<th className="text-center px-4 py-3 text-xs font-semibold text-purple-400 uppercase tracking-wider">
<div className="flex items-center justify-center gap-2">
<ArrowUp className="w-4 h-4" />
Synchronisation
</div>
</th>
<th className="text-center px-4 py-3 text-xs font-semibold text-orange-400 uppercase tracking-wider" colSpan={2}>
<div className="flex items-center justify-center gap-2">
<GitBranch className="w-4 h-4" />
Recette
</div>
</th>
<th className="text-center px-4 py-3 text-xs font-semibold text-green-400 uppercase tracking-wider" colSpan={2}>
<div className="flex items-center justify-center gap-2">
<GitBranch className="w-4 h-4" />
Production
</div>
</th>
</tr>
<tr className="border-b border-dark-700 bg-dark-800/50">
<th className="px-4 py-2"></th>
<th className="px-4 py-2"></th>
<th className="text-center px-3 py-2 text-xs text-gray-500 font-medium">Dépôt Git</th>
<th className="text-center px-3 py-2 text-xs text-gray-500 font-medium">Version</th>
<th className="text-center px-3 py-2 text-xs text-gray-500 font-medium">Dépôt Git</th>
<th className="text-center px-3 py-2 text-xs text-gray-500 font-medium">Version</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-700">
{loading && inventory.length === 0
? APPS_CONFIG.map((app) => (
<tr key={app.id} className="hover:bg-dark-800/30 transition-colors">
<td className="px-4 py-3">
<span className="text-sm font-medium text-white">{app.name}</span>
</td>
{[...Array(5)].map((_, i) => (
<td key={i} className="px-3 py-3 text-center">
<div className="h-5 bg-dark-700 rounded animate-pulse mx-auto w-24" />
</td>
))}
</tr>
))
: inventory.map((app) => {
const isInfra = ['portail-santinova', 'manus-dashboard'].includes(app.id);
return (
<tr key={app.id} className={`hover:bg-dark-800/30 transition-colors${isInfra ? ' bg-amber-950/10 border-l-2 border-amber-500/40' : ''}`}>
<td className="px-4 py-3">
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span className={`text-sm font-medium ${isInfra ? 'text-amber-300' : 'text-white'}`}>{app.name}</span>
{isInfra && (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-400 border border-amber-500/25 font-semibold">Infra</span>
)}
</div>
<span className="text-xs text-gray-500 mt-0.5">{app.repoName}</span>
</div>
</td>
{/* Synchronisation */}
<td className="px-3 py-3 text-center">
<SyncBadge
versionRecette={app.repoRecette?.version}
versionProd={app.repoProd?.version}
/>
</td>
{/* Recette - Dépôt */}
<td className="px-3 py-3">
<RepoBadge
present={app.repoRecette?.present}
url={app.repoRecette?.url}
label={`git.recette/…/${app.repoName}`}
/>
</td>
{/* Recette - Version */}
<td className="px-3 py-3 text-center">
<VersionBadge
version={app.repoRecette?.version}
loading={loading}
/>
</td>
{/* Prod - Dépôt */}
<td className="px-3 py-3">
<RepoBadge
present={app.repoProd?.present}
url={app.repoProd?.url}
label={`git.santinova/…/${app.repoName}`}
/>
</td>
{/* Prod - Version */}
<td className="px-3 py-3 text-center">
<VersionBadge
version={app.repoProd?.version}
loading={loading}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
}