Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
642 changes: 642 additions & 0 deletions public/dashboard/cost.js

Large diffs are not rendered by default.

157 changes: 148 additions & 9 deletions public/dashboard/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -2089,17 +2089,156 @@ 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%;
min-width: 320px;
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; }
}
4 changes: 2 additions & 2 deletions public/dashboard/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 6 additions & 5 deletions public/dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,14 @@
<svg class="dash-sidebar-icon" fill="none" viewBox="0 0 24 24" stroke-width="1.6" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 14.15v4.098a2.25 2.25 0 0 1-2.25 2.25h-12a2.25 2.25 0 0 1-2.25-2.25V5.625a2.25 2.25 0 0 1 2.25-2.25h8.25M15.75 9l3.75-3.75m0 0L23.25 9m-3.75-3.75v9"/></svg>
<span>Sessions</span>
</a>
<a href="#/cost" class="dash-sidebar-item" data-route="cost">
<svg class="dash-sidebar-icon" fill="none" viewBox="0 0 24 24" stroke-width="1.6" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18 9 11.25l4.306 4.306a11.95 11.95 0 0 1 5.814-5.518l2.74-1.22m0 0-5.94-2.281m5.94 2.28-2.28 5.941"/></svg>
<span>Cost</span>
</a>
</nav>

<div class="dash-sidebar-eyebrow" style="margin-top:var(--space-5);">Coming soon</div>
<nav class="dash-sidebar-nav">
<a href="#/cost" class="dash-sidebar-item dash-sidebar-item-soon" data-route="cost">
<svg class="dash-sidebar-icon" fill="none" viewBox="0 0 24 24" stroke-width="1.6" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18 9 11.25l4.306 4.306a11.95 11.95 0 0 1 5.814-5.518l2.74-1.22m0 0-5.94-2.281m5.94 2.28-2.28 5.941"/></svg>
<span>Cost</span>
<span class="dash-sidebar-soon-pill">soon</span>
</a>
<a href="#/scheduler" class="dash-sidebar-item dash-sidebar-item-soon" data-route="scheduler">
<svg class="dash-sidebar-icon" fill="none" viewBox="0 0 24 24" stroke-width="1.6" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/></svg>
<span>Scheduler</span>
Expand Down Expand Up @@ -104,6 +103,7 @@
<div id="route-hooks" class="dash-route" hidden></div>
<div id="route-settings" class="dash-route" hidden></div>
<div id="route-sessions" class="dash-route" hidden></div>
<div id="route-cost" class="dash-route" hidden></div>
<div id="route-soon" class="dash-route" hidden></div>
</main>

Expand All @@ -119,6 +119,7 @@
<script src="/ui/dashboard/hooks.js"></script>
<script src="/ui/dashboard/settings.js"></script>
<script src="/ui/dashboard/sessions.js"></script>
<script src="/ui/dashboard/cost.js"></script>
<script>window.PhantomDashboard.init();</script>
</body>
</html>
184 changes: 184 additions & 0 deletions src/agent/cost-queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// Cost aggregation queries shared between the MCP cost resource, the
// universal_metrics_read tool, and the dashboard cost API. All SQL uses
// COALESCE(SUM(...), 0) so empty result sets return 0 rather than NULL.

import type { Database } from "bun:sqlite";

export type CostForPeriod = { total: number; events: number };

export type CostHeadline = {
today: number;
yesterday: number;
this_week: number;
this_month: number;
all_time: number;
day_delta_pct: number;
week_delta_pct: number;
};

export type DailyCostRow = {
day: string;
cost_usd: number;
input_tokens: number;
output_tokens: number;
by_model: Array<{ model: string; cost_usd: number }>;
};

export type ByModelRow = {
model: string;
cost_usd: number;
pct: number;
input_tokens: number;
output_tokens: number;
events: number;
};

export type ByChannelRow = {
channel_id: string;
cost_usd: number;
sessions: number;
avg_per_session: number;
input_tokens: number;
output_tokens: number;
};

export type TopSessionRow = {
session_key: string;
channel_id: string;
conversation_id: string;
total_cost_usd: number;
turn_count: number;
last_active_at: string;
};

// Percent change from `prior` to `now`, or 0 when prior is 0 (avoids NaN).
function deltaPct(now: number, prior: number): number {
return prior === 0 ? 0 : ((now - prior) / prior) * 100;
}

// Helper: run a single-row `SELECT COALESCE(SUM(cost_usd), 0) AS total`
// scoped by an inlined date expression. `dateExpr` is a trusted constant,
// never interpolated from user input.
function sumCost(db: Database, dateExpr: string): number {
const row = db.query(`SELECT COALESCE(SUM(cost_usd), 0) AS total FROM cost_events WHERE ${dateExpr}`).get() as {
total: number;
};
return row.total;
}

// Shared by MCP cost resource and universal metrics tool. `dateFilter` is
// an inlined SQLite date expression (e.g. "date('now', '-7 days')").
export function getCostForPeriod(db: Database, dateFilter: string): CostForPeriod {
const row = db
.query(
`SELECT COALESCE(SUM(cost_usd), 0) AS total, COUNT(*) AS events
FROM cost_events WHERE created_at >= ${dateFilter}`,
)
.get() as { total: number; events: number };
return row;
}

