280 lines
15 KiB
HTML
280 lines
15 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Hermes Mission Control</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
|
<style>
|
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
:root{
|
|
--bg:#f5f7fa;--card:#fff;--card-hover:#f8fafc;--border:#e2e8f0;
|
|
--text:#1e293b;--dim:#64748b;--muted:#94a3b8;
|
|
--accent:#0891b2;--accent-dark:#0e7490;
|
|
--green:#059669;--green-bg:#ecfdf5;--green-bd:#a7f3d0;
|
|
--amber:#d97706;--amber-bg:#fffbeb;
|
|
--red:#dc2626;--red-bg:#fef2f2;
|
|
--blue:#3b82f6;--blue-bg:#eff6ff;
|
|
--r:12px;--rs:8px;
|
|
--sh:0 1px 2px rgba(0,0,0,.05),0 1px 3px rgba(0,0,0,.08);
|
|
--shl:0 4px 6px rgba(0,0,0,.04),0 10px 30px rgba(0,0,0,.08);
|
|
--tt:#3b82f6;--nyora:#059669;--perso:#d97706;
|
|
}
|
|
html{font-size:15px}
|
|
body{font-family:'Inter',-apple-system,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
|
|
|
.header{background:linear-gradient(135deg,#0e7490 0%,#0891b2 50%,#059669 100%);padding:0 32px;box-shadow:0 2px 12px rgba(8,145,178,.25)}
|
|
.header-inner{max-width:1280px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;height:60px}
|
|
.hicon{width:36px;height:36px;background:rgba(255,255,255,.2);border-radius:9px;display:flex;align-items:center;justify-content:center;font-size:18px}
|
|
.htitle{font-size:1.05rem;font-weight:700;color:#fff}
|
|
.hsub{font-size:.72rem;color:rgba(255,255,255,.7);margin-top:1px}
|
|
.hleft{display:flex;align-items:center;gap:12px}
|
|
.hright{display:flex;align-items:center;gap:16px}
|
|
.live-badge{display:flex;align-items:center;gap:6px;font-size:.75rem;color:rgba(255,255,255,.85)}
|
|
.dot{width:8px;height:8px;background:#4ade80;border-radius:50%;animation:pulse 2s infinite;box-shadow:0 0 0 2px rgba(74,222,128,.3)}
|
|
@keyframes pulse{0%,100%{box-shadow:0 0 0 2px rgba(74,222,128,.3)}50%{box-shadow:0 0 0 5px rgba(74,222,128,.1)}}
|
|
.uts{font-size:.72rem;color:rgba(255,255,255,.6)}
|
|
|
|
.main{max-width:1280px;margin:0 auto;padding:24px 32px 40px}
|
|
.stitle{font-size:.78rem;font-weight:700;color:var(--dim);text-transform:uppercase;letter-spacing:.08em;margin-bottom:12px}
|
|
|
|
.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:22px}
|
|
.sc{background:var(--card);border:1px solid var(--border);border-radius:var(--r);padding:18px 20px;box-shadow:var(--sh)}
|
|
.sc .lbl{font-size:.7rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;margin-bottom:6px}
|
|
.sc .val{font-size:2rem;font-weight:800;letter-spacing:-.04em;line-height:1}
|
|
.sc .sub{font-size:.72rem;color:var(--muted);margin-top:4px}
|
|
.c-blue{color:var(--blue)}.c-green{color:var(--green)}.c-amber{color:var(--amber)}.c-red{color:var(--red)}
|
|
|
|
.instances{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:22px}
|
|
.ic{background:var(--card);border:1px solid var(--border);border-radius:var(--r);padding:20px;box-shadow:var(--sh);position:relative;overflow:hidden}
|
|
.ic::before{content:'';position:absolute;top:0;left:0;right:0;height:3px}
|
|
.ic.tt::before{background:var(--tt)}.ic.nyora::before{background:var(--nyora)}.ic.perso::before{background:var(--perso)}
|
|
.it{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:12px}
|
|
.iname{font-size:1rem;font-weight:700}
|
|
.iname.tt{color:var(--tt)}.iname.nyora{color:var(--nyora)}.iname.perso{color:var(--perso)}
|
|
.iport{font-size:.7rem;color:var(--muted);margin-top:2px}
|
|
.istatus{font-size:.7rem;font-weight:600;padding:3px 9px;border-radius:20px;white-space:nowrap}
|
|
.istatus.up{background:var(--green-bg);color:var(--green);border:1px solid var(--green-bd)}
|
|
.istatus.down{background:var(--red-bg);color:var(--red)}
|
|
.irow{display:flex;gap:16px;margin-bottom:10px}
|
|
.isv{font-size:1.3rem;font-weight:800;letter-spacing:-.03em}
|
|
.isl{font-size:.65rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em}
|
|
.ilast{font-size:.75rem;color:var(--dim);background:var(--bg);border-radius:6px;padding:7px 10px;border:1px solid var(--border)}
|
|
.ilbl{font-size:.65rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:2px}
|
|
.iclip{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
|
|
.two-col{display:grid;grid-template-columns:1fr 320px;gap:18px;margin-bottom:22px}
|
|
.crons-wrap{background:var(--card);border:1px solid var(--border);border-radius:var(--r);padding:20px;box-shadow:var(--sh)}
|
|
.clist{display:flex;flex-direction:column;gap:8px}
|
|
.ci{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:var(--bg);border-radius:var(--rs);border:1px solid var(--border)}
|
|
.cidot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
|
|
.cidot.tt{background:var(--tt)}.cidot.nyora{background:var(--nyora)}.cidot.perso{background:var(--perso)}
|
|
.cileft{display:flex;align-items:center;gap:10px}
|
|
.ciname{font-size:.82rem;font-weight:600}
|
|
.ciinst{font-size:.68rem;color:var(--muted)}
|
|
.cisched{font-size:.72rem;font-weight:600;color:var(--accent);background:#ecfeff;border:1px solid #a5f3fc;padding:2px 8px;border-radius:4px;font-family:'SF Mono','Fira Code',monospace}
|
|
|
|
.budget{background:var(--card);border:1px solid var(--border);border-radius:var(--r);padding:20px;box-shadow:var(--sh)}
|
|
.bamt{font-size:2.2rem;font-weight:800;letter-spacing:-.04em;margin:10px 0 4px}
|
|
.bbar-w{margin:14px 0 8px;background:var(--border);border-radius:4px;height:6px;overflow:hidden}
|
|
.bbar{height:100%;border-radius:4px;background:linear-gradient(90deg,var(--green),var(--accent));transition:width .5s}
|
|
.bbar.warn{background:linear-gradient(90deg,var(--amber),var(--red))}
|
|
.bdet{font-size:.75rem;color:var(--muted)}
|
|
|
|
.mwrap{background:var(--card);border:1px solid var(--border);border-radius:var(--r);box-shadow:var(--sh);overflow:hidden}
|
|
.mhdr{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border)}
|
|
table.mt{width:100%;border-collapse:collapse;font-size:.82rem}
|
|
table.mt thead th{padding:10px 14px;text-align:left;font-size:.68rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;background:var(--bg);border-bottom:1px solid var(--border)}
|
|
table.mt tbody td{padding:11px 14px;border-bottom:1px solid var(--border);vertical-align:middle}
|
|
table.mt tbody tr:last-child td{border-bottom:none}
|
|
table.mt tbody tr:hover td{background:var(--card-hover)}
|
|
.badge{display:inline-flex;align-items:center;font-size:.7rem;font-weight:700;padding:3px 8px;border-radius:20px;white-space:nowrap}
|
|
.b-tt{background:var(--blue-bg);color:var(--tt)}
|
|
.b-nyora{background:var(--green-bg);color:var(--nyora)}
|
|
.b-perso{background:var(--amber-bg);color:var(--amber)}
|
|
.b-terminee{background:var(--green-bg);color:var(--green)}
|
|
.b-en-cours{background:var(--blue-bg);color:var(--blue)}
|
|
.b-en-attente{background:#f8fafc;color:var(--muted);border:1px solid var(--border)}
|
|
.b-bloquee{background:var(--amber-bg);color:var(--amber)}
|
|
.b-erreur{background:var(--red-bg);color:var(--red)}
|
|
.cm{max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}
|
|
.cr{max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dim)}
|
|
.cts{font-size:.72rem;color:var(--muted);white-space:nowrap}
|
|
.cmo{font-size:.72rem;color:var(--dim);font-family:'SF Mono','Fira Code',monospace}
|
|
.nodata{text-align:center;padding:40px 20px;color:var(--muted);font-size:.85rem}
|
|
|
|
#loader{position:fixed;inset:0;background:#f5f7fa;display:flex;align-items:center;justify-content:center;z-index:100;transition:opacity .4s}
|
|
.li{text-align:center}
|
|
.lic{font-size:2.5rem;margin-bottom:12px;animation:spin 2s linear infinite;display:inline-block}
|
|
@keyframes spin{to{transform:rotate(360deg)}}
|
|
.lit{font-size:.85rem;color:var(--muted)}
|
|
|
|
@media(max-width:900px){.main{padding:16px}.stats,.instances{grid-template-columns:1fr 1fr}.two-col{grid-template-columns:1fr}}
|
|
@media(max-width:600px){.stats,.instances{grid-template-columns:1fr}.header{padding:0 16px}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="loader"><div class="li"><div class="lic">+</div><div class="lit">Chargement Mission Control...</div></div></div>
|
|
<header class="header">
|
|
<div class="header-inner">
|
|
<div class="hleft">
|
|
<div class="hicon">[H]</div>
|
|
<div><div class="htitle">Hermes Mission Control</div><div class="hsub">Tunisie Telecom . Zone Sud</div></div>
|
|
</div>
|
|
<div class="hright">
|
|
<div class="live-badge"><span class="dot"></span>Live</div>
|
|
<div class="uts">MAJ <span id="last-update">--</span></div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
<main class="main">
|
|
<div class="stats">
|
|
<div class="sc"><div class="lbl">Missions totales</div><div class="val c-blue" id="st-total">--</div><div class="sub">dans Mission Control</div></div>
|
|
<div class="sc"><div class="lbl">En cours</div><div class="val c-amber" id="st-active">--</div><div class="sub">actives maintenant</div></div>
|
|
<div class="sc"><div class="lbl">Terminees</div><div class="val c-green" id="st-done">--</div><div class="sub">avec succes</div></div>
|
|
<div class="sc"><div class="lbl">Erreurs</div><div class="val c-red" id="st-err">--</div><div class="sub">bloquees ou erreur</div></div>
|
|
</div>
|
|
<div class="stitle">Instances Hermes</div>
|
|
<div class="instances" id="inst-grid"></div>
|
|
<div class="two-col">
|
|
<div class="crons-wrap"><div class="stitle">Crons planifies</div><div class="clist" id="cron-list"></div></div>
|
|
<div class="budget">
|
|
<div class="stitle">Budget OpenRouter</div>
|
|
<div class="bamt" id="b-val">--</div>
|
|
<div class="bdet" id="b-sub">sur EUR15.00 alloues</div>
|
|
<div class="bbar-w"><div class="bbar" id="b-bar" style="width:0%"></div></div>
|
|
<div class="bdet" id="b-pct">calcul en cours...</div>
|
|
</div>
|
|
</div>
|
|
<div class="mwrap">
|
|
<div class="mhdr">
|
|
<div class="stitle" style="margin-bottom:0">Journal des missions</div>
|
|
<span class="badge b-en-cours" id="m-count">0 missions</span>
|
|
</div>
|
|
<table class="mt">
|
|
<thead><tr><th>Mission</th><th>Instance</th><th>Statut</th><th>Modele</th><th>Debut</th><th>Resultat</th></tr></thead>
|
|
<tbody id="m-tbody"><tr><td colspan="6" class="nodata">Chargement...</td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
</main>
|
|
<script>
|
|
const INST={
|
|
'hermes-tt':{lbl:'Hermes TT',cls:'tt',port:3010},
|
|
'hermes-nyora':{lbl:'Hermes Nyora',cls:'nyora',port:3020},
|
|
'hermes-perso':{lbl:'Hermes Perso',cls:'perso',port:3030}
|
|
};
|
|
const STAT_LBL={'terminee':'Terminee','en-cours':'En cours','en-attente':'En attente','bloquee':'Bloquee','erreur':'Erreur'};
|
|
|
|
function iB(inst){
|
|
const k=(inst||'').toLowerCase();
|
|
const i=INST[k]||{lbl:inst||'?',cls:'tt'};
|
|
return `<span class="badge b-${i.cls}">${i.lbl}</span>`;
|
|
}
|
|
function sB(s){
|
|
return `<span class="badge b-${s||'en-attente'}">${STAT_LBL[s]||s||'?'}</span>`;
|
|
}
|
|
function fd(d){
|
|
if(!d)return '--';
|
|
const dt=new Date(d);
|
|
if(isNaN(dt))return d;
|
|
return dt.toLocaleString('fr-FR',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'});
|
|
}
|
|
|
|
function renderInst(status){
|
|
const g=document.getElementById('inst-grid');
|
|
g.innerHTML=Object.entries(INST).map(([k,info])=>{
|
|
const s=(status||{})[k]||{};
|
|
const up=s.running!==false;
|
|
const last=s.last_mission||'Aucune mission';
|
|
const ts=s.last_ts?fd(s.last_ts):'--';
|
|
return `<div class="ic ${info.cls}">
|
|
<div class="it">
|
|
<div><div class="iname ${info.cls}">${info.lbl}</div><div class="iport">:${info.port}</div></div>
|
|
<span class="istatus ${up?'up':'down'}">${up?'En ligne':'Hors ligne'}</span>
|
|
</div>
|
|
<div class="irow">
|
|
<div><div class="isv c-blue">${s.missions_total||0}</div><div class="isl">Missions</div></div>
|
|
<div><div class="isv c-green">${s.missions_done||0}</div><div class="isl">Terminees</div></div>
|
|
</div>
|
|
<div class="ilast">
|
|
<div class="ilbl">Derniere mission</div>
|
|
<div class="iclip">${last}</div>
|
|
<div style="font-size:.68rem;color:var(--muted);margin-top:2px">${ts}</div>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderCrons(crons){
|
|
const el=document.getElementById('cron-list');
|
|
if(!crons||!crons.length){el.innerHTML='<div class="nodata" style="padding:16px">Aucun cron configure</div>';return;}
|
|
const ic={'hermes-tt':'tt','hermes-nyora':'nyora','hermes-perso':'perso'};
|
|
el.innerHTML=crons.map(c=>{
|
|
const cls=ic[c.instance]||'tt';
|
|
return `<div class="ci">
|
|
<div class="cileft"><span class="cidot ${cls}"></span>
|
|
<div><div class="ciname">${c.name}</div><div class="ciinst">${INST[c.instance]?.lbl||c.instance}</div></div>
|
|
</div>
|
|
<span class="cisched">${c.schedule}</span>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderMissions(ms){
|
|
const tb=document.getElementById('m-tbody');
|
|
document.getElementById('m-count').textContent=`${ms.length} missions`;
|
|
if(!ms.length){
|
|
tb.innerHTML='<tr><td colspan="6" class="nodata">Aucune mission. Les agents Hermes alimenteront ce journal automatiquement.</td></tr>';
|
|
return;
|
|
}
|
|
tb.innerHTML=ms.map(m=>`<tr>
|
|
<td><div class="cm" title="${m.mission||''}">${m.mission||'--'}</div></td>
|
|
<td>${iB(m.instance)}</td>
|
|
<td>${sB(m.statut)}</td>
|
|
<td class="cmo">${m.modele||'--'}</td>
|
|
<td class="cts">${fd(m.debut)}</td>
|
|
<td><div class="cr" title="${m.resultat||''}">${m.resultat||'--'}</div></td>
|
|
</tr>`).join('');
|
|
}
|
|
|
|
function renderStats(ms){
|
|
document.getElementById('st-total').textContent=ms.length;
|
|
document.getElementById('st-active').textContent=ms.filter(m=>m.actif).length;
|
|
document.getElementById('st-done').textContent=ms.filter(m=>m.statut==='terminee').length;
|
|
document.getElementById('st-err').textContent=ms.filter(m=>m.statut==='bloquee'||m.statut==='erreur').length;
|
|
}
|
|
|
|
function renderBudget(b){
|
|
if(!b)return;
|
|
const used=b.used||0,total=b.total||15,pct=Math.min(100,Math.round(used/total*100));
|
|
document.getElementById('b-val').textContent=`EUR${used.toFixed(2)}`;
|
|
document.getElementById('b-sub').textContent=`sur EUR${total.toFixed(2)} alloues`;
|
|
document.getElementById('b-pct').textContent=`${pct}% consomme . reste EUR${(total-used).toFixed(2)}`;
|
|
const bar=document.getElementById('b-bar');
|
|
bar.style.width=pct+'%';
|
|
if(pct>75)bar.classList.add('warn');else bar.classList.remove('warn');
|
|
}
|
|
|
|
function update(data){
|
|
document.getElementById('loader').style.opacity='0';
|
|
setTimeout(()=>document.getElementById('loader').style.display='none',400);
|
|
renderInst(data.status);
|
|
renderCrons(data.crons);
|
|
renderMissions(data.missions);
|
|
renderStats(data.missions);
|
|
renderBudget(data.budget);
|
|
document.getElementById('last-update').textContent=new Date(data.ts*1000).toLocaleTimeString('fr-FR');
|
|
}
|
|
|
|
const es=new EventSource('/events');
|
|
es.onmessage=e=>update(JSON.parse(e.data));
|
|
es.onerror=()=>{};
|
|
fetch('/api/data').then(r=>r.json()).then(update).catch(()=>{
|
|
document.getElementById('loader').style.opacity='0';
|
|
setTimeout(()=>document.getElementById('loader').style.display='none',400);
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|