mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 14:00:09 +02:00
- Add explicit type="button" to 61 <button> elements missing the attribute - Replace useContext() with React 19 use() across 16 context consumers - Replace [...arr].sort() with arr.toSorted() in 12 web/desktop files (mobile excluded — Hermes lacks toSorted support) - Fix rules-of-hooks violation: useSidebar try/catch → useSidebarSafe null check - Fix nested component definition: useMemo wrapping HeaderRight → useCallback - Fix missing ARIA: add aria-expanded + aria-controls to combobox in create-squad React Doctor score: 23 → 30. No behavioral changes, no business logic modified. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
380 lines
13 KiB
TypeScript
380 lines
13 KiB
TypeScript
import type {
|
||
DashboardUsageDaily,
|
||
DashboardUsageByAgent,
|
||
DashboardAgentRunTime,
|
||
DashboardRunTimeDaily,
|
||
} from "@multica/core/types";
|
||
import {
|
||
addDaysIso,
|
||
estimateCost,
|
||
estimateCostBreakdown,
|
||
formatShortDate,
|
||
todayIso,
|
||
weekStartIso,
|
||
type DailyTokenData,
|
||
} from "../runtimes/utils";
|
||
import type {
|
||
DailyTimeData,
|
||
DailyTasksData,
|
||
WeeklyTimeData,
|
||
WeeklyTasksData,
|
||
} 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 Array.from(map.entries())
|
||
.toSorted(([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 Array.from(map.entries())
|
||
.toSorted(([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 Array.from(map.values()).toSorted((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 Array.from(merged.values()).toSorted((a, b) => {
|
||
if (b.cost !== a.cost) return b.cost - a.cost;
|
||
return b.seconds - a.seconds;
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Weekly fold for run-time + tasks. Mirrors `aggregateByWeek` in
|
||
// `runtimes/utils.ts` which already covers cost / tokens — same calendar
|
||
// week semantics (Mon–Sun anchored at today-in-tz), same pre-zeroed buckets,
|
||
// same partial-week metadata. Workspace dashboard uses the user-chosen
|
||
// timezone here; the runtime page uses the runtime's IANA tz. Behaviour is
|
||
// identical apart from where the tz comes from.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface WeekShell {
|
||
weekStart: string;
|
||
weekEnd: string;
|
||
label: string;
|
||
rangeLabel: string;
|
||
partial: boolean;
|
||
daysCovered: number;
|
||
}
|
||
|
||
// Build N trailing calendar week shells anchored at today-in-tz. Each shell
|
||
// carries the labels and partial-week metadata the chart components consume;
|
||
// downstream aggregators fold their own per-week values onto the matching
|
||
// shell.
|
||
function buildWeekShells(tz: string, weekCount: number): WeekShell[] {
|
||
const count = Math.max(1, Math.floor(weekCount));
|
||
const today = todayIso(tz);
|
||
const currentWeekStart = weekStartIso(today);
|
||
const firstWeekStart = addDaysIso(currentWeekStart, -(count - 1) * 7);
|
||
const shells: WeekShell[] = [];
|
||
for (let i = 0; i < count; i++) {
|
||
const weekStart = addDaysIso(firstWeekStart, i * 7);
|
||
const weekEnd = addDaysIso(weekStart, 6);
|
||
const partial = today < weekEnd;
|
||
// Inclusive count of how many days of this week have actually elapsed.
|
||
// Closed weeks sit at 7; the current week reports 1..6.
|
||
const clampedToday =
|
||
today < weekStart ? weekStart : today < weekEnd ? today : weekEnd;
|
||
const elapsed = Math.min(7, Math.max(1, diffDaysIso(weekStart, clampedToday) + 1));
|
||
shells.push({
|
||
weekStart,
|
||
weekEnd,
|
||
label: formatShortDate(weekStart),
|
||
rangeLabel: `${formatShortDate(weekStart)} – ${formatShortDate(weekEnd)}`,
|
||
partial,
|
||
daysCovered: partial ? elapsed : 7,
|
||
});
|
||
}
|
||
return shells;
|
||
}
|
||
|
||
function diffDaysIso(from: string, to: string): number {
|
||
const [y1, m1, d1] = from.split("-").map(Number);
|
||
const [y2, m2, d2] = to.split("-").map(Number);
|
||
const a = Date.UTC(y1 ?? 1970, (m1 ?? 1) - 1, d1 ?? 1);
|
||
const b = Date.UTC(y2 ?? 1970, (m2 ?? 1) - 1, d2 ?? 1);
|
||
return Math.round((b - a) / 86_400_000);
|
||
}
|
||
|
||
export function aggregateWeeklyTime(
|
||
rows: DashboardRunTimeDaily[],
|
||
tz: string,
|
||
weekCount: number,
|
||
): WeeklyTimeData[] {
|
||
const shells = buildWeekShells(tz, weekCount);
|
||
const totals = new Map<string, number>();
|
||
for (const shell of shells) totals.set(shell.weekStart, 0);
|
||
for (const r of rows) {
|
||
const wkStart = weekStartIso(r.date);
|
||
if (!totals.has(wkStart)) continue;
|
||
totals.set(wkStart, (totals.get(wkStart) ?? 0) + r.total_seconds);
|
||
}
|
||
return shells.map((s) => ({ ...s, totalSeconds: totals.get(s.weekStart) ?? 0 }));
|
||
}
|
||
|
||
export function aggregateWeeklyTasks(
|
||
rows: DashboardRunTimeDaily[],
|
||
tz: string,
|
||
weekCount: number,
|
||
): WeeklyTasksData[] {
|
||
const shells = buildWeekShells(tz, weekCount);
|
||
const buckets = new Map<string, { completed: number; failed: number }>();
|
||
for (const shell of shells)
|
||
buckets.set(shell.weekStart, { completed: 0, failed: 0 });
|
||
for (const r of rows) {
|
||
const wkStart = weekStartIso(r.date);
|
||
const bucket = buckets.get(wkStart);
|
||
if (!bucket) continue;
|
||
const failed = r.failed_count;
|
||
const completed = Math.max(0, r.task_count - failed);
|
||
bucket.completed += completed;
|
||
bucket.failed += failed;
|
||
}
|
||
return shells.map((s) => {
|
||
const b = buckets.get(s.weekStart) ?? { completed: 0, failed: 0 };
|
||
return { ...s, completed: b.completed, failed: b.failed };
|
||
});
|
||
}
|
||
|
||
// 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.toSorted((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.toSorted((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`;
|
||
}
|