feat(services-hub): SSH tunnel UI activated for 6 internal services (Listmonk Prometheus Loki SearXNG Qdrant Flaresolverr Node-Exporter) - click toggle reveals ssh -N -L <port>:127.0.0.1:<port> root@204.168.152.13 -p 49222 command + copy-to-clipboard - Yacine key yace@LAPTOP-VE75QUHF deja autorisee root authorized_keys - doctrine interne-only service access
Some checks failed
WEVAL NonReg / nonreg (push) Has been cancelled

This commit is contained in:
Opus
2026-04-24 17:08:00 +02:00
parent 7533928526
commit bda0d8ee93

465
services-hub.html Normal file
View File

@@ -0,0 +1,465 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WEVAL Services Hub · Self-Hosted Open Source Stack</title>
<meta name="description" content="Hub d'accès unifié à tous les services open-source self-hosted WEVAL : Langfuse, Gitea, Mattermost, n8n, Twenty CRM, Listmonk, Grafana, Prometheus, Qdrant, Uptime Kuma, Plausible, SearXNG…">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@500;700&family=Playfair+Display:wght@400;700&display=swap" rel="stylesheet">
<style>
:root{
--bg-0:#05060a;--bg-1:#0a0c14;--bg-2:#11141f;--bg-3:#181c2b;--bg-card:#0e111c;
--border:#1f2436;--border-hover:#3a425f;
--text-0:#f1f5f9;--text-1:#cbd5e1;--text-2:#94a3b8;--text-3:#64748b;
--accent:#6366f1;--accent-hover:#818cf8;
--success:#10b981;--warning:#f59e0b;--danger:#ef4444;--info:#06b6d4;
--gold:#f6d572;--mint:#5cdb95;--violet:#a78bfa;--coral:#ff6b6b;--cyan:#4ecdc4;
--shadow-lg:0 16px 48px rgba(99,102,241,.2);
--radius:14px;--radius-sm:10px;
--trans:.18s cubic-bezier(.4,0,.2,1);
}
*{box-sizing:border-box;margin:0;padding:0}
body{
background:var(--bg-0);color:var(--text-0);font-family:"Inter",sans-serif;min-height:100vh;
background-image:
radial-gradient(ellipse at 15% 15%,rgba(99,102,241,.08) 0%,transparent 60%),
radial-gradient(ellipse at 85% 85%,rgba(6,182,212,.06) 0%,transparent 60%);
}
header.topbar{
display:flex;align-items:center;justify-content:space-between;
padding:14px 28px;border-bottom:1px solid var(--border);
background:rgba(10,12,20,.85);backdrop-filter:blur(24px);
position:sticky;top:0;z-index:100;padding-right:130px;
}
.brand{display:flex;align-items:center;gap:14px}
.brand-logo{
width:38px;height:38px;border-radius:10px;
background:linear-gradient(135deg,var(--accent),var(--violet));
display:flex;align-items:center;justify-content:center;
font-weight:900;font-size:18px;color:#fff;
}
.brand-text{display:flex;flex-direction:column}
.brand-title{font-size:14px;font-weight:700;letter-spacing:.08em}
.brand-sub{font-size:10px;letter-spacing:.2em;color:var(--text-3);text-transform:uppercase;font-weight:600}
.health-bar{display:flex;gap:18px;align-items:center}
.h-item{display:flex;align-items:center;gap:7px;font-size:11px;color:var(--text-2);font-family:"JetBrains Mono",monospace}
.h-dot{width:7px;height:7px;border-radius:50%}
.h-dot.ok{background:var(--mint);box-shadow:0 0 8px var(--mint)}
.h-dot.warn{background:var(--warning);box-shadow:0 0 8px var(--warning)}
.h-dot.off{background:var(--danger);box-shadow:0 0 8px var(--danger)}
.h-val{color:var(--text-0);font-weight:700}
.page{max-width:1680px;margin:0 auto;padding:28px;display:flex;flex-direction:column;gap:24px}
.page-head{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:14px}
.page-title-wrap{display:flex;align-items:center;gap:18px}
.page-icon{
width:58px;height:58px;border-radius:14px;
background:linear-gradient(135deg,rgba(99,102,241,.2),rgba(6,182,212,.15));
border:1px solid rgba(99,102,241,.3);
display:flex;align-items:center;justify-content:center;font-size:28px;
}
.page-title{font-size:28px;font-weight:800;letter-spacing:-.02em}
.page-title .mono{font-family:"JetBrains Mono",monospace;color:var(--accent-hover);font-weight:700}
.page-sub{font-size:13px;color:var(--text-2);margin-top:4px;font-weight:500;max-width:820px;line-height:1.5}
.btn{padding:10px 16px;border-radius:10px;background:var(--bg-2);border:1px solid var(--border);color:var(--text-1);font-size:12px;font-weight:600;cursor:pointer;transition:var(--trans);display:inline-flex;align-items:center;gap:7px;text-decoration:none}
.btn:hover{background:var(--bg-3);border-color:var(--border-hover);color:var(--text-0)}
.btn-primary{background:linear-gradient(135deg,var(--accent),var(--violet));border:none;color:#fff}
.kpi-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:14px}
.kpi{background:linear-gradient(135deg,var(--bg-card),var(--bg-1));border:1px solid var(--border);border-radius:var(--radius);padding:18px;position:relative;overflow:hidden}
.kpi::before{content:"";position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,var(--accent),var(--violet))}
.kpi.mint::before{background:linear-gradient(90deg,var(--mint),var(--success))}
.kpi.gold::before{background:linear-gradient(90deg,var(--gold),var(--warning))}
.kpi.coral::before{background:linear-gradient(90deg,var(--coral),var(--danger))}
.kpi-lbl{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--text-3);font-weight:700;margin-bottom:8px}
.kpi-val{font-size:28px;font-weight:900;letter-spacing:-.03em;color:var(--text-0);font-family:"JetBrains Mono",monospace;line-height:1}
.kpi-sub{font-size:11px;color:var(--text-2);margin-top:4px;font-weight:500}
.section{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}
.section-head{
padding:18px 24px;border-bottom:1px solid var(--border);
display:flex;align-items:center;justify-content:space-between;
background:linear-gradient(180deg,rgba(99,102,241,.04),transparent);
}
.section-title{font-size:14px;font-weight:700;color:var(--text-0);display:flex;align-items:center;gap:10px}
.section-title::before{content:"";width:3px;height:18px;background:var(--accent);border-radius:2px}
.section-meta{font-size:11px;color:var(--text-3);font-family:"JetBrains Mono",monospace}
.section-body{padding:20px 24px}
.svc-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px}
.svc-card{
background:var(--bg-2);border:1px solid var(--border);border-radius:var(--radius);
padding:18px 20px;transition:var(--trans);position:relative;overflow:hidden;
display:flex;flex-direction:column;gap:10px;
}
.svc-card:hover{border-color:var(--border-hover);transform:translateY(-2px);box-shadow:0 12px 32px rgba(0,0,0,.4)}
.svc-card::before{
content:"";position:absolute;top:0;left:0;bottom:0;width:3px;background:var(--accent);
}
.svc-card.up::before{background:var(--mint)}
.svc-card.down::before{background:var(--danger)}
.svc-card.warn::before{background:var(--warning)}
.svc-head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px}
.svc-name-wrap{display:flex;align-items:center;gap:10px;flex:1;min-width:0}
.svc-icon{
width:38px;height:38px;border-radius:10px;display:flex;align-items:center;justify-content:center;
font-size:18px;font-weight:900;flex-shrink:0;
background:linear-gradient(135deg,rgba(99,102,241,.2),rgba(167,139,250,.15));
border:1px solid rgba(99,102,241,.3);
}
.svc-name{font-size:15px;font-weight:800;letter-spacing:-.01em;font-family:"Playfair Display",serif}
.svc-cat{font-size:9px;letter-spacing:.12em;text-transform:uppercase;color:var(--text-3);font-weight:700;font-family:"JetBrains Mono",monospace;margin-top:2px}
.svc-status{
font-size:9px;padding:3px 8px;border-radius:100px;font-weight:700;
text-transform:uppercase;letter-spacing:.08em;font-family:"JetBrains Mono",monospace;
background:rgba(16,185,129,.12);color:var(--mint);border:1px solid rgba(16,185,129,.3);
flex-shrink:0;
}
.svc-status.warn{background:rgba(246,213,114,.12);color:var(--gold);border-color:rgba(246,213,114,.3)}
.svc-status.off{background:rgba(239,68,68,.12);color:var(--danger);border-color:rgba(239,68,68,.3)}
.svc-desc{font-size:12px;color:var(--text-2);line-height:1.5}
.svc-creds{
background:var(--bg-1);border:1px solid var(--border);border-radius:8px;padding:10px 12px;
display:flex;flex-direction:column;gap:4px;
}
.cred-row{display:flex;justify-content:space-between;align-items:center;font-size:10px;font-family:"JetBrains Mono",monospace;gap:8px}
.cred-lbl{color:var(--text-3);min-width:65px;letter-spacing:.04em}
.cred-val{color:var(--text-1);font-weight:600;flex:1;text-align:right;word-break:break-all;cursor:pointer;transition:var(--trans)}
.cred-val:hover{color:var(--accent-hover)}
.cred-val.masked{filter:blur(4px);user-select:none}
.cred-val.masked:hover{filter:blur(0)}
.svc-actions{display:flex;gap:8px;margin-top:auto}
.svc-btn{
flex:1;padding:8px 12px;border-radius:8px;border:1px solid var(--border);
background:var(--bg-3);color:var(--text-1);font-size:11px;font-weight:700;
cursor:pointer;transition:var(--trans);display:flex;align-items:center;justify-content:center;gap:5px;
text-decoration:none;
}
.svc-btn:hover{background:var(--accent);color:#fff;border-color:var(--accent)}
.svc-btn.primary{background:linear-gradient(135deg,var(--accent),var(--violet));border:none;color:#fff}
.svc-btn.primary:hover{opacity:.9;transform:translateY(-1px)}
.svc-meta{display:flex;gap:10px;font-size:9px;color:var(--text-3);font-family:"JetBrains Mono",monospace;letter-spacing:.04em}
.svc-meta span{display:flex;align-items:center;gap:4px}
footer{text-align:center;padding:24px;color:var(--text-3);font-size:11px;font-family:"JetBrains Mono",monospace;border-top:1px solid var(--border);margin-top:24px}
footer a{color:var(--accent-hover);text-decoration:none}
.toast{position:fixed;bottom:24px;right:24px;background:var(--mint);color:#000;padding:12px 18px;border-radius:8px;font-size:13px;font-weight:700;box-shadow:0 8px 24px rgba(16,185,129,.4);z-index:9999;opacity:0;transition:.3s;pointer-events:none}
.toast.show{opacity:1}
@media (max-width:720px){
.page{padding:14px}
.page-head{flex-direction:column;align-items:flex-start}
.health-bar{display:none}
.kpi-row{grid-template-columns:repeat(2,1fr)}
}
/* ===== SSH_TUNNEL_ACTIVATED - internal services tunnel UI ===== */
.tunnel-box{
background:linear-gradient(135deg,rgba(246,213,114,.06),rgba(6,182,212,.04));
border:1px solid rgba(246,213,114,.25);border-radius:8px;padding:10px 12px;
display:none;flex-direction:column;gap:6px;margin-top:8px;
}
.tunnel-box.show{display:flex}
.tunnel-label{font-size:9px;letter-spacing:.12em;text-transform:uppercase;color:var(--gold);font-weight:700;font-family:"JetBrains Mono",monospace}
.tunnel-cmd{
background:var(--bg-0);border:1px solid var(--border);border-radius:6px;padding:8px 10px;
font-family:"JetBrains Mono",monospace;font-size:10px;color:var(--text-1);
word-break:break-all;cursor:pointer;transition:var(--trans);position:relative;
}
.tunnel-cmd:hover{background:var(--bg-1);border-color:var(--gold);color:var(--text-0)}
.tunnel-cmd::after{
content:"📋";position:absolute;top:6px;right:8px;font-size:10px;opacity:.5;
}
.tunnel-note{font-size:10px;color:var(--text-2);line-height:1.5}
.tunnel-note b{color:var(--cyan)}
.svc-btn.tunnel{
background:linear-gradient(135deg,var(--gold),var(--warning)) !important;
color:#000 !important;border:none !important;cursor:pointer !important;opacity:1 !important;
}
.svc-btn.tunnel:hover{transform:translateY(-1px);box-shadow:0 6px 16px rgba(246,213,114,.3) !important}
/* ===== END SSH_TUNNEL_ACTIVATED ===== */
</style>
</head>
<body>
<header class="topbar">
<div class="brand">
<div class="brand-logo">W</div>
<div class="brand-text">
<span class="brand-title">WEVAL Technology Platform</span>
<span class="brand-sub">Services Hub · Self-Hosted</span>
</div>
</div>
<div class="health-bar">
<div class="h-item"><span class="h-dot ok"></span>S204 LIVE</div>
<div class="h-item">Services · <span class="h-val" id="h-svc"></span></div>
<div class="h-item">UP · <span class="h-val" id="h-up" style="color:var(--mint)"></span></div>
<div class="h-item">Cost · <span class="h-val" style="color:var(--mint)">0 €</span></div>
</div>
</header>
<div class="page">
<div class="page-head">
<div class="page-title-wrap">
<div class="page-icon">🔓</div>
<div>
<h1 class="page-title">Services <span class="mono">Hub</span></h1>
<p class="page-sub">
Accès unifié à tous les services <b>open-source self-hosted</b> de l'infrastructure WEVAL :
observability (<b>Langfuse</b>, <b>Grafana</b>), code (<b>Gitea</b>), automation (<b>n8n</b>),
CRM (<b>Twenty</b>), email (<b>Listmonk</b>), chat (<b>Mattermost</b>), monitoring (<b>Uptime Kuma</b>, <b>Prometheus</b>),
analytics (<b>Plausible</b>), search (<b>SearXNG</b>), vectors (<b>Qdrant</b>).
</p>
</div>
</div>
<div style="display:flex;gap:10px">
<a href="/wevia-master.html" class="btn">💬 WEVIA Master</a>
<a href="/weval-technology-platform.html" class="btn btn-primary">🏛 WTP Hub</a>
</div>
</div>
<div class="kpi-row">
<div class="kpi mint">
<div class="kpi-lbl">📦 Services</div>
<div class="kpi-val" id="kpi-total"></div>
<div class="kpi-sub">open-source self-hosted</div>
</div>
<div class="kpi">
<div class="kpi-lbl">✅ UP</div>
<div class="kpi-val" id="kpi-up" style="color:var(--mint)"></div>
<div class="kpi-sub">opérationnels</div>
</div>
<div class="kpi gold">
<div class="kpi-lbl">⚠ Standby</div>
<div class="kpi-val" id="kpi-standby"></div>
<div class="kpi-sub">reachable mais ⚠ check</div>
</div>
<div class="kpi coral">
<div class="kpi-lbl">⚪ Internal</div>
<div class="kpi-val" id="kpi-internal"></div>
<div class="kpi-sub">port local seulement</div>
</div>
<div class="kpi mint">
<div class="kpi-lbl">💰 Coût</div>
<div class="kpi-val" style="font-size:24px">0 €</div>
<div class="kpi-sub">tout open-source</div>
</div>
</div>
<!-- AI / OBSERVABILITY -->
<div class="section">
<div class="section-head">
<div class="section-title">🧠 AI & Observability</div>
<div class="section-meta">LLM tracing · vectors · monitoring</div>
</div>
<div class="section-body">
<div class="svc-grid" id="grid-ai"></div>
</div>
</div>
<!-- CODE / DEV -->
<div class="section">
<div class="section-head">
<div class="section-title">💻 Code & Automation</div>
<div class="section-meta">git · workflows · ci</div>
</div>
<div class="section-body">
<div class="svc-grid" id="grid-code"></div>
</div>
</div>
<!-- BUSINESS -->
<div class="section">
<div class="section-head">
<div class="section-title">📊 Business & Marketing</div>
<div class="section-meta">CRM · email · analytics</div>
</div>
<div class="section-body">
<div class="svc-grid" id="grid-biz"></div>
</div>
</div>
<!-- COMMS -->
<div class="section">
<div class="section-head">
<div class="section-title">💬 Communication</div>
<div class="section-meta">chat · search</div>
</div>
<div class="section-body">
<div class="svc-grid" id="grid-comms"></div>
</div>
</div>
<!-- MONITORING -->
<div class="section">
<div class="section-head">
<div class="section-title">📈 Monitoring & Infra</div>
<div class="section-meta">uptime · metrics · logs</div>
</div>
<div class="section-body">
<div class="svc-grid" id="grid-mon"></div>
</div>
</div>
<footer>
WEVAL Services Hub · v1.0 · 17 services open-source · Cost 0 € ·
<a href="/wevia-master.html">💬 WEVIA chat</a> ·
<a href="/ia-cascade-mechanics.html">⚡ IA Cascade</a> ·
<a href="/release-train-dashboard.html">🚂 Release Train</a>
</footer>
</div>
<div id="toast" class="toast"></div>
<script>
const SERVICES = {
ai: [
{id:"langfuse",name:"Langfuse",icon:"L",cat:"LLM Observability",desc:"Tracing LLM calls + datasets + prompt management. WEVIA Master tracking ready.",url:"https://langfuse.weval-consulting.com",internal:"localhost:3001",docker:"langfuse",version:"v2.95.11",email:"yacine@weval-consulting.com",pwd:"WevalLangfuse2026!",role:"OWNER admin"},
{id:"qdrant",name:"Qdrant",icon:"Q",cat:"Vector DB",desc:"5 collections · 14K+ vectors · WEVIA RAG kb.",url:"http://localhost:6333",internal:"127.0.0.1:6333",docker:"qdrant",version:"latest",internalOnly:true},
{id:"flaresolverr",name:"Flaresolverr",icon:"F",cat:"CF Bypass",desc:"Cloudflare challenges solver · scraper helper.",url:"http://localhost:8191",internal:"127.0.0.1:8191",docker:"flaresolverr-w274",version:"latest",internalOnly:true},
],
code: [
{id:"gitea",name:"Gitea",icon:"G",cat:"Git server",desc:"50+ repos privés · push synchro avec GitHub origin.",url:"https://git.weval-consulting.com",internal:"localhost:3300",docker:"gitea",version:"latest",email:"ymahboub@weval-consulting.com",user:"yanis",pwd:"voir vault",role:"admin",statusOverride:"503 (CF/auth)"},
{id:"n8n",name:"n8n",icon:"N",cat:"Workflows",desc:"Automation workflows · STOPPED (CPU killer doctrine).",url:"https://n8n.weval-consulting.com",internal:"localhost:5678",docker:"n8n-docker-n8n-1",version:"latest",statusOverride:"STANDBY (off)"},
],
biz: [
{id:"twenty",name:"Twenty CRM",icon:"T",cat:"CRM",desc:"1006 leads · 402 emails · 638 LinkedIn · sequences 4x/jour.",url:"https://crm.weval-consulting.com",internal:"localhost:3000",docker:"twenty",version:"latest",email:"yacine@weval-consulting.com",pwd:"voir vault",role:"workspace admin"},
{id:"listmonk",name:"Listmonk",icon:"LM",cat:"Email marketing",desc:"Newsletter + campaigns · backup wevads sender.",url:"http://localhost:9000",internal:"127.0.0.1:9000",docker:"listmonk",version:"latest",user:"admin",pwd:"admin123",role:"admin",internalOnly:true},
{id:"plausible",name:"Plausible",icon:"P",cat:"Analytics",desc:"Web analytics privacy-friendly · weval-consulting.com tracking.",url:"https://analytics.weval-consulting.com",internal:"localhost:8000",docker:"plausible-...",version:"latest",statusOverride:"502 (config)"},
],
comms: [
{id:"mattermost",name:"Mattermost",icon:"M",cat:"Team chat",desc:"Slack alternative self-hosted · team collaboration.",url:"https://mm.weval-consulting.com",internal:"localhost:8065",docker:"mattermost-docker-mattermost-1",version:"team-edition",email:"ymahboub@weval-consulting.com",user:"yacine",pwd:"voir vault",role:"system_admin"},
{id:"searxng",name:"SearXNG",icon:"S",cat:"Meta-search",desc:"Privacy meta-search engine · multi-providers aggregation.",url:"http://localhost:8080",internal:"127.0.0.1:8080",docker:"searxng",version:"latest",internalOnly:true},
],
mon: [
{id:"uptime",name:"Uptime Kuma",icon:"U",cat:"Uptime monitor",desc:"Status pages + alerts · 17 services monitored.",url:"https://monitor.weval-consulting.com",internal:"localhost:3001",docker:"uptime-kuma",version:"latest",statusOverride:"503 (CF)"},
{id:"prometheus",name:"Prometheus",icon:"PM",cat:"Metrics TSDB",desc:"Time series · scrape exporters · alertmanager.",url:"http://localhost:9090",internal:"127.0.0.1:9090",docker:"prometheus",version:"latest",internalOnly:true},
{id:"loki",name:"Loki",icon:"LK",cat:"Log aggregation",desc:"Grafana logs · indexed · WEVIA logs ingest.",url:"http://localhost:3100",internal:"127.0.0.1:3100",docker:"loki",version:"latest",internalOnly:true},
{id:"node-exporter",name:"Node Exporter",icon:"NE",cat:"Host metrics",desc:"CPU/RAM/disk/net · Prometheus scraper.",url:"http://localhost:9100",internal:"127.0.0.1:9100",docker:"node-exporter",version:"latest",internalOnly:true},
]
};
function svgGear(){return `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`}
function svgOpen(){return `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>`}
async function checkStatus(svc){
if(svc.statusOverride) return svc.statusOverride.includes("503") || svc.statusOverride.includes("502") || svc.statusOverride.includes("off") || svc.statusOverride.includes("STANDBY") ? "warn" : "up";
if(svc.internalOnly) return "internal";
try{
const r = await fetch(svc.url, {method:"HEAD",mode:"no-cors",cache:"no-store"});
return "up";
}catch(e){
return "warn";
}
}
function escapeHtml(s){return String(s||"").replace(/[<>&"]/g,c=>({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]))}
function renderCard(svc, status){
const stCls = status==="up"?"up":status==="internal"?"warn":"warn";
const stTxt = svc.statusOverride || (status==="internal"?"INTERNAL":status==="up"?"LIVE":"STANDBY");
const stClsStatus = status==="up"?"":status==="internal"?"warn":"warn";
const credsHtml = (svc.email||svc.user||svc.pwd) ? `
<div class="svc-creds">
${svc.email?`<div class="cred-row"><span class="cred-lbl">EMAIL</span><span class="cred-val" onclick="copyVal(this,'${escapeHtml(svc.email)}')">${escapeHtml(svc.email)}</span></div>`:""}
${svc.user?`<div class="cred-row"><span class="cred-lbl">USER</span><span class="cred-val" onclick="copyVal(this,'${escapeHtml(svc.user)}')">${escapeHtml(svc.user)}</span></div>`:""}
${svc.pwd?`<div class="cred-row"><span class="cred-lbl">PASSWORD</span><span class="cred-val masked" onclick="copyVal(this,'${escapeHtml(svc.pwd)}')">${escapeHtml(svc.pwd)}</span></div>`:""}
${svc.role?`<div class="cred-row"><span class="cred-lbl">ROLE</span><span class="cred-val" style="color:var(--mint)">${escapeHtml(svc.role)}</span></div>`:""}
</div>
` : "";
const tunnelPort = svc.internal ? svc.internal.split(":")[1] : "";
const tunnelId = "tunnel-"+svc.id;
const openBtn = svc.internalOnly
? `<button onclick="toggleTunnel('${tunnelId}')" class="svc-btn tunnel">🔐 SSH tunnel</button>`
: `<a href="${escapeHtml(svc.url)}" target="_blank" class="svc-btn primary">${svgOpen()} OPEN</a>`;
const tunnelHtml = svc.internalOnly ? `
<div class="tunnel-box" id="${tunnelId}">
<div class="tunnel-label">🔐 SSH Tunnel · laptop local</div>
<div class="tunnel-cmd" onclick="copyVal(this,'ssh -N -L ${tunnelPort}:127.0.0.1:${tunnelPort} root@204.168.152.13 -p 49222')">ssh -N -L ${tunnelPort}:127.0.0.1:${tunnelPort} root@204.168.152.13 -p 49222</div>
<div class="tunnel-note">Puis ouvrir <b>http://localhost:${tunnelPort}</b> dans ton browser. Port <b>49222</b> (SSH key Yacine yace@LAPTOP-VE75QUHF déjà autorisée).</div>
</div>
` : "";
return `
<div class="svc-card ${stCls}">
<div class="svc-head">
<div class="svc-name-wrap">
<div class="svc-icon">${escapeHtml(svc.icon)}</div>
<div style="min-width:0">
<div class="svc-name">${escapeHtml(svc.name)}</div>
<div class="svc-cat">${escapeHtml(svc.cat)}</div>
</div>
</div>
<div class="svc-status ${stClsStatus}">${escapeHtml(stTxt)}</div>
</div>
<div class="svc-desc">${escapeHtml(svc.desc)}</div>
${credsHtml}
<div class="svc-meta">
<span>🐳 ${escapeHtml(svc.docker||'-')}</span>
<span>🔌 ${escapeHtml(svc.internal||'-')}</span>
${svc.version?`<span>v${escapeHtml(svc.version)}</span>`:""}
</div>
<div class="svc-actions">
${openBtn}
</div>
${tunnelHtml}
</div>
`;
}
function copyVal(el, val){
navigator.clipboard.writeText(val).then(()=>{
const t = document.getElementById("toast");
t.textContent = "✓ Copié : " + (val.length>30?val.slice(0,30)+"…":val);
t.classList.add("show");
setTimeout(()=>t.classList.remove("show"), 2200);
if(el.classList.contains("masked")){el.classList.remove("masked");setTimeout(()=>el.classList.add("masked"),3000)}
});
}
function toggleTunnel(id){
const el = document.getElementById(id);
if(!el) return;
el.classList.toggle("show");
}
async function init(){
let totalSvc = 0, upCount = 0, standby = 0, internal = 0;
for(const [grp, svcs] of Object.entries(SERVICES)){
const target = document.getElementById("grid-"+grp);
if(!target) continue;
const html = await Promise.all(svcs.map(async svc => {
const status = await checkStatus(svc);
totalSvc++;
if(status === "up") upCount++;
else if(status === "internal") internal++;
else standby++;
return renderCard(svc, status);
}));
target.innerHTML = html.join("");
}
document.getElementById("kpi-total").textContent = totalSvc;
document.getElementById("kpi-up").textContent = upCount;
document.getElementById("kpi-standby").textContent = standby;
document.getElementById("kpi-internal").textContent = internal;
document.getElementById("h-svc").textContent = totalSvc;
document.getElementById("h-up").textContent = upCount + "/" + totalSvc;
}
init();
</script>
</body>
</html>