diff --git a/public/dashboard/cost.js b/public/dashboard/cost.js new file mode 100644 index 0000000..cfc2609 --- /dev/null +++ b/public/dashboard/cost.js @@ -0,0 +1,642 @@ +// Cost tab: read-only view of token spend and model usage over time. +// +// Module contract: registers with PhantomDashboard via +// registerRoute("cost", { mount }). mount(container, arg, ctx) is called +// on hash change. Cost does not honor `arg` (no per-item deep link); it +// links OUT to Sessions via #/sessions/ instead. +// +// All values from the API flow through ctx.esc() or textContent. Operator- +// controlled fields include model (from cost_events), channel_id, +// conversation_id, and session_key. Audit every interpolation. + +(function () { + var CHANNELS = ["slack", "chat", "telegram", "email", "webhook", "scheduler", "cli", "mcp", "trigger"]; + var SERIES_PALETTE_LENGTH = 8; + + var state = { + loading: false, + error: null, + data: null, + range: "30", + groupBy: "day", + }; + var ctx = null; + var root = null; + var hoverTooltipEl = null; + + function esc(s) { return ctx.esc(s); } + + function formatCost(n) { + if (typeof n !== "number" || !isFinite(n)) return "$0.00"; + if (n === 0) return "$0.00"; + if (n > 0 && n < 0.01) return "<$0.01"; + if (n >= 1000) return "$" + Math.round(n).toLocaleString(); + return "$" + n.toFixed(2); + } + + function formatCostShort(n) { + if (typeof n !== "number" || !isFinite(n)) return "$0"; + if (n === 0) return "$0"; + if (n >= 1000) return "$" + (n / 1000).toFixed(1) + "k"; + if (n >= 10) return "$" + Math.round(n); + if (n >= 1) return "$" + n.toFixed(1); + return "$" + n.toFixed(2); + } + + function formatInt(n) { + if (typeof n !== "number" || !isFinite(n)) return "0"; + return Math.round(n).toLocaleString(); + } + + function formatPct(n) { + if (typeof n !== "number" || !isFinite(n)) return "0%"; + var sign = n > 0 ? "+" : ""; + return sign + n.toFixed(1) + "%"; + } + + function formatShortDate(isoDay) { + if (!isoDay) return ""; + var parts = String(isoDay).split("-"); + if (parts.length !== 3) return isoDay; + var months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + var m = parseInt(parts[1], 10) - 1; + if (m < 0 || m > 11) return isoDay; + return months[m] + " " + parseInt(parts[2], 10); + } + + function parseSqlDate(s) { + if (!s) return null; + var iso = String(s).replace(" ", "T") + "Z"; + var d = new Date(iso); + if (isNaN(d.getTime())) { + d = new Date(s); + if (isNaN(d.getTime())) return null; + } + return d; + } + + function relativeTime(s) { + var d = parseSqlDate(s); + if (!d) return ""; + var diff = Date.now() - d.getTime(); + if (diff < 0) diff = 0; + var sec = Math.floor(diff / 1000); + if (sec < 60) return sec + "s ago"; + var min = Math.floor(sec / 60); + if (min < 60) return min + "m ago"; + var hr = Math.floor(min / 60); + if (hr < 24) return hr + "h ago"; + var day = Math.floor(hr / 24); + if (day < 30) return day + "d ago"; + var mo = Math.floor(day / 30); + if (mo < 12) return mo + "mo ago"; + return Math.floor(day / 365) + "y ago"; + } + + function modelLabel(model) { + return String(model || "").replace(/^claude-/, "").replace(/-\d+$/, "") || "unknown"; + } + + function channelColorIdx(channelId) { + var idx = CHANNELS.indexOf(channelId); + if (idx < 0) { + var h = 0; + for (var i = 0; i < channelId.length; i++) h = ((h << 5) - h + channelId.charCodeAt(i)) | 0; + idx = Math.abs(h); + } + return idx % SERIES_PALETTE_LENGTH; + } + + // Build a stable model->seriesIdx map. The model with the highest total + // cost in the range gets idx 0 (primary color), then idx 1, etc. + function buildModelIndex(byModel) { + var map = {}; + for (var i = 0; i < byModel.length; i++) { + map[byModel[i].model] = i % SERIES_PALETTE_LENGTH; + } + return map; + } + + // Round up to a "nice" increment so y-axis ticks are human readable. + function niceCeil(v) { + if (v <= 0) return 1; + var pow = Math.pow(10, Math.floor(Math.log10(v))); + var n = v / pow; + var bucket = n <= 1 ? 1 : n <= 2 ? 2 : n <= 2.5 ? 2.5 : n <= 5 ? 5 : 10; + return bucket * pow; + } + + // ---- Rendering ---- + + function renderHeader() { + return ( + '
' + + '

