feat: ajouter le hub des portails Santinova
All checks were successful
Validation applicative / Tests de contrat (push) Successful in 13s

This commit is contained in:
manus-admin
2026-10-24 14:03:05 +00:00
parent a64634d003
commit 167767eaa2
2 changed files with 36 additions and 6 deletions

View File

@@ -15,6 +15,10 @@
.brand-title { font-size: clamp(1.24rem,2vw,1.55rem); font-weight: 750; letter-spacing: -.35px; }
.brand-subtitle { margin-top: 3px; color: #aeb5cf; font-size: .73rem; font-weight: 700; letter-spacing: 1.05px; text-transform: uppercase; }
.environment-badge { padding: 7px 13px; border: 1px solid var(--accent-border); border-radius: 999px; color: #f0f2ff; background: var(--accent-soft); font-size: .75rem; font-weight: 800; white-space: nowrap; }
.portal-hub-nav { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 9px; padding: 13px clamp(24px,5vw,64px); background: #141728; border-bottom: 1px solid rgba(255,255,255,.07); }
.portal-hub-label { margin-right: 5px; color: #7f88a9; font-size: .72rem; font-weight: 800; letter-spacing: .9px; text-transform: uppercase; }
.portal-hub-link { display: inline-flex; align-items: center; min-height: 36px; padding: 0 13px; border: 1px solid rgba(255,255,255,.11); border-radius: 10px; color: #dce2f5; background: rgba(255,255,255,.035); font-size: .82rem; font-weight: 700; text-decoration: none; transition: transform .16s ease-out,background .16s ease-out,border-color .16s ease-out; }
.portal-hub-link:hover { transform: translateY(-1px); color: #fff; background: var(--accent-soft); border-color: var(--accent-border); }
.app-card:focus-visible { outline: 3px solid white; outline-offset: 3px; }
main { width: 100%; max-width: 1240px; margin: auto; padding: 58px clamp(24px,5vw,64px) 72px; flex: 1; }
.section-header { max-width: 680px; margin: 0 auto 42px; text-align: center; }
@@ -33,11 +37,12 @@
.loading-msg { grid-column: 1 / -1; padding:64px 0; color:#aeb5cb; text-align:center; }.loading-msg.error { color:#fecaca; }
footer { padding:24px clamp(24px,5vw,64px); border-top:1px solid rgba(255,255,255,.06); background:#0a0c13; }.footer-inner { max-width:1240px; margin:auto; display:flex; align-items:center; justify-content:center; gap:9px; color:#737c99; font-size:.78rem; }.footer-inner img { height:50px; width:auto; object-fit:contain; }
@media (prefers-reduced-motion: reduce) { *,*:before,*:after { transition:none!important; } }
@media (max-width:640px) { header{padding-top:22px;padding-bottom:22px}.header-inner{align-items:flex-start}.environment-badge{margin-top:4px}main{padding-top:42px}.apps-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:23px 14px}.card-image-wrapper{width:min(100%,145px);height:145px;border-radius:27px} }
@media (max-width:640px) { header{padding-top:22px;padding-bottom:22px}.header-inner{align-items:flex-start}.environment-badge{margin-top:4px}.portal-hub-nav{justify-content:flex-start;padding-top:10px;padding-bottom:10px}.portal-hub-label{width:100%;margin-right:0}.portal-hub-link{min-height:34px;padding:0 10px;font-size:.76rem}main{padding-top:42px}.apps-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:23px 14px}.card-image-wrapper{width:min(100%,145px);height:145px;border-radius:27px} }
</style>
</head>
<body>
<header><div class="header-inner"><div class="brand"><div class="brand-mark" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" 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="brand-title" id="portal-title">Portail Applicatif</div><div class="brand-subtitle" id="portal-subtitle">Chargement de l'environnement</div></div></div><div class="environment-badge" id="environment-badge">…</div></div></header>
<nav class="portal-hub-nav" id="portal-hub-nav" aria-label="Accès aux portails d'entité" hidden></nav>
<main><section class="section-header" aria-labelledby="entity-heading"><h1 id="entity-heading">Chargement du portail<span class="count" id="app-count">…</span></h1><p id="entity-description">Les applications de cet environnement sont chargées depuis le dashboard correspondant.</p></section><section id="apps-grid" class="apps-grid" aria-live="polite"><div class="loading-msg">Chargement des applications…</div></section></main>
<footer><div class="footer-inner"><span>powered by</span><img src="images/logo_santinova.webp" alt="Santinova" /></div></footer>
<script>
@@ -52,10 +57,17 @@
'portail-santinova.santinova-soft.org': { env: 'prod', group: 'SANTINOVA', title: 'Portail Santinova', accent: '#7c3aed' },
'portail-santelis.santinova-soft.org': { env: 'prod', group: 'SANTELIS', title: 'Portail SANTELIS', accent: '#15803d' }
};
/* Le portail Santinova est le hub unique : les autres portails n’exposent pas cette navigation. */
var PORTAL_HUB_LINKS = [
{ label: 'Portail Itinova', host: 'portail-itinova' },
{ label: 'Portail Soins et Santé', host: 'portail-soins-et-sante' },
{ label: 'Portail SANTELIS', host: 'portail-santelis' }
];
/* SANTELIS Gestion est déployée uniquement en production et volontairement hors inventaire Dashboard. */
var PORTAL_ONLY_APPS = [{ id:'santelis-gestion', name:'SANTELIS Gestion', urls:{ prod:'https://gestion-santelis.santinova-soft.org' }, image:'images/santelis-gestion.jpg' }];
var STATUS_REFRESH_MS = 30000, APPS_REFRESH_MS = 300000, APP_IMAGE_OVERRIDES = { 'santelis-gestion': 'images/santelis-gestion.jpg' }, hasRenderedApps = false;
function normalizeHostname(value) { return String(value || '').toLowerCase().replace(/\.$/, ''); }
function getPortalHubUrl(portal) { return 'https://' + portal.host + (PORTAL.env === 'recette' ? '.recette' : '') + '.santinova-soft.org/'; }
function resolvePortalConfig(hostname) { var config = PORTAL_CONFIGS[normalizeHostname(hostname)]; if (!config) return null; var origin = config.env === 'recette' ? 'https://dashboard.recette.santinova-soft.org' : 'https://dashboard.santinova-soft.org'; return Object.assign({}, config, { dashboardAppsUrl: origin + '/api/public/apps', dashboardStatusUrl: origin + '/api/public/status', envLabel: config.env === 'recette' ? 'Recette' : 'Production' }); }
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; }
@@ -63,7 +75,8 @@
function isAppInCurrentPortal(app) { if (!PORTAL || !app || !isHttpsUrl(app.urls && app.urls[PORTAL.env])) return false; if (app.id === 'santelis-gestion' || app.id === 'portail-santinova') return false; if (PORTAL.group === 'SANTINOVA') return app.category === 'SANTINOVA' || app.category === 'INFRA'; return app.category === PORTAL.group; }
function escapeHtml(value) { return String(value || '').replace(/[&<>'"]/g, function(character) { return ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', "'":'&#39;', '"':'&quot;' })[character]; }); }
function buildCard(app) { var url = app.urls && app.urls[PORTAL.env], image = APP_IMAGE_OVERRIDES[app.id] || app.image || ('images/' + app.id + '.png'), offline = isOffline(app); return '<a class="app-card' + (offline ? ' offline' : '') + '" data-app-id="' + escapeHtml(app.id) + '" href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer"' + (offline ? ' aria-disabled="true"' : '') + '><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">' + PORTAL.envLabel + '</div><div class="card-status-badge">Hors ligne</div></a>'; }
function setPortalIdentity() { document.documentElement.style.setProperty('--accent', PORTAL.accent); document.title = PORTAL.title + ' — Santinova Soft'; document.getElementById('portal-title').textContent = PORTAL.title; document.getElementById('portal-subtitle').textContent = 'Environnement de ' + PORTAL.envLabel.toLowerCase(); document.getElementById('environment-badge').textContent = PORTAL.envLabel; document.getElementById('entity-heading').firstChild.nodeValue = PORTAL.title; document.getElementById('entity-description').textContent = 'Applications déployées dans l’environnement ' + PORTAL.envLabel.toLowerCase() + '.'; }
function renderPortalHubNavigation() { var navigation = document.getElementById('portal-hub-nav'); if (!PORTAL || PORTAL.group !== 'SANTINOVA') { navigation.innerHTML = ''; navigation.hidden = true; return; } navigation.innerHTML = '<span class="portal-hub-label">Accès aux portails</span>' + PORTAL_HUB_LINKS.map(function(portal) { return '<a class="portal-hub-link" href="' + getPortalHubUrl(portal) + '">' + portal.label + '</a>'; }).join(''); navigation.hidden = false; }
function setPortalIdentity() { document.documentElement.style.setProperty('--accent', PORTAL.accent); document.title = PORTAL.title + ' — Santinova Soft'; document.getElementById('portal-title').textContent = PORTAL.title; document.getElementById('portal-subtitle').textContent = 'Environnement de ' + PORTAL.envLabel.toLowerCase(); document.getElementById('environment-badge').textContent = PORTAL.envLabel; document.getElementById('entity-heading').firstChild.nodeValue = PORTAL.title; document.getElementById('entity-description').textContent = 'Applications déployées dans l’environnement ' + PORTAL.envLabel.toLowerCase() + '.'; renderPortalHubNavigation(); }
function renderApps(apps) { var visibleApps = apps.filter(isAppInCurrentPortal).concat(getPortalOnlyApps()), grid = document.getElementById('apps-grid'); grid.innerHTML = visibleApps.length ? visibleApps.map(buildCard).join('') : '<div class="loading-msg">Aucune application déployée pour ce portail.</div>'; document.getElementById('app-count').textContent = visibleApps.length; hasRenderedApps = true; }
async function requestJson(url) { var controller = new AbortController(), 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 fetchAndApplyStatuses() { try { var statuses = await requestJson(PORTAL.dashboardStatusUrl); 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)); }); } catch (error) { console.warn('[Portail] Mise à jour des statuts impossible :', error.message); } }

