Compare commits
1 Commits
main
...
fix/kaniko
| Author | SHA1 | Date | |
|---|---|---|---|
| e0bc522ecf |
2
.npmrc
2
.npmrc
@@ -1,3 +1 @@
|
|||||||
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaic/npm/
|
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaic/npm/
|
||||||
supportedArchitectures[libc][]=glibc
|
|
||||||
supportedArchitectures[cpu][]=x64
|
|
||||||
|
|||||||
@@ -29,10 +29,9 @@ when:
|
|||||||
- ".trivyignore"
|
- ".trivyignore"
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
- &node_image "node:24-slim"
|
- &node_image "node:24-alpine"
|
||||||
- &install_deps |
|
- &install_deps |
|
||||||
corepack enable
|
corepack enable
|
||||||
apt-get update && apt-get install -y --no-install-recommends python3 make g++
|
|
||||||
pnpm config set store-dir /root/.local/share/pnpm/store
|
pnpm config set store-dir /root/.local/share/pnpm/store
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
- &use_deps |
|
- &use_deps |
|
||||||
@@ -337,35 +336,3 @@ steps:
|
|||||||
- security-trivy-api
|
- security-trivy-api
|
||||||
- security-trivy-orchestrator
|
- security-trivy-orchestrator
|
||||||
- security-trivy-web
|
- security-trivy-web
|
||||||
|
|
||||||
# ─── Deploy to Docker Swarm (main only) ─────────────────────
|
|
||||||
|
|
||||||
deploy-swarm:
|
|
||||||
image: alpine:3
|
|
||||||
environment:
|
|
||||||
SSH_PRIVATE_KEY:
|
|
||||||
from_secret: ssh_private_key
|
|
||||||
SSH_KNOWN_HOSTS:
|
|
||||||
from_secret: ssh_known_hosts
|
|
||||||
commands:
|
|
||||||
- apk add --no-cache openssh-client
|
|
||||||
- |
|
|
||||||
set -e
|
|
||||||
# Setup SSH
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
|
||||||
chmod 600 ~/.ssh/known_hosts
|
|
||||||
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
|
||||||
|
|
||||||
# Deploy to swarm
|
|
||||||
echo "🚀 Deploying to Docker Swarm..."
|
|
||||||
ssh -o StrictHostKeyChecking=no localadmin@10.1.1.45 \
|
|
||||||
"cd /opt/mosaic-stack && \
|
|
||||||
docker login git.mosaicstack.dev -u \$(echo \$GITEA_USER) -p \$GITEA_TOKEN || true && \
|
|
||||||
docker stack deploy -c docker-compose.yml mosaic"
|
|
||||||
when:
|
|
||||||
- branch: [main]
|
|
||||||
event: [push, manual, tag]
|
|
||||||
depends_on:
|
|
||||||
- link-packages
|
|
||||||
|
|||||||
@@ -30,9 +30,6 @@ COPY packages/ui/package.json ./packages/ui/
|
|||||||
COPY packages/config/package.json ./packages/config/
|
COPY packages/config/package.json ./packages/config/
|
||||||
COPY apps/api/package.json ./apps/api/
|
COPY apps/api/package.json ./apps/api/
|
||||||
|
|
||||||
# Copy npm configuration for native binary architecture hints
|
|
||||||
COPY .npmrc ./
|
|
||||||
|
|
||||||
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
|
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
|
||||||
# Then explicitly rebuild node-pty from source since pnpm may skip postinstall
|
# Then explicitly rebuild node-pty from source since pnpm may skip postinstall
|
||||||
# scripts or fail to find prebuilt binaries for this Node.js version
|
# scripts or fail to find prebuilt binaries for this Node.js version
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, Query, Res, UseGuards } from "@nestjs/common";
|
import { Controller, Get, Res, UseGuards } from "@nestjs/common";
|
||||||
import { AgentStatus } from "@prisma/client";
|
import { AgentStatus } from "@prisma/client";
|
||||||
import type { Response } from "express";
|
import type { Response } from "express";
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
@@ -6,7 +6,6 @@ import { PrismaService } from "../prisma/prisma.service";
|
|||||||
|
|
||||||
const AGENT_POLL_INTERVAL_MS = 5_000;
|
const AGENT_POLL_INTERVAL_MS = 5_000;
|
||||||
const SSE_HEARTBEAT_MS = 15_000;
|
const SSE_HEARTBEAT_MS = 15_000;
|
||||||
const DEFAULT_EVENTS_LIMIT = 25;
|
|
||||||
|
|
||||||
interface OrchestratorAgentDto {
|
interface OrchestratorAgentDto {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,26 +15,6 @@ interface OrchestratorAgentDto {
|
|||||||
createdAt: Date;
|
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")
|
@Controller("orchestrator")
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
export class OrchestratorController {
|
export class OrchestratorController {
|
||||||
@@ -46,81 +25,6 @@ export class OrchestratorController {
|
|||||||
return this.fetchActiveAgents();
|
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")
|
@Get("events")
|
||||||
async streamEvents(@Res() res: Response): Promise<void> {
|
async streamEvents(@Res() res: Response): Promise<void> {
|
||||||
res.setHeader("Content-Type", "text/event-stream");
|
res.setHeader("Content-Type", "text/event-stream");
|
||||||
|
|||||||
@@ -22,9 +22,6 @@ COPY packages/shared/package.json ./packages/shared/
|
|||||||
COPY packages/config/package.json ./packages/config/
|
COPY packages/config/package.json ./packages/config/
|
||||||
COPY apps/orchestrator/package.json ./apps/orchestrator/
|
COPY apps/orchestrator/package.json ./apps/orchestrator/
|
||||||
|
|
||||||
# Copy npm configuration for native binary architecture hints
|
|
||||||
COPY .npmrc ./
|
|
||||||
|
|
||||||
# Install ALL dependencies (not just production)
|
# Install ALL dependencies (not just production)
|
||||||
# No cache mount — Kaniko builds are ephemeral in CI
|
# No cache mount — Kaniko builds are ephemeral in CI
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|||||||
@@ -24,9 +24,6 @@ COPY packages/ui/package.json ./packages/ui/
|
|||||||
COPY packages/config/package.json ./packages/config/
|
COPY packages/config/package.json ./packages/config/
|
||||||
COPY apps/web/package.json ./apps/web/
|
COPY apps/web/package.json ./apps/web/
|
||||||
|
|
||||||
# Copy npm configuration for native binary architecture hints
|
|
||||||
COPY .npmrc ./
|
|
||||||
|
|
||||||
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
|
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
@@ -41,9 +38,6 @@ COPY packages/ui/package.json ./packages/ui/
|
|||||||
COPY packages/config/package.json ./packages/config/
|
COPY packages/config/package.json ./packages/config/
|
||||||
COPY apps/web/package.json ./apps/web/
|
COPY apps/web/package.json ./apps/web/
|
||||||
|
|
||||||
# Copy npm configuration for native binary architecture hints
|
|
||||||
COPY .npmrc ./
|
|
||||||
|
|
||||||
# Install production dependencies only
|
# Install production dependencies only
|
||||||
RUN pnpm install --frozen-lockfile --prod
|
RUN pnpm install --frozen-lockfile --prod
|
||||||
|
|
||||||
|
|||||||
@@ -16,21 +16,6 @@ interface Agent {
|
|||||||
error?: string;
|
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 {
|
export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
||||||
const [agents, setAgents] = useState<Agent[]>([]);
|
const [agents, setAgents] = useState<Agent[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -89,20 +74,25 @@ export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): Re
|
|||||||
}, [fetchAgents]);
|
}, [fetchAgents]);
|
||||||
|
|
||||||
const getStatusIcon = (status: string): React.JSX.Element => {
|
const getStatusIcon = (status: string): React.JSX.Element => {
|
||||||
if (isWorking(status)) {
|
const statusLower = status.toLowerCase();
|
||||||
|
switch (statusLower) {
|
||||||
|
case "running":
|
||||||
|
case "working":
|
||||||
return <Activity className="w-4 h-4 text-blue-500 animate-pulse" />;
|
return <Activity className="w-4 h-4 text-blue-500 animate-pulse" />;
|
||||||
}
|
case "spawning":
|
||||||
if (isIdle(status)) {
|
case "queued":
|
||||||
return <Clock className="w-4 h-4 text-yellow-500" />;
|
return <Clock className="w-4 h-4 text-yellow-500" />;
|
||||||
}
|
case "completed":
|
||||||
if (isErrored(status)) {
|
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||||
|
case "failed":
|
||||||
|
case "error":
|
||||||
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
||||||
}
|
case "terminated":
|
||||||
const s = status.toLowerCase();
|
case "killed":
|
||||||
if (s === "completed" || s === "terminated" || s === "killed") {
|
|
||||||
return <CheckCircle className="w-4 h-4 text-gray-500" />;
|
return <CheckCircle className="w-4 h-4 text-gray-500" />;
|
||||||
}
|
default:
|
||||||
return <Clock className="w-4 h-4 text-gray-400" />;
|
return <Clock className="w-4 h-4 text-gray-400" />;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusText = (status: string): string => {
|
const getStatusText = (status: string): string => {
|
||||||
@@ -131,9 +121,9 @@ export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): Re
|
|||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
total: agents.length,
|
total: agents.length,
|
||||||
working: agents.filter((a) => isWorking(a.status)).length,
|
working: agents.filter((a) => a.status.toLowerCase() === "running").length,
|
||||||
idle: agents.filter((a) => isIdle(a.status)).length,
|
idle: agents.filter((a) => a.status.toLowerCase() === "spawning").length,
|
||||||
error: agents.filter((a) => isErrored(a.status)).length,
|
error: agents.filter((a) => a.status.toLowerCase() === "failed").length,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -186,9 +176,9 @@ export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): Re
|
|||||||
<div
|
<div
|
||||||
key={agent.agentId}
|
key={agent.agentId}
|
||||||
className={`p-3 rounded-lg border ${
|
className={`p-3 rounded-lg border ${
|
||||||
isErrored(agent.status)
|
agent.status.toLowerCase() === "failed"
|
||||||
? "bg-red-50 border-red-200"
|
? "bg-red-50 border-red-200"
|
||||||
: isWorking(agent.status)
|
: agent.status.toLowerCase() === "running"
|
||||||
? "bg-blue-50 border-blue-200"
|
? "bg-blue-50 border-blue-200"
|
||||||
: "bg-gray-50 border-gray-200"
|
: "bg-gray-50 border-gray-200"
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -4,43 +4,61 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Calendar as CalendarIcon, Clock, MapPin } from "lucide-react";
|
import { Calendar as CalendarIcon, Clock, MapPin } from "lucide-react";
|
||||||
import type { WidgetProps, Event } from "@mosaic/shared";
|
import type { WidgetProps } from "@mosaic/shared";
|
||||||
import { fetchEvents } from "@/lib/api/events";
|
|
||||||
|
interface Event {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime?: string;
|
||||||
|
location?: string;
|
||||||
|
allDay: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function CalendarWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
export function CalendarWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Mock data for now - will fetch from API later
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
const loadEvents = async (): Promise<void> => {
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
const now = new Date();
|
||||||
const data = await fetchEvents();
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
if (isMounted) {
|
const tomorrow = new Date(today);
|
||||||
setEvents(data);
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
}
|
|
||||||
} catch {
|
setTimeout(() => {
|
||||||
if (isMounted) {
|
setEvents([
|
||||||
setEvents([]);
|
{
|
||||||
}
|
id: "1",
|
||||||
} finally {
|
title: "Team Standup",
|
||||||
if (isMounted) {
|
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);
|
setIsLoading(false);
|
||||||
}
|
}, 500);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void loadEvents();
|
|
||||||
|
|
||||||
return (): void => {
|
|
||||||
isMounted = false;
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const formatTime = (dateValue: Date | string): string => {
|
const formatTime = (dateString: string): string => {
|
||||||
const date = new Date(dateValue);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleTimeString("en-US", {
|
return date.toLocaleTimeString("en-US", {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
@@ -48,8 +66,8 @@ export function CalendarWidget({ id: _id, config: _config }: WidgetProps): React
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDay = (dateValue: Date | string): string => {
|
const formatDay = (dateString: string): string => {
|
||||||
const date = new Date(dateValue);
|
const date = new Date(dateString);
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const tomorrow = new Date(today);
|
const tomorrow = new Date(today);
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|||||||
@@ -4,56 +4,68 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { CheckCircle, Circle, Clock, AlertCircle } from "lucide-react";
|
import { CheckCircle, Circle, Clock, AlertCircle } from "lucide-react";
|
||||||
import { TaskPriority, TaskStatus, type WidgetProps, type Task } from "@mosaic/shared";
|
import type { WidgetProps } from "@mosaic/shared";
|
||||||
import { fetchTasks } from "@/lib/api/tasks";
|
|
||||||
|
|
||||||
export function TasksWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
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 {
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Mock data for now - will fetch from API later
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
const loadTasks = async (): Promise<void> => {
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
// Simulate API call
|
||||||
const data = await fetchTasks();
|
setTimeout(() => {
|
||||||
if (isMounted) {
|
setTasks([
|
||||||
setTasks(data);
|
{
|
||||||
}
|
id: "1",
|
||||||
} catch {
|
title: "Complete project documentation",
|
||||||
if (isMounted) {
|
status: "IN_PROGRESS",
|
||||||
setTasks([]);
|
priority: "HIGH",
|
||||||
}
|
dueDate: "2024-02-01",
|
||||||
} finally {
|
},
|
||||||
if (isMounted) {
|
{
|
||||||
|
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);
|
setIsLoading(false);
|
||||||
}
|
}, 500);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void loadTasks();
|
|
||||||
|
|
||||||
return (): void => {
|
|
||||||
isMounted = false;
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getPriorityIcon = (priority: TaskPriority): React.JSX.Element => {
|
const getPriorityIcon = (priority: string): React.JSX.Element => {
|
||||||
switch (priority) {
|
switch (priority) {
|
||||||
case TaskPriority.HIGH:
|
case "HIGH":
|
||||||
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
||||||
case TaskPriority.MEDIUM:
|
case "MEDIUM":
|
||||||
return <Clock className="w-4 h-4 text-yellow-500" />;
|
return <Clock className="w-4 h-4 text-yellow-500" />;
|
||||||
case TaskPriority.LOW:
|
case "LOW":
|
||||||
return <Circle className="w-4 h-4 text-gray-400" />;
|
return <Circle className="w-4 h-4 text-gray-400" />;
|
||||||
default:
|
default:
|
||||||
return <Circle className="w-4 h-4 text-gray-400" />;
|
return <Circle className="w-4 h-4 text-gray-400" />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusIcon = (status: TaskStatus): React.JSX.Element => {
|
const getStatusIcon = (status: string): React.JSX.Element => {
|
||||||
return status === TaskStatus.COMPLETED ? (
|
return status === "COMPLETED" ? (
|
||||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||||
) : (
|
) : (
|
||||||
<Circle className="w-4 h-4 text-gray-400" />
|
<Circle className="w-4 h-4 text-gray-400" />
|
||||||
@@ -62,8 +74,8 @@ export function TasksWidget({ id: _id, config: _config }: WidgetProps): React.JS
|
|||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
total: tasks.length,
|
total: tasks.length,
|
||||||
inProgress: tasks.filter((t) => t.status === TaskStatus.IN_PROGRESS).length,
|
inProgress: tasks.filter((t) => t.status === "IN_PROGRESS").length,
|
||||||
completed: tasks.filter((t) => t.status === TaskStatus.COMPLETED).length,
|
completed: tasks.filter((t) => t.status === "COMPLETED").length,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|||||||
@@ -1,58 +1,16 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { act, render, screen } from "@testing-library/react";
|
||||||
import type { Event } from "@mosaic/shared";
|
|
||||||
import { CalendarWidget } from "../CalendarWidget";
|
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> {
|
async function finishWidgetLoad(): Promise<void> {
|
||||||
await waitFor(() => {
|
await act(async () => {
|
||||||
expect(screen.queryByText("Loading events...")).not.toBeInTheDocument();
|
await vi.advanceTimersByTimeAsync(500);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("CalendarWidget", (): void => {
|
describe("CalendarWidget", (): void => {
|
||||||
beforeEach((): void => {
|
beforeEach((): void => {
|
||||||
vi.clearAllMocks();
|
vi.useFakeTimers();
|
||||||
vi.mocked(fetchEvents).mockResolvedValue(mockEvents);
|
|
||||||
vi.setSystemTime(new Date("2026-02-01T08:00:00Z"));
|
vi.setSystemTime(new Date("2026-02-01T08:00:00Z"));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,15 +24,15 @@ describe("CalendarWidget", (): void => {
|
|||||||
expect(screen.getByText("Loading events...")).toBeInTheDocument();
|
expect(screen.getByText("Loading events...")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fetches and renders upcoming events after loading", async (): Promise<void> => {
|
it("renders upcoming events after loading", async (): Promise<void> => {
|
||||||
render(<CalendarWidget id="calendar-1" />);
|
render(<CalendarWidget id="calendar-1" />);
|
||||||
|
|
||||||
await finishWidgetLoad();
|
await finishWidgetLoad();
|
||||||
|
|
||||||
expect(fetchEvents).toHaveBeenCalledTimes(1);
|
|
||||||
expect(screen.getByText("Upcoming Events")).toBeInTheDocument();
|
expect(screen.getByText("Upcoming Events")).toBeInTheDocument();
|
||||||
expect(screen.getByText("API Planning")).toBeInTheDocument();
|
expect(screen.getByText("Team Standup")).toBeInTheDocument();
|
||||||
expect(screen.getByText("API Review")).toBeInTheDocument();
|
expect(screen.getByText("Project Review")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Sprint Planning")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows relative day labels", async (): Promise<void> => {
|
it("shows relative day labels", async (): Promise<void> => {
|
||||||
@@ -92,15 +50,6 @@ describe("CalendarWidget", (): void => {
|
|||||||
await finishWidgetLoad();
|
await finishWidgetLoad();
|
||||||
|
|
||||||
expect(screen.getByText("Zoom")).toBeInTheDocument();
|
expect(screen.getByText("Zoom")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Room 1")).toBeInTheDocument();
|
expect(screen.getByText("Conference Room A")).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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,80 +1,20 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { act, render, screen } from "@testing-library/react";
|
||||||
import { TaskStatus, TaskPriority, type Task } from "@mosaic/shared";
|
|
||||||
import { TasksWidget } from "../TasksWidget";
|
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> {
|
async function finishWidgetLoad(): Promise<void> {
|
||||||
await waitFor(() => {
|
await act(async () => {
|
||||||
expect(screen.queryByText("Loading tasks...")).not.toBeInTheDocument();
|
await vi.advanceTimersByTimeAsync(500);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("TasksWidget", (): void => {
|
describe("TasksWidget", (): void => {
|
||||||
beforeEach((): void => {
|
beforeEach((): void => {
|
||||||
vi.clearAllMocks();
|
vi.useFakeTimers();
|
||||||
vi.mocked(fetchTasks).mockResolvedValue(mockTasks);
|
});
|
||||||
|
|
||||||
|
afterEach((): void => {
|
||||||
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders loading state initially", (): void => {
|
it("renders loading state initially", (): void => {
|
||||||
@@ -83,26 +23,25 @@ describe("TasksWidget", (): void => {
|
|||||||
expect(screen.getByText("Loading tasks...")).toBeInTheDocument();
|
expect(screen.getByText("Loading tasks...")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fetches tasks and renders summary stats", async (): Promise<void> => {
|
it("renders default summary stats", async (): Promise<void> => {
|
||||||
render(<TasksWidget id="tasks-1" />);
|
render(<TasksWidget id="tasks-1" />);
|
||||||
|
|
||||||
await finishWidgetLoad();
|
await finishWidgetLoad();
|
||||||
|
|
||||||
expect(fetchTasks).toHaveBeenCalledTimes(1);
|
|
||||||
expect(screen.getByText("Total")).toBeInTheDocument();
|
expect(screen.getByText("Total")).toBeInTheDocument();
|
||||||
expect(screen.getByText("In Progress")).toBeInTheDocument();
|
expect(screen.getByText("In Progress")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Done")).toBeInTheDocument();
|
expect(screen.getByText("Done")).toBeInTheDocument();
|
||||||
expect(screen.getByText("3")).toBeInTheDocument();
|
expect(screen.getByText("3")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders task rows from API response", async (): Promise<void> => {
|
it("renders default task rows", async (): Promise<void> => {
|
||||||
render(<TasksWidget id="tasks-1" />);
|
render(<TasksWidget id="tasks-1" />);
|
||||||
|
|
||||||
await finishWidgetLoad();
|
await finishWidgetLoad();
|
||||||
|
|
||||||
expect(screen.getByText("API task one")).toBeInTheDocument();
|
expect(screen.getByText("Complete project documentation")).toBeInTheDocument();
|
||||||
expect(screen.getByText("API task two")).toBeInTheDocument();
|
expect(screen.getByText("Review pull requests")).toBeInTheDocument();
|
||||||
expect(screen.getByText("API task three")).toBeInTheDocument();
|
expect(screen.getByText("Update dependencies")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows due date labels for each task", async (): Promise<void> => {
|
it("shows due date labels for each task", async (): Promise<void> => {
|
||||||
@@ -112,13 +51,4 @@ describe("TasksWidget", (): void => {
|
|||||||
|
|
||||||
expect(screen.getAllByText(/Due:/).length).toBe(3);
|
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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Telemetry API Client
|
* Telemetry API Client
|
||||||
* Handles telemetry data fetching for the usage dashboard.
|
* 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";
|
import { apiGet, type ApiResponse } from "./client";
|
||||||
@@ -56,84 +60,65 @@ export interface EstimateResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProviderUsageAnalyticsItem {
|
// ─── Mock Data Generators ────────────────────────────────────────────
|
||||||
provider: string;
|
|
||||||
calls: number;
|
function generateDateRange(range: TimeRange): string[] {
|
||||||
promptTokens: number;
|
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
||||||
completionTokens: number;
|
const dates: string[] = [];
|
||||||
totalTokens: number;
|
const now = new Date();
|
||||||
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 ModelUsageAnalyticsItem {
|
return dates;
|
||||||
model: string;
|
|
||||||
calls: number;
|
|
||||||
promptTokens: number;
|
|
||||||
completionTokens: number;
|
|
||||||
totalTokens: number;
|
|
||||||
costCents: number;
|
|
||||||
averageDurationMs: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TaskTypeUsageAnalyticsItem {
|
function generateMockTokenUsage(range: TimeRange): TokenUsagePoint[] {
|
||||||
taskType: string;
|
const dates = generateDateRange(range);
|
||||||
calls: number;
|
|
||||||
promptTokens: number;
|
|
||||||
completionTokens: number;
|
|
||||||
totalTokens: number;
|
|
||||||
costCents: number;
|
|
||||||
averageDurationMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UsageAnalyticsResponse {
|
return dates.map((date) => {
|
||||||
totalCalls: number;
|
const baseInput = 8000 + Math.floor(Math.random() * 12000);
|
||||||
totalPromptTokens: number;
|
const baseOutput = 3000 + Math.floor(Math.random() * 7000);
|
||||||
totalCompletionTokens: number;
|
return {
|
||||||
totalTokens: number;
|
date,
|
||||||
totalCostCents: number;
|
inputTokens: baseInput,
|
||||||
averageDurationMs: number;
|
outputTokens: baseOutput,
|
||||||
byProvider: ProviderUsageAnalyticsItem[];
|
totalTokens: baseInput + baseOutput,
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = apiGet<ApiResponse<UsageAnalyticsResponse>>(buildAnalyticsEndpoint(timeRange))
|
|
||||||
.then((response) => response.data)
|
|
||||||
.finally(() => {
|
|
||||||
analyticsRequestCache.delete(timeRange);
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
analyticsRequestCache.set(timeRange, request);
|
function generateMockSummary(range: TimeRange): UsageSummary {
|
||||||
return request;
|
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 ───────────────────────────────────────────────────
|
// ─── API Functions ───────────────────────────────────────────────────
|
||||||
@@ -142,54 +127,47 @@ async function fetchUsageAnalytics(timeRange: TimeRange): Promise<UsageAnalytics
|
|||||||
* Fetch usage summary data (total tokens, cost, task count, quality rate)
|
* Fetch usage summary data (total tokens, cost, task count, quality rate)
|
||||||
*/
|
*/
|
||||||
export async function fetchUsageSummary(timeRange: TimeRange): Promise<UsageSummary> {
|
export async function fetchUsageSummary(timeRange: TimeRange): Promise<UsageSummary> {
|
||||||
const analytics = await fetchUsageAnalytics(timeRange);
|
// TODO: Replace with real API call when backend aggregation endpoints are ready
|
||||||
|
// const response = await apiGet<ApiResponse<UsageSummary>>(`/api/telemetry/summary?range=${timeRange}`);
|
||||||
return {
|
// return response.data;
|
||||||
totalTokens: analytics.totalTokens,
|
void apiGet; // suppress unused import warning in the meantime
|
||||||
totalCost: analytics.totalCostCents / 100,
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
taskCount: analytics.totalCalls,
|
return generateMockSummary(timeRange);
|
||||||
avgQualityGatePassRate: 0,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch token usage time series for charts
|
* Fetch token usage time series for charts
|
||||||
*/
|
*/
|
||||||
export function fetchTokenUsage(timeRange: TimeRange): Promise<TokenUsagePoint[]> {
|
export async function fetchTokenUsage(timeRange: TimeRange): Promise<TokenUsagePoint[]> {
|
||||||
void timeRange;
|
// TODO: Replace with real API call
|
||||||
return Promise.resolve([]);
|
// 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
|
* Fetch cost breakdown by model
|
||||||
*/
|
*/
|
||||||
export async function fetchCostBreakdown(timeRange: TimeRange): Promise<CostBreakdownItem[]> {
|
export async function fetchCostBreakdown(timeRange: TimeRange): Promise<CostBreakdownItem[]> {
|
||||||
const analytics = await fetchUsageAnalytics(timeRange);
|
// TODO: Replace with real API call
|
||||||
|
// const response = await apiGet<ApiResponse<CostBreakdownItem[]>>(`/api/telemetry/costs?range=${timeRange}`);
|
||||||
return analytics.byModel
|
// return response.data;
|
||||||
.filter((item) => item.calls > 0)
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
.sort((a, b) => b.costCents - a.costCents)
|
void timeRange;
|
||||||
.map((item) => ({
|
return generateMockCostBreakdown();
|
||||||
model: item.model,
|
|
||||||
provider: "unknown",
|
|
||||||
cost: item.costCents / 100,
|
|
||||||
taskCount: item.calls,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch task outcome distribution
|
* Fetch task outcome distribution
|
||||||
*/
|
*/
|
||||||
export async function fetchTaskOutcomes(timeRange: TimeRange): Promise<TaskOutcomeItem[]> {
|
export async function fetchTaskOutcomes(timeRange: TimeRange): Promise<TaskOutcomeItem[]> {
|
||||||
const analytics = await fetchUsageAnalytics(timeRange);
|
// TODO: Replace with real API call
|
||||||
|
// const response = await apiGet<ApiResponse<TaskOutcomeItem[]>>(`/api/telemetry/outcomes?range=${timeRange}`);
|
||||||
return analytics.byTaskType
|
// return response.data;
|
||||||
.filter((item) => item.calls > 0)
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
.map((item, index) => ({
|
void timeRange;
|
||||||
outcome: item.taskType,
|
return generateMockTaskOutcomes();
|
||||||
count: item.calls,
|
|
||||||
color: TASK_OUTCOME_COLORS[index % TASK_OUTCOME_COLORS.length] ?? "#94A3B8",
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user