feat: séparer les portails par entité
All checks were successful
Validation applicative / Tests de contrat (push) Successful in 15s

This commit is contained in:
manus-admin
2026-10-24 09:12:42 +00:00
parent 0213b25fe4
commit fbc2756f87
10 changed files with 199 additions and 539 deletions

View File

@@ -1,10 +1,10 @@
{
"id": "portail-santinova",
"name": "Portail Applicatif",
"name": "Portails Applicatifs",
"category": "INFRA",
"urls": {
"recette": "https://portail.recette.santinova-soft.org",
"prod": "https://portail.santinova-soft.org"
"recette": "https://portail-santinova.recette.santinova-soft.org",
"prod": "https://portail-santinova.santinova-soft.org"
},
"containerName": "portail-santinova",
"image": "images/portail.png",

View File

@@ -9,7 +9,7 @@ services:
- web
labels:
- "traefik.enable=true"
- "traefik.http.routers.portail-recette.rule=Host(`portail.recette.santinova-soft.org`)"
- "traefik.http.routers.portail-recette.rule=Host(`portail.recette.santinova-soft.org`,`portail-itinova.recette.santinova-soft.org`,`portail-soins-et-sante.recette.santinova-soft.org`,`portail-santinova.recette.santinova-soft.org`,`portail-santelis.recette.santinova-soft.org`)"
- "traefik.http.routers.portail-recette.entrypoints=websecure"
- "traefik.http.routers.portail-recette.tls.certresolver=letsencrypt"
- "traefik.http.services.portail-recette.loadbalancer.server.port=3000"

View File

@@ -1,8 +1,9 @@
# Crédits photographiques — tuiles Infrastructure
# Crédits photographiques — portails applicatifs
| Fichier | Tuile | Source | Licence |
|---|---|---|---|
| `portail.png` | Portail Applicatif | Photo de collaboration en environnement numérique, Unsplash | [Licence Unsplash](https://unsplash.com/license) |
| `dashboard.png` | Dashboard Recette | Photo de réseau et de supervision technique, Unsplash | [Licence Unsplash](https://unsplash.com/license) |
| `santelis-gestion.jpg` | SANTELIS Gestion | Healthcare Administration Pictures, Unsplash | [Licence Unsplash](https://unsplash.com/license) |
Les photos sont utilisées pour illustrer les deux tuiles d’infrastructure. Aucun logo, texte ni donnée métier n’est incorporé dans ces visuels.
Les photos sont utilisées pour illustrer les tuiles applicatives. Aucun logo, texte ni donnée métier n’est incorporé dans ces visuels.

BIN
images/budget-si.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

BIN
images/santelis-gestion.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

BIN
images/santino-chat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

View File

@@ -5,532 +5,80 @@
<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; }
}
:root { --accent: #4f46e5; --accent-soft: rgba(79,70,229,.16); --accent-border: rgba(129,140,248,.42); }
* { box-sizing: border-box; margin: 0; padding: 0; }
body { min-height: 100vh; display: flex; flex-direction: column; background: #0d0f16; color: #edf0fa; font-family: "Segoe UI", system-ui, sans-serif; }
header { padding: 28px clamp(24px,5vw,64px); background: linear-gradient(118deg,#171b30,#252b50 55%,#171a2c); border-bottom: 1px solid rgba(255,255,255,.09); box-shadow: 0 7px 28px rgba(0,0,0,.28); }
.header-inner { max-width: 1240px; margin: auto; display: flex; align-items: center; justify-content: space-between; gap: 24px; }
.brand { display: flex; align-items: center; gap: 16px; min-width: 0; }
.brand-mark { width: 46px; height: 46px; display: grid; place-items: center; flex: 0 0 46px; border-radius: 14px; color: white; background: linear-gradient(135deg,var(--accent),#171b39); box-shadow: 0 8px 21px var(--accent-soft); }
.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; }
.entity-nav { padding: 13px clamp(24px,5vw,64px); background: #141728; border-bottom: 1px solid rgba(255,255,255,.07); }
.entity-nav-inner { max-width: 1240px; margin: auto; display: flex; flex-wrap: wrap; gap: 8px; }
.entity-link { display: inline-flex; align-items: center; min-height: 36px; padding: 0 13px; border: 1px solid transparent; border-radius: 10px; color: #aeb5cb; font-size: .82rem; font-weight: 700; text-decoration: none; transition: transform .16s ease-out,background .16s ease-out,color .16s ease-out; }
.entity-link:hover { transform: translateY(-1px); color: white; background: rgba(255,255,255,.06); }
.entity-link.active { color: white; background: var(--accent-soft); border-color: var(--accent-border); }
.entity-link:focus-visible,.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; }
.section-header h1 { font-size: clamp(2rem,4.5vw,3rem); letter-spacing: -1.1px; line-height: 1.08; }
.section-header p { margin-top: 12px; color: #aeb5cb; font-size: 1rem; line-height: 1.55; }
.count { display: inline-flex; align-items: center; justify-content: center; min-width: 27px; height: 27px; margin-left: 9px; vertical-align: middle; border: 1px solid var(--accent-border); border-radius: 999px; background: var(--accent-soft); font-size: .76rem; }
.apps-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(210px,1fr)); gap: 28px; align-items: start; }
.app-card { display: flex; flex-direction: column; align-items: center; width: 100%; max-width: 250px; justify-self: center; color: inherit; text-decoration: none; transition: transform .2s cubic-bezier(.23,1,.32,1),opacity .2s ease,filter .2s ease; }
.app-card:hover { transform: translateY(-7px); }.app-card:active { transform: scale(.975); }
.card-image-wrapper { position: relative; width: 180px; height: 180px; overflow: hidden; border: 1px solid rgba(255,255,255,.1); border-radius: 34px; background: #22263b; box-shadow: 0 10px 28px rgba(0,0,0,.34); transition: box-shadow .2s ease; }
.app-card:hover .card-image-wrapper { box-shadow: 0 19px 44px rgba(0,0,0,.42),0 0 0 1px var(--accent-border); }
.card-image-wrapper img { width: 100%; height: 100%; display: block; object-fit: cover; }.card-image-wrapper:after { content:""; position:absolute; inset:0; border-radius:inherit; box-shadow:inset 0 0 0 1px rgba(255,255,255,.08); pointer-events:none; }
.card-name { margin-top: 15px; color: #d9deed; text-align: center; font-size: .96rem; font-weight: 700; line-height: 1.35; }.card-env-badge,.card-status-badge { margin-top: 7px; padding: 3px 9px; border-radius: 999px; font-size: .66rem; font-weight: 800; letter-spacing: .85px; text-transform: uppercase; }
.card-env-badge { border: 1px solid var(--accent-border); color:#d5dbfb; background:var(--accent-soft); }.card-status-badge { display:none; border:1px solid rgba(248,113,113,.42); color:#fecaca; background:rgba(239,68,68,.12); }
.app-card.offline { cursor:not-allowed; opacity:.42; pointer-events:none; filter:grayscale(85%); }.app-card.offline .card-status-badge { display:inline-flex; }.app-card.offline .card-image-wrapper { box-shadow:none; }
.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}.entity-nav{padding-top:10px;padding-bottom:10px}.entity-link{padding:0 10px;min-height:34px;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 -->
<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>
<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="entity-nav" aria-label="Portails par entité"><div class="entity-nav-inner" id="entity-nav"></div></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>
/*
* 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);
});
/* Le nom de domaine est la source d'autorité : il définit l'entité, le dashboard et l'environnement. */
var PORTAL_CONFIGS = {
'portail-itinova.recette.santinova-soft.org': { env: 'recette', group: 'ITINOVA', title: 'Portail Itinova', accent: '#4f46e5' },
'portail-soins-et-sante.recette.santinova-soft.org': { env: 'recette', group: 'SOINS_ET_SANTE', title: 'Portail Soins et Santé', accent: '#0f766e' },
'portail-santinova.recette.santinova-soft.org': { env: 'recette', group: 'SANTINOVA', title: 'Portail Santinova', accent: '#7c3aed' },
'portail-santelis.recette.santinova-soft.org': { env: 'recette', group: 'SANTELIS', title: 'Portail SANTELIS', accent: '#15803d' },
'portail-itinova.santinova-soft.org': { env: 'prod', group: 'ITINOVA', title: 'Portail Itinova', accent: '#4f46e5' },
'portail-soins-et-sante.santinova-soft.org': { env: 'prod', group: 'SOINS_ET_SANTE', title: 'Portail Soins et Santé', accent: '#0f766e' },
'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' }
};
var LEGACY_REDIRECTS = { 'portail.recette.santinova-soft.org': 'https://portail-santinova.recette.santinova-soft.org/', 'portail.santinova-soft.org': 'https://portail-santinova.santinova-soft.org/' };
var PORTAL_DEFINITIONS = [ { group:'ITINOVA',label:'Itinova',host:'portail-itinova' }, { group:'SOINS_ET_SANTE',label:'Soins et Santé',host:'portail-soins-et-sante' }, { group:'SANTINOVA',label:'Santinova',host:'portail-santinova' }, { group:'SANTELIS',label:'SANTELIS',host:'portail-santelis' } ];
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 getPortalUrl(definition, env) { return 'https://' + definition.host + (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; }
function isAppInCurrentPortal(app) { if (!PORTAL || !app || !isHttpsUrl(app.urls && app.urls[PORTAL.env])) return false; if (PORTAL.group === 'SANTELIS') return app.id === 'santelis-gestion'; if (app.id === 'santelis-gestion') 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 renderNavigation() { document.getElementById('entity-nav').innerHTML = PORTAL_DEFINITIONS.map(function(definition) { var active = definition.group === PORTAL.group; return '<a class="entity-link' + (active ? ' active' : '') + '" href="' + getPortalUrl(definition, PORTAL.env) + '"' + (active ? ' aria-current="page"' : '') + '>' + definition.label + '</a>'; }).join(''); }
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() + '.'; renderNavigation(); }
function renderApps(apps) { var visibleApps = apps.filter(isAppInCurrentPortal), 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); } }
async function loadApps() { try { var apps = await requestJson(PORTAL.dashboardAppsUrl); 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) { document.getElementById('apps-grid').innerHTML = '<div class="loading-msg error">Dashboard indisponible — veuillez réessayer.</div>'; document.getElementById('app-count').textContent = '!'; } } }
var CURRENT_HOST = normalizeHostname(window.location.hostname), LEGACY_REDIRECT = LEGACY_REDIRECTS[CURRENT_HOST], PORTAL = resolvePortalConfig(CURRENT_HOST);
if (LEGACY_REDIRECT) window.location.replace(LEGACY_REDIRECT);
else if (!PORTAL) document.addEventListener('DOMContentLoaded', function() { document.getElementById('apps-grid').innerHTML = '<div class="loading-msg error">Nom de portail non reconnu.</div>'; });
else document.addEventListener('DOMContentLoaded', function() { setPortalIdentity(); loadApps(); setInterval(fetchAndApplyStatuses, STATUS_REFRESH_MS); setInterval(loadApps, APPS_REFRESH_MS); });
</script>
</body>
</html>

View File

@@ -1,21 +1,132 @@
/*
* Contrat de non-régression du portail dynamique.
* Il bloque les marqueurs de conflit, les doublons de configuration
* et le mélange des URLs recette/production.
* Contrat de non-régression des portails applicatifs par entité.
* Un unique code sert les quatre domaines de chaque environnement, mais le
* domaine courant choisit strictement son dashboard, ses URLs et son groupe.
*/
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
const root = path.join(__dirname, '..');
const html = fs.readFileSync(path.join(root, 'index.html'), 'utf8');
const script = html.match(/<script>([\s\S]*?)<\/script>/)?.[1];
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.equal((html.match(/var DASHBOARD_APPS_URL/g) || []).length, 1, 'une seule URL API applications doit être déclarée');
assert.equal((html.match(/var DASHBOARD_STATUS_URL/g) || []).length, 1, 'une seule URL API statuts doit être déclarée');
assert.match(html, /var ENV = 'recette';/, 'la version recette doit déclarer explicitement son environnement');
assert.match(html, /app\.urls\[ENV\]/, 'les liens doivent être sélectionnés depuis l’environnement déclaré');
assert.match(html, /app\.category !== 'INFRA'/, 'les tuiles INFRA doivent être filtrées côté portail');
assert.match(html, /var STATUS_REFRESH_MS = 30000;/, 'le délai de grisage doit être explicite');
assert.match(html, /setInterval\(fetchAndApplyStatuses, STATUS_REFRESH_MS\)/, 'le grisage doit être réactualisé périodiquement');
assert.match(html, /PORTAL_CONFIGS/, 'les domaines des portails doivent être configurés explicitement');
assert.match(html, /STATUS_REFRESH_MS = 30000/, 'le délai de grisage doit être explicite');
assert.match(html, /setInterval\(fetchAndApplyStatuses, STATUS_REFRESH_MS\)/, 'les statuts doivent être réactualisés périodiquement');
function loadPortal(hostname) {
const redirects = [];
const elements = new Map();
const document = {
title: '',
addEventListener() {},
getElementById(id) {
if (!elements.has(id)) {
elements.set(id, {
innerHTML: '',
textContent: '',
className: '',
classList: { toggle() {} },
setAttribute() {}
});
}
return elements.get(id);
},
querySelectorAll() { return []; }
};
const window = {
location: {
hostname,
replace(target) { redirects.push(target); }
}
};
const sandbox = {
AbortController,
Map,
Set,
URL,
console,
document,
window,
setInterval() {},
setTimeout() { return 0; },
clearTimeout() {}
};
vm.createContext(sandbox);
vm.runInContext(script, sandbox);
return { sandbox, redirects };
}
const hosts = {
itinovaRecette: 'portail-itinova.recette.santinova-soft.org',
soinsRecette: 'portail-soins-et-sante.recette.santinova-soft.org',
santinovaRecette: 'portail-santinova.recette.santinova-soft.org',
santelisRecette: 'portail-santelis.recette.santinova-soft.org',
itinovaProd: 'portail-itinova.santinova-soft.org',
soinsProd: 'portail-soins-et-sante.santinova-soft.org',
santinovaProd: 'portail-santinova.santinova-soft.org',
santelisProd: 'portail-santelis.santinova-soft.org'
};
const expected = [
[hosts.itinovaRecette, 'recette', 'ITINOVA', 'https://dashboard.recette.santinova-soft.org/api/public/apps'],
[hosts.soinsRecette, 'recette', 'SOINS_ET_SANTE', 'https://dashboard.recette.santinova-soft.org/api/public/apps'],
[hosts.santinovaRecette, 'recette', 'SANTINOVA', 'https://dashboard.recette.santinova-soft.org/api/public/apps'],
[hosts.santelisRecette, 'recette', 'SANTELIS', 'https://dashboard.recette.santinova-soft.org/api/public/apps'],
[hosts.itinovaProd, 'prod', 'ITINOVA', 'https://dashboard.santinova-soft.org/api/public/apps'],
[hosts.soinsProd, 'prod', 'SOINS_ET_SANTE', 'https://dashboard.santinova-soft.org/api/public/apps'],
[hosts.santinovaProd, 'prod', 'SANTINOVA', 'https://dashboard.santinova-soft.org/api/public/apps'],
[hosts.santelisProd, 'prod', 'SANTELIS', 'https://dashboard.santinova-soft.org/api/public/apps']
];
for (const [host, env, group, appsUrl] of expected) {
const { sandbox } = loadPortal(host);
assert.equal(sandbox.PORTAL.env, env, `${host} doit utiliser l’environnement ${env}`);
assert.equal(sandbox.PORTAL.group, group, `${host} doit afficher le groupe ${group}`);
assert.equal(sandbox.PORTAL.dashboardAppsUrl, appsUrl, `${host} doit appeler uniquement le bon dashboard`);
assert.equal(sandbox.PORTAL.dashboardStatusUrl, appsUrl.replace('/apps', '/status'), `${host} doit appeler le bon endpoint statut`);
}
const recetteApps = [
{ id: 'itinova-contacts', category: 'ITINOVA', urls: { recette: 'https://contacts.recette.santinova-soft.org' } },
{ id: 'gestion-astreintes-had', category: 'SOINS_ET_SANTE', urls: { recette: 'https://astreintes-had.recette.santinova-soft.org' } },
{ id: 'demat-facturation-dsi', category: 'SANTINOVA', urls: { recette: 'https://demat-facturation.recette.santinova-soft.org' } },
{ id: 'referentiel-ia', category: 'INFRA', urls: { recette: 'https://referentiel-ia.recette.santinova-soft.org' } },
{ id: 'santelis-gestion', category: 'SANTINOVA', urls: { recette: 'https://gestion-santelis.recette.santinova-soft.org' } }
];
const expectedVisible = {
[hosts.itinovaRecette]: ['itinova-contacts'],
[hosts.soinsRecette]: ['gestion-astreintes-had'],
[hosts.santinovaRecette]: ['demat-facturation-dsi', 'referentiel-ia'],
[hosts.santelisRecette]: ['santelis-gestion']
};
for (const [host, ids] of Object.entries(expectedVisible)) {
const { sandbox } = loadPortal(host);
const visible = recetteApps.filter(sandbox.isAppInCurrentPortal).map(app => app.id);
assert.deepEqual(visible, ids, `${host} ne doit afficher que les tuiles de son portail`);
}
for (const imageName of ['budget-si.jpg', 'gestion-astreintes-had.jpg', 'gestion-indus-had.jpg', 'santelis-gestion.jpg', 'santino-chat.png']) {
const imagePath = path.join(root, 'images', imageName);
assert.equal(fs.existsSync(imagePath), true, `l’asset ${imageName} doit être publié`);
assert.ok(fs.statSync(imagePath).size > 2048, `l’asset ${imageName} ne doit pas être vide`);
}
assert.deepEqual(
loadPortal('portail.recette.santinova-soft.org').redirects,
['https://portail-santinova.recette.santinova-soft.org/'],
'l’ancienne URL recette doit rediriger vers le portail Santinova'
);
assert.deepEqual(
loadPortal('portail.santinova-soft.org').redirects,
['https://portail-santinova.santinova-soft.org/'],
'l’ancienne URL production doit rediriger vers le portail Santinova'
);
console.log('OK portal-contract');