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", "id": "portail-santinova",
"name": "Portail Applicatif", "name": "Portails Applicatifs",
"category": "INFRA", "category": "INFRA",
"urls": { "urls": {
"recette": "https://portail.recette.santinova-soft.org", "recette": "https://portail-santinova.recette.santinova-soft.org",
"prod": "https://portail.santinova-soft.org" "prod": "https://portail-santinova.santinova-soft.org"
}, },
"containerName": "portail-santinova", "containerName": "portail-santinova",
"image": "images/portail.png", "image": "images/portail.png",

View File

@@ -9,7 +9,7 @@ services:
- web - web
labels: labels:
- "traefik.enable=true" - "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.entrypoints=websecure"
- "traefik.http.routers.portail-recette.tls.certresolver=letsencrypt" - "traefik.http.routers.portail-recette.tls.certresolver=letsencrypt"
- "traefik.http.services.portail-recette.loadbalancer.server.port=3000" - "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 | | Fichier | Tuile | Source | Licence |
|---|---|---|---| |---|---|---|---|
| `portail.png` | Portail Applicatif | Photo de collaboration en environnement numérique, Unsplash | [Licence Unsplash](https://unsplash.com/license) | | `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) | | `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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Portail Applicatif — Santinova Soft</title> <title>Portail Applicatif — Santinova Soft</title>
<style> <style>
/* ===== RESET & BASE ===== */ :root { --accent: #4f46e5; --accent-soft: rgba(79,70,229,.16); --accent-border: rgba(129,140,248,.42); }
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } * { 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; }
body { 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); }
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; .header-inner { max-width: 1240px; margin: auto; display: flex; align-items: center; justify-content: space-between; gap: 24px; }
background: #0f1117; .brand { display: flex; align-items: center; gap: 16px; min-width: 0; }
color: #e8eaf0; .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); }
min-height: 100vh; .brand-title { font-size: clamp(1.24rem,2vw,1.55rem); font-weight: 750; letter-spacing: -.35px; }
display: flex; .brand-subtitle { margin-top: 3px; color: #aeb5cf; font-size: .73rem; font-weight: 700; letter-spacing: 1.05px; text-transform: uppercase; }
flex-direction: column; .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; }
/* ===== HEADER ===== */ .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; }
header { .entity-link:hover { transform: translateY(-1px); color: white; background: rgba(255,255,255,.06); }
background: linear-gradient(135deg, #1a1d2e 0%, #252840 100%); .entity-link.active { color: white; background: var(--accent-soft); border-color: var(--accent-border); }
border-bottom: 1px solid rgba(255,255,255,0.08); .entity-link:focus-visible,.app-card:focus-visible { outline: 3px solid white; outline-offset: 3px; }
padding: 28px 48px; main { width: 100%; max-width: 1240px; margin: auto; padding: 58px clamp(24px,5vw,64px) 72px; flex: 1; }
display: flex; .section-header { max-width: 680px; margin: 0 auto 42px; text-align: center; }
align-items: center; .section-header h1 { font-size: clamp(2rem,4.5vw,3rem); letter-spacing: -1.1px; line-height: 1.08; }
justify-content: space-between; .section-header p { margin-top: 12px; color: #aeb5cb; font-size: 1rem; line-height: 1.55; }
box-shadow: 0 4px 24px rgba(0,0,0,0.4); .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; }
.header-brand { .app-card:hover { transform: translateY(-7px); }.app-card:active { transform: scale(.975); }
display: flex; .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; }
align-items: center; .app-card:hover .card-image-wrapper { box-shadow: 0 19px 44px rgba(0,0,0,.42),0 0 0 1px var(--accent-border); }
gap: 16px; .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); }
.header-logo-placeholder { .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; }
width: 48px; .loading-msg { grid-column: 1 / -1; padding:64px 0; color:#aeb5cb; text-align:center; }.loading-msg.error { color:#fecaca; }
height: 48px; 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; }
background: linear-gradient(135deg, #6366f1, #8b5cf6); @media (prefers-reduced-motion: reduce) { *,*:before,*:after { transition:none!important; } }
border-radius: 12px; @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} }
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> </style>
</head> </head>
<body> <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>
<!-- HEADER --> <nav class="entity-nav" aria-label="Portails par entité"><div class="entity-nav-inner" id="entity-nav"></div></nav>
<header> <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>
<div class="header-brand"> <footer><div class="footer-inner"><span>powered by</span><img src="images/logo_santinova.webp" alt="Santinova" /></div></footer>
<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> <script>
/* /* Le nom de domaine est la source d'autorité : il définit l'entité, le dashboard et l'environnement. */
* Configuration d'environnement. Cette version est exclusivement destinée var PORTAL_CONFIGS = {
* à la recette. La version production ne diffère que par ces quatre valeurs. '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' },
var ENV = 'recette'; 'portail-santinova.recette.santinova-soft.org': { env: 'recette', group: 'SANTINOVA', title: 'Portail Santinova', accent: '#7c3aed' },
var ENV_LABEL = 'Recette'; 'portail-santelis.recette.santinova-soft.org': { env: 'recette', group: 'SANTELIS', title: 'Portail SANTELIS', accent: '#15803d' },
var DASHBOARD_APPS_URL = 'https://dashboard.recette.santinova-soft.org/api/public/apps'; 'portail-itinova.santinova-soft.org': { env: 'prod', group: 'ITINOVA', title: 'Portail Itinova', accent: '#4f46e5' },
var DASHBOARD_STATUS_URL = 'https://dashboard.recette.santinova-soft.org/api/public/status'; 'portail-soins-et-sante.santinova-soft.org': { env: 'prod', group: 'SOINS_ET_SANTE', title: 'Portail Soins et Santé', accent: '#0f766e' },
var STATUS_REFRESH_MS = 30000; 'portail-santinova.santinova-soft.org': { env: 'prod', group: 'SANTINOVA', title: 'Portail Santinova', accent: '#7c3aed' },
var APPS_REFRESH_MS = 300000; 'portail-santelis.santinova-soft.org': { env: 'prod', group: 'SANTELIS', title: 'Portail SANTELIS', accent: '#15803d' }
var hasRenderedApps = false; };
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/' };
function switchTab(tabName, button) { 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' } ];
document.querySelectorAll('.tab-btn').forEach(function(tab) { tab.classList.remove('active'); }); var STATUS_REFRESH_MS = 30000, APPS_REFRESH_MS = 300000, APP_IMAGE_OVERRIDES = { 'santelis-gestion': 'images/santelis-gestion.jpg' }, hasRenderedApps = false;
document.querySelectorAll('.tab-panel').forEach(function(panel) { panel.classList.remove('active'); }); function normalizeHostname(value) { return String(value || '').toLowerCase().replace(/\.$/, ''); }
button.classList.add('active'); function getPortalUrl(definition, env) { return 'https://' + definition.host + (env === 'recette' ? '.recette' : '') + '.santinova-soft.org/'; }
document.getElementById('panel-' + tabName).classList.add('active'); 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 escapeHtml(value) { 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; }
return String(value || '').replace(/[&<>'"]/g, function(character) { function escapeHtml(value) { return String(value || '').replace(/[&<>'"]/g, function(character) { return ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', "'":'&#39;', '"':'&quot;' })[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; }
function isHttpsUrl(value) { 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); } }
try { 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); } }
return new URL(value).protocol === 'https:'; 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 = '!'; } } }
} catch (error) { var CURRENT_HOST = normalizeHostname(window.location.hostname), LEGACY_REDIRECT = LEGACY_REDIRECTS[CURRENT_HOST], PORTAL = resolvePortalConfig(CURRENT_HOST);
return false; 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); });
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> </script>
</body> </body>
</html> </html>

