mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-31 00:40:46 +02:00
* feat(dashboard): workspace/project token + run-time dashboard
Add a `/{slug}/dashboard` page showing per-agent token spend and execution
time across the whole workspace, with an optional project filter.
Backend:
- Three new sqlc queries against task_usage + agent_task_queue: daily
usage, per-agent usage, per-agent total run-time. All optionally
scoped to a project via sqlc.narg('project_id'), reaching project
through the issue join.
- Handlers under /api/dashboard return the same wire shape the runtime
page already consumes (model preserved for client-side cost math).
Frontend: - Shared DashboardPage in packages/views/dashboard reusing KpiCard,
DailyCostChart, ActorAvatar, and estimateCost from the runtime page
so the visual style and pricing math stay in lock-step.
- Period selector (7/30/90d), project dropdown, four KPI tiles
(cost, tokens, run time, tasks), daily cost chart, and a combined
"cost + run time by agent" list.
- Routed in both web (app/[slug]/(dashboard)/dashboard) and desktop
(memory router); sidebar nav entry added under Workspace group.
Co-authored-by: multica-agent <github@multica.ai>
* fix(dashboard): drop stale project filter and stop double-counting tasks
Two issues caught in PR #2462 review:
1. Project filter held the previous selection's UUID across workspace
switches and project deletions: the dropdown gracefully showed
"All projects" (because the title lookup missed) while the three
dashboard queries kept forwarding the dead UUID, leaving the UI
looking like a full-workspace view but populated with empty
project-scoped data. Validate the picked UUID against the current
projects list before passing it to the queries.
2. The "by agent" table read its task count from the token rollup,
which is grouped per (agent, model). A single task that spans two
models lands twice and the agent's row reads e.g. "2 tasks" when
the real count is 1. Prefer `ListDashboardAgentRunTime`'s per-agent
distinct count when available; fall back to the token aggregate
only for agents with no terminal run yet (in-flight tasks).
Extract the merge into `mergeAgentDashboardRows` so the precedence
rules are unit-tested directly.
Co-authored-by: multica-agent <github@multica.ai>
* test(dashboard): allocate per-workspace issue.number explicitly
TestDashboardEndpoints creates two issues in the shared fixture
workspace. issue.number defaults to 0 (migration 020), and the table
carries UNIQUE (workspace_id, number), so the second insert raced the
first on the same default and failed in CI.
Allocate MAX(number) + 1 per insert so each row gets a fresh number
without stepping on rows other tests left behind in the same workspace.
Co-authored-by: multica-agent <github@multica.ai>
* feat(dashboard): rollup table + cron-driven aggregation for dashboard
Mirror the per-runtime rollup in `task_usage_daily` (migrations 073/077/082)
to remove the per-request raw aggregation the dashboard was doing.
Migration 084 adds:
- `task_usage_dashboard_daily` keyed on
(bucket_date, workspace_id, agent_id, project_id, model) — the
dimensions the dashboard actually queries, with project_id nullable
via UNIQUE NULLS NOT DISTINCT (PG15+) so "no-project" buckets
upsert cleanly.
- `task_usage_dashboard_rollup_state` watermark table.
- `task_usage_dashboard_dirty` invalidation queue.
- Triggers on agent_task_queue DELETE, task_usage DELETE, and
issue.project_id UPDATE — the cases the updated_at watermark can't
see. The project_id trigger re-attributes existing rollup rows when
a user moves an issue across projects.
- `rollup_task_usage_dashboard_daily_window(from, to)` —
idempotent recompute primitive (same shape as 077).
- `rollup_task_usage_dashboard_daily()` cron entry — own advisory
lock (4244) so it serialises independently of the runtime rollup.
- `task_usage_dashboard_rollup_lag_seconds()` health helper.
Sqlc queries `ListDashboardUsageDailyRollup` /
`ListDashboardUsageByAgentRollup` read from the new table; the handler
dispatches between rollup and raw on a separate
`UseDailyRollupForDashboard` config flag
(`USAGE_DASHBOARD_ROLLUP_ENABLED` env). Same fail-safe default (false →
raw) so operators can roll out independently of the per-runtime flag.
Bucket date is UTC (the dashboard aggregates across runtimes that may
sit in different tzs; there's no single correct local boundary).
Adds `cmd/backfill_task_usage_dashboard_daily` mirroring the existing
per-runtime backfill — operator runs it once before flipping the flag.
Tests: - TestDashboardEndpoints now also exercises the rollup read path
(raw vs. rollup, same project-scoped totals).
- TestDashboardRollupReattributesOnProjectChange verifies the
issue.project_id trigger enqueues both old + new buckets and the
next rollup tick zeroes the old project + populates the new one.
Co-authored-by: multica-agent <github@multica.ai>
* fix(dashboard-rollup): close two invalidation gaps
Two leak paths missed by migration 084 review:
1. Issue cascade DELETE — the atq BEFORE DELETE trigger runs AFTER the
issue row is gone, so `LEFT JOIN issue` returns NULL project_id and
the original-project bucket never gets cleared (issue 077 calls this
out for the runtime rollup but didn't need to act on it). Adds an
`issue BEFORE DELETE` trigger that enqueues using OLD.project_id
while the issue row is still readable.
2. `LinkTaskToIssue` (quick-create task attaching to a real issue post-
completion) UPDATEs `agent_task_queue.issue_id` from NULL to a real
id. Migration 084 only watched DELETE on atq, so usage already
rolled up under the no-project bucket stayed attributed to NULL
forever. Extends the atq trigger to fire on UPDATE OF issue_id too,
enqueueing both OLD (NULL project) and NEW (linked issue's project).
Tests: - TestDashboardRollupClearsOnIssueDelete asserts rollup row drops to
zero after issue delete + rollup tick.
- TestDashboardRollupReattributesOnLinkTaskToIssue verifies tokens
move from the NULL bucket to the project bucket after the UPDATE.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
203 lines
6.9 KiB
TypeScript
203 lines
6.9 KiB
TypeScript
import type {
|
|
DashboardUsageDaily,
|
|
DashboardUsageByAgent,
|
|
DashboardAgentRunTime,
|
|
} from "@multica/core/types";
|
|
import { estimateCost, estimateCostBreakdown } 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),
|
|
};
|
|
});
|
|
}
|
|
|
|
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`;
|
|
}
|