From 486842d2d19dc5876fee217ded890cdef2c4a863 Mon Sep 17 00:00:00 2001 From: tcpsyn Date: Thu, 2 Apr 2026 23:58:22 -0600 Subject: [PATCH] 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 --- frontend/costs.html | 119 +++++++++++++++++ frontend/css/costs.css | 268 +++++++++++++++++++++++++++++++++++++ frontend/js/costs.js | 296 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 683 insertions(+) create mode 100644 frontend/costs.html create mode 100644 frontend/css/costs.css create mode 100644 frontend/js/costs.js diff --git a/frontend/costs.html b/frontend/costs.html new file mode 100644 index 0000000..5cf6466 --- /dev/null +++ b/frontend/costs.html @@ -0,0 +1,119 @@ + + + + + + Cost Dashboard - Luke at the Roost + + + + + +
+

Cost Dashboard

+ Back to Show + +
+
+
+
+
Total Spend
+
--
+
+
+
+
LLM / TTS
+
--
+
+
+
Sessions
+
--
+
+
+
Avg / Session
+
--
+
+
+
+
+

Cost Over Time

+ +
+
+

Cost by Model

+ +
+
+
+
+

Cost by Category

+ +
+
+

Cost Per Session

+ +
+
+
+
+

Most Expensive Calls

+ + + + + +
ModelCategoryCallerTokensCostLatency
+
+
+

Sessions

+ + + + + +
DateLLMTTSTotalCalls
+
+
+ +
+ + + diff --git a/frontend/css/costs.css b/frontend/css/costs.css new file mode 100644 index 0000000..7e81d90 --- /dev/null +++ b/frontend/css/costs.css @@ -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; + } +} diff --git a/frontend/js/costs.js b/frontend/js/costs.js new file mode 100644 index 0000000..cef9e78 --- /dev/null +++ b/frontend/js/costs.js @@ -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 => ` + + ${shortenModel(d.model)} + ${d.category} + ${d.caller_name || '\u2014'} + ${(d.prompt_tokens + d.completion_tokens).toLocaleString()} + ${formatCost(d.cost)} + ${d.latency_ms.toFixed(0)}ms + + `).join(''); +} + +function renderSessionsTable(data) { + const tbody = document.querySelector('#sessions-table tbody'); + tbody.innerHTML = data.map(d => ` + + ${formatDate(d.started_at)} + ${formatCost(d.llm_cost)} + ${formatCost(d.tts_cost)} + ${formatCost(d.total_cost)} + ${d.total_llm_calls} + + + `).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 => `${d.caller_name}${d.calls}${formatCost(d.cost)}`); + + populateDetailTable('#detail-category-table tbody', detail.by_category, + d => `${d.category}${d.calls}${formatCost(d.cost)}`); + + populateDetailTable('#detail-model-table tbody', detail.by_model, + d => `${shortenModel(d.model)}${d.calls}${formatCost(d.cost)}`); + + populateDetailTable('#detail-expensive-table tbody', detail.expensive_calls, + d => `${d.category}${shortenModel(d.model)}${d.caller_name || '\u2014'}` + + `${formatCost(d.cost)}${(d.prompt_tokens + d.completion_tokens).toLocaleString()}` + + `${d.latency_ms.toFixed(0)}ms`); + + 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 => `${rowFn(d)}`).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(); +});