Cost

' + + '

Cost

' + + '

Token spend and model usage over time. Drill down to the most expensive sessions to see what drove the bill.

' + + '
' + + '' + + '
' + + '
' + ); + } + + function renderFilterBar() { + var rangeOpts = [ + { v: "7", l: "Last 7 days" }, + { v: "30", l: "Last 30 days" }, + { v: "90", l: "Last 90 days" }, + { v: "all", l: "All time" }, + ].map(function (o) { + return ''; + }).join(""); + + return ( + '
' + + '
' + + '' + + '' + + '
' + + '
' + + 'Group by' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + ); + } + + function metricCard(label, valueHtml, deltaHtml) { + return ( + '
' + + '

' + esc(label) + '

' + + '

' + valueHtml + '

' + + (deltaHtml ? '

' + deltaHtml + '

' : "") + + '
' + ); + } + + function deltaClass(n) { + return n > 0 ? "dash-metric-delta-down" : n < 0 ? "dash-metric-delta-up" : ""; + } + + function renderMetricStrip() { + if (!state.data) { + var skel = ''; + return '
' + skel + skel + skel + skel + skel + '
'; + } + var h = state.data.headline; + var dayDelta = h.yesterday === 0 && h.today === 0 + ? "No activity" + : '' + esc(formatPct(h.day_delta_pct) + " vs yesterday") + ''; + var weekDelta = '' + esc(formatPct(h.week_delta_pct) + " vs prior week") + ''; + var avgDaily = state.data.daily.length > 0 + ? state.data.daily.reduce(function (a, r) { return a + r.cost_usd; }, 0) / state.data.daily.length + : 0; + return ( + '
' + + metricCard("Today", esc(formatCost(h.today)), dayDelta) + + metricCard("Yesterday", esc(formatCost(h.yesterday))) + + metricCard("This week", esc(formatCost(h.this_week)), weekDelta) + + metricCard("This month", esc(formatCost(h.this_month)), esc("avg " + formatCost(avgDaily) + "/d")) + + metricCard("All time", esc(formatCost(h.all_time))) + + '
' + ); + } + + // ---- Chart ---- + + // Bucket daily rows into Monday-start weeks when groupBy === "week". + function bucketByWeek(daily) { + if (daily.length === 0) return []; + var buckets = []; + var current = null; + for (var i = 0; i < daily.length; i++) { + var d = daily[i]; + var dt = new Date(d.day + "T00:00:00Z"); + var monday = new Date(dt.getTime() - ((dt.getUTCDay() + 6) % 7) * 86400000); + var key = monday.toISOString().slice(0, 10); + if (!current || current.day !== key) { + current = { day: key, cost_usd: 0, by_model: {} }; + buckets.push(current); + } + current.cost_usd += d.cost_usd; + for (var j = 0; j < d.by_model.length; j++) { + var m = d.by_model[j]; + current.by_model[m.model] = (current.by_model[m.model] || 0) + m.cost_usd; + } + } + return buckets.map(function (b) { + var arr = Object.keys(b.by_model).map(function (k) { return { model: k, cost_usd: b.by_model[k] }; }); + arr.sort(function (a, b2) { return b2.cost_usd - a.cost_usd; }); + return { day: b.day, cost_usd: b.cost_usd, by_model: arr }; + }); + } + + // Generic stacked-bar SVG renderer. opts.rows is an array of + // { day, segments: [{ value, seriesIdx }] }. Returns SVG markup; the + // caller wires up hover on .dash-chart-bar-hit after it lands in DOM. + function renderStackedBarChart(opts) { + var rows = opts.rows; + var width = opts.width; + var height = opts.height; + var formatY = opts.formatY || function (n) { return String(n); }; + var padL = 46, padR = 12, padT = 8, padB = 28; + var innerW = Math.max(1, width - padL - padR); + var innerH = Math.max(1, height - padT - padB); + + if (rows.length === 0) { + return ''; + } + + var maxTotal = 0; + for (var i = 0; i < rows.length; i++) { + var t = 0; + for (var k = 0; k < rows[i].segments.length; k++) t += rows[i].segments[k].value; + if (t > maxTotal) maxTotal = t; + } + var yMax = niceCeil(maxTotal || 0.01); + var ticks = 4; + + var parts = ['']; + + for (var tk = 0; tk <= ticks; tk++) { + var yv = (yMax * tk) / ticks; + var yP = padT + innerH - (yv / yMax) * innerH; + parts.push(''); + parts.push('' + esc(formatY(yv)) + ''); + } + parts.push(''); + + var gap = rows.length > 60 ? 1 : rows.length > 30 ? 2 : 3; + var barW = Math.max(2, (innerW - gap * (rows.length - 1)) / rows.length); + var labelEvery = Math.max(1, Math.ceil(rows.length / 8)); + + for (var b = 0; b < rows.length; b++) { + var row = rows[b]; + var x = padL + b * (barW + gap); + var cursorY = padT + innerH; + var group = ''; + for (var s = 0; s < row.segments.length; s++) { + var seg = row.segments[s]; + if (seg.value <= 0) continue; + var segH = (seg.value / yMax) * innerH; + cursorY -= segH; + group += ''; + } + group += ''; + parts.push(group); + if (b % labelEvery === 0 || b === rows.length - 1) { + parts.push('' + esc(formatShortDate(row.day)) + ''); + } + } + parts.push(''); + return parts.join(""); + } + + function chartFrame(title, id, body) { + var idAttr = id ? ' id="' + id + '"' : ""; + return ( + '
' + + '

