Compare commits

..

4 Commits

Author SHA1 Message Date
b63d94f8ef fix(web): handle correct agent status values in AgentStatusWidget
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- Use isWorking/isIdle/isErrored helpers to match both old
  status names (running/failed) and new enum values (WORKING/ERROR)
- Fix stats computation and card styling
2026-03-02 10:41:08 -06:00
a1a37c77f6 fix(api): add missing /orchestrator/health and /orchestrator/events/recent endpoints
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
- Added GET /orchestrator/health for widget health checks
- Added GET /orchestrator/events/recent for recent agent events
- Widgets were calling endpoints that returned 404
2026-03-02 09:33:31 -06:00
5b77774d91 fix(web): remove mock data from dashboard telemetry/tasks/calendar (#656)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-02 14:19:27 +00:00
a16371c6f9 fix(ci): use node:24-slim (glibc) instead of Alpine (musl) (#655)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/manual/base-image Pipeline was successful
ci/woodpecker/manual/coordinator Pipeline was successful
ci/woodpecker/manual/infra Pipeline was successful
ci/woodpecker/manual/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-02 01:40:37 +00:00
8 changed files with 497 additions and 225 deletions

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Res, UseGuards } from "@nestjs/common";
import { Controller, Get, Query, Res, UseGuards } from "@nestjs/common";
import { AgentStatus } from "@prisma/client";
import type { Response } from "express";
import { AuthGuard } from "../auth/guards/auth.guard";
@@ -6,6 +6,7 @@ import { PrismaService } from "../prisma/prisma.service";
const AGENT_POLL_INTERVAL_MS = 5_000;
const SSE_HEARTBEAT_MS = 15_000;
const DEFAULT_EVENTS_LIMIT = 25;
interface OrchestratorAgentDto {
id: string;
@@ -15,6 +16,26 @@ interface OrchestratorAgentDto {
createdAt: Date;
}
interface OrchestratorEventDto {
type: string;
timestamp: string;
agentId?: string;
taskId?: string;
data?: Record<string, unknown>;
}
interface OrchestratorHealthDto {
status: "healthy" | "degraded" | "unhealthy";
database: "connected" | "disconnected";
agents: {
total: number;
working: number;
idle: number;
errored: number;
};
timestamp: string;
}
@Controller("orchestrator")
@UseGuards(AuthGuard)
export class OrchestratorController {
@@ -25,6 +46,81 @@ export class OrchestratorController {
return this.fetchActiveAgents();
}
@Get("events/recent")
async getRecentEvents(
@Query("limit") limit?: string
): Promise<{ events: OrchestratorEventDto[] }> {
const eventsLimit = limit ? parseInt(limit, 10) : DEFAULT_EVENTS_LIMIT;
const safeLimit = Math.min(Math.max(eventsLimit, 1), 100);
// Fetch recent agent activity to derive events
const agents = await this.prisma.agent.findMany({
where: {
status: {
not: AgentStatus.TERMINATED,
},
},
orderBy: {
createdAt: "desc",
},
take: safeLimit,
});
// Derive events from agent status changes
const events: OrchestratorEventDto[] = agents.map((agent) => ({
type: `agent:${agent.status.toLowerCase()}`,
timestamp: agent.createdAt.toISOString(),
agentId: agent.id,
data: {
name: agent.name,
role: agent.role,
model: agent.model,
},
}));
return { events };
}
@Get("health")
async getHealth(): Promise<OrchestratorHealthDto> {
let databaseConnected = false;
let agents: OrchestratorAgentDto[] = [];
try {
// Check database connectivity
await this.prisma.$queryRaw`SELECT 1`;
databaseConnected = true;
// Get agent counts
agents = await this.fetchActiveAgents();
} catch {
databaseConnected = false;
}
const working = agents.filter((a) => a.status === AgentStatus.WORKING).length;
const idle = agents.filter((a) => a.status === AgentStatus.IDLE).length;
const errored = agents.filter((a) => a.status === AgentStatus.ERROR).length;
let status: OrchestratorHealthDto["status"] = "healthy";
if (!databaseConnected) {
status = "unhealthy";
} else if (errored > 0) {
status = "degraded";
}
return {
status,
database: databaseConnected ? "connected" : "disconnected",
agents: {
total: agents.length,
working,
idle,
errored,
},
timestamp: new Date().toISOString(),
};
}
@Get("events")
async streamEvents(@Res() res: Response): Promise<void> {
res.setHeader("Content-Type", "text/event-stream");

View File

@@ -16,6 +16,21 @@ interface Agent {
error?: string;
}
function isWorking(status: string): boolean {
const s = status.toLowerCase();
return s === "running" || s === "working";
}
function isIdle(status: string): boolean {
const s = status.toLowerCase();
return s === "idle" || s === "spawning" || s === "waiting" || s === "queued";
}
function isErrored(status: string): boolean {
const s = status.toLowerCase();
return s === "failed" || s === "error";
}
export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
const [agents, setAgents] = useState<Agent[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -74,25 +89,20 @@ export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): Re
}, [fetchAgents]);
const getStatusIcon = (status: string): React.JSX.Element => {
const statusLower = status.toLowerCase();
switch (statusLower) {
case "running":
case "working":
return <Activity className="w-4 h-4 text-blue-500 animate-pulse" />;
case "spawning":
case "queued":
return <Clock className="w-4 h-4 text-yellow-500" />;
case "completed":
return <CheckCircle className="w-4 h-4 text-green-500" />;
case "failed":
case "error":
return <AlertCircle className="w-4 h-4 text-red-500" />;
case "terminated":
case "killed":
return <CheckCircle className="w-4 h-4 text-gray-500" />;
default:
return <Clock className="w-4 h-4 text-gray-400" />;
if (isWorking(status)) {
return <Activity className="w-4 h-4 text-blue-500 animate-pulse" />;
}
if (isIdle(status)) {
return <Clock className="w-4 h-4 text-yellow-500" />;
}
if (isErrored(status)) {
return <AlertCircle className="w-4 h-4 text-red-500" />;
}
const s = status.toLowerCase();
if (s === "completed" || s === "terminated" || s === "killed") {
return <CheckCircle className="w-4 h-4 text-gray-500" />;
}
return <Clock className="w-4 h-4 text-gray-400" />;
};
const getStatusText = (status: string): string => {
@@ -121,9 +131,9 @@ export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): Re
const stats = {
total: agents.length,
working: agents.filter((a) => a.status.toLowerCase() === "running").length,
idle: agents.filter((a) => a.status.toLowerCase() === "spawning").length,
error: agents.filter((a) => a.status.toLowerCase() === "failed").length,
working: agents.filter((a) => isWorking(a.status)).length,
idle: agents.filter((a) => isIdle(a.status)).length,
error: agents.filter((a) => isErrored(a.status)).length,
};
if (isLoading) {
@@ -176,9 +186,9 @@ export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): Re
<div
key={agent.agentId}
className={`p-3 rounded-lg border ${
agent.status.toLowerCase() === "failed"
isErrored(agent.status)
? "bg-red-50 border-red-200"
: agent.status.toLowerCase() === "running"
: isWorking(agent.status)
? "bg-blue-50 border-blue-200"
: "bg-gray-50 border-gray-200"
}`}

View File

@@ -4,61 +4,43 @@
import { useState, useEffect } from "react";
import { Calendar as CalendarIcon, Clock, MapPin } from "lucide-react";
import type { WidgetProps } from "@mosaic/shared";
interface Event {
id: string;
title: string;
startTime: string;
endTime?: string;
location?: string;
allDay: boolean;
}
import type { WidgetProps, Event } from "@mosaic/shared";
import { fetchEvents } from "@/lib/api/events";
export function CalendarWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
const [events, setEvents] = useState<Event[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Mock data for now - will fetch from API later
useEffect(() => {
setIsLoading(true);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
let isMounted = true;
setTimeout(() => {
setEvents([
{
id: "1",
title: "Team Standup",
startTime: new Date(today.setHours(9, 0, 0, 0)).toISOString(),
endTime: new Date(today.setHours(9, 30, 0, 0)).toISOString(),
location: "Zoom",
allDay: false,
},
{
id: "2",
title: "Project Review",
startTime: new Date(today.setHours(14, 0, 0, 0)).toISOString(),
endTime: new Date(today.setHours(15, 0, 0, 0)).toISOString(),
location: "Conference Room A",
allDay: false,
},
{
id: "3",
title: "Sprint Planning",
startTime: new Date(tomorrow.setHours(10, 0, 0, 0)).toISOString(),
endTime: new Date(tomorrow.setHours(12, 0, 0, 0)).toISOString(),
allDay: false,
},
]);
setIsLoading(false);
}, 500);
const loadEvents = async (): Promise<void> => {
setIsLoading(true);
try {
const data = await fetchEvents();
if (isMounted) {
setEvents(data);
}
} catch {
if (isMounted) {
setEvents([]);
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
void loadEvents();
return (): void => {
isMounted = false;
};
}, []);
const formatTime = (dateString: string): string => {
const date = new Date(dateString);
const formatTime = (dateValue: Date | string): string => {
const date = new Date(dateValue);
return date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
@@ -66,8 +48,8 @@ export function CalendarWidget({ id: _id, config: _config }: WidgetProps): React
});
};
const formatDay = (dateString: string): string => {
const date = new Date(dateString);
const formatDay = (dateValue: Date | string): string => {
const date = new Date(dateValue);
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);

View File

@@ -4,68 +4,56 @@
import { useState, useEffect } from "react";
import { CheckCircle, Circle, Clock, AlertCircle } from "lucide-react";
import type { WidgetProps } from "@mosaic/shared";
import { TaskPriority, TaskStatus, type WidgetProps, type Task } from "@mosaic/shared";
import { fetchTasks } from "@/lib/api/tasks";
interface Task {
id: string;
title: string;
status: string;
priority: string;
dueDate?: string;
}
// eslint-disable-next-line no-empty-pattern
export function TasksWidget({}: WidgetProps): React.JSX.Element {
export function TasksWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
const [tasks, setTasks] = useState<Task[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Mock data for now - will fetch from API later
useEffect(() => {
setIsLoading(true);
// Simulate API call
setTimeout(() => {
setTasks([
{
id: "1",
title: "Complete project documentation",
status: "IN_PROGRESS",
priority: "HIGH",
dueDate: "2024-02-01",
},
{
id: "2",
title: "Review pull requests",
status: "NOT_STARTED",
priority: "MEDIUM",
dueDate: "2024-02-02",
},
{
id: "3",
title: "Update dependencies",
status: "COMPLETED",
priority: "LOW",
dueDate: "2024-01-30",
},
]);
setIsLoading(false);
}, 500);
let isMounted = true;
const loadTasks = async (): Promise<void> => {
setIsLoading(true);
try {
const data = await fetchTasks();
if (isMounted) {
setTasks(data);
}
} catch {
if (isMounted) {
setTasks([]);
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
void loadTasks();
return (): void => {
isMounted = false;
};
}, []);
const getPriorityIcon = (priority: string): React.JSX.Element => {
const getPriorityIcon = (priority: TaskPriority): React.JSX.Element => {
switch (priority) {
case "HIGH":
case TaskPriority.HIGH:
return <AlertCircle className="w-4 h-4 text-red-500" />;
case "MEDIUM":
case TaskPriority.MEDIUM:
return <Clock className="w-4 h-4 text-yellow-500" />;
case "LOW":
case TaskPriority.LOW:
return <Circle className="w-4 h-4 text-gray-400" />;
default:
return <Circle className="w-4 h-4 text-gray-400" />;
}
};
const getStatusIcon = (status: string): React.JSX.Element => {
return status === "COMPLETED" ? (
const getStatusIcon = (status: TaskStatus): React.JSX.Element => {
return status === TaskStatus.COMPLETED ? (
<CheckCircle className="w-4 h-4 text-green-500" />
) : (
<Circle className="w-4 h-4 text-gray-400" />
@@ -74,8 +62,8 @@ export function TasksWidget({}: WidgetProps): React.JSX.Element {
const stats = {
total: tasks.length,
inProgress: tasks.filter((t) => t.status === "IN_PROGRESS").length,
completed: tasks.filter((t) => t.status === "COMPLETED").length,
inProgress: tasks.filter((t) => t.status === TaskStatus.IN_PROGRESS).length,
completed: tasks.filter((t) => t.status === TaskStatus.COMPLETED).length,
};
if (isLoading) {

View File

@@ -1,16 +1,58 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import type { Event } from "@mosaic/shared";
import { CalendarWidget } from "../CalendarWidget";
import { fetchEvents } from "@/lib/api/events";
vi.mock("@/lib/api/events", () => ({
fetchEvents: vi.fn(),
}));
const mockEvents: Event[] = [
{
id: "event-1",
title: "API Planning",
description: null,
startTime: new Date("2026-02-01T09:00:00Z"),
endTime: new Date("2026-02-01T09:30:00Z"),
allDay: false,
location: "Zoom",
recurrence: null,
creatorId: "user-1",
workspaceId: "workspace-1",
projectId: null,
metadata: {},
createdAt: new Date("2026-01-30T09:00:00Z"),
updatedAt: new Date("2026-01-30T09:00:00Z"),
},
{
id: "event-2",
title: "API Review",
description: null,
startTime: new Date("2026-02-02T10:00:00Z"),
endTime: new Date("2026-02-02T11:00:00Z"),
allDay: false,
location: "Room 1",
recurrence: null,
creatorId: "user-1",
workspaceId: "workspace-1",
projectId: null,
metadata: {},
createdAt: new Date("2026-01-30T09:00:00Z"),
updatedAt: new Date("2026-01-30T09:00:00Z"),
},
];
async function finishWidgetLoad(): Promise<void> {
await act(async () => {
await vi.advanceTimersByTimeAsync(500);
await waitFor(() => {
expect(screen.queryByText("Loading events...")).not.toBeInTheDocument();
});
}
describe("CalendarWidget", (): void => {
beforeEach((): void => {
vi.useFakeTimers();
vi.clearAllMocks();
vi.mocked(fetchEvents).mockResolvedValue(mockEvents);
vi.setSystemTime(new Date("2026-02-01T08:00:00Z"));
});
@@ -24,15 +66,15 @@ describe("CalendarWidget", (): void => {
expect(screen.getByText("Loading events...")).toBeInTheDocument();
});
it("renders upcoming events after loading", async (): Promise<void> => {
it("fetches and renders upcoming events after loading", async (): Promise<void> => {
render(<CalendarWidget id="calendar-1" />);
await finishWidgetLoad();
expect(fetchEvents).toHaveBeenCalledTimes(1);
expect(screen.getByText("Upcoming Events")).toBeInTheDocument();
expect(screen.getByText("Team Standup")).toBeInTheDocument();
expect(screen.getByText("Project Review")).toBeInTheDocument();
expect(screen.getByText("Sprint Planning")).toBeInTheDocument();
expect(screen.getByText("API Planning")).toBeInTheDocument();
expect(screen.getByText("API Review")).toBeInTheDocument();
});
it("shows relative day labels", async (): Promise<void> => {
@@ -50,6 +92,15 @@ describe("CalendarWidget", (): void => {
await finishWidgetLoad();
expect(screen.getByText("Zoom")).toBeInTheDocument();
expect(screen.getByText("Conference Room A")).toBeInTheDocument();
expect(screen.getByText("Room 1")).toBeInTheDocument();
});
it("shows empty state when no events are returned", async (): Promise<void> => {
vi.mocked(fetchEvents).mockResolvedValueOnce([]);
render(<CalendarWidget id="calendar-1" />);
await finishWidgetLoad();
expect(screen.getByText("No upcoming events")).toBeInTheDocument();
});
});

View File

@@ -1,20 +1,80 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { TaskStatus, TaskPriority, type Task } from "@mosaic/shared";
import { TasksWidget } from "../TasksWidget";
import { fetchTasks } from "@/lib/api/tasks";
vi.mock("@/lib/api/tasks", () => ({
fetchTasks: vi.fn(),
}));
const mockTasks: Task[] = [
{
id: "task-1",
title: "API task one",
description: null,
status: TaskStatus.IN_PROGRESS,
priority: TaskPriority.HIGH,
dueDate: new Date("2026-02-03T09:00:00Z"),
creatorId: "user-1",
assigneeId: "user-1",
workspaceId: "workspace-1",
projectId: null,
parentId: null,
sortOrder: 0,
metadata: {},
completedAt: null,
createdAt: new Date("2026-02-01T09:00:00Z"),
updatedAt: new Date("2026-02-01T09:00:00Z"),
},
{
id: "task-2",
title: "API task two",
description: null,
status: TaskStatus.NOT_STARTED,
priority: TaskPriority.MEDIUM,
dueDate: new Date("2026-02-04T09:00:00Z"),
creatorId: "user-1",
assigneeId: "user-1",
workspaceId: "workspace-1",
projectId: null,
parentId: null,
sortOrder: 1,
metadata: {},
completedAt: null,
createdAt: new Date("2026-02-01T09:00:00Z"),
updatedAt: new Date("2026-02-01T09:00:00Z"),
},
{
id: "task-3",
title: "API task three",
description: null,
status: TaskStatus.COMPLETED,
priority: TaskPriority.LOW,
dueDate: new Date("2026-02-05T09:00:00Z"),
creatorId: "user-1",
assigneeId: "user-1",
workspaceId: "workspace-1",
projectId: null,
parentId: null,
sortOrder: 2,
metadata: {},
completedAt: new Date("2026-02-02T09:00:00Z"),
createdAt: new Date("2026-02-01T09:00:00Z"),
updatedAt: new Date("2026-02-02T09:00:00Z"),
},
];
async function finishWidgetLoad(): Promise<void> {
await act(async () => {
await vi.advanceTimersByTimeAsync(500);
await waitFor(() => {
expect(screen.queryByText("Loading tasks...")).not.toBeInTheDocument();
});
}
describe("TasksWidget", (): void => {
beforeEach((): void => {
vi.useFakeTimers();
});
afterEach((): void => {
vi.useRealTimers();
vi.clearAllMocks();
vi.mocked(fetchTasks).mockResolvedValue(mockTasks);
});
it("renders loading state initially", (): void => {
@@ -23,25 +83,26 @@ describe("TasksWidget", (): void => {
expect(screen.getByText("Loading tasks...")).toBeInTheDocument();
});
it("renders default summary stats", async (): Promise<void> => {
it("fetches tasks and renders summary stats", async (): Promise<void> => {
render(<TasksWidget id="tasks-1" />);
await finishWidgetLoad();
expect(fetchTasks).toHaveBeenCalledTimes(1);
expect(screen.getByText("Total")).toBeInTheDocument();
expect(screen.getByText("In Progress")).toBeInTheDocument();
expect(screen.getByText("Done")).toBeInTheDocument();
expect(screen.getByText("3")).toBeInTheDocument();
});
it("renders default task rows", async (): Promise<void> => {
it("renders task rows from API response", async (): Promise<void> => {
render(<TasksWidget id="tasks-1" />);
await finishWidgetLoad();
expect(screen.getByText("Complete project documentation")).toBeInTheDocument();
expect(screen.getByText("Review pull requests")).toBeInTheDocument();
expect(screen.getByText("Update dependencies")).toBeInTheDocument();
expect(screen.getByText("API task one")).toBeInTheDocument();
expect(screen.getByText("API task two")).toBeInTheDocument();
expect(screen.getByText("API task three")).toBeInTheDocument();
});
it("shows due date labels for each task", async (): Promise<void> => {
@@ -51,4 +112,13 @@ describe("TasksWidget", (): void => {
expect(screen.getAllByText(/Due:/).length).toBe(3);
});
it("shows empty state when API returns no tasks", async (): Promise<void> => {
vi.mocked(fetchTasks).mockResolvedValueOnce([]);
render(<TasksWidget id="tasks-1" />);
await finishWidgetLoad();
expect(screen.getByText("No tasks yet")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,53 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { fetchUsageSummary } from "./telemetry";
vi.mock("./client", () => ({
apiGet: vi.fn(),
}));
const { apiGet } = await import("./client");
describe("Telemetry API Client", (): void => {
beforeEach((): void => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-02T12:00:00Z"));
});
afterEach((): void => {
vi.useRealTimers();
});
it("fetches usage summary from llm usage analytics endpoint", async (): Promise<void> => {
vi.mocked(apiGet).mockResolvedValueOnce({
data: {
totalCalls: 47,
totalPromptTokens: 120000,
totalCompletionTokens: 125800,
totalTokens: 245800,
totalCostCents: 342,
averageDurationMs: 3200,
byProvider: [],
byModel: [],
byTaskType: [],
},
});
const result = await fetchUsageSummary("30d");
const calledEndpoint = vi.mocked(apiGet).mock.calls[0]?.[0];
expect(calledEndpoint).toMatch(/^\/api\/llm-usage\/analytics\?/);
const queryString = calledEndpoint?.split("?")[1] ?? "";
const params = new URLSearchParams(queryString);
expect(params.get("startDate")).toBeTruthy();
expect(params.get("endDate")).toBeTruthy();
expect(result).toEqual({
totalTokens: 245800,
totalCost: 3.42,
taskCount: 47,
avgQualityGatePassRate: 0,
});
});
});

View File

@@ -1,10 +1,6 @@
/**
* 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";
@@ -60,65 +56,84 @@ export interface EstimateResponse {
};
}
// ─── Mock Data Generators ────────────────────────────────────────────
interface ProviderUsageAnalyticsItem {
provider: string;
calls: number;
promptTokens: number;
completionTokens: number;
totalTokens: number;
costCents: number;
averageDurationMs: number;
}
function generateDateRange(range: TimeRange): string[] {
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
const dates: string[] = [];
const now = new Date();
interface ModelUsageAnalyticsItem {
model: string;
calls: number;
promptTokens: number;
completionTokens: number;
totalTokens: number;
costCents: number;
averageDurationMs: number;
}
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] ?? "");
interface TaskTypeUsageAnalyticsItem {
taskType: string;
calls: number;
promptTokens: number;
completionTokens: number;
totalTokens: number;
costCents: number;
averageDurationMs: number;
}
interface UsageAnalyticsResponse {
totalCalls: number;
totalPromptTokens: number;
totalCompletionTokens: number;
totalTokens: number;
totalCostCents: number;
averageDurationMs: number;
byProvider: ProviderUsageAnalyticsItem[];
byModel: ModelUsageAnalyticsItem[];
byTaskType: TaskTypeUsageAnalyticsItem[];
}
const TASK_OUTCOME_COLORS = ["#6EBF8B", "#F5C862", "#94A3B8", "#C4A5DE", "#7AA2F7"];
const DAYS_BY_RANGE: Record<TimeRange, number> = {
"7d": 7,
"30d": 30,
"90d": 90,
};
const analyticsRequestCache = new Map<TimeRange, Promise<UsageAnalyticsResponse>>();
function buildAnalyticsEndpoint(timeRange: TimeRange): string {
const endDate = new Date();
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - (DAYS_BY_RANGE[timeRange] - 1));
startDate.setHours(0, 0, 0, 0);
const query = new URLSearchParams({
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
}).toString();
return `/api/llm-usage/analytics?${query}`;
}
async function fetchUsageAnalytics(timeRange: TimeRange): Promise<UsageAnalyticsResponse> {
const cachedRequest = analyticsRequestCache.get(timeRange);
if (cachedRequest) {
return cachedRequest;
}
return dates;
}
const request = apiGet<ApiResponse<UsageAnalyticsResponse>>(buildAnalyticsEndpoint(timeRange))
.then((response) => response.data)
.finally(() => {
analyticsRequestCache.delete(timeRange);
});
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" },
];
analyticsRequestCache.set(timeRange, request);
return request;
}
// ─── API Functions ───────────────────────────────────────────────────
@@ -127,47 +142,54 @@ function generateMockTaskOutcomes(): TaskOutcomeItem[] {
* 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);
const analytics = await fetchUsageAnalytics(timeRange);
return {
totalTokens: analytics.totalTokens,
totalCost: analytics.totalCostCents / 100,
taskCount: analytics.totalCalls,
avgQualityGatePassRate: 0,
};
}
/**
* 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);
export function fetchTokenUsage(timeRange: TimeRange): Promise<TokenUsagePoint[]> {
void timeRange;
return Promise.resolve([]);
}
/**
* 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();
const analytics = await fetchUsageAnalytics(timeRange);
return analytics.byModel
.filter((item) => item.calls > 0)
.sort((a, b) => b.costCents - a.costCents)
.map((item) => ({
model: item.model,
provider: "unknown",
cost: item.costCents / 100,
taskCount: item.calls,
}));
}
/**
* 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();
const analytics = await fetchUsageAnalytics(timeRange);
return analytics.byTaskType
.filter((item) => item.calls > 0)
.map((item, index) => ({
outcome: item.taskType,
count: item.calls,
color: TASK_OUTCOME_COLORS[index % TASK_OUTCOME_COLORS.length] ?? "#94A3B8",
}));
}
/**