Add cost dashboard frontend: HTML, CSS, JavaScript
- costs.html: summary cards, 4 Chart.js charts, expensive calls table, sessions table with drill-down, session detail view - costs.css: dark theme matching existing style.css variables, responsive grid layout, card/table/chart styling - costs.js: parallel API fetching, Chart.js rendering (timeline, doughnut, bar, session bars), session detail drill-down, period tab filtering Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,119 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Cost Dashboard - Luke at the Roost</title>
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
<link rel="stylesheet" href="/css/costs.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="costs-header">
|
||||||
|
<h1>Cost Dashboard</h1>
|
||||||
|
<a href="/" class="back-link">Back to Show</a>
|
||||||
|
<nav class="period-tabs">
|
||||||
|
<button class="period-tab active" data-period="all">All Time</button>
|
||||||
|
<button class="period-tab" data-period="month">This Month</button>
|
||||||
|
<button class="period-tab" data-period="week">This Week</button>
|
||||||
|
<button class="period-tab" data-period="today">Today</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main class="costs-main">
|
||||||
|
<section class="summary-cards">
|
||||||
|
<div class="card" id="card-total">
|
||||||
|
<div class="card-label">Total Spend</div>
|
||||||
|
<div class="card-value" id="total-spend">--</div>
|
||||||
|
<div class="card-change" id="total-change"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="card-llm-tts">
|
||||||
|
<div class="card-label">LLM / TTS</div>
|
||||||
|
<div class="card-value" id="llm-tts-split">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="card-sessions">
|
||||||
|
<div class="card-label">Sessions</div>
|
||||||
|
<div class="card-value" id="session-count">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="card-avg">
|
||||||
|
<div class="card-label">Avg / Session</div>
|
||||||
|
<div class="card-value" id="avg-cost">--</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="chart-row">
|
||||||
|
<div class="chart-container">
|
||||||
|
<h3>Cost Over Time</h3>
|
||||||
|
<canvas id="timeline-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="chart-container">
|
||||||
|
<h3>Cost by Model</h3>
|
||||||
|
<canvas id="model-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="chart-row">
|
||||||
|
<div class="chart-container">
|
||||||
|
<h3>Cost by Category</h3>
|
||||||
|
<canvas id="category-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="chart-container">
|
||||||
|
<h3>Cost Per Session</h3>
|
||||||
|
<canvas id="session-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="tables-section">
|
||||||
|
<div class="table-container">
|
||||||
|
<h3>Most Expensive Calls</h3>
|
||||||
|
<table id="expensive-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Model</th><th>Category</th><th>Caller</th><th>Tokens</th><th>Cost</th><th>Latency</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<h3>Sessions</h3>
|
||||||
|
<table id="sessions-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Date</th><th>LLM</th><th>TTS</th><th>Total</th><th>Calls</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="session-detail hidden" id="session-detail">
|
||||||
|
<h2>Session Detail: <span id="detail-session-id"></span></h2>
|
||||||
|
<button class="close-detail" id="close-detail">Back</button>
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="table-container">
|
||||||
|
<h3>By Caller</h3>
|
||||||
|
<table id="detail-caller-table">
|
||||||
|
<thead><tr><th>Caller</th><th>Calls</th><th>Cost</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<h3>By Category</h3>
|
||||||
|
<table id="detail-category-table">
|
||||||
|
<thead><tr><th>Category</th><th>Calls</th><th>Cost</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<h3>By Model</h3>
|
||||||
|
<table id="detail-model-table">
|
||||||
|
<thead><tr><th>Model</th><th>Calls</th><th>Cost</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<h3>Most Expensive Calls</h3>
|
||||||
|
<table id="detail-expensive-table">
|
||||||
|
<thead><tr><th>Category</th><th>Model</th><th>Caller</th><th>Cost</th><th>Tokens</th><th>Latency</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="/js/costs.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
/* Cost Dashboard */
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costs-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid rgba(232, 121, 29, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.costs-header h1 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-tab {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid rgba(232, 121, 29, 0.15);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-tab:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-tab.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-dark);
|
||||||
|
border-color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costs-main {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Summary Cards */
|
||||||
|
|
||||||
|
.summary-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--bg-light);
|
||||||
|
border: 1px solid rgba(232, 121, 29, 0.08);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-value {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-change {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-change.positive {
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-change.negative {
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Charts */
|
||||||
|
|
||||||
|
.chart-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
background: var(--bg-light);
|
||||||
|
border: 1px solid rgba(232, 121, 29, 0.08);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container h3 {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
|
||||||
|
.tables-section {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
background: var(--bg-light);
|
||||||
|
border: 1px solid rgba(232, 121, 29, 0.08);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 18px 20px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container h3 {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: 1px solid rgba(232, 121, 29, 0.15);
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid rgba(232, 121, 29, 0.06);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover td {
|
||||||
|
background: rgba(232, 121, 29, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable:hover td {
|
||||||
|
background: rgba(232, 121, 29, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Session Detail */
|
||||||
|
|
||||||
|
.session-detail {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-detail.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-detail h2 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-detail h2 span {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-detail {
|
||||||
|
background: var(--bg-light);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid rgba(232, 121, 29, 0.15);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 6px 16px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-detail:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.costs-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-cards {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-row,
|
||||||
|
.tables-section,
|
||||||
|
.detail-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
let currentPeriod = 'all';
|
||||||
|
let charts = {};
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
accent: '#e8791d',
|
||||||
|
devon: '#c4944a',
|
||||||
|
green: '#5a8a3c',
|
||||||
|
red: '#cc2222',
|
||||||
|
blue: '#4a8ac4',
|
||||||
|
purple: '#8a5ac4',
|
||||||
|
tan: '#c4845a',
|
||||||
|
teal: '#3c8a7a',
|
||||||
|
pink: '#c45a8a',
|
||||||
|
slate: '#6a7a8a',
|
||||||
|
};
|
||||||
|
const COLOR_LIST = Object.values(COLORS);
|
||||||
|
|
||||||
|
Chart.defaults.color = '#9a8b78';
|
||||||
|
Chart.defaults.borderColor = 'rgba(245, 240, 229, 0.08)';
|
||||||
|
|
||||||
|
// --- Utilities ---
|
||||||
|
|
||||||
|
function formatCost(n) {
|
||||||
|
if (n == null) return '--';
|
||||||
|
return n < 0.01 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
if (!iso) return '--';
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +
|
||||||
|
', ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortenModel(name) {
|
||||||
|
if (!name) return '--';
|
||||||
|
const i = name.indexOf('/');
|
||||||
|
return i >= 0 ? name.slice(i + 1) : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyChart(key) {
|
||||||
|
if (charts[key]) {
|
||||||
|
charts[key].destroy();
|
||||||
|
charts[key] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Data Loading ---
|
||||||
|
|
||||||
|
async function loadDashboard() {
|
||||||
|
const p = currentPeriod;
|
||||||
|
try {
|
||||||
|
const [summary, timeline, models, categories, sessions, expensive] = await Promise.all([
|
||||||
|
fetch(`/api/costs/summary?period=${p}`).then(r => r.json()),
|
||||||
|
fetch(`/api/costs/timeline?period=${p}&group_by=session`).then(r => r.json()),
|
||||||
|
fetch(`/api/costs/models?period=${p}`).then(r => r.json()),
|
||||||
|
fetch(`/api/costs/categories?period=${p}`).then(r => r.json()),
|
||||||
|
fetch(`/api/costs/sessions?period=${p}`).then(r => r.json()),
|
||||||
|
fetch(`/api/costs/expensive?period=${p}&limit=10`).then(r => r.json()),
|
||||||
|
]);
|
||||||
|
renderSummary(summary);
|
||||||
|
renderTimeline(timeline);
|
||||||
|
renderModels(models);
|
||||||
|
renderCategories(categories);
|
||||||
|
renderSessionBars(timeline);
|
||||||
|
renderExpensiveTable(expensive);
|
||||||
|
renderSessionsTable(sessions);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Dashboard load error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Render Functions ---
|
||||||
|
|
||||||
|
function renderSummary(data) {
|
||||||
|
document.getElementById('total-spend').textContent = formatCost(data.total_cost);
|
||||||
|
document.getElementById('llm-tts-split').textContent =
|
||||||
|
`${formatCost(data.llm_cost)} / ${formatCost(data.tts_cost)}`;
|
||||||
|
document.getElementById('session-count').textContent = data.sessions;
|
||||||
|
document.getElementById('avg-cost').textContent = formatCost(data.avg_cost_per_session);
|
||||||
|
|
||||||
|
const changeEl = document.getElementById('total-change');
|
||||||
|
if (data.cost_change_pct != null) {
|
||||||
|
const up = data.cost_change_pct > 0;
|
||||||
|
changeEl.textContent = `${up ? '\u2191' : '\u2193'} ${Math.abs(data.cost_change_pct)}% vs prev period`;
|
||||||
|
changeEl.className = 'card-change ' + (up ? 'positive' : 'negative');
|
||||||
|
} else {
|
||||||
|
changeEl.textContent = '';
|
||||||
|
changeEl.className = 'card-change';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTimeline(data) {
|
||||||
|
destroyChart('timeline');
|
||||||
|
const ctx = document.getElementById('timeline-chart');
|
||||||
|
const labels = data.map(d => d.date || formatDate(d.started_at));
|
||||||
|
charts.timeline = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'LLM',
|
||||||
|
data: data.map(d => d.llm_cost),
|
||||||
|
borderColor: COLORS.accent,
|
||||||
|
backgroundColor: COLORS.accent + '22',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'TTS',
|
||||||
|
data: data.map(d => d.tts_cost),
|
||||||
|
borderColor: COLORS.devon,
|
||||||
|
backgroundColor: COLORS.devon + '22',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top' },
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: { beginAtZero: true, ticks: { callback: v => '$' + v.toFixed(2) } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModels(data) {
|
||||||
|
destroyChart('models');
|
||||||
|
const ctx = document.getElementById('model-chart');
|
||||||
|
charts.models = new Chart(ctx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: data.map(d => shortenModel(d.model)),
|
||||||
|
datasets: [{
|
||||||
|
data: data.map(d => d.cost),
|
||||||
|
backgroundColor: data.map((_, i) => COLOR_LIST[i % COLOR_LIST.length]),
|
||||||
|
borderWidth: 0,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'right', labels: { boxWidth: 12, padding: 8, font: { size: 11 } } },
|
||||||
|
tooltip: { callbacks: { label: ctx => `${ctx.label}: ${formatCost(ctx.parsed)}` } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCategories(data) {
|
||||||
|
destroyChart('categories');
|
||||||
|
const ctx = document.getElementById('category-chart');
|
||||||
|
charts.categories = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: data.map(d => d.category),
|
||||||
|
datasets: [{
|
||||||
|
data: data.map(d => d.cost),
|
||||||
|
backgroundColor: COLORS.accent + 'cc',
|
||||||
|
borderRadius: 4,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
indexAxis: 'y',
|
||||||
|
responsive: true,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: {
|
||||||
|
x: { beginAtZero: true, ticks: { callback: v => '$' + v.toFixed(2) } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSessionBars(data) {
|
||||||
|
destroyChart('sessionBars');
|
||||||
|
const ctx = document.getElementById('session-chart');
|
||||||
|
const costs = data.map(d => d.total_cost);
|
||||||
|
const avg = costs.length ? costs.reduce((a, b) => a + b, 0) / costs.length : 0;
|
||||||
|
charts.sessionBars = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: data.map(d => d.date || formatDate(d.started_at)),
|
||||||
|
datasets: [{
|
||||||
|
data: costs,
|
||||||
|
backgroundColor: costs.map(c => c > avg ? COLORS.red + 'cc' : COLORS.green + 'cc'),
|
||||||
|
borderRadius: 4,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: {
|
||||||
|
y: { beginAtZero: true, ticks: { callback: v => '$' + v.toFixed(2) } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderExpensiveTable(data) {
|
||||||
|
const tbody = document.querySelector('#expensive-table tbody');
|
||||||
|
tbody.innerHTML = data.map(d => `
|
||||||
|
<tr>
|
||||||
|
<td>${shortenModel(d.model)}</td>
|
||||||
|
<td>${d.category}</td>
|
||||||
|
<td>${d.caller_name || '\u2014'}</td>
|
||||||
|
<td>${(d.prompt_tokens + d.completion_tokens).toLocaleString()}</td>
|
||||||
|
<td>${formatCost(d.cost)}</td>
|
||||||
|
<td>${d.latency_ms.toFixed(0)}ms</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSessionsTable(data) {
|
||||||
|
const tbody = document.querySelector('#sessions-table tbody');
|
||||||
|
tbody.innerHTML = data.map(d => `
|
||||||
|
<tr class="clickable" data-session="${d.session_id}">
|
||||||
|
<td>${formatDate(d.started_at)}</td>
|
||||||
|
<td>${formatCost(d.llm_cost)}</td>
|
||||||
|
<td>${formatCost(d.tts_cost)}</td>
|
||||||
|
<td>${formatCost(d.total_cost)}</td>
|
||||||
|
<td>${d.total_llm_calls}</td>
|
||||||
|
<td><button class="view-btn" data-session="${d.session_id}">View</button></td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
tbody.querySelectorAll('.view-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
showSessionDetail(btn.dataset.session);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
tbody.querySelectorAll('.clickable').forEach(row => {
|
||||||
|
row.addEventListener('click', () => showSessionDetail(row.dataset.session));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Session Detail ---
|
||||||
|
|
||||||
|
async function showSessionDetail(sessionId) {
|
||||||
|
try {
|
||||||
|
const detail = await fetch(`/api/costs/session/${sessionId}`).then(r => r.json());
|
||||||
|
|
||||||
|
document.getElementById('detail-session-id').textContent = sessionId;
|
||||||
|
|
||||||
|
populateDetailTable('#detail-caller-table tbody', detail.by_caller,
|
||||||
|
d => `<td>${d.caller_name}</td><td>${d.calls}</td><td>${formatCost(d.cost)}</td>`);
|
||||||
|
|
||||||
|
populateDetailTable('#detail-category-table tbody', detail.by_category,
|
||||||
|
d => `<td>${d.category}</td><td>${d.calls}</td><td>${formatCost(d.cost)}</td>`);
|
||||||
|
|
||||||
|
populateDetailTable('#detail-model-table tbody', detail.by_model,
|
||||||
|
d => `<td>${shortenModel(d.model)}</td><td>${d.calls}</td><td>${formatCost(d.cost)}</td>`);
|
||||||
|
|
||||||
|
populateDetailTable('#detail-expensive-table tbody', detail.expensive_calls,
|
||||||
|
d => `<td>${d.category}</td><td>${shortenModel(d.model)}</td><td>${d.caller_name || '\u2014'}</td>` +
|
||||||
|
`<td>${formatCost(d.cost)}</td><td>${(d.prompt_tokens + d.completion_tokens).toLocaleString()}</td>` +
|
||||||
|
`<td>${d.latency_ms.toFixed(0)}ms</td>`);
|
||||||
|
|
||||||
|
document.querySelectorAll('.costs-main > section:not(.session-detail)').forEach(
|
||||||
|
s => s.style.display = 'none');
|
||||||
|
document.getElementById('session-detail').classList.remove('hidden');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Detail load error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateDetailTable(selector, data, rowFn) {
|
||||||
|
const tbody = document.querySelector(selector);
|
||||||
|
tbody.innerHTML = data.map(d => `<tr>${rowFn(d)}</tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDetail() {
|
||||||
|
document.getElementById('session-detail').classList.add('hidden');
|
||||||
|
document.querySelectorAll('.costs-main > section:not(.session-detail)').forEach(
|
||||||
|
s => s.style.display = '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Init ---
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.querySelectorAll('.period-tab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
document.querySelector('.period-tab.active').classList.remove('active');
|
||||||
|
tab.classList.add('active');
|
||||||
|
currentPeriod = tab.dataset.period;
|
||||||
|
loadDashboard();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.getElementById('close-detail').addEventListener('click', closeDetail);
|
||||||
|
loadDashboard();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user