' + esc(title) + '

' + + body + + '
' + ); + } + + function renderChart() { + var title = state.groupBy === "week" ? "Weekly spend" : "Daily spend"; + if (state.loading && !state.data) { + return chartFrame(title, "", ''); + } + if (!state.data || state.data.daily.length === 0) { + return chartFrame(title, "", '
No cost events in this range yet.
'); + } + var modelIdx = buildModelIndex(state.data.by_model); + var source = state.groupBy === "week" ? bucketByWeek(state.data.daily) : state.data.daily; + var chartRows = source.map(function (d) { + return { + day: d.day, + segments: d.by_model.map(function (m) { + return { value: m.cost_usd, seriesIdx: modelIdx[m.model] || 0 }; + }), + }; + }); + var svg = renderStackedBarChart({ + rows: chartRows, + width: Math.max(640, chartRows.length * 24), + height: 260, + formatY: formatCostShort, + }); + return chartFrame(title, "cost-chart", + '
' + svg + '
' + + ''); + } + + // Generic table shell: headers declare label + class, rows is raw HTML. + // When rows is "" renders an empty-state row spanning all columns. + function renderTable(label, headers, rowsHtml, emptyMsg, bodyId) { + var headHtml = headers.map(function (h) { + var cls = "dash-table-head-cell" + (h.numeric ? " dash-table-head-cell-numeric" : ""); + return '' + esc(h.label) + ''; + }).join(""); + var body = rowsHtml + ? rowsHtml + : '' + esc(emptyMsg) + ''; + var bodyAttr = bodyId ? ' id="' + bodyId + '"' : ""; + return ( + '
' + + '' + + '' + headHtml + '' + + '' + body + '' + + '
' + + '
' + ); + } + + function renderByModel() { + var headers = [ + { label: "Model" }, + { label: "Cost", numeric: true }, + { label: "Share", numeric: true }, + ]; + if (!state.data) return renderTable("By model", headers, "", "Loading."); + var rows = state.data.by_model; + if (rows.length === 0) return renderTable("By model", headers, "", "No models in this range."); + var modelIdx = buildModelIndex(rows); + var body = rows.map(function (r) { + return ( + '' + + '' + + '' + + '' + esc(modelLabel(r.model)) + '' + + '' + + '' + esc(formatCost(r.cost_usd)) + '' + + '' + esc((r.pct * 100).toFixed(1) + "%") + '' + + '' + ); + }).join(""); + return renderTable("By model", headers, body); + } + + function channelCell(channelId) { + return '' + esc(channelId) + ''; + } + + function conversationCell(row) { + if (row.channel_id === "slack") { + var parts = String(row.conversation_id || "").split("/"); + if (parts.length >= 2) { + return '' + esc(parts[0]) + ' / ' + esc(parts.slice(1).join("/")); + } + } + return esc(row.conversation_id); + } + + function renderByChannel() { + var headers = [{ label: "Channel" }, { label: "Cost", numeric: true }, { label: "Per session", numeric: true }]; + if (!state.data) return renderTable("By channel", headers, "", "Loading."); + var rows = state.data.by_channel; + if (rows.length === 0) return renderTable("By channel", headers, "", "No channels in this range."); + var body = rows.map(function (r) { + return ( + '' + + '' + channelCell(r.channel_id) + '' + + '' + esc(formatCost(r.cost_usd)) + '' + + '' + esc(formatCost(r.avg_per_session)) + '' + + '' + ); + }).join(""); + return renderTable("By channel", headers, body); + } + + function renderTopSessions() { + var headers = [ + { label: "Channel" }, + { label: "Conversation" }, + { label: "Cost", numeric: true }, + { label: "Turns", numeric: true }, + { label: "Last active" }, + ]; + var rows = state.data ? state.data.top_sessions : null; + if (!rows) return renderTable("Top sessions", headers, "", "Loading.", "cost-top-tbody"); + if (rows.length === 0) return renderTable("Top sessions", headers, "", "No sessions in this range.", "cost-top-tbody"); + var body = rows.map(function (r) { + var keyAttr = encodeURIComponent(r.session_key); + return ( + '' + + '' + channelCell(r.channel_id) + '' + + '' + conversationCell(r) + '' + + '' + esc(formatCost(r.total_cost_usd)) + '' + + '' + formatInt(r.turn_count) + '' + + '' + esc(relativeTime(r.last_active_at)) + '' + + '' + ); + }).join(""); + return renderTable("Top sessions", headers, body, "", "cost-top-tbody"); + } + + function renderError() { + return ( + '
' + + '