export function getCostHeadline(db: Database): CostHeadline {
const today = sumCost(db, "date(created_at) = date('now')");
const yesterday = sumCost(db, "date(created_at) = date('now', '-1 day')");
const thisWeek = sumCost(db, "created_at >= datetime('now', '-7 days')");
const priorWeek = sumCost(
db,
"created_at >= datetime('now', '-14 days') AND created_at < datetime('now', '-7 days')",
);
const thisMonth = sumCost(db, "created_at >= datetime('now', '-30 days')");
const allTime = (db.query("SELECT COALESCE(SUM(cost_usd), 0) AS total FROM cost_events").get() as { total: number })
.total;

return {
today,
yesterday,
this_week: thisWeek,
this_month: thisMonth,
all_time: allTime,
day_delta_pct: deltaPct(today, yesterday),
week_delta_pct: deltaPct(thisWeek, priorWeek),
};
}

// `days` is null for all-time, otherwise clamped by the caller. Returns
// only days that have events; the chart skips missing days on its x-axis.
export function getDailyCost(db: Database, days: number | null): DailyCostRow[] {
const where = days === null ? "" : "WHERE created_at >= datetime('now', ?)";
const params: string[] = days === null ? [] : [`-${days} days`];

const dayRows = db
.query(
`SELECT date(created_at) AS day,
COALESCE(SUM(cost_usd), 0) AS cost_usd,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens
FROM cost_events ${where} GROUP BY day ORDER BY day ASC`,
)
.all(...params) as Array<{ day: string; cost_usd: number; input_tokens: number; output_tokens: number }>;

const modelRows = db
.query(
`SELECT date(created_at) AS day, model, COALESCE(SUM(cost_usd), 0) AS cost_usd
FROM cost_events ${where} GROUP BY day, model ORDER BY day ASC, cost_usd DESC`,
)
.all(...params) as Array<{ day: string; model: string; cost_usd: number }>;

const byDay = new Map<string, Array<{ model: string; cost_usd: number }>>();
for (const mr of modelRows) {
const list = byDay.get(mr.day) ?? [];
list.push({ model: mr.model, cost_usd: mr.cost_usd });
byDay.set(mr.day, list);
}

return dayRows.map((r) => ({ ...r, by_model: byDay.get(r.day) ?? [] }));
}

export function getByModel(db: Database, days: number | null): ByModelRow[] {
const where = days === null ? "" : "WHERE created_at >= datetime('now', ?)";
const params: string[] = days === null ? [] : [`-${days} days`];

const rows = db
.query(
`SELECT model, COALESCE(SUM(cost_usd), 0) AS cost_usd,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens, COUNT(*) AS events
FROM cost_events ${where} GROUP BY model ORDER BY cost_usd DESC`,
)
.all(...params) as Array<Omit<ByModelRow, "pct">>;

const total = rows.reduce((acc, r) => acc + r.cost_usd, 0);
return rows.map((r) => ({ ...r, pct: total > 0 ? r.cost_usd / total : 0 }));
}

export function getByChannel(db: Database, days: number | null): ByChannelRow[] {
const where = days === null ? "" : "WHERE ce.created_at >= datetime('now', ?)";
const params: string[] = days === null ? [] : [`-${days} days`];

const rows = db
.query(
`SELECT s.channel_id AS channel_id, COALESCE(SUM(ce.cost_usd), 0) AS cost_usd,
COUNT(DISTINCT ce.session_key) AS sessions,
COALESCE(SUM(ce.input_tokens), 0) AS input_tokens,
COALESCE(SUM(ce.output_tokens), 0) AS output_tokens
FROM cost_events ce JOIN sessions s ON ce.session_key = s.session_key
${where} GROUP BY s.channel_id ORDER BY cost_usd DESC`,
)
.all(...params) as Array<Omit<ByChannelRow, "avg_per_session">>;

return rows.map((r) => ({ ...r, avg_per_session: r.sessions > 0 ? r.cost_usd / r.sessions : 0 }));
}

export function getTopSessions(db: Database, limit: number, days: number | null): TopSessionRow[] {
const where = days === null ? "" : "WHERE s.last_active_at >= datetime('now', ?)";
const params: Array<string | number> = days === null ? [] : [`-${days} days`];

return db
.query(
`SELECT s.session_key, s.channel_id, s.conversation_id,
s.total_cost_usd, s.turn_count, s.last_active_at
FROM sessions s ${where}
ORDER BY s.total_cost_usd DESC, s.last_active_at DESC LIMIT ?`,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scope top-session costs to selected range

This query uses sessions.total_cost_usd, which is a lifetime cumulative value, so the Cost tab’s 7/30/90-day filter does not actually constrain the cost shown or ranking in top sessions. In practice, any session with large historical spend but minimal recent activity can dominate the table even when the selected range is short, producing misleading analytics for the “in this range” view. Compute and sort by SUM(cost_events.cost_usd) within the active window instead of sessions.total_cost_usd.

Useful? React with 👍 / 👎.

)
.all(...params, limit) as TopSessionRow[];
}
Loading
Loading