diff --git a/canvas/src/components/WorkspaceUsage.tsx b/canvas/src/components/WorkspaceUsage.tsx new file mode 100644 index 00000000..5ef629d4 --- /dev/null +++ b/canvas/src/components/WorkspaceUsage.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let ignore = false; + setLoading(true); + setError(null); + + api + .get(`/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 ( +
+
+

+ Usage +

+ {!loading && metrics && ( + + {formatPeriod(metrics.period_start, metrics.period_end)} + + )} +
+ +
+ {loading ? ( + <> + + + + + ) : error ? ( +

+ {error} +

+ ) : metrics ? ( + <> + + + + + ) : null} +
+
+ ); +} + +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 ( +
+
+
+
+ ); +} + +function StatRow({ + label, + value, + testId, +}: { + label: string; + value: string; + testId?: string; +}) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/canvas/src/components/__tests__/WorkspaceUsage.test.tsx b/canvas/src/components/__tests__/WorkspaceUsage.test.tsx new file mode 100644 index 00000000..d40deac8 --- /dev/null +++ b/canvas/src/components/__tests__/WorkspaceUsage.test.tsx @@ -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(); + expect(container.firstChild).toBeTruthy(); + }); + + it("renders the Usage heading", () => { + mockGet.mockReturnValue(new Promise(() => {})); + render(); + expect(screen.getByText("Usage")).toBeTruthy(); + }); + + it("shows skeleton rows while loading", () => { + mockGet.mockReturnValue(new Promise(() => {})); + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(1)); + + rerender(); + 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(); + expect(screen.getByTestId("usage-stats")).toBeTruthy(); + }); +}); diff --git a/canvas/src/components/ui/RevealToggle.tsx b/canvas/src/components/ui/RevealToggle.tsx index c0e051b1..541cfe72 100644 --- a/canvas/src/components/ui/RevealToggle.tsx +++ b/canvas/src/components/ui/RevealToggle.tsx @@ -1,3 +1,5 @@ +'use client'; + interface RevealToggleProps { revealed: boolean; onToggle: () => void;