Could not load cost data

' + + '

' + esc((state.error && state.error.message) || "Unknown error") + '

' + + '' + + '
' + ); + } + + function sectionLabel(text) { + return '

' + esc(text) + '

'; + } + + function render() { + if (!root) return; + if (state.error) { + root.innerHTML = renderHeader() + renderFilterBar() + renderError(); + wireFilterBar(); + var r = document.getElementById("cost-retry-btn"); + if (r) r.addEventListener("click", load); + return; + } + root.innerHTML = ( + renderHeader() + renderFilterBar() + renderMetricStrip() + renderChart() + + '
' + + '
' + sectionLabel("By model") + renderByModel() + '
' + + '
' + sectionLabel("By channel") + renderByChannel() + '
' + + '
' + + '
' + sectionLabel("Top 10 sessions") + renderTopSessions() + '
' + ); + wireFilterBar(); + wireChart(); + wireTopSessions(); + wireExport(); + } + + // ---- Wiring ---- + + function wireFilterBar() { + var range = document.getElementById("cost-filter-range"); + if (range) range.addEventListener("change", function () { + state.range = range.value; + load(); + }); + var d = document.getElementById("cost-group-day"); + var w = document.getElementById("cost-group-week"); + if (d) d.addEventListener("click", function () { + if (state.groupBy === "day") return; + state.groupBy = "day"; + render(); + }); + if (w) w.addEventListener("click", function () { + if (state.groupBy === "week") return; + state.groupBy = "week"; + render(); + }); + } + + function wireTopSessions() { + var tbody = document.getElementById("cost-top-tbody"); + if (!tbody) return; + var rows = tbody.querySelectorAll(".dash-table-row[data-clickable]"); + for (var i = 0; i < rows.length; i++) { + rows[i].addEventListener("click", onRowActivate); + rows[i].addEventListener("keydown", onRowKeyDown); + } + } + + function onRowActivate(e) { + var encoded = e.currentTarget.getAttribute("data-session-key-encoded"); + if (encoded) ctx.navigate("#/sessions/" + encoded); + } + + function onRowKeyDown(e) { + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onRowActivate(e); } + } + + function wireExport() { + var btn = document.getElementById("cost-export-btn"); + if (btn) btn.addEventListener("click", exportCsv); + } + + function wireChart() { + var chart = document.getElementById("cost-chart"); + if (!chart || !state.data) return; + hoverTooltipEl = document.getElementById("cost-chart-tooltip"); + var hits = chart.querySelectorAll(".dash-chart-bar-hit"); + var source = state.groupBy === "week" ? bucketByWeek(state.data.daily) : state.data.daily; + var modelIdx = buildModelIndex(state.data.by_model); + for (var i = 0; i < hits.length; i++) { + (function (hit) { + hit.addEventListener("mouseenter", function (e) { showTooltip(e, hit, source, modelIdx); }); + hit.addEventListener("mousemove", function (e) { positionTooltip(e, hit); }); + hit.addEventListener("mouseleave", hideTooltip); + })(hits[i]); + } + } + + function showTooltip(evt, hit, source, modelIdx) { + if (!hoverTooltipEl) return; + var idx = parseInt(hit.getAttribute("data-bar-index"), 10); + if (isNaN(idx) || !source[idx]) return; + var row = source[idx]; + var title = (state.groupBy === "week" ? "Week of " : "") + formatShortDate(row.day); + var segRows = row.by_model.map(function (m) { + var mi = (modelIdx[m.model] || 0) % SERIES_PALETTE_LENGTH; + return ( + '
' + + '' + + '' + esc(modelLabel(m.model)) + '' + + '' + esc(formatCost(m.cost_usd)) + '' + + '
' + ); + }).join(""); + hoverTooltipEl.innerHTML = ( + '

