mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-01 09:28:57 +02:00
Extends the workspace /usage page Daily tokens chart toggle from Tokens | Cost to Tokens | Cost | Time | Tasks, so users see daily run-time and task-count trends alongside spend without leaving the page. - New SQL `ListDashboardRunTimeDaily`: per-date totals from agent_task_queue (terminal tasks only), scoped to workspace and optionally project. Same time anchor as ListDashboardAgentRunTime so day boundaries line up. - New handler GET /api/dashboard/runtime/daily + TanStack Query option. - New DailyTimeChart (single-series, smart h/m/s unit) and DailyTasksChart (completed + failed stacked). - Empty-state is per-metric so a workspace with tokens but no terminal runs (or vice-versa) doesn't get a false "no data". - i18n: en + zh-Hans daily.metric_time / metric_tasks + titles. Co-authored-by: multica-agent <github@multica.ai>
275 lines
9.3 KiB
TypeScript
275 lines
9.3 KiB
TypeScript
import type {
|
|
DashboardUsageDaily,
|
|
DashboardUsageByAgent,
|
|
DashboardAgentRunTime,
|
|
DashboardRunTimeDaily,
|
|
} from "@multica/core/types";
|
|
import { estimateCost, estimateCostBreakdown, type DailyTokenData } from "../runtimes/utils";
|
|
import type {
|
|
DailyTimeData,
|
|
DailyTasksData,
|
|
} from "../runtimes/components/charts";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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;
|
|
});
|
|
}
|
|
|
|
// Per-date run-time rows → one row per date with `totalSeconds` for the
|
|
// DailyTimeChart. Sorted ascending so the x-axis reads oldest-to-newest,
|
|
// matching the cost / tokens aggregators.
|
|
export function aggregateDailyTime(rows: DashboardRunTimeDaily[]): DailyTimeData[] {
|
|
return [...rows]
|
|
.sort((a, b) => a.date.localeCompare(b.date))
|
|
.map((r) => ({
|
|
date: r.date,
|
|
label: formatDateLabel(r.date),
|
|
totalSeconds: r.total_seconds,
|
|
}));
|
|
}
|
|
|
|
// Per-date run-time rows → one row per date with `completed` and `failed`
|
|
// counts for the DailyTasksChart's stacked bar (failed_count is a subset
|
|
// of task_count, so completed = task_count - failed_count).
|
|
export function aggregateDailyTasks(rows: DashboardRunTimeDaily[]): DailyTasksData[] {
|
|
return [...rows]
|
|
.sort((a, b) => a.date.localeCompare(b.date))
|
|
.map((r) => {
|
|
const failed = r.failed_count;
|
|
const completed = Math.max(0, r.task_count - failed);
|
|
return {
|
|
date: r.date,
|
|
label: formatDateLabel(r.date),
|
|
completed,
|
|
failed,
|
|
};
|
|
});
|
|
}
|
|
|
|
// 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`;
|
|
}
|