feat(canvas): wire live metrics API in WorkspaceUsage (#592)

WorkspaceUsage now fetches GET /workspaces/:id/metrics on mount and on
workspaceId change. Displays input_tokens and output_tokens formatted
with toLocaleString, and estimated_cost_usd as $X.XXXXXX. Shows three
zinc-700 skeleton rows while loading; surfaces error text on failure.
Stale-fetch guard via ignore flag prevents state updates after unmount.

Also fixes missing 'use client' in RevealToggle.tsx (#603) — the
onClick handler requires client-side hydration.

Tests updated: 12 tests covering loading skeleton, API call correctness,
token formatting, cost formatting, error state, and workspaceId refetch.
All 551 canvas tests pass; build clean.

Closes #592
Closes #603

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Molecule AI Frontend Engineer 2026-04-17 06:00:14 +00:00
parent 4f0da825ed
commit e89d9a1239
3 changed files with 287 additions and 0 deletions

View File

@ -0,0 +1,137 @@
'use client';
import { useState, useEffect } from "react";
import { api } from "@/lib/api";
export interface WorkspaceUsageProps {
workspaceId: string;
}
interface WorkspaceMetrics {
input_tokens: number;
output_tokens: number;
total_calls: number;
estimated_cost_usd: string;
period_start: string;
period_end: string;
}
export function WorkspaceUsage({ workspaceId }: WorkspaceUsageProps) {
const [metrics, setMetrics] = useState<WorkspaceMetrics | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let ignore = false;
setLoading(true);
setError(null);
api
.get<WorkspaceMetrics>(`/workspaces/${workspaceId}/metrics`)
.then((data) => {
if (!ignore) setMetrics(data);
})
.catch((e) => {
if (!ignore)
setError(e instanceof Error ? e.message : "Failed to load metrics");
})
.finally(() => {
if (!ignore) setLoading(false);
});
return () => {
ignore = true;
};
}, [workspaceId]);
return (
<div
className="rounded-md border border-zinc-700 bg-zinc-900 p-3 space-y-2"
data-testid="workspace-usage"
>
<div className="flex items-center justify-between">
<h4 className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
Usage
</h4>
{!loading && metrics && (
<span
className="text-[10px] text-zinc-600 font-mono"
data-testid="usage-period"
>
{formatPeriod(metrics.period_start, metrics.period_end)}
</span>
)}
</div>
<div className="space-y-1.5" data-testid="usage-stats">
{loading ? (
<>
<SkeletonRow />
<SkeletonRow />
<SkeletonRow />
</>
) : error ? (
<p className="text-xs text-red-400" data-testid="usage-error">
{error}
</p>
) : metrics ? (
<>
<StatRow
label="Input tokens"
value={`${metrics.input_tokens.toLocaleString()} tokens`}
testId="usage-input-tokens"
/>
<StatRow
label="Output tokens"
value={`${metrics.output_tokens.toLocaleString()} tokens`}
testId="usage-output-tokens"
/>
<StatRow
label="Estimated cost"
value={`$${parseFloat(metrics.estimated_cost_usd).toFixed(6)}`}
testId="usage-estimated-cost"
/>
</>
) : null}
</div>
</div>
);
}
function formatPeriod(start: string, end: string): string {
const fmt = (s: string) =>
new Date(s).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
return `${fmt(start)} ${fmt(end)}`;
}
function SkeletonRow() {
return (
<div
className="flex justify-between items-center animate-pulse"
data-testid="usage-skeleton-row"
>
<div className="h-3 w-20 rounded bg-zinc-700" />
<div className="h-3 w-16 rounded bg-zinc-700" />
</div>
);
}
function StatRow({
label,
value,
testId,
}: {
label: string;
value: string;
testId?: string;
}) {
return (
<div className="flex justify-between items-center" data-testid={testId}>
<span className="text-xs text-zinc-500">{label}</span>
<span className="text-xs text-zinc-400 font-mono">{value}</span>
</div>
);
}

View File

