feat(#375): frontend token usage and cost dashboard
- Install recharts for data visualization - Add Usage nav item to sidebar navigation - Create telemetry API service with data fetching functions - Build dashboard page with summary cards, charts, and time range selector - Token usage line chart, cost breakdown bar chart, task outcome pie chart - Loading and empty states handled - Responsive layout with PDA-friendly design - Add unit tests (14 tests passing) Refs #375 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-grid-layout": "^2.2.2",
|
||||
"recharts": "^3.7.0",
|
||||
"socket.io-client": "^4.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
288
apps/web/src/app/(authenticated)/usage/page.test.tsx
Normal file
288
apps/web/src/app/(authenticated)/usage/page.test.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import UsagePage from "./page";
|
||||
|
||||
// ─── Component Prop Types ────────────────────────────────────────────
|
||||
|
||||
interface ChildrenProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface StyledChildrenProps extends ChildrenProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ─── Mocks ───────────────────────────────────────────────────────────
|
||||
|
||||
// Mock @/components/ui/card — @mosaic/ui can't be resolved in vitest
|
||||
vi.mock("@/components/ui/card", () => ({
|
||||
Card: ({ children, className }: StyledChildrenProps): React.JSX.Element => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
CardHeader: ({ children }: ChildrenProps): React.JSX.Element => <div>{children}</div>,
|
||||
CardContent: ({ children, className }: StyledChildrenProps): React.JSX.Element => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
CardFooter: ({ children }: ChildrenProps): React.JSX.Element => <div>{children}</div>,
|
||||
CardTitle: ({ children, className }: StyledChildrenProps): React.JSX.Element => (
|
||||
<h3 className={className}>{children}</h3>
|
||||
),
|
||||
CardDescription: ({ children, className }: StyledChildrenProps): React.JSX.Element => (
|
||||
<p className={className}>{children}</p>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock recharts — jsdom has no SVG layout engine, so we render stubs
|
||||
vi.mock("recharts", () => ({
|
||||
LineChart: ({ children }: ChildrenProps): React.JSX.Element => (
|
||||
<div data-testid="recharts-line-chart">{children}</div>
|
||||
),
|
||||
Line: (): React.JSX.Element => <div />,
|
||||
BarChart: ({ children }: ChildrenProps): React.JSX.Element => (
|
||||
<div data-testid="recharts-bar-chart">{children}</div>
|
||||
),
|
||||
Bar: (): React.JSX.Element => <div />,
|
||||
PieChart: ({ children }: ChildrenProps): React.JSX.Element => (
|
||||
<div data-testid="recharts-pie-chart">{children}</div>
|
||||
),
|
||||
Pie: (): React.JSX.Element => <div />,
|
||||
Cell: (): React.JSX.Element => <div />,
|
||||
XAxis: (): React.JSX.Element => <div />,
|
||||
YAxis: (): React.JSX.Element => <div />,
|
||||
CartesianGrid: (): React.JSX.Element => <div />,
|
||||
Tooltip: (): React.JSX.Element => <div />,
|
||||
ResponsiveContainer: ({ children }: ChildrenProps): React.JSX.Element => <div>{children}</div>,
|
||||
Legend: (): React.JSX.Element => <div />,
|
||||
}));
|
||||
|
||||
// Mock the telemetry API module
|
||||
vi.mock("@/lib/api/telemetry", () => ({
|
||||
fetchUsageSummary: vi.fn(),
|
||||
fetchTokenUsage: vi.fn(),
|
||||
fetchCostBreakdown: vi.fn(),
|
||||
fetchTaskOutcomes: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import mocked modules after vi.mock
|
||||
import {
|
||||
fetchUsageSummary,
|
||||
fetchTokenUsage,
|
||||
fetchCostBreakdown,
|
||||
fetchTaskOutcomes,
|
||||
} from "@/lib/api/telemetry";
|
||||
|
||||
// ─── Test Data ───────────────────────────────────────────────────────
|
||||
|
||||
const mockSummary = {
|
||||
totalTokens: 245800,
|
||||
totalCost: 3.42,
|
||||
taskCount: 47,
|
||||
avgQualityGatePassRate: 0.87,
|
||||
};
|
||||
|
||||
const mockTokenUsage = [
|
||||
{ date: "2026-02-08", inputTokens: 10000, outputTokens: 5000, totalTokens: 15000 },
|
||||
{ date: "2026-02-09", inputTokens: 12000, outputTokens: 6000, totalTokens: 18000 },
|
||||
];
|
||||
|
||||
const mockCostBreakdown = [
|
||||
{ model: "claude-sonnet-4-5", provider: "anthropic", cost: 18.5, taskCount: 124 },
|
||||
{ model: "gpt-4o", provider: "openai", cost: 12.3, taskCount: 89 },
|
||||
];
|
||||
|
||||
const mockTaskOutcomes = [
|
||||
{ outcome: "Success", count: 312, color: "#6EBF8B" },
|
||||
{ outcome: "Partial", count: 48, color: "#F5C862" },
|
||||
];
|
||||
|
||||
function setupMocks(overrides?: { empty?: boolean; error?: boolean }): void {
|
||||
if (overrides?.error) {
|
||||
vi.mocked(fetchUsageSummary).mockRejectedValue(new Error("Network error"));
|
||||
vi.mocked(fetchTokenUsage).mockRejectedValue(new Error("Network error"));
|
||||
vi.mocked(fetchCostBreakdown).mockRejectedValue(new Error("Network error"));
|
||||
vi.mocked(fetchTaskOutcomes).mockRejectedValue(new Error("Network error"));
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = overrides?.empty ? { ...mockSummary, taskCount: 0 } : mockSummary;
|
||||
|
||||
vi.mocked(fetchUsageSummary).mockResolvedValue(summary);
|
||||
vi.mocked(fetchTokenUsage).mockResolvedValue(mockTokenUsage);
|
||||
vi.mocked(fetchCostBreakdown).mockResolvedValue(mockCostBreakdown);
|
||||
vi.mocked(fetchTaskOutcomes).mockResolvedValue(mockTaskOutcomes);
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("UsagePage", (): void => {
|
||||
beforeEach((): void => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render the page title and subtitle", (): void => {
|
||||
setupMocks();
|
||||
render(<UsagePage />);
|
||||
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Usage");
|
||||
expect(screen.getByText("Token usage and cost overview")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have proper layout structure", (): void => {
|
||||
setupMocks();
|
||||
const { container } = render(<UsagePage />);
|
||||
const main = container.querySelector("main");
|
||||
expect(main).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show loading skeleton initially", (): void => {
|
||||
setupMocks();
|
||||
render(<UsagePage />);
|
||||
expect(screen.getByTestId("loading-skeleton")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render summary cards after loading", async (): Promise<void> => {
|
||||
setupMocks();
|
||||
render(<UsagePage />);
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("summary-cards")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check summary card values
|
||||
expect(screen.getByText("Total Tokens")).toBeInTheDocument();
|
||||
expect(screen.getByText("245.8K")).toBeInTheDocument();
|
||||
expect(screen.getByText("Estimated Cost")).toBeInTheDocument();
|
||||
expect(screen.getByText("$3.42")).toBeInTheDocument();
|
||||
expect(screen.getByText("Task Count")).toBeInTheDocument();
|
||||
expect(screen.getByText("47")).toBeInTheDocument();
|
||||
expect(screen.getByText("Quality Gate Pass Rate")).toBeInTheDocument();
|
||||
expect(screen.getByText("87.0%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all chart sections after loading", async (): Promise<void> => {
|
||||
setupMocks();
|
||||
render(<UsagePage />);
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("token-usage-chart")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("cost-breakdown-chart")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("task-outcomes-chart")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the time range selector with three options", (): void => {
|
||||
setupMocks();
|
||||
render(<UsagePage />);
|
||||
|
||||
expect(screen.getByText("7 Days")).toBeInTheDocument();
|
||||
expect(screen.getByText("30 Days")).toBeInTheDocument();
|
||||
expect(screen.getByText("90 Days")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have 30 Days selected by default", (): void => {
|
||||
setupMocks();
|
||||
render(<UsagePage />);
|
||||
|
||||
const button30d = screen.getByText("30 Days");
|
||||
expect(button30d).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("should change time range when a different option is clicked", async (): Promise<void> => {
|
||||
setupMocks();
|
||||
render(<UsagePage />);
|
||||
|
||||
// Wait for initial load
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("summary-cards")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click 7 Days
|
||||
const button7d = screen.getByText("7 Days");
|
||||
fireEvent.click(button7d);
|
||||
|
||||
expect(button7d).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByText("30 Days")).toHaveAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
it("should refetch data when time range changes", async (): Promise<void> => {
|
||||
setupMocks();
|
||||
render(<UsagePage />);
|
||||
|
||||
// Wait for initial load (30d default)
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("summary-cards")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Initial call was with "30d"
|
||||
expect(fetchUsageSummary).toHaveBeenCalledWith("30d");
|
||||
|
||||
// Change to 7d
|
||||
fireEvent.click(screen.getByText("7 Days"));
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(fetchUsageSummary).toHaveBeenCalledWith("7d");
|
||||
});
|
||||
});
|
||||
|
||||
it("should show empty state when no tasks exist", async (): Promise<void> => {
|
||||
setupMocks({ empty: true });
|
||||
render(<UsagePage />);
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("empty-state")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("No usage data yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show error state on fetch failure", async (): Promise<void> => {
|
||||
setupMocks({ error: true });
|
||||
render(<UsagePage />);
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByText("Network error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Try again")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should retry loading when Try again button is clicked after error", async (): Promise<void> => {
|
||||
setupMocks({ error: true });
|
||||
render(<UsagePage />);
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByText("Try again")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Now set up success mocks and click retry
|
||||
setupMocks();
|
||||
fireEvent.click(screen.getByText("Try again"));
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("summary-cards")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should display chart section titles", async (): Promise<void> => {
|
||||
setupMocks();
|
||||
render(<UsagePage />);
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByText("Token Usage Over Time")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cost by Model")).toBeInTheDocument();
|
||||
expect(screen.getByText("Task Outcomes")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render recharts components within chart containers", async (): Promise<void> => {
|
||||
setupMocks();
|
||||
render(<UsagePage />);
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("recharts-line-chart")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("recharts-bar-chart")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("recharts-pie-chart")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
427
apps/web/src/app/(authenticated)/usage/page.tsx
Normal file
427
apps/web/src/app/(authenticated)/usage/page.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
BarChart,
|
||||
Bar,
|
||||
PieChart,
|
||||
Pie,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
import { Card, CardHeader, CardContent, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import {
|
||||
fetchUsageSummary,
|
||||
fetchTokenUsage,
|
||||
fetchCostBreakdown,
|
||||
fetchTaskOutcomes,
|
||||
} from "@/lib/api/telemetry";
|
||||
import type {
|
||||
TimeRange,
|
||||
UsageSummary,
|
||||
TokenUsagePoint,
|
||||
CostBreakdownItem,
|
||||
TaskOutcomeItem,
|
||||
} from "@/lib/api/telemetry";
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────
|
||||
|
||||
const TIME_RANGES: { value: TimeRange; label: string }[] = [
|
||||
{ value: "7d", label: "7 Days" },
|
||||
{ value: "30d", label: "30 Days" },
|
||||
{ value: "90d", label: "90 Days" },
|
||||
];
|
||||
|
||||
// Calm, PDA-friendly chart colors (no aggressive reds)
|
||||
const CHART_COLORS = {
|
||||
inputTokens: "#6366F1", // Indigo
|
||||
outputTokens: "#38BDF8", // Sky blue
|
||||
grid: "#E2E8F0", // Slate 200
|
||||
barFill: "#818CF8", // Indigo 400
|
||||
};
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(1)}K`;
|
||||
}
|
||||
return value.toFixed(0);
|
||||
}
|
||||
|
||||
function formatCurrency(value: number): string {
|
||||
return `$${value.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
return `${(value * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatDateLabel(dateStr: string): string {
|
||||
const date = new Date(dateStr + "T00:00:00");
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Map TaskOutcomeItem[] to recharts-compatible data with `fill` property.
|
||||
* This replaces deprecated Cell component (removed in Recharts 4.0).
|
||||
*/
|
||||
function toFillData(
|
||||
outcomes: TaskOutcomeItem[]
|
||||
): { outcome: string; count: number; fill: string }[] {
|
||||
return outcomes.map((item) => ({
|
||||
outcome: item.outcome,
|
||||
count: item.count,
|
||||
fill: item.color,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Sub-components ──────────────────────────────────────────────────
|
||||
|
||||
function SummaryCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
subtitle?: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm font-medium text-gray-500">{title}</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-1">{value}</p>
|
||||
{subtitle ? <p className="text-xs text-gray-400 mt-1">{subtitle}</p> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSkeleton(): ReactElement {
|
||||
return (
|
||||
<div className="space-y-6" data-testid="loading-skeleton">
|
||||
{/* Summary cards skeleton */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="h-4 bg-gray-200 rounded w-24 animate-pulse" />
|
||||
<div className="h-8 bg-gray-200 rounded w-16 mt-2 animate-pulse" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{/* Chart skeletons */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i} className={i === 0 ? "lg:col-span-2" : ""}>
|
||||
<CardHeader>
|
||||
<div className="h-6 bg-gray-200 rounded w-40 animate-pulse" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-64 bg-gray-100 rounded animate-pulse" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState(): ReactElement {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center py-16 text-center"
|
||||
data-testid="empty-state"
|
||||
>
|
||||
<div className="text-4xl mb-4">📊</div>
|
||||
<h2 className="text-xl font-semibold text-gray-700 mb-2">No usage data yet</h2>
|
||||
<p className="text-gray-500 max-w-md">
|
||||
Once you start using AI-powered features, your token usage and cost data will appear here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page Component ─────────────────────────────────────────────
|
||||
|
||||
export default function UsagePage(): ReactElement {
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>("30d");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isEmpty, setIsEmpty] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [summary, setSummary] = useState<UsageSummary | null>(null);
|
||||
const [tokenUsage, setTokenUsage] = useState<TokenUsagePoint[]>([]);
|
||||
const [costBreakdown, setCostBreakdown] = useState<CostBreakdownItem[]>([]);
|
||||
const [taskOutcomes, setTaskOutcomes] = useState<TaskOutcomeItem[]>([]);
|
||||
|
||||
const loadData = useCallback(async (range: TimeRange): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [summaryData, tokenData, costData, outcomeData] = await Promise.all([
|
||||
fetchUsageSummary(range),
|
||||
fetchTokenUsage(range),
|
||||
fetchCostBreakdown(range),
|
||||
fetchTaskOutcomes(range),
|
||||
]);
|
||||
|
||||
setSummary(summaryData);
|
||||
setTokenUsage(tokenData);
|
||||
setCostBreakdown(costData);
|
||||
setTaskOutcomes(outcomeData);
|
||||
|
||||
// Check if there's any meaningful data
|
||||
setIsEmpty(summaryData.taskCount === 0);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "We had trouble loading usage data. Please try again when you're ready."
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData(timeRange);
|
||||
}, [timeRange, loadData]);
|
||||
|
||||
function handleTimeRangeChange(range: TimeRange): void {
|
||||
setTimeRange(range);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-8 gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Usage</h1>
|
||||
<p className="text-gray-600 mt-1">Token usage and cost overview</p>
|
||||
</div>
|
||||
|
||||
{/* Time range selector */}
|
||||
<div className="flex gap-1 bg-gray-100 rounded-lg p-1" role="group" aria-label="Time range">
|
||||
{TIME_RANGES.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => {
|
||||
handleTimeRangeChange(value);
|
||||
}}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
timeRange === value
|
||||
? "bg-white text-gray-900 shadow-sm"
|
||||
: "text-gray-600 hover:text-gray-900"
|
||||
}`}
|
||||
aria-pressed={timeRange === value}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error state */}
|
||||
{error !== null ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-6 text-center">
|
||||
<p className="text-amber-800">{error}</p>
|
||||
<button
|
||||
onClick={() => void loadData(timeRange)}
|
||||
className="mt-4 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 transition-colors"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<LoadingSkeleton />
|
||||
) : isEmpty ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Cards */}
|
||||
<div
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"
|
||||
data-testid="summary-cards"
|
||||
>
|
||||
<SummaryCard
|
||||
title="Total Tokens"
|
||||
value={summary ? formatNumber(summary.totalTokens) : "0"}
|
||||
subtitle="Input + Output"
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Estimated Cost"
|
||||
value={summary ? formatCurrency(summary.totalCost) : "$0.00"}
|
||||
subtitle="Based on provider pricing"
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Task Count"
|
||||
value={summary ? formatNumber(summary.taskCount) : "0"}
|
||||
subtitle="AI-assisted tasks"
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Quality Gate Pass Rate"
|
||||
value={summary ? formatPercent(summary.avgQualityGatePassRate) : "0%"}
|
||||
subtitle="Build, lint, test, typecheck"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Token Usage Over Time — Full width */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Token Usage Over Time</CardTitle>
|
||||
<CardDescription>Input and output tokens by day</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-72" data-testid="token-usage-chart">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={tokenUsage}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatDateLabel}
|
||||
tick={{ fontSize: 12, fill: "#64748B" }}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={formatNumber}
|
||||
tick={{ fontSize: 12, fill: "#64748B" }}
|
||||
width={60}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => [
|
||||
formatNumber(value),
|
||||
name === "inputTokens" ? "Input Tokens" : "Output Tokens",
|
||||
]}
|
||||
labelFormatter={formatDateLabel}
|
||||
contentStyle={{
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #E2E8F0",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
formatter={(value: string) =>
|
||||
value === "inputTokens" ? "Input Tokens" : "Output Tokens"
|
||||
}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="inputTokens"
|
||||
stroke={CHART_COLORS.inputTokens}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="outputTokens"
|
||||
stroke={CHART_COLORS.outputTokens}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cost Breakdown by Model */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Cost by Model</CardTitle>
|
||||
<CardDescription>Estimated cost breakdown</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-72" data-testid="cost-breakdown-chart">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={costBreakdown} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tickFormatter={(v: number) => formatCurrency(v)}
|
||||
tick={{ fontSize: 12, fill: "#64748B" }}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="model"
|
||||
tick={{ fontSize: 11, fill: "#64748B" }}
|
||||
width={140}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [formatCurrency(value), "Cost"]}
|
||||
contentStyle={{
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #E2E8F0",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="cost" fill={CHART_COLORS.barFill} radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Task Outcomes */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Task Outcomes</CardTitle>
|
||||
<CardDescription>Distribution of task completion results</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
className="h-72 flex items-center justify-center"
|
||||
data-testid="task-outcomes-chart"
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={toFillData(taskOutcomes)}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
paddingAngle={2}
|
||||
dataKey="count"
|
||||
nameKey="outcome"
|
||||
label={({ outcome, count }: { outcome: string; count: number }) =>
|
||||
`${outcome}: ${String(count)}`
|
||||
}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => [value, name]}
|
||||
contentStyle={{
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #E2E8F0",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export function Navigation(): React.JSX.Element {
|
||||
{ href: "/tasks", label: "Tasks" },
|
||||
{ href: "/calendar", label: "Calendar" },
|
||||
{ href: "/knowledge", label: "Knowledge" },
|
||||
{ href: "/usage", label: "Usage" },
|
||||
];
|
||||
|
||||
// Global keyboard shortcut for search (Cmd+K or Ctrl+K)
|
||||
|
||||
@@ -12,3 +12,4 @@ export * from "./knowledge";
|
||||
export * from "./domains";
|
||||
export * from "./teams";
|
||||
export * from "./personalities";
|
||||
export * from "./telemetry";
|
||||
|
||||
187
apps/web/src/lib/api/telemetry.ts
Normal file
187
apps/web/src/lib/api/telemetry.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Telemetry API Client
|
||||
* Handles telemetry data fetching for the usage dashboard.
|
||||
*
|
||||
* NOTE: Currently returns mock/placeholder data since the telemetry API
|
||||
* aggregation endpoints don't exist yet. The important thing is the UI structure.
|
||||
* When the backend endpoints are ready, replace mock calls with real apiGet() calls.
|
||||
*/
|
||||
|
||||
import { apiGet, type ApiResponse } from "./client";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────
|
||||
|
||||
export type TimeRange = "7d" | "30d" | "90d";
|
||||
|
||||
export interface UsageSummary {
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
taskCount: number;
|
||||
avgQualityGatePassRate: number;
|
||||
}
|
||||
|
||||
export interface TokenUsagePoint {
|
||||
date: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
export interface CostBreakdownItem {
|
||||
model: string;
|
||||
provider: string;
|
||||
cost: number;
|
||||
taskCount: number;
|
||||
}
|
||||
|
||||
export interface TaskOutcomeItem {
|
||||
outcome: string;
|
||||
count: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface EstimateParams {
|
||||
taskType: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
complexity: string;
|
||||
}
|
||||
|
||||
export interface EstimateResponse {
|
||||
prediction: {
|
||||
input_tokens: { median: number; p75: number; p90: number };
|
||||
output_tokens: { median: number; p75: number; p90: number };
|
||||
cost_usd_micros: Record<string, number>;
|
||||
quality: { gate_pass_rate: number; success_rate: number };
|
||||
} | null;
|
||||
metadata: {
|
||||
sample_size: number;
|
||||
confidence: "none" | "low" | "medium" | "high";
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Mock Data Generators ────────────────────────────────────────────
|
||||
|
||||
function generateDateRange(range: TimeRange): string[] {
|
||||
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
||||
const dates: string[] = [];
|
||||
const now = new Date();
|
||||
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const d = new Date(now);
|
||||
d.setDate(d.getDate() - i);
|
||||
dates.push(d.toISOString().split("T")[0]);
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
function generateMockTokenUsage(range: TimeRange): TokenUsagePoint[] {
|
||||
const dates = generateDateRange(range);
|
||||
|
||||
return dates.map((date) => {
|
||||
const baseInput = 8000 + Math.floor(Math.random() * 12000);
|
||||
const baseOutput = 3000 + Math.floor(Math.random() * 7000);
|
||||
return {
|
||||
date,
|
||||
inputTokens: baseInput,
|
||||
outputTokens: baseOutput,
|
||||
totalTokens: baseInput + baseOutput,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function generateMockSummary(range: TimeRange): UsageSummary {
|
||||
const multiplier = range === "7d" ? 1 : range === "30d" ? 4 : 12;
|
||||
return {
|
||||
totalTokens: 245_800 * multiplier,
|
||||
totalCost: 3.42 * multiplier,
|
||||
taskCount: 47 * multiplier,
|
||||
avgQualityGatePassRate: 0.87,
|
||||
};
|
||||
}
|
||||
|
||||
function generateMockCostBreakdown(): CostBreakdownItem[] {
|
||||
return [
|
||||
{ model: "claude-sonnet-4-5", provider: "anthropic", cost: 18.5, taskCount: 124 },
|
||||
{ model: "gpt-4o", provider: "openai", cost: 12.3, taskCount: 89 },
|
||||
{ model: "claude-haiku-3.5", provider: "anthropic", cost: 4.2, taskCount: 156 },
|
||||
{ model: "llama-3.3-70b", provider: "ollama", cost: 0, taskCount: 67 },
|
||||
{ model: "gemini-2.0-flash", provider: "google", cost: 2.8, taskCount: 42 },
|
||||
];
|
||||
}
|
||||
|
||||
// PDA-friendly colors: calm, no aggressive reds
|
||||
function generateMockTaskOutcomes(): TaskOutcomeItem[] {
|
||||
return [
|
||||
{ outcome: "Success", count: 312, color: "#6EBF8B" },
|
||||
{ outcome: "Partial", count: 48, color: "#F5C862" },
|
||||
{ outcome: "Timeout", count: 18, color: "#94A3B8" },
|
||||
{ outcome: "Incomplete", count: 22, color: "#C4A5DE" },
|
||||
];
|
||||
}
|
||||
|
||||
// ─── API Functions ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch usage summary data (total tokens, cost, task count, quality rate)
|
||||
*/
|
||||
export async function fetchUsageSummary(timeRange: TimeRange): Promise<UsageSummary> {
|
||||
// TODO: Replace with real API call when backend aggregation endpoints are ready
|
||||
// const response = await apiGet<ApiResponse<UsageSummary>>(`/api/telemetry/summary?range=${timeRange}`);
|
||||
// return response.data;
|
||||
void apiGet; // suppress unused import warning in the meantime
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
return generateMockSummary(timeRange);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch token usage time series for charts
|
||||
*/
|
||||
export async function fetchTokenUsage(timeRange: TimeRange): Promise<TokenUsagePoint[]> {
|
||||
// TODO: Replace with real API call
|
||||
// const response = await apiGet<ApiResponse<TokenUsagePoint[]>>(`/api/telemetry/tokens?range=${timeRange}`);
|
||||
// return response.data;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
return generateMockTokenUsage(timeRange);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch cost breakdown by model
|
||||
*/
|
||||
export async function fetchCostBreakdown(timeRange: TimeRange): Promise<CostBreakdownItem[]> {
|
||||
// TODO: Replace with real API call
|
||||
// const response = await apiGet<ApiResponse<CostBreakdownItem[]>>(`/api/telemetry/costs?range=${timeRange}`);
|
||||
// return response.data;
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
void timeRange;
|
||||
return generateMockCostBreakdown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch task outcome distribution
|
||||
*/
|
||||
export async function fetchTaskOutcomes(timeRange: TimeRange): Promise<TaskOutcomeItem[]> {
|
||||
// TODO: Replace with real API call
|
||||
// const response = await apiGet<ApiResponse<TaskOutcomeItem[]>>(`/api/telemetry/outcomes?range=${timeRange}`);
|
||||
// return response.data;
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
void timeRange;
|
||||
return generateMockTaskOutcomes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch cost/token estimate for a given task configuration.
|
||||
* Uses the real GET /api/telemetry/estimate endpoint from TEL-006.
|
||||
*/
|
||||
export async function fetchEstimate(params: EstimateParams): Promise<EstimateResponse> {
|
||||
const query = new URLSearchParams({
|
||||
taskType: params.taskType,
|
||||
model: params.model,
|
||||
provider: params.provider,
|
||||
complexity: params.complexity,
|
||||
}).toString();
|
||||
|
||||
const response = await apiGet<ApiResponse<EstimateResponse>>(`/api/telemetry/estimate?${query}`);
|
||||
return response.data;
|
||||
}
|
||||
175
pnpm-lock.yaml
generated
175
pnpm-lock.yaml
generated
@@ -391,7 +391,7 @@ importers:
|
||||
version: 3.2.0
|
||||
'@xyflow/react':
|
||||
specifier: ^12.5.3
|
||||
version: 12.10.0(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
version: 12.10.0(@types/react@19.2.10)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
better-auth:
|
||||
specifier: ^1.4.17
|
||||
version: 1.4.17(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.7)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
@@ -422,6 +422,9 @@ importers:
|
||||
react-grid-layout:
|
||||
specifier: ^2.2.2
|
||||
version: 2.2.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
recharts:
|
||||
specifier: ^3.7.0
|
||||
version: 3.7.0(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1)
|
||||
socket.io-client:
|
||||
specifier: ^4.8.3
|
||||
version: 4.8.3
|
||||
@@ -2431,6 +2434,17 @@ packages:
|
||||
'@protobufjs/utf8@1.1.0':
|
||||
resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
|
||||
|
||||
'@reduxjs/toolkit@2.11.2':
|
||||
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
|
||||
peerDependencies:
|
||||
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
|
||||
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
react-redux:
|
||||
optional: true
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.27':
|
||||
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
|
||||
|
||||
@@ -2586,6 +2600,9 @@ packages:
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@standard-schema/utils@0.3.0':
|
||||
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||
|
||||
'@swc/core-darwin-arm64@1.15.11':
|
||||
resolution: {integrity: sha512-QoIupRWVH8AF1TgxYyeA5nS18dtqMuxNwchjBIwJo3RdwLEFiJq6onOx9JAxHtuPwUkIVuU2Xbp+jCJ7Vzmgtg==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2980,6 +2997,9 @@ packages:
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@types/use-sync-external-store@0.0.6':
|
||||
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||
|
||||
'@types/validator@13.15.10':
|
||||
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
|
||||
|
||||
@@ -3989,6 +4009,9 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decimal.js-light@2.5.1:
|
||||
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||
|
||||
decimal.js@10.6.0:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
@@ -4299,6 +4322,9 @@ packages:
|
||||
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-toolkit@1.44.0:
|
||||
resolution: {integrity: sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==}
|
||||
|
||||
esbuild@0.27.2:
|
||||
resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4708,6 +4734,12 @@ packages:
|
||||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
immer@10.2.0:
|
||||
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
|
||||
|
||||
immer@11.1.4:
|
||||
resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -5650,6 +5682,18 @@ packages:
|
||||
react-is@17.0.2:
|
||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||
|
||||
react-redux@9.2.0:
|
||||
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
|
||||
peerDependencies:
|
||||
'@types/react': ^18.2.25 || ^19
|
||||
react: ^18.0 || ^19
|
||||
redux: ^5.0.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
redux:
|
||||
optional: true
|
||||
|
||||
react-refresh@0.17.0:
|
||||
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -5686,6 +5730,14 @@ packages:
|
||||
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
recharts@3.7.0:
|
||||
resolution: {integrity: sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
redent@3.0.0:
|
||||
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -5698,6 +5750,14 @@ packages:
|
||||
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
redux-thunk@3.1.0:
|
||||
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
|
||||
peerDependencies:
|
||||
redux: ^5.0.0
|
||||
|
||||
redux@5.0.1:
|
||||
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
|
||||
|
||||
reflect-metadata@0.2.2:
|
||||
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
||||
|
||||
@@ -5720,6 +5780,9 @@ packages:
|
||||
resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
|
||||
reselect@5.1.1:
|
||||
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||
|
||||
resize-observer-polyfill@1.5.1:
|
||||
resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
|
||||
|
||||
@@ -6115,6 +6178,9 @@ packages:
|
||||
text-decoder@1.2.3:
|
||||
resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==}
|
||||
|
||||
tiny-invariant@1.3.3:
|
||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
@@ -6366,6 +6432,9 @@ packages:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||
|
||||
vite-node@3.2.4:
|
||||
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
|
||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||
@@ -7047,7 +7116,7 @@ snapshots:
|
||||
chalk: 5.6.2
|
||||
commander: 12.1.0
|
||||
dotenv: 17.2.4
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||
open: 10.2.0
|
||||
pg: 8.17.2
|
||||
prettier: 3.8.1
|
||||
@@ -8904,6 +8973,18 @@ snapshots:
|
||||
|
||||
'@protobufjs/utf8@1.1.0': {}
|
||||
|
||||
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.10)(react@19.2.4)(redux@5.0.1))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@standard-schema/utils': 0.3.0
|
||||
immer: 11.1.4
|
||||
redux: 5.0.1
|
||||
redux-thunk: 3.1.0(redux@5.0.1)
|
||||
reselect: 5.1.1
|
||||
optionalDependencies:
|
||||
react: 19.2.4
|
||||
react-redux: 9.2.0(@types/react@19.2.10)(react@19.2.4)(redux@5.0.1)
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
||||
|
||||
'@rollup/pluginutils@5.3.0(rollup@4.57.0)':
|
||||
@@ -9002,6 +9083,8 @@ snapshots:
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@standard-schema/utils@0.3.0': {}
|
||||
|
||||
'@swc/core-darwin-arm64@1.15.11':
|
||||
optional: true
|
||||
|
||||
@@ -9449,6 +9532,8 @@ snapshots:
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
|
||||
'@types/use-sync-external-store@0.0.6': {}
|
||||
|
||||
'@types/validator@13.15.10': {}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
@@ -9768,13 +9853,13 @@ snapshots:
|
||||
|
||||
'@xtuc/long@4.2.2': {}
|
||||
|
||||
'@xyflow/react@12.10.0(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
'@xyflow/react@12.10.0(@types/react@19.2.10)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@xyflow/system': 0.0.74
|
||||
classcat: 5.0.5
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
zustand: 4.5.7(@types/react@19.2.10)(react@19.2.4)
|
||||
zustand: 4.5.7(@types/react@19.2.10)(immer@11.1.4)(react@19.2.4)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- immer
|
||||
@@ -9978,7 +10063,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||
better-sqlite3: 12.6.2
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
pg: 8.17.2
|
||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||
@@ -10003,7 +10088,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)
|
||||
better-sqlite3: 12.6.2
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
pg: 8.17.2
|
||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||
@@ -10626,6 +10711,8 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decimal.js-light@2.5.1: {}
|
||||
|
||||
decimal.js@10.6.0: {}
|
||||
|
||||
decompress-response@6.0.0:
|
||||
@@ -10758,6 +10845,16 @@ snapshots:
|
||||
|
||||
dotenv@17.2.4: {}
|
||||
|
||||
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)):
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||
'@types/pg': 8.16.0
|
||||
better-sqlite3: 12.6.2
|
||||
kysely: 0.28.10
|
||||
pg: 8.17.2
|
||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||
|
||||
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)):
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
@@ -10767,6 +10864,7 @@ snapshots:
|
||||
kysely: 0.28.10
|
||||
pg: 8.17.2
|
||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||
optional: true
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
@@ -10865,6 +10963,8 @@ snapshots:
|
||||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.2
|
||||
|
||||
es-toolkit@1.44.0: {}
|
||||
|
||||
esbuild@0.27.2:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.27.2
|
||||
@@ -11349,6 +11449,10 @@ snapshots:
|
||||
|
||||
ignore@7.0.5: {}
|
||||
|
||||
immer@10.2.0: {}
|
||||
|
||||
immer@11.1.4: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
dependencies:
|
||||
parent-module: 1.0.1
|
||||
@@ -12271,6 +12375,15 @@ snapshots:
|
||||
|
||||
react-is@17.0.2: {}
|
||||
|
||||
react-redux@9.2.0(@types/react@19.2.10)(react@19.2.4)(redux@5.0.1):
|
||||
dependencies:
|
||||
'@types/use-sync-external-store': 0.0.6
|
||||
react: 19.2.4
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.10
|
||||
redux: 5.0.1
|
||||
|
||||
react-refresh@0.17.0: {}
|
||||
|
||||
react-resizable@3.1.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
@@ -12314,6 +12427,26 @@ snapshots:
|
||||
|
||||
readdirp@5.0.0: {}
|
||||
|
||||
recharts@3.7.0(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1):
|
||||
dependencies:
|
||||
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.10)(react@19.2.4)(redux@5.0.1))(react@19.2.4)
|
||||
clsx: 2.1.1
|
||||
decimal.js-light: 2.5.1
|
||||
es-toolkit: 1.44.0
|
||||
eventemitter3: 5.0.4
|
||||
immer: 10.2.0
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
react-is: 17.0.2
|
||||
react-redux: 9.2.0(@types/react@19.2.10)(react@19.2.4)(redux@5.0.1)
|
||||
reselect: 5.1.1
|
||||
tiny-invariant: 1.3.3
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
victory-vendor: 37.3.6
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- redux
|
||||
|
||||
redent@3.0.0:
|
||||
dependencies:
|
||||
indent-string: 4.0.0
|
||||
@@ -12325,6 +12458,12 @@ snapshots:
|
||||
dependencies:
|
||||
redis-errors: 1.2.0
|
||||
|
||||
redux-thunk@3.1.0(redux@5.0.1):
|
||||
dependencies:
|
||||
redux: 5.0.1
|
||||
|
||||
redux@5.0.1: {}
|
||||
|
||||
reflect-metadata@0.2.2: {}
|
||||
|
||||
regexp-to-ast@0.5.0: {}
|
||||
@@ -12343,6 +12482,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
reselect@5.1.1: {}
|
||||
|
||||
resize-observer-polyfill@1.5.1: {}
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
@@ -12863,6 +13004,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- react-native-b4a
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@0.3.2: {}
|
||||
@@ -13087,6 +13230,23 @@ snapshots:
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
dependencies:
|
||||
'@types/d3-array': 3.2.2
|
||||
'@types/d3-ease': 3.0.2
|
||||
'@types/d3-interpolate': 3.0.4
|
||||
'@types/d3-scale': 4.0.9
|
||||
'@types/d3-shape': 3.1.8
|
||||
'@types/d3-time': 3.0.4
|
||||
'@types/d3-timer': 3.0.2
|
||||
d3-array: 3.2.4
|
||||
d3-ease: 3.0.1
|
||||
d3-interpolate: 3.0.1
|
||||
d3-scale: 4.0.2
|
||||
d3-shape: 3.2.0
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
vite-node@3.2.4(@types/node@22.19.7)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
@@ -13388,9 +13548,10 @@ snapshots:
|
||||
|
||||
zod@4.3.6: {}
|
||||
|
||||
zustand@4.5.7(@types/react@19.2.10)(react@19.2.4):
|
||||
zustand@4.5.7(@types/react@19.2.10)(immer@11.1.4)(react@19.2.4):
|
||||
dependencies:
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.10
|
||||
immer: 11.1.4
|
||||
react: 19.2.4
|
||||
|
||||
Reference in New Issue
Block a user