Compare commits

...

3 Commits

Author SHA1 Message Date
Jiang Bohan
ff5d96ef29 Merge branch 'main' into feat/usage-daily-tokens-toggle 2026-05-13 21:53:24 +08:00
Jiang Bohan
a711786ad4 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.
2026-05-13 19:36:43 +08:00
Jiang Bohan
9500736c27 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.
2026-05-13 19:36:43 +08:00
4 changed files with 88 additions and 12 deletions

View File

@@ -22,7 +22,7 @@ import {
import { useCustomPricingStore } from "@multica/core/runtimes/custom-pricing-store";
import { PageHeader } from "../../layout/page-header";
import { KpiCard } from "../../runtimes/components/shared";
import { DailyCostChart } from "../../runtimes/components/charts";
import { DailyCostChart, DailyTokensChart } from "../../runtimes/components/charts";
import { ProjectIcon } from "../../projects/components/project-icon";
import { ActorAvatar } from "../../common/actor-avatar";
import { formatTokens } from "../../runtimes/utils";
@@ -30,6 +30,7 @@ import { useT } from "../../i18n";
import {
aggregateAgentTokens,
aggregateDailyCost,
aggregateDailyTokens,
computeDailyTotals,
formatDuration,
mergeAgentDashboardRows,
@@ -151,6 +152,7 @@ export function DashboardPage() {
// Cost / token math — re-derived when usage, days, or pricings change.
const totals = useMemo(() => computeDailyTotals(dailyUsage), [dailyUsage]);
const dailyCost = useMemo(() => aggregateDailyCost(dailyUsage), [dailyUsage]);
const dailyTokens = useMemo(() => aggregateDailyTokens(dailyUsage), [dailyUsage]);
const agentTokenRows = useMemo(
() => aggregateAgentTokens(byAgentUsage),
[byAgentUsage],
@@ -242,8 +244,10 @@ export function DashboardPage() {
/>
</div>
{/* Daily cost chart — reuses the runtime DailyCostChart. */}
<DailyCostBlock dailyCost={dailyCost} />
{/* Daily trend chart — toggle picks Cost vs Tokens axis,
mirroring the runtime-detail Usage section so both
surfaces share one chart language. */}
<DailyTrendBlock dailyCost={dailyCost} dailyTokens={dailyTokens} />
{/* Per-agent leaderboard — user picks the ranking metric;
the progress bar and column emphasis follow the metric. */}
@@ -317,28 +321,58 @@ function ProjectFilter({
);
}
function DailyCostBlock({
type DailyMetric = "cost" | "tokens";
function DailyTrendBlock({
dailyCost,
dailyTokens,
}: {
dailyCost: ReturnType<typeof aggregateDailyCost>;
dailyTokens: ReturnType<typeof aggregateDailyTokens>;
}) {
const { t } = useT("usage");
const total = dailyCost.reduce((sum, d) => sum + d.total, 0);
const [metric, setMetric] = useState<DailyMetric>("tokens");
// Empty-state is per-metric so a workspace that recorded tokens but
// has no priced models (unmapped) still gets a real Tokens chart while
// its Cost view falls through to the empty-state — same convention as
// the runtimes-side DailyTab in usage-section.tsx.
const totalCost = dailyCost.reduce((sum, d) => sum + d.total, 0);
const totalTokens = dailyTokens.reduce(
(sum, d) => sum + d.input + d.output + d.cacheRead + d.cacheWrite,
0,
);
const isEmpty = metric === "cost" ? totalCost === 0 : totalTokens === 0;
return (
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center justify-between">
<h4 className="text-sm font-semibold">{t(($) => $.daily.title)}</h4>
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<h4 className="text-sm font-semibold">
{metric === "cost"
? t(($) => $.daily.title_cost)
: t(($) => $.daily.title_tokens)}
</h4>
<Segmented
value={metric}
onChange={setMetric}
options={[
{ label: t(($) => $.daily.metric_tokens), value: "tokens" as const },
{ label: t(($) => $.daily.metric_cost), value: "cost" as const },
]}
/>
</div>
<div className="min-h-[240px]">
{total === 0 ? (
{isEmpty ? (
<div className="flex aspect-[3/1] flex-col items-center justify-center gap-2 rounded-md border border-dashed bg-muted/20 p-6 text-center">
<BarChart3 className="h-5 w-5 text-muted-foreground/50" />
<p className="text-xs text-muted-foreground">
{t(($) => $.daily.no_data)}
</p>
</div>
) : (
) : metric === "cost" ? (
<DailyCostChart data={dailyCost} />
) : (
<DailyTokensChart data={dailyTokens} />
)}
</div>
</div>

View File

@@ -3,7 +3,7 @@ import type {
DashboardUsageByAgent,
DashboardAgentRunTime,
} from "@multica/core/types";
import { estimateCost, estimateCostBreakdown } from "../runtimes/utils";
import { estimateCost, estimateCostBreakdown, type DailyTokenData } from "../runtimes/utils";
// ---------------------------------------------------------------------------
// Dashboard data aggregations
@@ -66,6 +66,42 @@ export function aggregateDailyCost(usage: DashboardUsageDaily[]): DailyCostStack
});
}
// 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;

View File

@@ -16,7 +16,10 @@
"tasks_hint": "{{failed}} failed"
},
"daily": {
"title": "Daily cost",
"title_cost": "Daily cost",
"title_tokens": "Daily tokens",
"metric_cost": "Cost",
"metric_tokens": "Tokens",
"no_data": "No usage in this window."
},
"leaderboard": {

View File

@@ -16,7 +16,10 @@
"tasks_hint": "失败 {{failed}} 个"
},
"daily": {
"title": "每日费用",
"title_cost": "每日费用",
"title_tokens": "每日 Token",
"metric_cost": "费用",
"metric_tokens": "Token",
"no_data": "所选时间范围内暂无消耗。"
},
"leaderboard": {