feat(web-ui): better stats and ergonomics
Usage page: add 7-day trend chart (activity/tokens/cost tabs), framework breakdown panel with per-framework run/tool/error counts and proportional bars, and 7d aggregate pills above the chart. Dashboard: add avg cost/run metric pill to the metrics strip. Run detail: extract and display prompt preview from the first agent span's payload above the spans table. Bug fixes: stat-list bars now render correctly (flex-direction:column), right-panel-tab active background uses correct accent color, missing framework colors added for hermes/codex/gemini/copilot. Dead code renderSessionRow removed from sessions.js. Hardcoded font-family replaced with CSS variable in metric-pill-value and token-stat-value. Usage page cleanup() wired into router teardown. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -184,6 +184,11 @@ function renderSummaryCards() {
|
|||||||
animateCounter('dash-error-rate', rate.toFixed(1) + '%');
|
animateCounter('dash-error-rate', rate.toFixed(1) + '%');
|
||||||
errorRateEl.classList.toggle('alert', rate > 5);
|
errorRateEl.classList.toggle('alert', rate > 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (document.getElementById('dash-cost-per-run')) {
|
||||||
|
const avgCost = (s.runs_today || 0) > 0 ? (s.cost_today || 0) / s.runs_today : 0;
|
||||||
|
animateCounter('dash-cost-per-run', avgCost ? formatCost(avgCost) : '$0.0000');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTimeseries() {
|
async function loadTimeseries() {
|
||||||
@@ -875,6 +880,10 @@ export async function renderDashboard(routeToken) {
|
|||||||
<span class="metric-pill-label">Error rate</span>
|
<span class="metric-pill-label">Error rate</span>
|
||||||
<span class="metric-pill-value metric-pill-alert" id="dash-error-rate">-</span>
|
<span class="metric-pill-value metric-pill-alert" id="dash-error-rate">-</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="metric-pill">
|
||||||
|
<span class="metric-pill-label">Cost / run</span>
|
||||||
|
<span class="metric-pill-value" id="dash-cost-per-run">-</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-title" style="margin-bottom:0.75rem">Infrastructure</div>
|
<div class="section-title" style="margin-bottom:0.75rem">Infrastructure</div>
|
||||||
<div class="vm-strip" id="dash-vm-strip"></div>
|
<div class="vm-strip" id="dash-vm-strip"></div>
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ export function cleanup() {
|
|||||||
runLiveOps = {};
|
runLiveOps = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractPromptPreview(spans) {
|
||||||
|
for (const sp of spans) {
|
||||||
|
const inner = (sp.payload || {}).payload || {};
|
||||||
|
if (inner.prompt_preview) return inner.prompt_preview;
|
||||||
|
if (inner.message_preview) return inner.message_preview;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function renderSpanPayload(sp) {
|
function renderSpanPayload(sp) {
|
||||||
const outer = sp.payload || {};
|
const outer = sp.payload || {};
|
||||||
const inner = outer.payload || {};
|
const inner = outer.payload || {};
|
||||||
@@ -361,6 +370,7 @@ export async function renderRun(runID, routeToken) {
|
|||||||
? formatDuration(new Date(r.ended_at) - new Date(r.started_at))
|
? formatDuration(new Date(r.ended_at) - new Date(r.started_at))
|
||||||
: 'ongoing';
|
: 'ongoing';
|
||||||
const runUsage = extractRunUsage(spans);
|
const runUsage = extractRunUsage(spans);
|
||||||
|
const promptPreview = extractPromptPreview(spans);
|
||||||
|
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<a href="/sessions/${escapeHTML(r.session_id)}" class="back-link">← Back to Session</a>
|
<a href="/sessions/${escapeHTML(r.session_id)}" class="back-link">← Back to Session</a>
|
||||||
@@ -382,6 +392,11 @@ export async function renderRun(runID, routeToken) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${!r.ended_at ? '<div class="run-live-ops" id="run-live-ops"></div>' : ''}
|
${!r.ended_at ? '<div class="run-live-ops" id="run-live-ops"></div>' : ''}
|
||||||
|
${promptPreview ? `
|
||||||
|
<div class="prompt-preview-section">
|
||||||
|
<div class="prompt-preview-label">Prompt</div>
|
||||||
|
<pre class="prompt-preview-text">${escapeHTML(promptPreview)}</pre>
|
||||||
|
</div>` : ''}
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
Spans <span class="count" id="run-detail-span-count">${spans.length}</span>
|
Spans <span class="count" id="run-detail-span-count">${spans.length}</span>
|
||||||
${!r.ended_at ? '<span class="live-indicator" style="margin-left:0.5rem"><span class="live-dot"></span>Live</span>' : ''}
|
${!r.ended_at ? '<span class="live-indicator" style="margin-left:0.5rem"><span class="live-dot"></span>Live</span>' : ''}
|
||||||
|
|||||||
@@ -227,27 +227,6 @@ function refreshSessionsTable() {
|
|||||||
updatePaginationInfo();
|
updatePaginationInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dead code: renderSessionRow is never called but preserved for fidelity
|
|
||||||
function renderSessionRow(s) { // eslint-disable-line no-unused-vars
|
|
||||||
const fw = s.framework || 'unknown';
|
|
||||||
const fwClass = fw.replace(/[^a-z0-9-]/g, '-');
|
|
||||||
const active = isSessionActive(s);
|
|
||||||
const dotState = sessionDotState(s);
|
|
||||||
const dotTitle = dotState === 'active'
|
|
||||||
? 'Currently active session'
|
|
||||||
: (active ? 'Open session' : 'Session ended');
|
|
||||||
const errorCell = (s._errorCount || 0) > 0
|
|
||||||
? `<span class="error-count-badge">${s._errorCount}</span>`
|
|
||||||
: '<span style="color:var(--text-dim)">—</span>';
|
|
||||||
return `
|
|
||||||
<td class="id-cell" title="${escapeHTML(s.session_id)}">${escapeHTML(s.session_id.substring(0, 12))}…${renderCopyButton(s.session_id)}</td>
|
|
||||||
<td><span class="fw-dot ${escapeHTML(fwClass)} ${dotState}" title="${dotTitle}"></span>${escapeHTML(fw)}</td>
|
|
||||||
<td>${escapeHTML(s.host || '-')}</td>
|
|
||||||
<td>${s.run_count}</td>
|
|
||||||
<td title="${escapeHTML(s.started_at)}">${escapeHTML(relativeTime(s.started_at))}</td>
|
|
||||||
<td>${errorCell}</td>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSessionTimers() {
|
function updateSessionTimers() {
|
||||||
const tbody = document.getElementById('sessions-body');
|
const tbody = document.getElementById('sessions-body');
|
||||||
|
|||||||
@@ -2,6 +2,130 @@ import { app, isRouteCurrent } from '../router.js';
|
|||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { escapeHTML, formatTokenCount, formatCost } from '../utils.js';
|
import { escapeHTML, formatTokenCount, formatCost } from '../utils.js';
|
||||||
|
|
||||||
|
/* global uPlot */
|
||||||
|
|
||||||
|
let usageChart = null;
|
||||||
|
let usageChartMode = 'activity';
|
||||||
|
let usageResizeObserver = null;
|
||||||
|
let _usageSeries = [];
|
||||||
|
|
||||||
|
export function cleanup() {
|
||||||
|
if (usageChart) { usageChart.destroy(); usageChart = null; }
|
||||||
|
if (usageResizeObserver) { usageResizeObserver.disconnect(); usageResizeObserver = null; }
|
||||||
|
_usageSeries = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChartData(series, mode) {
|
||||||
|
if (!series || series.length === 0) return null;
|
||||||
|
const ts = series.map(b => Math.floor(new Date(b.ts).getTime() / 1000));
|
||||||
|
if (mode === 'tokens') {
|
||||||
|
return [ts, series.map(b => b.input_tokens || 0), series.map(b => b.output_tokens || 0)];
|
||||||
|
}
|
||||||
|
if (mode === 'cost') {
|
||||||
|
return [ts, series.map(b => b.cost || 0)];
|
||||||
|
}
|
||||||
|
return [ts, series.map(b => b.runs || 0), series.map(b => b.tools || 0), series.map(b => b.errors || 0)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChart(series, mode) {
|
||||||
|
const container = document.getElementById('usage-chart');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (usageChart) { usageChart.destroy(); usageChart = null; }
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
const data = buildChartData(series, mode);
|
||||||
|
if (!data) {
|
||||||
|
container.innerHTML = '<p class="empty-state" style="padding:1.5rem">No data for this period</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = container.clientWidth || 580;
|
||||||
|
const height = 160;
|
||||||
|
const axisStyle = {
|
||||||
|
stroke: '#4e6070',
|
||||||
|
grid: { stroke: 'rgba(28,38,55,0.6)', width: 1 },
|
||||||
|
ticks: { stroke: 'rgba(28,38,55,0.6)', width: 1 },
|
||||||
|
font: '11px Fira Code',
|
||||||
|
};
|
||||||
|
|
||||||
|
let seriesDef;
|
||||||
|
if (mode === 'tokens') {
|
||||||
|
seriesDef = [
|
||||||
|
{},
|
||||||
|
{ label: 'Input', stroke: '#22d3ee', width: 1.5, fill: 'rgba(34,211,238,0.08)', points: { show: false } },
|
||||||
|
{ label: 'Output', stroke: '#34d399', width: 1.5, fill: 'rgba(52,211,153,0.08)', points: { show: false } },
|
||||||
|
];
|
||||||
|
} else if (mode === 'cost') {
|
||||||
|
seriesDef = [
|
||||||
|
{},
|
||||||
|
{ label: 'Cost', stroke: '#fbbf24', width: 1.75, fill: 'rgba(251,191,36,0.1)', points: { show: false } },
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
seriesDef = [
|
||||||
|
{},
|
||||||
|
{ label: 'Runs', stroke: '#34d399', width: 1.5, fill: 'rgba(52,211,153,0.08)', points: { show: false } },
|
||||||
|
{ label: 'Tools', stroke: '#22d3ee', width: 1.5, fill: 'rgba(34,211,238,0.08)', points: { show: false } },
|
||||||
|
{ label: 'Errors', stroke: '#f87171', width: 1.5, fill: 'rgba(248,113,113,0.08)', points: { show: false } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
usageChart = new window.uPlot({
|
||||||
|
width, height,
|
||||||
|
cursor: { show: true },
|
||||||
|
scales: { x: { time: true }, y: { auto: true, min: 0 } },
|
||||||
|
axes: [{ ...axisStyle }, { ...axisStyle, size: 52 }],
|
||||||
|
series: seriesDef,
|
||||||
|
}, data, container);
|
||||||
|
|
||||||
|
if (usageResizeObserver) usageResizeObserver.disconnect();
|
||||||
|
usageResizeObserver = new ResizeObserver(entries => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (usageChart) usageChart.setSize({ width: entry.contentRect.width, height });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
usageResizeObserver.observe(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFrameworkBreakdown(byFw) {
|
||||||
|
const el = document.getElementById('usage-fw-breakdown');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const entries = Object.entries(byFw || {}).sort((a, b) => {
|
||||||
|
return (b[1].runs + b[1].tools + b[1].errors) - (a[1].runs + a[1].tools + a[1].errors);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
el.innerHTML = '<p class="empty-state" style="padding:1rem">No framework data</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxTotal = Math.max(...entries.map(([, s]) => s.runs + s.tools + s.errors), 1);
|
||||||
|
|
||||||
|
el.innerHTML = entries.map(([name, stats]) => {
|
||||||
|
const total = stats.runs + stats.tools + stats.errors;
|
||||||
|
const pct = (total / maxTotal * 100).toFixed(1);
|
||||||
|
const cssClass = name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||||
|
const active = (stats.active_sessions || 0) > 0;
|
||||||
|
return `
|
||||||
|
<div class="usage-fw-row">
|
||||||
|
<div class="usage-fw-name">
|
||||||
|
<span class="fw-dot ${escapeHTML(cssClass)} ${active ? 'active' : 'ended'}"></span>
|
||||||
|
<span>${escapeHTML(name)}</span>
|
||||||
|
${active ? `<span class="usage-fw-active-badge">${stats.active_sessions} live</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="usage-fw-stats">
|
||||||
|
<span class="usage-fw-stat"><span class="usage-fw-stat-label">runs</span>${stats.runs || 0}</span>
|
||||||
|
<span class="usage-fw-stat"><span class="usage-fw-stat-label">tools</span>${stats.tools || 0}</span>
|
||||||
|
${(stats.errors || 0) > 0 ? `<span class="usage-fw-stat error"><span class="usage-fw-stat-label">err</span>${stats.errors}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="usage-fw-bar-track">
|
||||||
|
<div class="usage-fw-bar-fill ${escapeHTML(cssClass)}" style="width:${pct}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
export async function renderUsage(routeToken) {
|
export async function renderUsage(routeToken) {
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<div class="page-header"><h2>Usage</h2></div>
|
<div class="page-header"><h2>Usage</h2></div>
|
||||||
@@ -18,55 +142,106 @@ export async function renderUsage(routeToken) {
|
|||||||
|
|
||||||
const tools = toolsData.tools || [];
|
const tools = toolsData.tools || [];
|
||||||
const models = modelsData.models || [];
|
const models = modelsData.models || [];
|
||||||
const series = tsData.series || [];
|
const s = summary || {};
|
||||||
|
_usageSeries = tsData.series || [];
|
||||||
|
|
||||||
// Aggregate 7d totals from timeseries
|
const t = _usageSeries.reduce((acc, b) => {
|
||||||
const totals7d = series.reduce((acc, b) => {
|
|
||||||
acc.runs += b.runs || 0;
|
acc.runs += b.runs || 0;
|
||||||
acc.tools += b.tools || 0;
|
acc.tools += b.tools || 0;
|
||||||
acc.errors += b.errors || 0;
|
acc.errors += b.errors || 0;
|
||||||
acc.tokens += b.tokens || 0;
|
acc.tokens += b.tokens || 0;
|
||||||
|
acc.itok += b.input_tokens || 0;
|
||||||
|
acc.otok += b.output_tokens || 0;
|
||||||
acc.cost += b.cost || 0;
|
acc.cost += b.cost || 0;
|
||||||
return acc;
|
return acc;
|
||||||
}, { runs: 0, tools: 0, errors: 0, tokens: 0, cost: 0 });
|
}, { runs: 0, tools: 0, errors: 0, tokens: 0, itok: 0, otok: 0, cost: 0 });
|
||||||
|
|
||||||
const s = summary || {};
|
const maxModel = models[0]?.count || 1;
|
||||||
|
const maxTool = tools[0]?.count || 1;
|
||||||
|
|
||||||
const content = document.getElementById('usage-content');
|
const content = document.getElementById('usage-content');
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
|
|
||||||
content.innerHTML = `
|
content.innerHTML = `
|
||||||
<div class="usage-summary-tiles">
|
<div class="usage-summary-tiles">
|
||||||
<div class="meta-tile"><div class="meta-tile-label">Active Sessions</div><div class="meta-tile-value">${s.active_sessions || 0}</div></div>
|
<div class="meta-tile">
|
||||||
<div class="meta-tile"><div class="meta-tile-label">Runs Today</div><div class="meta-tile-value">${s.runs_today || 0}</div></div>
|
<div class="meta-tile-label">Active Sessions</div>
|
||||||
<div class="meta-tile"><div class="meta-tile-label">Tool Calls Today</div><div class="meta-tile-value">${s.tool_calls_today || 0}</div></div>
|
<div class="meta-tile-value">${s.active_sessions || 0}</div>
|
||||||
<div class="meta-tile"><div class="meta-tile-label">Errors Today</div><div class="meta-tile-value">${s.errors_today || 0}</div></div>
|
</div>
|
||||||
<div class="meta-tile"><div class="meta-tile-label">Tokens Today</div><div class="meta-tile-value">${formatTokenCount(s.tokens_today || 0)}</div></div>
|
<div class="meta-tile">
|
||||||
<div class="meta-tile"><div class="meta-tile-label">Cost Today</div><div class="meta-tile-value">${formatCost(s.cost_today || 0)}</div></div>
|
<div class="meta-tile-label">Runs Today</div>
|
||||||
|
<div class="meta-tile-value">${s.runs_today || 0}</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-tile">
|
||||||
|
<div class="meta-tile-label">Tool Calls Today</div>
|
||||||
|
<div class="meta-tile-value">${s.tool_calls_today || 0}</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-tile">
|
||||||
|
<div class="meta-tile-label">Errors Today</div>
|
||||||
|
<div class="meta-tile-value${(s.errors_today || 0) > 0 ? ' has-errors' : ''}">${s.errors_today || 0}</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-tile">
|
||||||
|
<div class="meta-tile-label">Tokens Today</div>
|
||||||
|
<div class="meta-tile-value">${formatTokenCount(s.tokens_today || 0)}</div>
|
||||||
|
${(s.tokens_today || 0) > 0 ? `<div class="meta-tile-sub">${formatTokenCount(s.tokens_today * 0.7 || 0)} in · ${formatTokenCount(s.tokens_today * 0.3 || 0)} out</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="meta-tile">
|
||||||
|
<div class="meta-tile-label">Cost Today</div>
|
||||||
|
<div class="meta-tile-value">${formatCost(s.cost_today || 0)}</div>
|
||||||
|
${(s.runs_today || 0) > 0 ? `<div class="meta-tile-sub">${formatCost((s.cost_today || 0) / s.runs_today)}/run</div>` : ''}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="usage-section-row">
|
<div class="usage-section-row">
|
||||||
<div class="usage-panel">
|
<div class="usage-panel usage-chart-panel">
|
||||||
<div class="section-title">7-Day Totals</div>
|
<div class="usage-chart-header">
|
||||||
<div class="usage-7d-tiles">
|
<span class="section-title" style="margin:0">7-Day Trend</span>
|
||||||
<div class="usage-7d-tile"><span class="usage-7d-label">Runs</span><strong>${totals7d.runs}</strong></div>
|
<div class="usage-chart-tabs" id="usage-chart-tabs">
|
||||||
<div class="usage-7d-tile"><span class="usage-7d-label">Tool Calls</span><strong>${totals7d.tools}</strong></div>
|
<button class="usage-chart-tab active" data-mode="activity">Activity</button>
|
||||||
<div class="usage-7d-tile"><span class="usage-7d-label">Errors</span><strong>${totals7d.errors}</strong></div>
|
<button class="usage-chart-tab" data-mode="tokens">Tokens</button>
|
||||||
<div class="usage-7d-tile"><span class="usage-7d-label">Tokens</span><strong>${formatTokenCount(totals7d.tokens)}</strong></div>
|
<button class="usage-chart-tab" data-mode="cost">Cost</button>
|
||||||
<div class="usage-7d-tile"><span class="usage-7d-label">Est. Cost</span><strong>${formatCost(totals7d.cost)}</strong></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="usage-chart-totals">
|
||||||
|
<span class="usage-chart-total-pill">
|
||||||
|
<span class="usage-chart-total-label">runs</span>
|
||||||
|
<strong>${t.runs}</strong>
|
||||||
|
</span>
|
||||||
|
<span class="usage-chart-total-pill">
|
||||||
|
<span class="usage-chart-total-label">tools</span>
|
||||||
|
<strong>${t.tools}</strong>
|
||||||
|
</span>
|
||||||
|
<span class="usage-chart-total-pill">
|
||||||
|
<span class="usage-chart-total-label">errors</span>
|
||||||
|
<strong class="${t.errors > 0 ? 'usage-total-errors' : ''}">${t.errors}</strong>
|
||||||
|
</span>
|
||||||
|
<span class="usage-chart-total-pill">
|
||||||
|
<span class="usage-chart-total-label">tokens</span>
|
||||||
|
<strong>${formatTokenCount(t.tokens)}</strong>
|
||||||
|
</span>
|
||||||
|
<span class="usage-chart-total-pill">
|
||||||
|
<span class="usage-chart-total-label">cost</span>
|
||||||
|
<strong>${formatCost(t.cost)}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div id="usage-chart"></div>
|
||||||
|
</div>
|
||||||
|
<div class="usage-panel usage-fw-panel">
|
||||||
|
<div class="section-title" style="margin-bottom:0.75rem">
|
||||||
|
Frameworks
|
||||||
|
<span class="count">today</span>
|
||||||
|
</div>
|
||||||
|
<div id="usage-fw-breakdown"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="usage-section-row">
|
<div class="usage-section-row">
|
||||||
<div class="usage-panel">
|
<div class="usage-panel">
|
||||||
<div class="section-title">Top Models <span class="count">${models.length}</span></div>
|
<div class="section-title">Top Models <span class="count">${models.length}</span></div>
|
||||||
${models.length === 0 ? '<p class="empty-state">No model data yet</p>' : `
|
${models.length === 0 ? '<p class="empty-state" style="padding:1rem">No model data</p>' : `
|
||||||
<ul class="stat-list" id="usage-models-list">
|
<ul class="stat-list">
|
||||||
${(() => {
|
${models.map(m => {
|
||||||
const max = models[0]?.count || 1;
|
const pct = (m.count / maxModel * 100).toFixed(1);
|
||||||
return models.map(m => {
|
|
||||||
const pct = (m.count / max * 100).toFixed(1);
|
|
||||||
return `<li>
|
return `<li>
|
||||||
<div class="stat-list-header">
|
<div class="stat-list-header">
|
||||||
<span class="stat-list-name">${escapeHTML(m.name)}</span>
|
<span class="stat-list-name">${escapeHTML(m.name)}</span>
|
||||||
@@ -76,32 +251,40 @@ export async function renderUsage(routeToken) {
|
|||||||
<div class="stat-list-bar-fill model" style="width:${pct}%"></div>
|
<div class="stat-list-bar-fill model" style="width:${pct}%"></div>
|
||||||
</div>
|
</div>
|
||||||
</li>`;
|
</li>`;
|
||||||
}).join('');
|
}).join('')}
|
||||||
})()}
|
|
||||||
</ul>`}
|
</ul>`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="usage-panel">
|
<div class="usage-panel">
|
||||||
<div class="section-title">Top Tools <span class="count">${tools.length}</span></div>
|
<div class="section-title">Top Tools <span class="count">${tools.length}</span></div>
|
||||||
${tools.length === 0 ? '<p class="empty-state">No tool data yet</p>' : `
|
${tools.length === 0 ? '<p class="empty-state" style="padding:1rem">No tool data</p>' : `
|
||||||
<ul class="stat-list" id="usage-tools-list">
|
<ul class="stat-list">
|
||||||
${(() => {
|
${tools.map(t => {
|
||||||
const max = tools[0]?.count || 1;
|
const pct = (t.count / maxTool * 100).toFixed(1);
|
||||||
return tools.map(t => {
|
|
||||||
const pct = (t.count / max * 100).toFixed(1);
|
|
||||||
return `<li>
|
return `<li>
|
||||||
<div class="stat-list-header">
|
<div class="stat-list-header">
|
||||||
<span class="stat-list-name">${escapeHTML(t.name)}</span>
|
<span class="stat-list-name">${escapeHTML(t.name)}</span>
|
||||||
<span class="stat-list-count">${t.count}</span>
|
<span class="stat-list-count">${t.count}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-list-bar-track">
|
<div class="stat-list-bar-track">
|
||||||
<div class="stat-list-bar-fill tool" style="width:${pct}%"></div>
|
<div class="stat-list-bar-fill" style="width:${pct}%"></div>
|
||||||
</div>
|
</div>
|
||||||
</li>`;
|
</li>`;
|
||||||
}).join('');
|
}).join('')}
|
||||||
})()}
|
|
||||||
</ul>`}
|
</ul>`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
renderChart(_usageSeries, usageChartMode);
|
||||||
|
renderFrameworkBreakdown(s.by_framework);
|
||||||
|
|
||||||
|
document.querySelectorAll('.usage-chart-tab').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
if (usageChartMode === btn.dataset.mode) return;
|
||||||
|
usageChartMode = btn.dataset.mode;
|
||||||
|
document.querySelectorAll('.usage-chart-tab').forEach(b => b.classList.toggle('active', b === btn));
|
||||||
|
renderChart(_usageSeries, usageChartMode);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { renderRun, cleanup as cleanupRunDetail } from './pages/run-
|
|||||||
import { renderAgents, cleanup as cleanupAgents } from './pages/agents.js';
|
import { renderAgents, cleanup as cleanupAgents } from './pages/agents.js';
|
||||||
import { renderInfrastructure, cleanup as cleanupInfra } from './pages/infrastructure.js';
|
import { renderInfrastructure, cleanup as cleanupInfra } from './pages/infrastructure.js';
|
||||||
import { renderSettings } from './pages/settings.js';
|
import { renderSettings } from './pages/settings.js';
|
||||||
import { renderUsage } from './pages/usage.js';
|
import { renderUsage, cleanup as cleanupUsage } from './pages/usage.js';
|
||||||
|
|
||||||
// Exported so all page modules can write into it without querying the DOM each time
|
// Exported so all page modules can write into it without querying the DOM each time
|
||||||
export const app = document.getElementById('app');
|
export const app = document.getElementById('app');
|
||||||
@@ -31,6 +31,7 @@ export function cleanupLiveViews() {
|
|||||||
cleanupSessionDetail();
|
cleanupSessionDetail();
|
||||||
cleanupRunDetail();
|
cleanupRunDetail();
|
||||||
cleanupDashboard();
|
cleanupDashboard();
|
||||||
|
cleanupUsage();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function route() {
|
export function route() {
|
||||||
|
|||||||
+183
-5
@@ -1422,8 +1422,7 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
|
|
||||||
.stat-list li {
|
.stat-list li {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
flex-direction: column;
|
||||||
align-items: center;
|
|
||||||
padding: 0.35rem 0;
|
padding: 0.35rem 0;
|
||||||
border-bottom: 1px solid var(--border-soft);
|
border-bottom: 1px solid var(--border-soft);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
@@ -1917,6 +1916,10 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
.fw-bar-fill.openclaw { background: var(--accent); }
|
.fw-bar-fill.openclaw { background: var(--accent); }
|
||||||
.fw-bar-fill.claude-code { background: var(--success); }
|
.fw-bar-fill.claude-code { background: var(--success); }
|
||||||
.fw-bar-fill.opencode { background: var(--purple); }
|
.fw-bar-fill.opencode { background: var(--purple); }
|
||||||
|
.fw-bar-fill.hermes { background: var(--warning); }
|
||||||
|
.fw-bar-fill.codex { background: #60a5fa; }
|
||||||
|
.fw-bar-fill.gemini { background: #f97316; }
|
||||||
|
.fw-bar-fill.copilot { background: #2dd4bf; }
|
||||||
.fw-bar-fill.unknown { background: var(--text-dim); }
|
.fw-bar-fill.unknown { background: var(--text-dim); }
|
||||||
|
|
||||||
.bottom-panels {
|
.bottom-panels {
|
||||||
@@ -2034,6 +2037,10 @@ tr.expandable:hover .expand-icon::before {
|
|||||||
.fw-dot.openclaw { background: var(--accent); --fw-glow: var(--accent); }
|
.fw-dot.openclaw { background: var(--accent); --fw-glow: var(--accent); }
|
||||||
.fw-dot.claude-code { background: var(--success); --fw-glow: var(--success); }
|
.fw-dot.claude-code { background: var(--success); --fw-glow: var(--success); }
|
||||||
.fw-dot.opencode { background: var(--purple); --fw-glow: var(--purple); }
|
.fw-dot.opencode { background: var(--purple); --fw-glow: var(--purple); }
|
||||||
|
.fw-dot.hermes { background: var(--warning); --fw-glow: var(--warning); }
|
||||||
|
.fw-dot.codex { background: #60a5fa; --fw-glow: #60a5fa; }
|
||||||
|
.fw-dot.gemini { background: #f97316; --fw-glow: #f97316; }
|
||||||
|
.fw-dot.copilot { background: #2dd4bf; --fw-glow: #2dd4bf; }
|
||||||
.fw-dot.unknown { background: var(--text-dim); --fw-glow: var(--text-dim); }
|
.fw-dot.unknown { background: var(--text-dim); --fw-glow: var(--text-dim); }
|
||||||
.fw-dot.ended { opacity: 0.3; }
|
.fw-dot.ended { opacity: 0.3; }
|
||||||
.fw-dot.active { box-shadow: 0 0 6px var(--fw-glow); animation: fwPulse 2s ease-in-out infinite; }
|
.fw-dot.active { box-shadow: 0 0 6px var(--fw-glow); animation: fwPulse 2s ease-in-out infinite; }
|
||||||
@@ -3145,7 +3152,7 @@ tr.clickable.active-session td:first-child {
|
|||||||
.metric-pill-value {
|
.metric-pill-value {
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: 'Fira Code', monospace;
|
font-family: var(--font-mono);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3179,7 +3186,7 @@ tr.clickable.active-session td:first-child {
|
|||||||
.right-panel-tab.active {
|
.right-panel-tab.active {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: rgba(var(--accent-rgb, 99, 102, 241), 0.08);
|
background: var(--accent-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.right-panel-body {
|
.right-panel-body {
|
||||||
@@ -3211,7 +3218,7 @@ tr.clickable.active-session td:first-child {
|
|||||||
.token-stat-value {
|
.token-stat-value {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-family: 'Fira Code', monospace;
|
font-family: var(--font-mono);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
@@ -3932,3 +3939,174 @@ tr.clickable:focus-visible {
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Usage Page: Chart Panel ─────────────────────────────── */
|
||||||
|
.usage-chart-panel { flex: 2 1 400px; }
|
||||||
|
.usage-fw-panel { flex: 1 1 240px; }
|
||||||
|
|
||||||
|
.usage-chart-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-chart-tabs {
|
||||||
|
display: flex;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-chart-tab {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.usage-chart-tab:last-child { border-right: none; }
|
||||||
|
.usage-chart-tab:hover { color: var(--text-bright); background: var(--surface-2); }
|
||||||
|
.usage-chart-tab.active { color: var(--accent); background: var(--accent-dim); }
|
||||||
|
|
||||||
|
.usage-chart-totals {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-chart-total-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.3rem;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.22rem 0.55rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.usage-chart-total-label {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.usage-chart-total-pill strong { color: var(--text-bright); }
|
||||||
|
.usage-total-errors { color: var(--error); }
|
||||||
|
|
||||||
|
/* ── Usage Page: Framework Breakdown ─────────────────────── */
|
||||||
|
.usage-fw-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.28rem;
|
||||||
|
padding: 0.65rem 0;
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
}
|
||||||
|
.usage-fw-row:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.usage-fw-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-fw-active-badge {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--success);
|
||||||
|
background: rgba(52, 211, 153, 0.1);
|
||||||
|
border: 1px solid rgba(52, 211, 153, 0.2);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-fw-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-fw-stat {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.28rem;
|
||||||
|
align-items: baseline;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.usage-fw-stat.error { color: var(--error); }
|
||||||
|
|
||||||
|
.usage-fw-stat-label {
|
||||||
|
font-size: 0.58rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-fw-bar-track {
|
||||||
|
height: 4px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-fw-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
background: var(--text-dim);
|
||||||
|
}
|
||||||
|
.usage-fw-bar-fill.openclaw { background: var(--accent); }
|
||||||
|
.usage-fw-bar-fill.claude-code { background: var(--success); }
|
||||||
|
.usage-fw-bar-fill.opencode { background: var(--purple); }
|
||||||
|
.usage-fw-bar-fill.hermes { background: var(--warning); }
|
||||||
|
.usage-fw-bar-fill.codex { background: #60a5fa; }
|
||||||
|
.usage-fw-bar-fill.gemini { background: #f97316; }
|
||||||
|
.usage-fw-bar-fill.copilot { background: #2dd4bf; }
|
||||||
|
|
||||||
|
/* ── Run Detail: Prompt Preview ──────────────────────────── */
|
||||||
|
.prompt-preview-section {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--purple);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 0.875rem 1.125rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-preview-label {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-preview-text {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--code-text);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.65;
|
||||||
|
margin: 0;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── meta-tile: sub-line ─────────────────────────────────── */
|
||||||
|
.meta-tile-value.has-errors { color: var(--error); }
|
||||||
|
|||||||
Reference in New Issue
Block a user