View File

@@ -1,21 +1,132 @@
/* /*
* Contrat de non-régression du portail dynamique. * Contrat de non-régression des portails applicatifs par entité.
* Il bloque les marqueurs de conflit, les doublons de configuration * Un unique code sert les quatre domaines de chaque environnement, mais le
* et le mélange des URLs recette/production. * domaine courant choisit strictement son dashboard, ses URLs et son groupe.
*/ */
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); 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(/^(<<<<<<<|=======|>>>>>>>)/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.match(html, /PORTAL_CONFIGS/, 'les domaines des portails doivent être configurés explicitement');
assert.equal((html.match(/var DASHBOARD_STATUS_URL/g) || []).length, 1, 'une seule URL API statuts doit être déclarée'); assert.match(html, /STATUS_REFRESH_MS = 30000/, 'le délai de grisage doit être explicite');
assert.match(html, /var ENV = 'recette';/, 'la version recette doit déclarer explicitement son environnement'); assert.match(html, /setInterval\(fetchAndApplyStatuses, STATUS_REFRESH_MS\)/, 'les statuts doivent être réactualisés périodiquement');
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'); function loadPortal(hostname) {
assert.match(html, /var STATUS_REFRESH_MS = 30000;/, 'le délai de grisage doit être explicite'); const redirects = [];
assert.match(html, /setInterval\(fetchAndApplyStatuses, STATUS_REFRESH_MS\)/, 'le grisage doit être réactualisé périodiquement'); 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'); console.log('OK portal-contract');