' + esc(title) + '

' + + segRows + + '

Total ' + esc(formatCost(row.cost_usd)) + '

' + ); + hoverTooltipEl.setAttribute("data-visible", "true"); + positionTooltip(evt, hit); + } + + function positionTooltip(_evt, hit) { + if (!hoverTooltipEl) return; + var chart = document.getElementById("cost-chart"); + if (!chart) return; + var rect = chart.getBoundingClientRect(); + var hitRect = hit.getBoundingClientRect(); + var cx = hitRect.left + hitRect.width / 2 - rect.left; + var half = (hoverTooltipEl.offsetWidth || 200) / 2; + if (cx - half < 6) cx = half + 6; + if (cx + half > rect.width - 6) cx = rect.width - half - 6; + hoverTooltipEl.style.left = cx + "px"; + hoverTooltipEl.style.top = Math.max(0, hitRect.top - rect.top) + "px"; + } + + function hideTooltip() { + if (hoverTooltipEl) hoverTooltipEl.setAttribute("data-visible", "false"); + } + + // ---- Load ---- + + function load() { + state.loading = true; + state.error = null; + render(); + var url = "/ui/api/cost?days=" + encodeURIComponent(state.range); + return ctx.api("GET", url).then(function (res) { + state.data = res; + state.loading = false; + render(); + }).catch(function (err) { + state.loading = false; + state.error = err; + state.data = null; + render(); + ctx.toast("error", "Failed to load cost data", err.message || String(err)); + }); + } + + // ---- CSV export ---- + + function exportCsv() { + if (!state.data || state.data.daily.length === 0) { + ctx.toast("error", "Nothing to export", "No cost data in the current range."); + return; + } + var headers = ["day", "cost_usd", "input_tokens", "output_tokens"]; + var rows = [headers.join(",")]; + state.data.daily.forEach(function (d) { + rows.push([d.day, d.cost_usd.toFixed(6), d.input_tokens, d.output_tokens].join(",")); + }); + var csv = rows.join("\n"); + var blob = new Blob([csv], { type: "text/csv;charset=utf-8" }); + var url = URL.createObjectURL(blob); + var a = document.createElement("a"); + a.href = url; + a.download = "cost-" + new Date().toISOString().slice(0, 10) + ".csv"; + document.body.appendChild(a); + a.click(); + setTimeout(function () { + URL.revokeObjectURL(url); + if (a.parentNode) a.parentNode.removeChild(a); + }, 100); + } + + // ---- Mount ---- + + function mount(container, _arg, dashCtx) { + ctx = dashCtx; + root = container; + ctx.setBreadcrumb("Cost"); + render(); + return load(); + } + + if (window.PhantomDashboard && window.PhantomDashboard.registerRoute) { + window.PhantomDashboard.registerRoute("cost", { mount: mount }); + } +})(); diff --git a/public/dashboard/dashboard.css b/public/dashboard/dashboard.css index b41442c..0130a0f 100644 --- a/public/dashboard/dashboard.css +++ b/public/dashboard/dashboard.css @@ -2089,17 +2089,159 @@ body { line-height: 1.5; } +/* ---- .dash-chart: generic SVG chart primitive. Used by Cost for the + stacked bar; Evolution reuses it as a sparkline. Series fills come + from [data-series-idx] below so dark/light themes track automatically. */ +.dash-chart { + position: relative; + padding: var(--space-4); + border: 1px solid var(--color-base-300); + border-radius: var(--radius-md); + background: var(--color-base-200); + margin-bottom: var(--space-5); +} +.dash-chart-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-3); + margin-bottom: var(--space-3); +} +.dash-chart-title { font-size: 13px; font-weight: 600; margin: 0; } +.dash-chart-scroll { overflow-x: auto; overflow-y: hidden; } +.dash-chart-svg { + display: block; + width: 100%; + /* Floor at 540px so narrow viewports trigger the .dash-chart-scroll + horizontal scroll instead of squashing the bars and tick labels via + preserveAspectRatio="none". */ + min-width: 540px; + height: auto; + font-family: 'JetBrains Mono', ui-monospace, monospace; +} +.dash-chart-axis { stroke: color-mix(in oklab, var(--color-base-content) 22%, transparent); stroke-width: 1; } +.dash-chart-gridline { + stroke: color-mix(in oklab, var(--color-base-content) 10%, transparent); + stroke-width: 1; + stroke-dasharray: 2 3; +} +.dash-chart-tick-label { fill: color-mix(in oklab, var(--color-base-content) 55%, transparent); font-size: 10px; } +.dash-chart-bar { cursor: pointer; transition: filter var(--motion-fast) var(--ease-out); } +.dash-chart-bar:hover { filter: brightness(1.15); } +.dash-chart-tooltip { + position: absolute; + background: var(--color-base-content); + color: var(--color-base-100); + font-family: Inter, system-ui, sans-serif; + font-size: 11.5px; + line-height: 1.45; + padding: 8px 10px; + border-radius: var(--radius-sm); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2); + pointer-events: none; + opacity: 0; + transform: translate(-50%, calc(-100% - 8px)); + transition: opacity var(--motion-fast) var(--ease-out); + white-space: nowrap; + z-index: 5; + max-width: 280px; +} +.dash-chart-tooltip[data-visible="true"] { opacity: 1; } +.dash-chart-tooltip-title { font-weight: 600; margin: 0 0 4px; font-size: 11px; letter-spacing: 0.02em; } +.dash-chart-tooltip-row { display: flex; align-items: center; gap: 6px; margin: 2px 0; } +.dash-chart-tooltip-swatch, .dash-breakdown-swatch { + width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; +} +.dash-chart-tooltip-swatch { width: 8px; height: 8px; } +.dash-chart-tooltip-total { + margin-top: 4px; padding-top: 4px; font-weight: 600; + border-top: 1px solid color-mix(in oklab, var(--color-base-100) 25%, transparent); +} +.dash-chart-empty { + padding: var(--space-8) var(--space-5); + text-align: center; + color: color-mix(in oklab, var(--color-base-content) 55%, transparent); + font-size: 12.5px; +} +.dash-chart-skeleton { + height: 240px; + border-radius: var(--radius-sm); + background: linear-gradient(90deg, + var(--color-base-300) 25%, + color-mix(in oklab, var(--color-base-300) 50%, transparent) 50%, + var(--color-base-300) 75%); + background-size: 200% 100%; + animation: dash-shimmer 1.5s infinite; +} +.dash-breakdown-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-4); + margin-bottom: var(--space-5); +} +@media (max-width: 860px) { + .dash-breakdown-grid { grid-template-columns: 1fr; } +} +.dash-breakdown-swatch { + display: inline-block; + margin-right: 8px; + vertical-align: middle; +} + +/* Segmented control for compact on/off toggles (e.g. Day | Week). */ +.dash-segmented { + display: inline-flex; + background: var(--color-base-100); + border: 1px solid var(--color-base-300); + border-radius: var(--radius-sm); + padding: 2px; + gap: 2px; +} +.dash-segmented button { + font: 500 12px Inter, system-ui, sans-serif; + color: color-mix(in oklab, var(--color-base-content) 65%, transparent); + background: transparent; + border: 0; + padding: 5px 10px; + border-radius: 4px; + cursor: pointer; + transition: background-color var(--motion-fast), color var(--motion-fast); +} +.dash-segmented button:hover { color: var(--color-base-content); } +.dash-segmented button[aria-pressed="true"] { + background: var(--color-base-200); + color: var(--color-base-content); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); +} +.dash-segmented button:focus-visible { + outline: none; + box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-primary) 25%, transparent); +} + +/* Series palette: shared by SVG bars, tooltip dots, breakdown swatches. */ +[data-series-idx="0"].dash-chart-bar { fill: var(--color-primary); } +[data-series-idx="1"].dash-chart-bar { fill: var(--color-info); } +[data-series-idx="2"].dash-chart-bar { fill: var(--color-success); } +[data-series-idx="3"].dash-chart-bar { fill: var(--color-warning); } +[data-series-idx="4"].dash-chart-bar { fill: #a855f7; } +[data-series-idx="5"].dash-chart-bar { fill: #ec4899; } +[data-series-idx="6"].dash-chart-bar { fill: #0891b2; } +[data-series-idx="7"].dash-chart-bar { fill: #d97706; } +[data-series-idx="0"].dash-chart-tooltip-swatch, [data-series-idx="0"].dash-breakdown-swatch { background: var(--color-primary); } +[data-series-idx="1"].dash-chart-tooltip-swatch, [data-series-idx="1"].dash-breakdown-swatch { background: var(--color-info); } +[data-series-idx="2"].dash-chart-tooltip-swatch, [data-series-idx="2"].dash-breakdown-swatch { background: var(--color-success); } +[data-series-idx="3"].dash-chart-tooltip-swatch, [data-series-idx="3"].dash-breakdown-swatch { background: var(--color-warning); } +[data-series-idx="4"].dash-chart-tooltip-swatch, [data-series-idx="4"].dash-breakdown-swatch { background: #a855f7; } +[data-series-idx="5"].dash-chart-tooltip-swatch, [data-series-idx="5"].dash-breakdown-swatch { background: #ec4899; } +[data-series-idx="6"].dash-chart-tooltip-swatch, [data-series-idx="6"].dash-breakdown-swatch { background: #0891b2; } +[data-series-idx="7"].dash-chart-tooltip-swatch, [data-series-idx="7"].dash-breakdown-swatch { background: #d97706; } + /* Respect reduced motion: snap drawer in, skip shimmer. */ @media (prefers-reduced-motion: reduce) { - .dash-drawer { - animation: none; - } - .dash-drawer-backdrop { - animation: none; - } + .dash-drawer, .dash-drawer-backdrop { animation: none; } .dash-metric-card.dash-metric-skeleton .dash-metric-label, .dash-metric-card.dash-metric-skeleton .dash-metric-value, - .dash-table-skeleton-pill { - animation: none; - } + .dash-table-skeleton-pill, + .dash-chart-skeleton { animation: none; } + .dash-chart-bar, .dash-chart-tooltip { transition: none; } } diff --git a/public/dashboard/dashboard.js b/public/dashboard/dashboard.js index f988319..ee385a6 100644 --- a/public/dashboard/dashboard.js +++ b/public/dashboard/dashboard.js @@ -265,8 +265,8 @@ var name = parsed.route; deactivateAllRoutes(); - var liveRoutes = ["skills", "memory-files", "plugins", "subagents", "hooks", "settings", "sessions"]; - var comingSoon = ["cost", "scheduler", "evolution", "memory"]; + var liveRoutes = ["skills", "memory-files", "plugins", "subagents", "hooks", "settings", "sessions", "cost"]; + var comingSoon = ["scheduler", "evolution", "memory"]; if (liveRoutes.indexOf(name) >= 0 && routes[name]) { var containerId = "route-" + name; diff --git a/public/dashboard/index.html b/public/dashboard/index.html index 686ba2d..3115e51 100644 --- a/public/dashboard/index.html +++ b/public/dashboard/index.html @@ -64,15 +64,14 @@ Sessions + + + Cost +
Coming soon