mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-29 18:39:17 +02:00
Compare commits
1 Commits
agent/j/sl
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd4897f954 |
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { BarChart3, FolderKanban } from "lucide-react";
|
||||
import { BarChart3, FolderKanban, Trash2 } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import {
|
||||
@@ -52,8 +52,9 @@ import {
|
||||
aggregateDailyTokens,
|
||||
aggregateWeeklyTasks,
|
||||
aggregateWeeklyTime,
|
||||
bucketUnknownAgentRows,
|
||||
computeDailyTotals,
|
||||
filterKnownAgentRows,
|
||||
DELETED_AGENTS_ROW_ID,
|
||||
formatDuration,
|
||||
mergeAgentDashboardRows,
|
||||
type AgentDashboardRow,
|
||||
@@ -314,17 +315,29 @@ export function DashboardPage() {
|
||||
[agentTokenRows, runTimeRows],
|
||||
);
|
||||
|
||||
// Hide rollup rows for agents that were hard-deleted from the workspace —
|
||||
// they'd otherwise show up as a bare UUID on the leaderboard (MUL-3771).
|
||||
// Archived agents stay (the agent list is fetched with archived included);
|
||||
// only truly-removed agents drop out. Skip filtering until the agent list
|
||||
// has loaded so a slow agents fetch doesn't transiently blank the list.
|
||||
// Fold rollup rows for hard-deleted agents into one aggregated "Deleted
|
||||
// agents" row instead of showing them as a bare UUID (MUL-3771) or dropping
|
||||
// them outright — dropping made the per-agent breakdown stop reconciling
|
||||
// with the top-line Cost/Tokens KPIs, which still count that spend (MUL-3776,
|
||||
// #4640). Archived agents stay as themselves (the agent list is fetched with
|
||||
// archived included); only truly-removed agents collapse into the bucket.
|
||||
// Skip bucketing until the agent list has loaded so a slow agents fetch
|
||||
// doesn't transiently merge every row.
|
||||
const knownAgentIds = useMemo(
|
||||
() => (agentsQuery.isSuccess ? new Set(agents.map((a) => a.id)) : null),
|
||||
[agentsQuery.isSuccess, agents],
|
||||
);
|
||||
const visibleAgentRows = useMemo(
|
||||
() => filterKnownAgentRows(agentRows, knownAgentIds),
|
||||
() => bucketUnknownAgentRows(agentRows, knownAgentIds),
|
||||
[agentRows, knownAgentIds],
|
||||
);
|
||||
// Distinct hard-deleted agents folded into the bucket — drives the caption's
|
||||
// "· N deleted" suffix (the bucket itself is a single row).
|
||||
const deletedAgentCount = useMemo(
|
||||
() =>
|
||||
knownAgentIds
|
||||
? agentRows.filter((r) => !knownAgentIds.has(r.agentId)).length
|
||||
: 0,
|
||||
[agentRows, knownAgentIds],
|
||||
);
|
||||
|
||||
@@ -431,6 +444,7 @@ export function DashboardPage() {
|
||||
<Leaderboard
|
||||
rows={visibleAgentRows}
|
||||
agents={agents}
|
||||
deletedAgentCount={deletedAgentCount}
|
||||
lessThanMinuteLabel={t(($) => $.duration.less_than_minute)}
|
||||
/>
|
||||
</>
|
||||
@@ -640,10 +654,12 @@ const SORT_METRIC: Record<LeaderboardSort, (r: AgentDashboardRow) => number> = {
|
||||
function Leaderboard({
|
||||
rows,
|
||||
agents,
|
||||
deletedAgentCount,
|
||||
lessThanMinuteLabel,
|
||||
}: {
|
||||
rows: AgentDashboardRow[];
|
||||
agents: { id: string; name: string }[];
|
||||
deletedAgentCount: number;
|
||||
lessThanMinuteLabel: string;
|
||||
}) {
|
||||
const { t } = useT("usage");
|
||||
@@ -684,7 +700,12 @@ function Leaderboard({
|
||||
<div className="flex items-center gap-3">
|
||||
<Segmented value={sortBy} onChange={setSortBy} options={sortOptions} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(($) => $.leaderboard.caption, { count: rows.length })}
|
||||
{deletedAgentCount > 0
|
||||
? t(($) => $.leaderboard.caption_with_deleted, {
|
||||
count: rows.length - 1,
|
||||
deleted: deletedAgentCount,
|
||||
})
|
||||
: t(($) => $.leaderboard.caption, { count: rows.length })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -704,6 +725,11 @@ function Leaderboard({
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{sortedRows.map((row) => {
|
||||
// The deleted-agents bucket is a synthetic row, not a real agent:
|
||||
// render a neutral placeholder (no avatar fetch / hover card / UUID)
|
||||
// and dash out Time/Tasks, which it never carries (see
|
||||
// bucketUnknownAgentRows).
|
||||
const isDeletedBucket = row.agentId === DELETED_AGENTS_ROW_ID;
|
||||
const agent = agents.find((a) => a.id === row.agentId);
|
||||
const value = SORT_METRIC[sortBy](row);
|
||||
const pct = maxValue > 0 ? (value / maxValue) * 100 : 0;
|
||||
@@ -713,15 +739,28 @@ function Leaderboard({
|
||||
className="grid grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)_5rem_5rem_5rem_4rem] items-center gap-3 px-4 py-2"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={row.agentId}
|
||||
size={22}
|
||||
enableHoverCard
|
||||
/>
|
||||
<span className="cursor-pointer truncate text-sm font-medium">
|
||||
{agent?.name ?? row.agentId}
|
||||
</span>
|
||||
{isDeletedBucket ? (
|
||||
<>
|
||||
<span className="flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</span>
|
||||
<span className="truncate text-sm font-medium italic text-muted-foreground">
|
||||
{t(($) => $.leaderboard.deleted_agents)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={row.agentId}
|
||||
size={22}
|
||||
enableHoverCard
|
||||
/>
|
||||
<span className="cursor-pointer truncate text-sm font-medium">
|
||||
{agent?.name ?? row.agentId}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
@@ -742,12 +781,14 @@ function Leaderboard({
|
||||
<div
|
||||
className={`text-right text-xs tabular-nums ${sortBy === "time" ? "font-medium text-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
{formatDuration(row.seconds, lessThanMinuteLabel)}
|
||||
{isDeletedBucket
|
||||
? "—"
|
||||
: formatDuration(row.seconds, lessThanMinuteLabel)}
|
||||
</div>
|
||||
<div
|
||||
className={`text-right text-xs tabular-nums ${sortBy === "tasks" ? "font-medium text-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
{row.taskCount}
|
||||
{isDeletedBucket ? "—" : row.taskCount}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,8 +4,9 @@ import {
|
||||
aggregateDailyCost,
|
||||
aggregateWeeklyTasks,
|
||||
aggregateWeeklyTime,
|
||||
bucketUnknownAgentRows,
|
||||
computeDailyTotals,
|
||||
filterKnownAgentRows,
|
||||
DELETED_AGENTS_ROW_ID,
|
||||
formatDuration,
|
||||
mergeAgentDashboardRows,
|
||||
} from "./utils";
|
||||
@@ -202,26 +203,81 @@ describe("mergeAgentDashboardRows", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterKnownAgentRows", () => {
|
||||
const rows = [
|
||||
{ agentId: "live", tokens: 100, cost: 1, seconds: 10, taskCount: 1 },
|
||||
{ agentId: "deleted", tokens: 50, cost: 0.5, seconds: 5, taskCount: 1 },
|
||||
];
|
||||
describe("bucketUnknownAgentRows", () => {
|
||||
const live = { agentId: "live", tokens: 100, cost: 1, seconds: 10, taskCount: 1 };
|
||||
const archived = {
|
||||
agentId: "archived",
|
||||
tokens: 80,
|
||||
cost: 0.8,
|
||||
seconds: 8,
|
||||
taskCount: 2,
|
||||
};
|
||||
const deletedA = {
|
||||
agentId: "deleted-a",
|
||||
tokens: 50,
|
||||
cost: 0.5,
|
||||
seconds: 5,
|
||||
taskCount: 1,
|
||||
};
|
||||
const deletedB = {
|
||||
agentId: "deleted-b",
|
||||
tokens: 30,
|
||||
cost: 0.25,
|
||||
seconds: 3,
|
||||
taskCount: 4,
|
||||
};
|
||||
|
||||
it("drops rows whose agent is no longer in the workspace", () => {
|
||||
// "deleted" is absent from the known set — it's a hard-deleted agent whose
|
||||
// legacy rollup row would otherwise render as a bare UUID.
|
||||
const out = filterKnownAgentRows(rows, new Set(["live"]));
|
||||
expect(out.map((r) => r.agentId)).toEqual(["live"]);
|
||||
it("folds every hard-deleted agent into one aggregated bucket row", () => {
|
||||
// "deleted-a" / "deleted-b" are absent from the known set — they'd otherwise
|
||||
// render as bare UUIDs. They collapse into a single sentinel row.
|
||||
const out = bucketUnknownAgentRows(
|
||||
[live, deletedA, deletedB],
|
||||
new Set(["live"]),
|
||||
);
|
||||
expect(out.map((r) => r.agentId)).toEqual(["live", DELETED_AGENTS_ROW_ID]);
|
||||
const bucket = out.find((r) => r.agentId === DELETED_AGENTS_ROW_ID)!;
|
||||
expect(bucket.tokens).toBe(80);
|
||||
expect(bucket.cost).toBeCloseTo(0.75);
|
||||
// Time/Tasks never attach to the bucket — the run-time rollup inner-joins
|
||||
// `agent`, so deleted agents contribute nothing to those columns.
|
||||
expect(bucket.seconds).toBe(0);
|
||||
expect(bucket.taskCount).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps every row while the agent list is still loading (null set)", () => {
|
||||
const out = filterKnownAgentRows(rows, null);
|
||||
expect(out.map((r) => r.agentId)).toEqual(["live", "deleted"]);
|
||||
it("keeps the bucket total reconciled with the top-line spend", () => {
|
||||
// The KPI total counts deleted-agent spend; sum(visible rows) must match it
|
||||
// so the breakdown reconciles (MUL-3776).
|
||||
const out = bucketUnknownAgentRows(
|
||||
[live, deletedA, deletedB],
|
||||
new Set(["live"]),
|
||||
);
|
||||
const visibleCost = out.reduce((s, r) => s + r.cost, 0);
|
||||
const kpiCost = [live, deletedA, deletedB].reduce((s, r) => s + r.cost, 0);
|
||||
expect(visibleCost).toBeCloseTo(kpiCost);
|
||||
});
|
||||
|
||||
it("drops every row when the known set is empty", () => {
|
||||
expect(filterKnownAgentRows(rows, new Set())).toEqual([]);
|
||||
it("keeps archived agents as themselves, never in the bucket", () => {
|
||||
// The agent list is fetched with archived included, so archived agents are
|
||||
// in the known set and stay on the board under their own id.
|
||||
const out = bucketUnknownAgentRows(
|
||||
[live, archived, deletedA],
|
||||
new Set(["live", "archived"]),
|
||||
);
|
||||
expect(out.map((r) => r.agentId)).toEqual([
|
||||
"live",
|
||||
"archived",
|
||||
DELETED_AGENTS_ROW_ID,
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds no bucket row when every agent is known", () => {
|
||||
const out = bucketUnknownAgentRows([live, archived], new Set(["live", "archived"]));
|
||||
expect(out.map((r) => r.agentId)).toEqual(["live", "archived"]);
|
||||
});
|
||||
|
||||
it("keeps every row untouched while the agent list is still loading (null set)", () => {
|
||||
const out = bucketUnknownAgentRows([live, deletedA], null);
|
||||
expect(out.map((r) => r.agentId)).toEqual(["live", "deleted-a"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -227,21 +227,54 @@ export function mergeAgentDashboardRows(
|
||||
});
|
||||
}
|
||||
|
||||
// Drop usage rows whose agent no longer exists in the workspace. The agent
|
||||
// list is fetched with `include_archived: true`, so archived agents keep
|
||||
// their names and stay on the leaderboard; only hard-deleted agents fall out
|
||||
// of `knownAgentIds`. Those are legacy rollup rows that would otherwise
|
||||
// render as a bare UUID (MUL-3771).
|
||||
// Synthetic agentId for the row that aggregates all hard-deleted agents.
|
||||
// Sentinel (not a real UUID) so the component can detect it and render a
|
||||
// placeholder instead of looking the id up in the agent list.
|
||||
export const DELETED_AGENTS_ROW_ID = "__deleted_agents__";
|
||||
|
||||
// Fold usage rows whose agent no longer exists in the workspace into a single
|
||||
// aggregated "Deleted agents" row instead of dropping them. The agent list is
|
||||
// fetched with `include_archived: true`, so archived agents keep their names
|
||||
// and stay on the leaderboard as themselves; only hard-deleted agents fall out
|
||||
// of `knownAgentIds` and collapse into the bucket.
|
||||
//
|
||||
// `knownAgentIds` is empty while the agent list is still loading; callers
|
||||
// MUL-3771 (PR #4637) originally *dropped* these rows so they'd stop rendering
|
||||
// as a bare UUID — but the top-line Cost/Tokens KPIs still count their spend
|
||||
// (those totals aggregate `task_usage_hourly` without joining `agent`), so the
|
||||
// per-agent breakdown no longer reconciled with the totals (MUL-3776, #4640).
|
||||
// Aggregating instead of dropping keeps `sum(visible rows) == KPI total` while
|
||||
// still never exposing a UUID. The bucket carries tokens + cost only; seconds
|
||||
// and taskCount stay 0 because the run-time rollups inner-join `agent`, so
|
||||
// deleted agents already contribute nothing to the Time/Tasks KPIs — the
|
||||
// component renders those two columns as "—" for this row.
|
||||
//
|
||||
// `knownAgentIds` is `null` while the agent list is still loading; callers
|
||||
// pass `null` in that case so the rows pass through untouched instead of the
|
||||
// whole leaderboard blanking on a slow fetch.
|
||||
export function filterKnownAgentRows(
|
||||
// whole leaderboard collapsing into one bucket on a slow fetch.
|
||||
export function bucketUnknownAgentRows(
|
||||
rows: AgentDashboardRow[],
|
||||
knownAgentIds: ReadonlySet<string> | null,
|
||||
): AgentDashboardRow[] {
|
||||
if (!knownAgentIds) return rows;
|
||||
return rows.filter((r) => knownAgentIds.has(r.agentId));
|
||||
const known: AgentDashboardRow[] = [];
|
||||
const bucket: AgentDashboardRow = {
|
||||
agentId: DELETED_AGENTS_ROW_ID,
|
||||
tokens: 0,
|
||||
cost: 0,
|
||||
seconds: 0,
|
||||
taskCount: 0,
|
||||
};
|
||||
let hasDeleted = false;
|
||||
for (const r of rows) {
|
||||
if (knownAgentIds.has(r.agentId)) {
|
||||
known.push(r);
|
||||
continue;
|
||||
}
|
||||
hasDeleted = true;
|
||||
bucket.tokens += r.tokens;
|
||||
bucket.cost += r.cost;
|
||||
}
|
||||
return hasDeleted ? [...known, bucket] : known;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
"leaderboard": {
|
||||
"title": "Leaderboard",
|
||||
"caption": "{{count}} agents",
|
||||
"caption_with_deleted": "{{count}} agents · {{deleted}} deleted",
|
||||
"deleted_agents": "Deleted agents",
|
||||
"header_agent": "Agent",
|
||||
"header_tokens": "Tokens",
|
||||
"header_cost": "Cost",
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
"leaderboard": {
|
||||
"title": "リーダーボード",
|
||||
"caption": "{{count}} 件のエージェント",
|
||||
"caption_with_deleted": "{{count}} 件のエージェント · 削除済み {{deleted}} 件",
|
||||
"deleted_agents": "削除済みエージェント",
|
||||
"header_agent": "エージェント",
|
||||
"header_tokens": "トークン",
|
||||
"header_cost": "コスト",
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
"leaderboard": {
|
||||
"title": "리더보드",
|
||||
"caption": "에이전트 {{count}}개",
|
||||
"caption_with_deleted": "에이전트 {{count}}개 · 삭제됨 {{deleted}}개",
|
||||
"deleted_agents": "삭제된 에이전트",
|
||||
"header_agent": "에이전트",
|
||||
"header_tokens": "토큰",
|
||||
"header_cost": "비용",
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
"leaderboard": {
|
||||
"title": "排行榜",
|
||||
"caption": "{{count}} 个智能体",
|
||||
"caption_with_deleted": "{{count}} 个智能体 · {{deleted}} 个已删除",
|
||||
"deleted_agents": "已删除的智能体",
|
||||
"header_agent": "智能体",
|
||||
"header_tokens": "Token",
|
||||
"header_cost": "费用",
|
||||
|
||||
Reference in New Issue
Block a user