molecule-core/canvas/src/components/WorkspaceUsage.tsx
Molecule AI Frontend Engineer bfe4f65c92 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>
2026-04-17 06:00:14 +00:00

138 lines
3.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
);
}