Files
portail-santinova/index.html
Manus Déploiement Santinova f0cd2caa1b
Some checks failed
Validation applicative / Tests de contrat (push) Failing after 9s
feat: ajouter l onglet infrastructure dynamique
2026-09-04 15:51:49 +00:00

537 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Portail Applicatif — Santinova Soft</title>
<style>
/* ===== RESET & BASE ===== */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: #0f1117;
color: #e8eaf0;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ===== HEADER ===== */
header {
background: linear-gradient(135deg, #1a1d2e 0%, #252840 100%);
border-bottom: 1px solid rgba(255,255,255,0.08);
padding: 28px 48px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 4px 24px rgba(0,0,0,0.4);
}
.header-brand {
display: flex;
align-items: center;
gap: 16px;
}
.header-logo-placeholder {
width: 48px;
height: 48px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
font-weight: 800;
color: white;
letter-spacing: -1px;
box-shadow: 0 4px 16px rgba(99,102,241,0.4);
}
.header-title {
font-size: 1.5rem;
font-weight: 700;
color: #ffffff;
letter-spacing: -0.3px;
}
.header-subtitle {
font-size: 0.8rem;
color: #8b8fa8;
margin-top: 2px;
text-transform: uppercase;
letter-spacing: 1px;
}
.header-badge {
background: rgba(99,102,241,0.15);
border: 1px solid rgba(99,102,241,0.3);
color: #a5b4fc;
padding: 6px 14px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.5px;
}
/* ===== TABS ===== */
.tabs-wrapper {
background: linear-gradient(135deg, #1a1d2e 0%, #252840 100%);
border-bottom: 1px solid rgba(255,255,255,0.08);
padding: 0 48px;
display: flex;
gap: 4px;
}
.tab-btn {
background: none;
border: none;
cursor: pointer;
padding: 18px 28px;
font-size: 0.95rem;
font-weight: 600;
color: #8b8fa8;
border-bottom: 3px solid transparent;
transition: color 0.2s ease, border-color 0.2s ease;
letter-spacing: 0.2px;
position: relative;
top: 1px;
}
.tab-btn:hover { color: #c5c8d8; }
.tab-btn.active {
color: #ffffff;
border-bottom: 3px solid #6366f1;
}
.tab-btn .tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(99,102,241,0.15);
border: 1px solid rgba(99,102,241,0.25);
color: #a5b4fc;
font-size: 0.7rem;
font-weight: 700;
width: 20px;
height: 20px;
border-radius: 50%;
margin-left: 8px;
}
.tab-btn.active .tab-count {
background: rgba(99,102,241,0.3);
border-color: rgba(99,102,241,0.5);
}
/* ===== MAIN CONTENT ===== */
main {
flex: 1;
padding: 60px 48px;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
.tab-panel { display: none; }
.tab-panel.active {
display: block;
animation: fadeIn 0.25s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.section-header {
margin-bottom: 48px;
text-align: center;
}
.section-header h2 {
font-size: 2rem;
font-weight: 700;
color: #ffffff;
margin-bottom: 10px;
}
.section-header p { color: #8b8fa8; font-size: 1rem; }
/* ===== GRID ===== */
.apps-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 28px;
justify-items: center;
}
/* ===== APP CARD ===== */
.app-card {
display: flex;
flex-direction: column;
align-items: center;
text-decoration: none;
width: 100%;
max-width: 260px;
cursor: pointer;
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.app-card:hover { transform: translateY(-8px) scale(1.03); }
.app-card:hover .card-image-wrapper {
box-shadow: 0 20px 60px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.1);
}
.app-card:hover .card-name { color: #ffffff; }
.card-image-wrapper {
width: 180px;
height: 180px;
border-radius: 36px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.06);
transition: box-shadow 0.25s ease;
position: relative;
}
.card-image-wrapper::after {
content: '';
position: absolute;
inset: 0;
border-radius: 36px;
border: 1px solid rgba(255,255,255,0.12);
pointer-events: none;
}
.card-image-wrapper img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.card-name {
margin-top: 16px;
font-size: 0.95rem;
font-weight: 600;
color: #c5c8d8;
text-align: center;
transition: color 0.2s ease;
line-height: 1.3;
}
.card-env-badge {
margin-top: 6px;
font-size: 0.7rem;
font-weight: 600;
color: #6366f1;
text-transform: uppercase;
letter-spacing: 0.8px;
background: rgba(99,102,241,0.1);
padding: 3px 10px;
border-radius: 10px;
border: 1px solid rgba(99,102,241,0.2);
}
/* ===== APP OFFLINE (grisage) ===== */
.app-card.offline {
opacity: 0.42;
pointer-events: none;
cursor: not-allowed;
filter: grayscale(80%);
}
.app-card.offline .card-image-wrapper { box-shadow: none; }
.card-status-badge {
display: none;
margin-top: 4px;
font-size: 0.68rem;
font-weight: 700;
color: #ef4444;
text-transform: uppercase;
letter-spacing: 0.8px;
background: rgba(239,68,68,0.12);
padding: 2px 9px;
border-radius: 10px;
border: 1px solid rgba(239,68,68,0.3);
}
.app-card.offline .card-status-badge { display: inline-block; }
/* ===== LOADING ===== */
.loading-msg {
text-align: center;
color: #8b8fa8;
padding: 60px 0;
font-size: 1rem;
}
/* ===== FOOTER ===== */
footer {
background: #0a0c14;
border-top: 1px solid rgba(255,255,255,0.06);
padding: 24px 48px;
text-align: center;
}
.footer-powered {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: #555870;
font-size: 0.8rem;
}
/* ===== RESPONSIVE ===== */
@media (max-width: 768px) {
header { padding: 20px 24px; }
.tabs-wrapper { padding: 0 24px; }
main { padding: 40px 24px; }
.apps-grid { grid-template-columns: repeat(2, 1fr); gap: 20px; }
.card-image-wrapper { width: 140px; height: 140px; border-radius: 28px; }
.tab-btn { padding: 14px 16px; font-size: 0.85rem; }
}
@media (max-width: 480px) {
.apps-grid { grid-template-columns: repeat(2, 1fr); gap: 16px; }
.card-image-wrapper { width: 120px; height: 120px; border-radius: 24px; }
.header-badge { display: none; }
.tab-btn { padding: 12px 12px; font-size: 0.8rem; }
}
</style>
</head>
<body>
<!-- HEADER -->
<header>
<div class="header-brand">
<div class="header-logo-placeholder" style="background:none;padding:0;display:flex;align-items:center;justify-content:center;">
<svg xmlns="http://www.w3.org/2000/svg" width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="#4f46e5" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1.5"/>
<rect x="14" y="3" width="7" height="7" rx="1.5"/>
<rect x="3" y="14" width="7" height="7" rx="1.5"/>
<rect x="14" y="14" width="7" height="7" rx="1.5"/>
</svg>
</div>
<div>
<div class="header-title">Portail Applicatif</div>
<div class="header-subtitle">Environnement de recette</div>
</div>
</div>
<div class="header-badge">Recette</div>
</header>
<!-- TABS NAV -->
<div class="tabs-wrapper">
<button id="tab-itinova" class="tab-btn active" onclick="switchTab('itinova', this)">
Applications Itinova
<span class="tab-count" id="count-itinova">…</span>
</button>
<button id="tab-santinova" class="tab-btn" onclick="switchTab('santinova', this)">
Applications Santinova
<span class="tab-count" id="count-santinova">…</span>
</button>
<button id="tab-infra" class="tab-btn" onclick="switchTab('infra', this)">
Infrastructure
<span class="tab-count" id="count-infra">…</span>
</button>
</div>
<!-- MAIN -->
<main>
<!-- ONGLET ITINOVA -->
<div id="panel-itinova" class="tab-panel active">
<div class="section-header">
<h2>Applications Itinova</h2>
<p>Cliquez sur une application pour l'ouvrir dans un nouvel onglet</p>
</div>
<div id="grid-itinova" class="apps-grid">
<div class="loading-msg">Chargement des applications…</div>
</div>
</div>
<!-- ONGLET SANTINOVA -->
<div id="panel-santinova" class="tab-panel">
<div class="section-header">
<h2>Applications Santinova</h2>
<p>Cliquez sur une application pour l'ouvrir dans un nouvel onglet</p>
</div>
<div id="grid-santinova" class="apps-grid">
<div class="loading-msg">Chargement des applications…</div>
</div>
</div>
<!-- ONGLET INFRASTRUCTURE -->
<div id="panel-infra" class="tab-panel">
<div class="section-header">
<h2>Infrastructure</h2>
<p>Accès direct aux services techniques de cet environnement</p>
</div>
<div id="grid-infra" class="apps-grid">
<div class="loading-msg">Chargement des applications…</div>
</div>
</div>
</main>
<!-- FOOTER -->
<footer>
<div class="footer-powered" style="display:flex;align-items:center;gap:8px;">
powered by &nbsp;<img src="images/logo_santinova.webp" alt="Santinova" style="height:84px;vertical-align:middle;" />
</div>
</footer>
<script>
/*
* Configuration d'environnement. Cette version est exclusivement destinée
* à la recette. La version production ne diffère que par ces quatre valeurs.
*/
var ENV = 'recette';
var ENV_LABEL = 'Recette';
var DASHBOARD_APPS_URL = 'https://dashboard.recette.santinova-soft.org/api/public/apps';
var DASHBOARD_STATUS_URL = 'https://dashboard.recette.santinova-soft.org/api/public/status';
var STATUS_REFRESH_MS = 30000;
var APPS_REFRESH_MS = 300000;
var hasRenderedApps = false;
function switchTab(tabName, button) {
document.querySelectorAll('.tab-btn').forEach(function(tab) { tab.classList.remove('active'); });
document.querySelectorAll('.tab-panel').forEach(function(panel) { panel.classList.remove('active'); });
button.classList.add('active');
document.getElementById('panel-' + tabName).classList.add('active');
}
function escapeHtml(value) {
return String(value || '').replace(/[&<>'"]/g, function(character) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' })[character];
});
}
function isHttpsUrl(value) {
try {
return new URL(value).protocol === 'https:';
} catch (error) {
return false;
}
}
function isOffline(app) {
return app.status === 'offline' || app.status === 'error' || app.containerRunning === false;
}
/** Construit une tuile depuis un manifeste validé par le dashboard local. */
function buildCard(app) {
var url = app.urls && app.urls[ENV];
if (!isHttpsUrl(url)) return '';
var image = app.image || ('images/' + app.id + '.png');
var offline = isOffline(app);
var offlineClass = offline ? ' offline' : '';
var ariaDisabled = offline ? ' aria-disabled="true"' : '';
return '<a class="app-card' + offlineClass + '" data-app-id="' + escapeHtml(app.id) + '" href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer"' + ariaDisabled + '>' +
'<div class="card-image-wrapper"><img src="' + escapeHtml(image) + '" alt="' + escapeHtml(app.name) + '" onerror="this.style.display=\'none\'" /></div>' +
'<div class="card-name">' + escapeHtml(app.name) + '</div>' +
'<div class="card-env-badge">' + ENV_LABEL + '</div>' +
'<div class="card-status-badge">Hors ligne</div>' +
'</a>';
}
function renderApps(apps) {
// La source est le dashboard local : chaque app est présentée dans son onglet métier.
var deployableApps = apps.filter(function(app) {
return isHttpsUrl(app.urls && app.urls[ENV]);
});
var itinovaApps = deployableApps.filter(function(app) { return app.category === 'ITINOVA'; });
var santinovaApps = deployableApps.filter(function(app) { return app.category === 'SANTINOVA'; });
var infraApps = deployableApps.filter(function(app) { return app.category === 'INFRA'; });
var gridItinova = document.getElementById('grid-itinova');
var gridSantinova = document.getElementById('grid-santinova');
var gridInfra = document.getElementById('grid-infra');
gridItinova.innerHTML = itinovaApps.length
? itinovaApps.map(buildCard).join('')
: '<div class="loading-msg">Aucune application Itinova déployée.</div>';
gridSantinova.innerHTML = santinovaApps.length
? santinovaApps.map(buildCard).join('')
: '<div class="loading-msg">Aucune application Santinova déployée.</div>';
gridInfra.innerHTML = infraApps.length
? infraApps.map(buildCard).join('')
: '<div class="loading-msg">Aucun service d’infrastructure déployé.</div>';
document.getElementById('count-itinova').textContent = itinovaApps.length;
document.getElementById('count-santinova').textContent = santinovaApps.length;
document.getElementById('count-infra').textContent = infraApps.length;
hasRenderedApps = true;
}
async function requestJson(url) {
var controller = new AbortController();
var timeoutId = setTimeout(function() { controller.abort(); }, 8000);
try {
var response = await fetch(url, { cache: 'no-store', signal: controller.signal });
if (!response.ok) throw new Error('HTTP ' + response.status);
return await response.json();
} finally {
clearTimeout(timeoutId);
}
}
async function loadApps() {
try {
var apps = await requestJson(DASHBOARD_APPS_URL);
if (!Array.isArray(apps)) throw new Error('réponse applications invalide');
renderApps(apps);
await fetchAndApplyStatuses();
} catch (error) {
console.warn('[Portail] Chargement des applications impossible :', error.message);
if (!hasRenderedApps) {
var message = '<div class="loading-msg">Dashboard indisponible — veuillez réessayer.</div>';
document.getElementById('grid-itinova').innerHTML = message;
document.getElementById('grid-santinova').innerHTML = message;
document.getElementById('grid-infra').innerHTML = message;
document.getElementById('count-itinova').textContent = '!';
document.getElementById('count-santinova').textContent = '!';
document.getElementById('count-infra').textContent = '!';
}
}
}
function applyStatuses(statuses) {
if (!Array.isArray(statuses)) return;
var byId = new Map(statuses.map(function(status) { return [status.id, status]; }));
document.querySelectorAll('.app-card[data-app-id]').forEach(function(card) {
var status = byId.get(card.getAttribute('data-app-id'));
if (!status) return;
var offline = isOffline(status);
card.classList.toggle('offline', offline);
card.setAttribute('aria-disabled', String(offline));
});
}
async function fetchAndApplyStatuses() {
try {
applyStatuses(await requestJson(DASHBOARD_STATUS_URL));
} catch (error) {
// Conserver le dernier état connu est préférable à un grisage erroné.
console.warn('[Portail] Mise à jour des statuts impossible :', error.message);
}
}
document.addEventListener('DOMContentLoaded', function() {
loadApps();
setInterval(fetchAndApplyStatuses, STATUS_REFRESH_MS);
setInterval(loadApps, APPS_REFRESH_MS);
});
</script>
</body>
</html>