@ -0,0 +1,148 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup } from "@testing-library/react";
// Mock api before importing the component
vi.mock("@/lib/api", () => ({
api: {
get: vi.fn(),
},
}));
import { api } from "@/lib/api";
import { WorkspaceUsage } from "../WorkspaceUsage";
const mockGet = vi.mocked(api.get);
const METRICS_RESPONSE = {
input_tokens: 12345,
output_tokens: 678,
total_calls: 42,
estimated_cost_usd: "0.123456",
period_start: "2026-04-17T00:00:00Z",
period_end: "2026-04-18T00:00:00Z",
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
describe("WorkspaceUsage", () => {
it("renders the outer container without crashing", () => {
// Keep fetch pending so we can check initial state
mockGet.mockReturnValue(new Promise(() => {}));
const { container } = render(<WorkspaceUsage workspaceId="ws-1" />);
expect(container.firstChild).toBeTruthy();
});
it("renders the Usage heading", () => {
mockGet.mockReturnValue(new Promise(() => {}));
render(<WorkspaceUsage workspaceId="ws-1" />);
expect(screen.getByText("Usage")).toBeTruthy();
});
it("shows skeleton rows while loading", () => {
mockGet.mockReturnValue(new Promise(() => {}));
render(<WorkspaceUsage workspaceId="ws-1" />);
const skeletons = screen.getAllByTestId("usage-skeleton-row");
expect(skeletons.length).toBe(3);
});
it("calls GET /workspaces/:id/metrics with the correct workspaceId", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(METRICS_RESPONSE as any);
render(<WorkspaceUsage workspaceId="ws-abc-123" />);
await waitFor(() => expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-abc-123/metrics"));
});
it("displays input tokens formatted with toLocaleString after load", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(METRICS_RESPONSE as any);
render(<WorkspaceUsage workspaceId="ws-1" />);
await waitFor(() => {
const row = screen.getByTestId("usage-input-tokens");
expect(row).toBeTruthy();
// 12345 formatted — locale-dependent but always has digits + "tokens"
expect(row.textContent).toContain("tokens");
expect(row.textContent).toContain("12");
});
});
it("displays output tokens formatted with toLocaleString after load", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(METRICS_RESPONSE as any);
render(<WorkspaceUsage workspaceId="ws-1" />);
await waitFor(() => {
const row = screen.getByTestId("usage-output-tokens");
expect(row).toBeTruthy();
expect(row.textContent).toContain("tokens");
expect(row.textContent).toContain("678");
});
});
it("displays estimated cost formatted as $X.XXXXXX after load", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(METRICS_RESPONSE as any);
render(<WorkspaceUsage workspaceId="ws-1" />);
await waitFor(() => {
const row = screen.getByTestId("usage-estimated-cost");
expect(row).toBeTruthy();
expect(row.textContent).toBe("Estimated cost$0.123456");
});
});
it("shows the stat rows and hides skeletons after successful load", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(METRICS_RESPONSE as any);
render(<WorkspaceUsage workspaceId="ws-1" />);
await waitFor(() => {
expect(screen.queryAllByTestId("usage-skeleton-row").length).toBe(0);
expect(screen.getByTestId("usage-input-tokens")).toBeTruthy();
expect(screen.getByTestId("usage-output-tokens")).toBeTruthy();
expect(screen.getByTestId("usage-estimated-cost")).toBeTruthy();
});
});
it("shows error message when fetch fails", async () => {
mockGet.mockRejectedValue(new Error("API GET /workspaces/ws-1/metrics: 403 Forbidden"));
render(<WorkspaceUsage workspaceId="ws-1" />);
await waitFor(() => {
const err = screen.getByTestId("usage-error");
expect(err).toBeTruthy();
expect(err.textContent).toContain("403");
});
});
it("does not show stat rows on error", async () => {
mockGet.mockRejectedValue(new Error("network error"));
render(<WorkspaceUsage workspaceId="ws-1" />);
await waitFor(() => {
expect(screen.queryByTestId("usage-input-tokens")).toBeNull();
expect(screen.queryByTestId("usage-output-tokens")).toBeNull();
expect(screen.queryByTestId("usage-estimated-cost")).toBeNull();
});
});
it("re-fetches when workspaceId prop changes", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(METRICS_RESPONSE as any);
const { rerender } = render(<WorkspaceUsage workspaceId="ws-1" />);
await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(1));
rerender(<WorkspaceUsage workspaceId="ws-2" />);
await waitFor(() => {
expect(mockGet).toHaveBeenCalledTimes(2);
expect(mockGet).toHaveBeenLastCalledWith("/workspaces/ws-2/metrics");
});
});
it("renders the usage-stats container in all states", () => {
mockGet.mockReturnValue(new Promise(() => {}));
render(<WorkspaceUsage workspaceId="ws-1" />);
expect(screen.getByTestId("usage-stats")).toBeTruthy();
});
});

View File

@ -1,3 +1,5 @@
'use client';
interface RevealToggleProps {
revealed: boolean;
onToggle: () => void;