Files
agent-manager/src/frontend/index.html
T
bruno fa51cdb339
release / linux (push) Successful in 14m16s
release / windows (push) Canceled after 0s
release / macos (push) Canceled after 0s
feat: phase 2 — am web : dashboard web local + API JSON (contrats --json, 127.0.0.1, tiny_http, frontend embarqué) (closes #54)
2026-08-18 16:40:15 -04:00

505 lines
24 KiB
HTML

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>am — {{Vue d'ensemble}}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg:#0b0f14; --panel:#121821; --panel2:#0f151c; --border:#1d2733;
--fg:#e6edf3; --muted:#8b98a5; --accent:#3ddc97; --accent2:#38bdf8;
--ok:#34d399; --warn:#f59e0b; --err:#f87171; --purple:#a78bfa;
}
body {
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
background:var(--bg); color:var(--fg); line-height:1.5; font-size:14px;
}
.layout { display:flex; min-height:100vh; }
/* ---------- Sidebar ---------- */
.side {
width:225px; flex-shrink:0; background:var(--panel2);
border-right:1px solid var(--border); padding:18px 12px;
display:flex; flex-direction:column; gap:4px; position:sticky; top:0; height:100vh;
}
.logo { display:flex; align-items:center; gap:10px; padding:4px 8px 18px; }
.logo-badge {
width:34px; height:34px; border-radius:9px; background:linear-gradient(135deg,#3ddc97,#38bdf8);
display:flex; align-items:center; justify-content:center; font-weight:800; font-size:16px; color:#0b0f14;
}
.logo b { font-size:16px; letter-spacing:.5px; }
.logo small { display:block; color:var(--muted); font-size:11px; font-weight:400; }
.nav-item {
display:flex; align-items:center; gap:10px; padding:9px 10px; border-radius:8px;
color:var(--muted); cursor:pointer; border:1px solid transparent; font-size:13.5px;
}
.nav-item:hover { background:rgba(61,220,151,.07); color:var(--fg); }
.nav-item.active { background:rgba(61,220,151,.12); color:var(--accent); border-color:rgba(61,220,151,.25); font-weight:600; }
.nav-item .ico { width:18px; text-align:center; }
.side-foot { margin-top:auto; padding:12px 8px 4px; border-top:1px solid var(--border); color:var(--muted); font-size:11.5px; }
.side-foot .dot { display:inline-block; width:8px; height:8px; border-radius:50%; background:var(--ok); margin-right:6px; animation:pulse 2.4s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
/* ---------- Main ---------- */
.main { flex:1; padding:22px 28px 40px; max-width:1200px; }
.topbar { display:flex; align-items:center; justify-content:space-between; margin-bottom:22px; gap:12px; flex-wrap:wrap; }
.topbar h1 { font-size:20px; font-weight:700; }
.topbar .sub { color:var(--muted); font-size:12.5px; margin-top:2px; }
.top-actions { display:flex; align-items:center; gap:10px; }
.badge {
font-size:11.5px; padding:4px 10px; border-radius:99px; border:1px solid var(--border);
color:var(--muted); background:var(--panel); font-family:ui-monospace,Consolas,monospace;
}
.badge.live { color:var(--ok); border-color:rgba(52,211,153,.4); }
.badge.live::before { content:"● "; }
.btn {
background:var(--accent); color:#0b0f14; border:none; border-radius:8px; padding:7px 14px;
font-weight:700; font-size:13px; cursor:pointer;
}
.btn:hover { filter:brightness(1.1); }
.btn.ghost { background:transparent; color:var(--muted); border:1px solid var(--border); font-weight:500; }
.btn.ghost:hover { color:var(--fg); border-color:var(--muted); }
/* ---------- KPI cards ---------- */
.kpis { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-bottom:18px; }
.kpi { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:16px 18px; }
.kpi .label { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.6px; }
.kpi .value { font-size:28px; font-weight:800; margin-top:6px; }
.kpi .delta { font-size:11.5px; margin-top:4px; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.kpi .emoji { float:right; font-size:20px; opacity:.9; }
/* ---------- Panels ---------- */
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:14px; margin-bottom:18px; }
.panel { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:16px 18px; margin-bottom:14px; }
.panel h3 { font-size:13px; text-transform:uppercase; letter-spacing:.6px; color:var(--muted); margin-bottom:14px; }
.bar-row { display:flex; align-items:center; gap:10px; margin-bottom:9px; font-size:12.5px; }
.bar-row .name { width:110px; color:var(--fg); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.bar-row .track { flex:1; height:14px; background:var(--panel2); border-radius:7px; overflow:hidden; }
.bar-row .fill { height:100%; border-radius:7px; background:linear-gradient(90deg,var(--accent),var(--accent2)); transition:width .5s ease; }
.bar-row .val { width:34px; text-align:right; color:var(--muted); font-family:ui-monospace,monospace; }
canvas#trend-canvas { width:100%; height:150px; display:block; }
.trend-axis { display:flex; justify-content:space-between; color:var(--muted); font-size:10.5px; margin-top:4px; font-family:ui-monospace,monospace; }
/* ---------- Tables ---------- */
table { width:100%; border-collapse:collapse; font-size:12.5px; }
th { text-align:left; color:var(--muted); font-weight:600; font-size:11px; text-transform:uppercase; letter-spacing:.5px; padding:7px 10px; border-bottom:1px solid var(--border); }
td { padding:8px 10px; border-bottom:1px solid rgba(29,39,51,.5); }
tbody tr:hover { background:rgba(61,220,151,.05); }
.pill { font-size:10.5px; padding:2px 8px; border-radius:99px; font-weight:600; white-space:nowrap; }
.pill.ok { background:rgba(52,211,153,.14); color:var(--ok); }
.pill.run { background:rgba(56,189,248,.14); color:var(--accent2); }
.pill.fail { background:rgba(248,113,113,.14); color:var(--err); }
.pill.mut { background:rgba(167,139,250,.14); color:var(--purple); }
.mono { font-family:ui-monospace,Consolas,monospace; font-size:11.5px; color:var(--muted); }
.search { width:100%; background:var(--panel2); border:1px solid var(--border); border-radius:8px; color:var(--fg); padding:8px 12px; font-size:12.5px; margin-bottom:10px; }
.search:focus { outline:none; border-color:var(--accent); }
/* ---------- Timeline ---------- */
.tl { list-style:none; }
.tl li { display:flex; gap:12px; padding:8px 0; border-bottom:1px solid rgba(29,39,51,.5); align-items:baseline; }
.tl .dot { width:8px; height:8px; border-radius:50%; margin-top:6px; flex-shrink:0; }
.tl .t { color:var(--fg); } .tl .a { color:var(--accent); font-weight:600; }
.tl .m { color:var(--muted); font-size:11.5px; margin-left:auto; white-space:nowrap; font-family:ui-monospace,monospace; }
.empty-note { color:var(--muted); font-size:12px; text-align:center; padding:18px 0; }
.view { display:none; } .view.active { display:block; }
.err-bar { background:rgba(248,113,113,.12); border:1px solid rgba(248,113,113,.35); color:var(--err); border-radius:8px; padding:10px 14px; margin-bottom:14px; font-size:12.5px; display:none; }
@media (max-width:900px){ .kpis{grid-template-columns:repeat(2,1fr);} .grid2{grid-template-columns:1fr;} .side{width:64px;} .side .lbl,.logo small,.side-foot span{display:none;} }
</style>
</head>
<body>
<div class="layout">
<aside class="side">
<div class="logo">
<div class="logo-badge">am</div>
<div><b>agent-manager</b><small>cockpit des agents</small></div>
</div>
<div class="nav-item active" data-view="overview"><span class="ico">▦</span><span class="lbl">{{Vue d'ensemble}}</span></div>
<div class="nav-item" data-view="stats"><span class="ico">📊</span><span class="lbl">{{Statistiques}}</span></div>
<div class="nav-item" data-view="sessions"><span class="ico">⏱</span><span class="lbl">Sessions</span></div>
<div class="nav-item" data-view="events"><span class="ico">📜</span><span class="lbl">{{Événements}}</span></div>
<div class="nav-item" data-view="projects"><span class="ico">🗂</span><span class="lbl">{{Projets}}</span></div>
<div class="side-foot">
<span><span class="dot"></span>v<span id="side-version">—</span> · <span id="side-lang">{{serveur local}}</span></span>
</div>
</aside>
<main class="main">
<div class="topbar">
<div>
<h1 id="view-title">{{Vue d'ensemble}}</h1>
<div class="sub"><span>{{Dernière mise à jour}}</span> : <span id="last-update">—</span></div>
</div>
<div class="top-actions">
<span class="badge live">{{serveur local}}</span>
<span class="badge">127.0.0.1</span>
<button class="btn ghost" onclick="loadAll()">↻ {{Actualiser}}</button>
</div>
</div>
<div class="err-bar" id="err-bar"></div>
<!-- ===== OVERVIEW ===== -->
<section class="view active" id="overview">
<div class="kpis">
<div class="kpi"><span class="emoji">🤖</span><div class="label">{{Agents installés}}</div><div class="value" id="kpi-installed">—</div><div class="delta" id="kpi-installed-delta"></div></div>
<div class="kpi"><span class="emoji">⚡</span><div class="label">{{En cours}}</div><div class="value" id="kpi-running">—</div><div class="delta" id="kpi-running-delta"></div></div>
<div class="kpi"><span class="emoji">⏱</span><div class="label">{{Sessions (7 j)}}</div><div class="value" id="kpi-sessions">—</div><div class="delta" id="kpi-sessions-delta"></div></div>
<div class="kpi"><span class="emoji">📜</span><div class="label">{{Événements (24 h)}}</div><div class="value" id="kpi-events">—</div><div class="delta" id="kpi-events-delta"></div></div>
</div>
<div class="grid2">
<div class="panel">
<h3>{{Top agents — sessions (7 j)}}</h3>
<div id="top-agents"></div>
</div>
<div class="panel">
<h3>{{Activité — sessions par jour (14 j)}}</h3>
<canvas id="trend-canvas"></canvas>
<div class="trend-axis"><span id="axis-a"></span><span id="axis-b"></span><span id="axis-c"></span></div>
</div>
</div>
<div class="grid2">
<div class="panel">
<h3>{{Activité récente}}</h3>
<ul class="tl" id="recent-activity"></ul>
</div>
<div class="panel">
<h3>{{Sessions en cours}}</h3>
<table>
<thead><tr><th>{{Agent}}</th><th>{{Projet}}</th><th>{{Durée}}</th><th>{{Démarrée}}</th></tr></thead>
<tbody id="running-sessions"></tbody>
</table>
<div class="empty-note" id="running-empty" style="display:none">{{Aucune session en cours}}</div>
</div>
</div>
</section>
<!-- ===== STATS ===== -->
<section class="view" id="stats">
<div class="panel">
<h3>{{Statistiques par agent — période 30 j}}</h3>
<table>
<thead><tr><th>{{Agent}}</th><th>{{Lancements}}</th><th>{{Durée}}</th><th>{{Durée moyenne}}</th><th>{{Échecs}}</th><th>{{Dernière activité}}</th></tr></thead>
<tbody id="stats-body"></tbody>
</table>
<div class="empty-note" id="stats-empty" style="display:none">{{Aucune activité enregistrée}}</div>
</div>
</section>
<!-- ===== SESSIONS ===== -->
<section class="view" id="sessions">
<div class="panel">
<h3>Sessions</h3>
<input class="search" id="sess-search" placeholder="{{Filtrer par agent, projet ou id…}}" oninput="filterSessions(this.value)">
<table>
<thead><tr><th>{{Session}}</th><th>{{Agent}}</th><th>{{Projet}}</th><th>{{Statut}}</th><th>{{Durée}}</th><th>{{Démarrée}}</th><th>{{Fin}}</th></tr></thead>
<tbody id="sessions-body"></tbody>
</table>
<div class="empty-note" id="sessions-empty" style="display:none">{{Aucune session ne correspond au filtre}}</div>
</div>
</section>
<!-- ===== EVENTS ===== -->
<section class="view" id="events">
<div class="panel">
<h3>{{Journal d'événements — aujourd'hui}}</h3>
<ul class="tl" id="events-list"></ul>
<div class="empty-note" id="events-empty" style="display:none">{{Aucun événement}}</div>
</div>
</section>
<!-- ===== PROJECTS ===== -->
<section class="view" id="projects">
<div class="kpis">
<div class="kpi"><span class="emoji">🗂</span><div class="label">{{Projets suivis}}</div><div class="value" id="kpi-projects">—</div><div class="delta"></div></div>
<div class="kpi"><span class="emoji">⏱</span><div class="label">{{Sessions (7 j)}}</div><div class="value" id="kpi-proj-sessions">—</div><div class="delta"></div></div>
<div class="kpi"><span class="emoji">🕒</span><div class="label">{{Temps total}}</div><div class="value" id="kpi-proj-time">—</div><div class="delta"></div></div>
</div>
<div class="panel">
<h3>{{Projets}}</h3>
<table>
<thead><tr><th>{{Projet}}</th><th>{{Agent}}</th><th>Sessions</th><th>{{Lancements}}</th><th>{{Durée}}</th><th>{{Dernière activité}}</th></tr></thead>
<tbody id="projects-body"></tbody>
</table>
<div class="empty-note" id="projects-empty" style="display:none">{{Aucun projet suivi}}</div>
</div>
</section>
</main>
</div>
<script>
// Injected by the server: localized status labels (fr/en, gettext keys).
window.I18N = {{i18n_bundle}};
const state = { overview: null, stats: null, sessions: null, events: null, projects: null, health: null };
const $ = (id) => document.getElementById(id);
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function fmtTime(iso) {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d)) return String(iso).slice(11, 16);
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
}
function fmtDate(iso) { return iso ? String(iso).slice(0, 10) : '—'; }
function fmtDur(s) {
if (s == null) return '—';
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
if (h > 0) return h + 'h' + String(m).padStart(2, '0') + 'm';
if (m > 0) return m + 'm';
return s + 's';
}
function shortRoot(root) {
if (!root) return '—';
const parts = String(root).split(/[\\/]/).filter(Boolean);
return esc(parts[parts.length - 1] || root);
}
const STATUS_CLASS = { running: 'run', stopped: 'ok', failed: 'fail', interrupted: 'mut' };
function statusPill(s) {
const cls = STATUS_CLASS[s] || 'ok';
const label = (window.I18N && window.I18N[s]) || s;
return '<span class="pill ' + cls + '">' + esc(label) + '</span>';
}
const KIND_COLORS = {
start:'#38bdf8', run:'#38bdf8', restart:'#38bdf8', cost:'#38bdf8',
stop:'#34d399', shell:'#34d399', repl:'#34d399',
install:'#a78bfa', update:'#a78bfa', uninstall:'#a78bfa', config:'#a78bfa',
annotate:'#a78bfa', backup:'#a78bfa', rollback:'#a78bfa', model:'#a78bfa',
catalog:'#a78bfa', prune:'#a78bfa', lab:'#a78bfa',
doctor:'#f59e0b', alert:'#f87171',
};
function eventDetail(e) {
if (e.args && e.args.length) return esc(e.args.join(' '));
if (e.reason) return esc(e.reason);
return '';
}
async function jget(url) {
const r = await fetch(url, { cache: 'no-store' });
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
return r.json();
}
async function loadAll() {
try {
const [ov, st, se, ev, pr, h] = await Promise.all([
jget('/api/overview'), jget('/api/stats'), jget('/api/sessions'),
jget('/api/events?limit=2000'), jget('/api/projects'), jget('/api/health'),
]);
state.overview = ov; state.stats = st; state.sessions = se;
state.events = ev; state.projects = pr; state.health = h;
$('err-bar').style.display = 'none';
$('side-version').textContent = (h && h.version) || '—';
renderAll();
const t = new Date();
$('last-update').textContent = t.toLocaleTimeString();
} catch (e) {
const bar = $('err-bar');
bar.textContent = '{{erreur de chargement}} : ' + e.message;
bar.style.display = 'block';
}
}
function renderAll() {
renderOverview(); renderStats(); renderSessions(); renderEvents(); renderProjects();
}
// ---------- Overview ----------
function renderOverview() {
const ov = state.overview || {};
const counts = ov.counts || {};
$('kpi-installed').textContent = counts.installed != null ? counts.installed : '—';
$('kpi-running').textContent = counts.running != null ? counts.running : '—';
const now = Date.now();
const DAY = 86400000;
const sessions = ov.sessions || [];
const s7 = sessions.filter(s => now - new Date(s.started_at).getTime() <= 7 * DAY).length;
const ev24 = (state.events || []).filter(e => now - new Date(e.ts).getTime() <= DAY).length;
$('kpi-sessions').textContent = s7;
$('kpi-events').textContent = ev24;
$('kpi-sessions-delta').textContent = sessions.length + ' total';
$('kpi-events-delta').textContent = (state.events || []).length + ' total';
// Top agents by launches (7d from the dashboard snapshot).
const stats = (ov.stats || []).slice().sort((a, b) => b.launches - a.launches).slice(0, 6);
const max = Math.max(1, ...stats.map(s => s.launches));
$('top-agents').innerHTML = stats.length
? stats.map(s =>
'<div class="bar-row"><span class="name" title="' + esc(s.agent) + '">' + esc(s.agent) + '</span>' +
'<div class="track"><div class="fill" style="width:' + Math.round(s.launches / max * 100) + '%"></div></div>' +
'<span class="val">' + s.launches + '</span></div>').join('')
: '<div class="empty-note">{{Aucune activité enregistrée}}</div>';
renderTrend();
setAxis();
// Recent activity (newest first, already sorted by the server).
const activity = (ov.activity || []).slice(0, 8);
$('recent-activity').innerHTML = activity.length
? activity.map(a => {
const color = KIND_COLORS[a.kind] || '#8b98a5';
const who = a.agent ? '<span class="a">' + esc(a.agent) + '</span> ' : '';
return '<li><span class="dot" style="background:' + color + '"></span>' +
'<span class="t">' + who + esc(a.detail) + '</span>' +
'<span class="m">' + fmtTime(a.ts) + '</span></li>';
}).join('')
: '<li class="empty-note">{{Aucune activité enregistrée}}</li>';
// Running sessions.
const running = sessions.filter(s => s.status === 'running');
$('running-sessions').innerHTML = running.length
? running.map(s =>
'<tr><td>' + esc(s.agent || s.kind) + '</td><td>' + esc(s.project || '—') + '</td>' +
'<td class="mono">' + fmtDur(s.duration_s) + '</td><td class="mono">' + fmtTime(s.started_at) + '</td></tr>').join('')
: '';
$('running-empty').style.display = running.length ? 'none' : 'block';
}
function setAxis() {
const d = new Date();
const fmt = (off) => { const x = new Date(d); x.setDate(x.getDate() + off); return (x.getMonth() + 1) + '/' + x.getDate(); };
$('axis-a').textContent = fmt(-13);
$('axis-b').textContent = fmt(-7);
$('axis-c').textContent = fmt(0);
}
function renderTrend() {
const canvas = $('trend-canvas');
const wrap = canvas.parentElement;
const W = Math.max(240, wrap.clientWidth - 4) || 480, H = 150;
const dpr = window.devicePixelRatio || 1;
canvas.width = W * dpr; canvas.height = H * dpr;
canvas.style.width = W + 'px'; canvas.style.height = H + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, W, H);
const days = [];
const today = new Date();
for (let i = 13; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
days.push({ key, n: 0 });
}
(state.events || []).forEach(e => {
if (e.kind === 'start' || e.kind === 'run') {
const k = String(e.ts).slice(0, 10);
const day = days.find(d => d.key === k);
if (day) day.n++;
}
});
const max = Math.max(1, ...days.map(d => d.n));
const pad = 16, base = H - 26, bw = (W - 2 * pad) / days.length;
// baseline + gridline
ctx.strokeStyle = '#1d2733'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(pad, base + 0.5); ctx.lineTo(W - pad, base + 0.5); ctx.stroke();
const grad = ctx.createLinearGradient(0, 0, 0, base);
grad.addColorStop(0, '#3ddc97'); grad.addColorStop(1, '#38bdf8');
days.forEach((d, i) => {
const x = pad + i * bw, h = Math.round((d.n / max) * (base - 18));
ctx.fillStyle = grad;
ctx.fillRect(x + 2, base - h, bw - 4, h);
if (d.n > 0) {
ctx.fillStyle = '#0b0f14'; ctx.font = '10px ui-monospace, monospace';
ctx.textAlign = 'center';
ctx.fillText(String(d.n), x + bw / 2, base - h - 4);
}
});
}
// ---------- Stats ----------
function renderStats() {
const rows = state.stats || [];
$('stats-body').innerHTML = rows.length
? rows.slice().sort((a, b) => b.launches - a.launches).map(s =>
'<tr><td>' + esc(s.agent) + '</td><td>' + s.launches + '</td>' +
'<td class="mono">' + fmtDur(s.total_duration_s) + '</td>' +
'<td class="mono">' + fmtDur(s.avg_duration_s) + '</td>' +
'<td>' + (s.failures > 0 ? '<span class="pill fail">' + s.failures + '</span>' : '<span class="pill ok">0</span>') + '</td>' +
'<td class="mono">' + fmtDate(s.last_used) + ' ' + fmtTime(s.last_used) + '</td></tr>').join('')
: '';
$('stats-empty').style.display = rows.length ? 'none' : 'block';
}
// ---------- Sessions ----------
function renderSessions() {
const rows = state.sessions || [];
$('sessions-body').innerHTML = rows.length
? rows.map(s =>
'<tr><td class="mono">' + esc(s.id) + '</td><td>' + esc(s.agent || s.kind) + '</td>' +
'<td>' + esc(s.project || '—') + '</td><td>' + statusPill(s.status) + '</td>' +
'<td class="mono">' + fmtDur(s.duration_s) + '</td>' +
'<td class="mono">' + fmtDate(s.started_at) + ' ' + fmtTime(s.started_at) + '</td>' +
'<td class="mono">' + fmtTime(s.ended_at) + '</td></tr>').join('')
: '';
filterSessions(($('sess-search') || {}).value || '');
$('sessions-empty').style.display = 'none';
}
function filterSessions(q) {
q = (q || '').toLowerCase();
let shown = 0;
document.querySelectorAll('#sessions-body tr').forEach(tr => {
const ok = tr.textContent.toLowerCase().includes(q);
tr.style.display = ok ? '' : 'none';
if (ok) shown++;
});
$('sessions-empty').style.display = shown ? 'none' : 'block';
}
// ---------- Events ----------
function renderEvents() {
const rows = state.events || [];
$('events-list').innerHTML = rows.length
? rows.map(e => {
const color = KIND_COLORS[e.kind] || '#8b98a5';
const who = e.agent ? '<span class="a">' + esc(e.agent) + '</span> ' : '';
return '<li><span class="dot" style="background:' + color + '"></span>' +
'<span class="t"><span class="a">' + esc(e.kind) + '</span> ' + who + eventDetail(e) + '</span>' +
'<span class="m">' + fmtTime(e.ts) + '</span></li>';
}).join('')
: '';
$('events-empty').style.display = rows.length ? 'none' : 'block';
}
// ---------- Projects ----------
function renderProjects() {
const rows = state.projects || [];
$('kpi-projects').textContent = rows.length;
$('kpi-proj-sessions').textContent = rows.reduce((a, p) => a + (p.sessions || 0), 0);
$('kpi-proj-time').textContent = fmtDur(rows.reduce((a, p) => a + (p.total_duration_s || 0), 0));
$('projects-body').innerHTML = rows.length
? rows.map(p =>
'<tr><td><b>' + shortRoot(p.root) + '</b></td>' +
'<td>' + (p.agents || []).map(esc).join(' · ') + '</td>' +
'<td>' + (p.sessions || 0) + '</td><td>' + (p.launches || 0) + '</td>' +
'<td class="mono">' + fmtDur(p.total_duration_s) + '</td>' +
'<td class="mono">' + fmtDate(p.last_activity) + ' ' + fmtTime(p.last_activity) + '</td></tr>').join('')
: '';
$('projects-empty').style.display = rows.length ? 'none' : 'block';
}
// ---------- Navigation ----------
const TITLES = {
overview: "{{Vue d'ensemble}}", stats: "{{Statistiques}}", sessions: "Sessions",
events: "{{Événements}}", projects: "{{Projets}}",
};
document.querySelectorAll('.nav-item').forEach(el => {
el.addEventListener('click', () => {
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
el.classList.add('active');
const v = el.dataset.view;
document.getElementById(v).classList.add('active');
$('view-title').textContent = TITLES[v];
if (v === 'overview') renderTrend(); // canvas needs the visible size
});
});
window.addEventListener('resize', () => { if (state.overview) renderTrend(); });
loadAll();
setInterval(loadAll, 30000);
</script>
</body>
</html>