505 lines
24 KiB
HTML
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 =>
|
|
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|