Phase 1 of the backend/frontend code split (#273). Additive — no existing code is touched; the legacy hive-c0re/assets, hive-ag3nt/ assets and hive-fr0nt/assets trees stay in place until the Rust cutover later in this branch. Layout: frontend/package.json npm workspaces root frontend/packages/shared/ @hive/shared src/{base,terminal}.css + terminal.js (ES module) src/index.js re-exports terminal.js frontend/packages/dashboard/ @hive/dashboard src/{index.html, app.js, dashboard.css} ported from hive-c0re/assets build.mjs esbuild config → dist/ frontend/packages/agent/ @hive/agent src/{index,stats,screen}.html + agent.css + {app,stats}.js ported from hive-ag3nt/assets build.mjs esbuild config → dist/ Changes vs the existing assets: - terminal.js is an ES module exporting { create, linkify } instead of assigning to window.HiveTerminal. The dashboard / agent app.js files re-expose them on window so the IIFE bodies keep working unchanged through Phase 1; the global aliases can be dropped in a follow-up once the IIFEs are unwrapped. - marked is imported from the marked@4.3.0 npm package (replacing the vendored hive-fr0nt/assets/marked.umd.js bundle). - chart.js is imported from chart.js@4.4.4 (replacing the jsDelivr CDN script tag on the per-agent stats page — page now works offline / on operator machines without internet egress). - dashboard.css and agent.css both gain @import lines at the top that pull base.css + terminal.css from @hive/shared, replacing the runtime string concatenation in serve_css. - index.html / stats.html collapse from three / two script tags to one type="module" tag pointing at the bundled output. package-lock.json is intentionally omitted from this commit — npm isn't available in the iris container yet (approval pending) and the lockfile will land in the next commit on this branch once the toolchain is in place. The PR will not be opened until it's there. Phase 2 (nix derivations), Phase 3 (container plumbing + the hyperhive.frontend.extraFiles option for per-agent layering), and Phase 4 (Rust cutover to tower_http::ServeDir, delete hive-fr0nt + legacy assets dirs) land as follow-up commits on this same branch. Refs #273.
346 lines
11 KiB
JavaScript
346 lines
11 KiB
JavaScript
// Per-agent stats page. Fetches /api/state for the title + dashboard link
|
|
// once on load, then /api/stats?window=... for the chart data — re-fetches
|
|
// when the operator clicks a window tab.
|
|
|
|
import Chart from 'chart.js/auto';
|
|
|
|
// Expose for the IIFE below — pre-split this was a window global from
|
|
// the jsDelivr CDN script tag. esbuild now bundles chart.js into
|
|
// stats.js; once the IIFE opens up we can use the imported `Chart`
|
|
// directly.
|
|
window.Chart = Chart;
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
const cssVar = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
const palette = {
|
|
bg: cssVar('--bg'),
|
|
bgElev: cssVar('--bg-elev'),
|
|
fg: cssVar('--fg'),
|
|
muted: cssVar('--muted'),
|
|
purple: cssVar('--purple'),
|
|
cyan: cssVar('--cyan'),
|
|
pink: cssVar('--pink'),
|
|
amber: cssVar('--amber'),
|
|
green: cssVar('--green'),
|
|
red: cssVar('--red'),
|
|
border: cssVar('--border'),
|
|
};
|
|
// Distinct hues for categorical charts (top tools / wake mix / result mix).
|
|
const wheel = [palette.purple, palette.cyan, palette.pink, palette.amber,
|
|
palette.green, palette.red, '#94e2d5', '#f9e2af',
|
|
'#74c7ec', '#b4befe'];
|
|
|
|
// Apply Catppuccin defaults globally so each Chart inherits without per-call
|
|
// overrides. Chart.js v4 reads these on chart construction.
|
|
Chart.defaults.color = palette.fg;
|
|
Chart.defaults.borderColor = palette.border;
|
|
Chart.defaults.font.family = '"JetBrains Mono", "Fira Code", monospace';
|
|
Chart.defaults.font.size = 11;
|
|
Chart.defaults.plugins.legend.labels.color = palette.fg;
|
|
|
|
const charts = {};
|
|
let currentWindow = '24h';
|
|
|
|
function fmtMs(ms) {
|
|
if (!Number.isFinite(ms) || ms <= 0) return '0';
|
|
if (ms < 1000) return ms.toFixed(0) + 'ms';
|
|
return (ms / 1000).toFixed(ms < 10000 ? 2 : 1) + 's';
|
|
}
|
|
|
|
function fmtInt(n) {
|
|
if (!Number.isFinite(n)) return '0';
|
|
return new Intl.NumberFormat().format(Math.round(n));
|
|
}
|
|
|
|
function bucketLabel(ts, bucketSecs) {
|
|
const d = new Date(ts * 1000);
|
|
if (bucketSecs >= 86400) {
|
|
return d.toISOString().slice(5, 10); // MM-DD
|
|
}
|
|
return d.toISOString().slice(11, 16); // HH:MM
|
|
}
|
|
|
|
function destroy(name) {
|
|
if (charts[name]) {
|
|
charts[name].destroy();
|
|
delete charts[name];
|
|
}
|
|
}
|
|
|
|
function paintEmpty(canvasId, msg) {
|
|
destroy(canvasId);
|
|
const cv = document.getElementById(canvasId);
|
|
if (!cv) return;
|
|
const ctx = cv.getContext('2d');
|
|
ctx.clearRect(0, 0, cv.width, cv.height);
|
|
ctx.fillStyle = palette.muted;
|
|
ctx.font = '12px monospace';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(msg, cv.width / 2, cv.height / 2);
|
|
}
|
|
|
|
function renderSummary(s) {
|
|
const root = document.getElementById('summary');
|
|
root.replaceChildren();
|
|
const chips = [
|
|
['turns', fmtInt(s.turn_count)],
|
|
['avg duration', fmtMs(s.duration_summary.avg_ms)],
|
|
['p50 duration', fmtMs(s.duration_summary.p50_ms)],
|
|
['p95 duration', fmtMs(s.duration_summary.p95_ms)],
|
|
['window', s.window],
|
|
];
|
|
for (const [label, value] of chips) {
|
|
const chip = document.createElement('span');
|
|
chip.className = 'chip';
|
|
const l = document.createElement('span');
|
|
l.className = 'label';
|
|
l.textContent = label;
|
|
const v = document.createElement('span');
|
|
v.className = 'value';
|
|
v.textContent = value;
|
|
chip.append(l, v);
|
|
root.append(chip);
|
|
}
|
|
}
|
|
|
|
function renderTurnsChart(s) {
|
|
const id = 'chart-turns';
|
|
destroy(id);
|
|
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
|
|
const data = s.buckets.map((b) => b.turn_count);
|
|
charts[id] = new Chart(document.getElementById(id), {
|
|
type: 'bar',
|
|
data: {
|
|
labels,
|
|
datasets: [{
|
|
label: 'turns',
|
|
data,
|
|
backgroundColor: palette.purple,
|
|
borderColor: palette.purple,
|
|
borderWidth: 1,
|
|
}],
|
|
},
|
|
options: {
|
|
responsive: true, maintainAspectRatio: false,
|
|
plugins: { legend: { display: false } },
|
|
scales: {
|
|
x: { grid: { color: palette.border } },
|
|
y: { beginAtZero: true, grid: { color: palette.border }, ticks: { precision: 0 } },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function renderDurationChart(s) {
|
|
const id = 'chart-duration';
|
|
destroy(id);
|
|
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
|
|
const ds = (label, color, key) => ({
|
|
label, data: s.buckets.map((b) => b[key]),
|
|
borderColor: color, backgroundColor: color + '33',
|
|
tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true,
|
|
});
|
|
charts[id] = new Chart(document.getElementById(id), {
|
|
type: 'line',
|
|
data: {
|
|
labels,
|
|
datasets: [
|
|
ds('p50', palette.cyan, 'p50_duration_ms'),
|
|
ds('p95', palette.pink, 'p95_duration_ms'),
|
|
ds('avg', palette.amber, 'avg_duration_ms'),
|
|
],
|
|
},
|
|
options: {
|
|
responsive: true, maintainAspectRatio: false,
|
|
scales: {
|
|
x: { grid: { color: palette.border } },
|
|
y: {
|
|
beginAtZero: true,
|
|
grid: { color: palette.border },
|
|
ticks: { callback: (v) => fmtMs(v) },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function renderCtxChart(s) {
|
|
const id = 'chart-ctx';
|
|
destroy(id);
|
|
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
|
|
charts[id] = new Chart(document.getElementById(id), {
|
|
type: 'line',
|
|
data: {
|
|
labels,
|
|
datasets: [
|
|
{
|
|
label: 'avg ctx',
|
|
data: s.buckets.map((b) => b.avg_ctx_tokens),
|
|
borderColor: palette.cyan,
|
|
backgroundColor: palette.cyan + '33',
|
|
tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true,
|
|
},
|
|
{
|
|
label: 'max ctx',
|
|
data: s.buckets.map((b) => b.max_ctx_tokens),
|
|
borderColor: palette.amber,
|
|
backgroundColor: palette.amber + '33',
|
|
tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true,
|
|
},
|
|
],
|
|
},
|
|
options: {
|
|
responsive: true, maintainAspectRatio: false,
|
|
scales: {
|
|
x: { grid: { color: palette.border } },
|
|
y: { beginAtZero: true, grid: { color: palette.border }, ticks: { callback: (v) => fmtInt(v) } },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function renderCostChart(s) {
|
|
const id = 'chart-cost';
|
|
destroy(id);
|
|
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
|
|
// Stacked bars: cache_read (cheap) / cache_creation / input / output.
|
|
// Highlights "what's actually getting billed at full rate" vs cache hits.
|
|
charts[id] = new Chart(document.getElementById(id), {
|
|
type: 'bar',
|
|
data: {
|
|
labels,
|
|
datasets: [
|
|
{ label: 'cache_read', data: s.buckets.map((b) => b.cache_read_input_tokens),
|
|
backgroundColor: palette.muted },
|
|
{ label: 'cache_creation', data: s.buckets.map((b) => b.cache_creation_input_tokens),
|
|
backgroundColor: palette.cyan },
|
|
{ label: 'input', data: s.buckets.map((b) => b.input_tokens),
|
|
backgroundColor: palette.amber },
|
|
{ label: 'output', data: s.buckets.map((b) => b.output_tokens),
|
|
backgroundColor: palette.pink },
|
|
],
|
|
},
|
|
options: {
|
|
responsive: true, maintainAspectRatio: false,
|
|
scales: {
|
|
x: { stacked: true, grid: { color: palette.border } },
|
|
y: { stacked: true, beginAtZero: true,
|
|
grid: { color: palette.border }, ticks: { callback: (v) => fmtInt(v) } },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function renderModelChart(s) {
|
|
const id = 'chart-model';
|
|
destroy(id);
|
|
const models = s.models || [];
|
|
if (!models.length) { paintEmpty(id, 'no turns in window'); return; }
|
|
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
|
|
// One stacked series per model. Model choice drives token cost,
|
|
// so this lines up against the cost chart above it.
|
|
const datasets = models.map((m, i) => ({
|
|
label: m,
|
|
data: s.buckets.map((b) => (b.model_counts && b.model_counts[m]) || 0),
|
|
backgroundColor: wheel[i % wheel.length],
|
|
}));
|
|
charts[id] = new Chart(document.getElementById(id), {
|
|
type: 'bar',
|
|
data: { labels, datasets },
|
|
options: {
|
|
responsive: true, maintainAspectRatio: false,
|
|
plugins: { legend: { position: 'top', labels: { boxWidth: 12 } } },
|
|
scales: {
|
|
x: { stacked: true, grid: { color: palette.border } },
|
|
y: { stacked: true, beginAtZero: true,
|
|
grid: { color: palette.border }, ticks: { precision: 0 } },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function renderKeyCount(canvasId, items, emptyMsg) {
|
|
destroy(canvasId);
|
|
if (!items || items.length === 0) {
|
|
paintEmpty(canvasId, emptyMsg);
|
|
return;
|
|
}
|
|
const labels = items.map((kc) => kc.key);
|
|
const data = items.map((kc) => kc.count);
|
|
const colors = items.map((_, i) => wheel[i % wheel.length]);
|
|
charts[canvasId] = new Chart(document.getElementById(canvasId), {
|
|
type: 'doughnut',
|
|
data: { labels, datasets: [{ data, backgroundColor: colors, borderColor: palette.bg, borderWidth: 2 }] },
|
|
options: {
|
|
responsive: true, maintainAspectRatio: false,
|
|
plugins: { legend: { position: 'right', labels: { boxWidth: 12 } } },
|
|
},
|
|
});
|
|
}
|
|
|
|
function render(s) {
|
|
renderSummary(s);
|
|
if (s.turn_count === 0) {
|
|
paintEmpty('chart-turns', 'no turns in window');
|
|
paintEmpty('chart-duration', 'no turns in window');
|
|
paintEmpty('chart-ctx', 'no turns in window');
|
|
paintEmpty('chart-cost', 'no turns in window');
|
|
paintEmpty('chart-model', 'no turns in window');
|
|
paintEmpty('chart-tools', 'no tool calls');
|
|
paintEmpty('chart-wake', 'no wakes');
|
|
paintEmpty('chart-result', 'no results');
|
|
return;
|
|
}
|
|
renderTurnsChart(s);
|
|
renderDurationChart(s);
|
|
renderCtxChart(s);
|
|
renderCostChart(s);
|
|
renderModelChart(s);
|
|
renderKeyCount('chart-tools', s.tool_breakdown, 'no tool calls');
|
|
renderKeyCount('chart-wake', s.wake_mix, 'no wakes');
|
|
renderKeyCount('chart-result', s.result_mix, 'no results');
|
|
}
|
|
|
|
async function loadStats() {
|
|
try {
|
|
const resp = await fetch('/api/stats?window=' + encodeURIComponent(currentWindow));
|
|
if (!resp.ok) throw new Error('http ' + resp.status);
|
|
const snap = await resp.json();
|
|
render(snap);
|
|
} catch (e) {
|
|
document.getElementById('summary').textContent = 'stats fetch failed: ' + e;
|
|
}
|
|
}
|
|
|
|
async function loadIdentity() {
|
|
try {
|
|
const resp = await fetch('/api/state');
|
|
if (!resp.ok) return;
|
|
const s = await resp.json();
|
|
document.title = 'stats · ' + s.label;
|
|
document.getElementById('title').textContent = '◆ ' + s.label + ' ◆';
|
|
const dl = document.getElementById('dashboard-link');
|
|
dl.href = 'http://' + window.location.hostname + ':' + s.dashboard_port + '/';
|
|
} catch (_) { /* non-fatal */ }
|
|
}
|
|
|
|
function bindTabs() {
|
|
const tabs = document.getElementById('window-tabs');
|
|
tabs.addEventListener('click', (ev) => {
|
|
const btn = ev.target.closest('button[data-w]');
|
|
if (!btn) return;
|
|
currentWindow = btn.dataset.w;
|
|
for (const b of tabs.querySelectorAll('button')) b.classList.toggle('active', b === btn);
|
|
loadStats();
|
|
});
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
bindTabs();
|
|
loadIdentity();
|
|
loadStats();
|
|
});
|
|
})();
|