View File

@@ -18,9 +18,9 @@ const historicalProductionHost = ['portail', 'santinova-soft', 'org'].join('.');
assert.ok(script, 'le script de configuration des portails doit être présent');
assert.equal(/^(<<<<<<<|=======|>>>>>>>)/m.test(html), false, 'aucun marqueur de conflit Git ne doit être livré');
assert.match(html, /PORTAL_CONFIGS/, 'les domaines des portails doivent être configurés explicitement');
assert.doesNotMatch(html, /class="entity-nav"/, 'un portail ne doit proposer aucun accès aux autres portails');
assert.doesNotMatch(html, /PORTAL_DEFINITIONS/, 'la liste de navigation croisée ne doit pas être livrée');
assert.doesNotMatch(html, /function renderNavigation\(/, 'aucune fonction ne doit construire des liens vers les autres portails');
assert.match(html, /id="portal-hub-nav"/, 'le portail Santinova doit posséder une navigation dédiée vers les autres entités');
assert.match(html, /PORTAL_HUB_LINKS/, 'les liens du hub Santinova doivent être définis explicitement');
assert.match(html, /function renderPortalHubNavigation\(/, 'la navigation doit être rendue uniquement selon le portail courant');
assert.equal(html.includes(historicalRecetteHost), false, 'le portail historique recette ne doit plus être livré');
assert.equal(html.includes(historicalProductionHost), false, 'le portail historique production ne doit plus être livré');
assert.match(html, /STATUS_REFRESH_MS = 30000/, 'le délai de grisage doit être explicite');
@@ -66,7 +66,7 @@ function loadPortal(hostname) {
};
vm.createContext(sandbox);
vm.runInContext(script, sandbox);
return { sandbox, redirects };
return { sandbox, redirects, elements };
}
const hosts = {
@@ -99,6 +99,23 @@ for (const [host, env, group, appsUrl] of expected) {
assert.equal(sandbox.PORTAL.dashboardStatusUrl, appsUrl.replace('/apps', '/status'), `${host} doit appeler le bon endpoint statut`);
}
for (const [host, env, group] of expected) {
const { sandbox, elements } = loadPortal(host);
sandbox.renderPortalHubNavigation();
const navigation = elements.get('portal-hub-nav');
const expectedVisible = group === 'SANTINOVA';
assert.equal(navigation.hidden, !expectedVisible, `${host} doit ${expectedVisible ? '' : 'ne pas '}afficher la navigation centrale`);
if (expectedVisible) {
for (const portal of ['portail-itinova', 'portail-soins-et-sante', 'portail-santelis']) {
const suffix = env === 'recette' ? '.recette' : '';
assert.match(navigation.innerHTML, new RegExp(`https://${portal}${suffix}\\.santinova-soft\\.org/`), `${host} doit accéder à ${portal} dans le même environnement`);
}
assert.doesNotMatch(navigation.innerHTML, /portail-santinova/, `${host} ne doit pas contenir un lien vers lui-même`);
} else {
assert.equal(navigation.innerHTML, '', `${host} ne doit rendre aucun lien vers les autres portails`);
}
}
for (const host of [hosts.itinovaRecette, hosts.soinsRecette, hosts.santinovaRecette, hosts.santelisRecette]) {
assert.match(
compose,