mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-30 07:10:49 +02:00
* feat(usage): mirror Tokens metric toggle onto Usage page Daily chart (MUL-2148) #2537 added the Cost/Tokens metric toggle to the Daily chart inside the runtime-detail Usage section (packages/views/runtimes/components/ usage-section.tsx). The workspace-level Usage page at /{slug}/usage imports the same DailyCostChart primitive but renders it from dashboard-page.tsx without any toggle wrapper, so #2537 only landed on half of the surface that says "Daily cost". This PR mirrors the same pattern to dashboard-page.tsx so users see the toggle wherever a "Daily" chart appears. Changes - `packages/views/dashboard/utils.ts`: new `aggregateDailyTokens` helper that folds DashboardUsageDaily[] into the same DailyTokenData[] shape the DailyTokensChart consumes (mirrors aggregateByDate's dailyTokens branch from the runtimes side, adapted to DashboardUsageDaily field names). - `packages/views/dashboard/components/dashboard-page.tsx`: rename `DailyCostBlock` → `DailyTrendBlock`, add a Cost/Tokens Segmented next to the section title, switch chart and title based on the active metric, per-metric empty-state (so a workspace with unmapped pricing but recorded tokens still gets a real Tokens chart while the Cost view falls through to the empty-state — same convention as DailyTab in usage-section.tsx). - usage.json (en + zh-Hans): split `daily.title` into `title_cost` + `title_tokens`, add `metric_cost` + `metric_tokens` toggle labels. * feat(usage): default Daily chart to Tokens metric Most users land on /{slug}/usage to gauge "how much agent work happened" rather than "how much was spent." Tokens is the more universally meaningful axis on first read (Cost depends on having pricing mapped for every model and on whether the workspace has unmaintained models). Cost stays one click away via the same toggle. Also reorder the Segmented so Tokens sits first, matching the new default.
239 lines
8.1 KiB
TypeScript
239 lines
8.1 KiB
TypeScript
import type {
|
|
DashboardUsageDaily,
|
|
DashboardUsageByAgent,
|
|
DashboardAgentRunTime,
|
|
} from "@multica/core/types";
|
|
import { estimateCost, estimateCostBreakdown, type DailyTokenData } from "../runtimes/utils";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Dashboard data aggregations
|
|
//
|
|
// The workspace dashboard returns the same per-(date, model) and
|
|
// per-(agent, model) shapes the runtime page does, so cost math reuses
|
|
// `estimateCost` / `estimateCostBreakdown` from the runtimes utils. What
|
|
// the runtimes view does with `aggregateByDate` (works on RuntimeUsage,
|
|
// which carries a `provider` field) we replicate here with a tighter
|
|
// type — fewer optional fields, less conditional logic on the consumer
|
|
// side.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface DailyCostStack {
|
|
date: string;
|
|
label: string;
|
|
input: number;
|
|
output: number;
|
|
cacheWrite: number;
|
|
total: number;
|
|
}
|
|
|
|
function formatDateLabel(d: string): string {
|
|
// Anchor to local midnight so the formatted label matches the bucket the
|
|
// server picked (which is already in workspace time). Pasting the raw
|
|
// date as the body of `new Date()` would interpret it as UTC and shift
|
|
// by the user's offset.
|
|
const date = new Date(d + "T00:00:00");
|
|
return `${date.getMonth() + 1}/${date.getDate()}`;
|
|
}
|
|
|
|
// Per-(date, model) rows → 1 row per date with cost broken into the three
|
|
// segments the stacked bar chart consumes. Stable sort by date asc so the
|
|
// chart x-axis is left-to-right oldest-to-newest.
|
|
export function aggregateDailyCost(usage: DashboardUsageDaily[]): DailyCostStack[] {
|
|
const map = new Map<string, { input: number; output: number; cacheWrite: number }>();
|
|
for (const u of usage) {
|
|
const b = estimateCostBreakdown(u);
|
|
const entry = map.get(u.date) ?? { input: 0, output: 0, cacheWrite: 0 };
|
|
entry.input += b.input;
|
|
entry.output += b.output;
|
|
entry.cacheWrite += b.cacheWrite;
|
|
map.set(u.date, entry);
|
|
}
|
|
const round = (n: number) => Math.round(n * 100) / 100;
|
|
return [...map.entries()]
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([date, s]) => {
|
|
const input = round(s.input);
|
|
const output = round(s.output);
|
|
const cacheWrite = round(s.cacheWrite);
|
|
return {
|
|
date,
|
|
label: formatDateLabel(date),
|
|
input,
|
|
output,
|
|
cacheWrite,
|
|
total: round(input + output + cacheWrite),
|
|
};
|
|
});
|
|
}
|
|
|
|
// Per-(date, model) rows → 1 row per date with raw token counts split
|
|
// across the four chart segments. Independent of pricing — unmapped
|
|
// models still contribute here, even if they're excluded from cost.
|
|
// Mirrors `aggregateByDate(...).dailyTokens` from the runtimes utils so
|
|
// the Tokens chart on the Usage page consumes the same shape as the one
|
|
// on the runtime-detail page.
|
|
export function aggregateDailyTokens(usage: DashboardUsageDaily[]): DailyTokenData[] {
|
|
const map = new Map<
|
|
string,
|
|
{ input: number; output: number; cacheRead: number; cacheWrite: number }
|
|
>();
|
|
for (const u of usage) {
|
|
const entry = map.get(u.date) ?? {
|
|
input: 0,
|
|
output: 0,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
};
|
|
entry.input += u.input_tokens;
|
|
entry.output += u.output_tokens;
|
|
entry.cacheRead += u.cache_read_tokens;
|
|
entry.cacheWrite += u.cache_write_tokens;
|
|
map.set(u.date, entry);
|
|
}
|
|
return [...map.entries()]
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([date, t]) => ({
|
|
date,
|
|
label: formatDateLabel(date),
|
|
input: t.input,
|
|
output: t.output,
|
|
cacheRead: t.cacheRead,
|
|
cacheWrite: t.cacheWrite,
|
|
}));
|
|
}
|
|
|
|
export interface DashboardTokenTotals {
|
|
input: number;
|
|
output: number;
|
|
cacheRead: number;
|
|
cacheWrite: number;
|
|
cost: number;
|
|
taskCount: number;
|
|
}
|
|
|
|
// Whole-window totals for the KPI tiles. taskCount sums DISTINCT task counts
|
|
// per row — these are already collapsed server-side per (date, model), so
|
|
// the value can over-count if the same task has tokens in two days; that's
|
|
// acceptable for a KPI ("rough volume") and the per-agent run-time card
|
|
// gives the precise figure.
|
|
export function computeDailyTotals(usage: DashboardUsageDaily[]): DashboardTokenTotals {
|
|
return usage.reduce<DashboardTokenTotals>(
|
|
(acc, u) => ({
|
|
input: acc.input + u.input_tokens,
|
|
output: acc.output + u.output_tokens,
|
|
cacheRead: acc.cacheRead + u.cache_read_tokens,
|
|
cacheWrite: acc.cacheWrite + u.cache_write_tokens,
|
|
cost: acc.cost + estimateCost(u),
|
|
taskCount: acc.taskCount + u.task_count,
|
|
}),
|
|
{ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, taskCount: 0 },
|
|
);
|
|
}
|
|
|
|
export interface AgentCostRow {
|
|
agentId: string;
|
|
tokens: number;
|
|
cost: number;
|
|
taskCount: number;
|
|
}
|
|
|
|
// Fold per-(agent, model) rows into one row per agent. Cost is the sum
|
|
// across this agent's models, which is the figure the user cares about.
|
|
// Sort by cost desc so the heaviest spender lands first.
|
|
export function aggregateAgentTokens(rows: DashboardUsageByAgent[]): AgentCostRow[] {
|
|
const map = new Map<string, AgentCostRow>();
|
|
for (const r of rows) {
|
|
const entry = map.get(r.agent_id) ?? {
|
|
agentId: r.agent_id,
|
|
tokens: 0,
|
|
cost: 0,
|
|
taskCount: 0,
|
|
};
|
|
entry.tokens +=
|
|
r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens;
|
|
entry.cost += estimateCost(r);
|
|
entry.taskCount += r.task_count;
|
|
map.set(r.agent_id, entry);
|
|
}
|
|
return [...map.values()].sort((a, b) => b.cost - a.cost);
|
|
}
|
|
|
|
export interface AgentDashboardRow {
|
|
agentId: string;
|
|
tokens: number;
|
|
cost: number;
|
|
seconds: number;
|
|
taskCount: number;
|
|
}
|
|
|
|
// Merge per-agent token totals with per-agent run-time totals into one
|
|
// row per agent.
|
|
//
|
|
// taskCount comes from `runTimeRows` when available — that rollup is a
|
|
// true per-agent distinct count (`COUNT(*)` on (agent, terminal-task) in
|
|
// SQL). The token rollup's per-(agent, model) counts double-count a task
|
|
// when it spans multiple models, so we only fall back to it for agents
|
|
// with no terminal run yet (in-flight tasks reported tokens but haven't
|
|
// completed). Sorted by cost desc, then run time desc.
|
|
export function mergeAgentDashboardRows(
|
|
tokenRows: AgentCostRow[],
|
|
runTimeRows: DashboardAgentRunTime[],
|
|
): AgentDashboardRow[] {
|
|
const runTimeByAgent = new Map(
|
|
runTimeRows.map((r) => [r.agent_id, r] as const),
|
|
);
|
|
const merged = new Map<string, AgentDashboardRow>();
|
|
for (const r of tokenRows) {
|
|
const rt = runTimeByAgent.get(r.agentId);
|
|
merged.set(r.agentId, {
|
|
agentId: r.agentId,
|
|
tokens: r.tokens,
|
|
cost: r.cost,
|
|
seconds: rt?.total_seconds ?? 0,
|
|
taskCount: rt ? rt.task_count : r.taskCount,
|
|
});
|
|
}
|
|
// Agents with run-time rows but zero tokens still belong on the list
|
|
// (a task that errored before producing usage). Their token columns
|
|
// stay at 0.
|
|
for (const r of runTimeRows) {
|
|
if (merged.has(r.agent_id)) continue;
|
|
merged.set(r.agent_id, {
|
|
agentId: r.agent_id,
|
|
tokens: 0,
|
|
cost: 0,
|
|
seconds: r.total_seconds,
|
|
taskCount: r.task_count,
|
|
});
|
|
}
|
|
return [...merged.values()].sort((a, b) => {
|
|
if (b.cost !== a.cost) return b.cost - a.cost;
|
|
return b.seconds - a.seconds;
|
|
});
|
|
}
|
|
|
|
// Compact human duration: "1h 23m" / "12m 30s" / "45s" / "<1m". Used for
|
|
// the dashboard run-time KPI and the per-agent run-time column. Keeps two
|
|
// segments max — three segments adds visual noise without precision the
|
|
// dashboard actually needs.
|
|
export function formatDuration(seconds: number, lessThanMinuteLabel: string): string {
|
|
if (seconds < 0 || !Number.isFinite(seconds)) return lessThanMinuteLabel;
|
|
if (seconds < 60) {
|
|
if (seconds < 1) return lessThanMinuteLabel;
|
|
return `${Math.round(seconds)}s`;
|
|
}
|
|
const totalMinutes = Math.floor(seconds / 60);
|
|
const hours = Math.floor(totalMinutes / 60);
|
|
const mins = totalMinutes % 60;
|
|
if (hours === 0) {
|
|
const secs = Math.floor(seconds) % 60;
|
|
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
|
|
}
|
|
if (hours >= 24) {
|
|
const days = Math.floor(hours / 24);
|
|
const h = hours % 24;
|
|
return h > 0 ? `${days}d ${h}h` : `${days}d`;
|
|
}
|
|
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
|
}
|