All checks were successful
Validation Dashboard / Tests backend et build frontend (push) Successful in 42s
459 lines
17 KiB
JavaScript
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>
|
|
);
|
|
}
|