hermes-dashboard/templates/index.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>