Compare commits
15 Commits
fix/ci-gli
...
feature/ch
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a6cf113c8 | |||
| 48d734516a | |||
| 83477165d4 | |||
| c45cec3bba | |||
| b1baa70e00 | |||
| 55340dc661 | |||
| a8d426e3c0 | |||
| 40e12214cf | |||
| 892ffd637f | |||
| 394a46bef2 | |||
| 29a78890c9 | |||
| 0c88010123 | |||
| 7f94ecdc7a | |||
| 5b77774d91 | |||
| a16371c6f9 |
@@ -337,3 +337,44 @@ 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 to Docker Swarm via Portainer (main only) ─────────────────────
|
||||||
|
|
||||||
|
deploy-swarm:
|
||||||
|
image: alpine:3
|
||||||
|
environment:
|
||||||
|
SSH_PRIVATE_KEY:
|
||||||
|
from_secret: ssh_private_key
|
||||||
|
SSH_KNOWN_HOSTS:
|
||||||
|
from_secret: ssh_known_hosts
|
||||||
|
PORTAINER_URL:
|
||||||
|
from_secret: portainer_url
|
||||||
|
PORTAINER_API_KEY:
|
||||||
|
from_secret: portainer_api_key
|
||||||
|
commands:
|
||||||
|
- apk add --no-cache curl openssh-client
|
||||||
|
- |
|
||||||
|
set -e
|
||||||
|
echo "🚀 Deploying to Docker Swarm..."
|
||||||
|
|
||||||
|
# Setup SSH for fallback
|
||||||
|
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
|
||||||
|
|
||||||
|
# Force service updates (images are pulled from public registry)
|
||||||
|
ssh -o StrictHostKeyChecking=no localadmin@10.1.1.45 \
|
||||||
|
"docker service update --with-registry-auth --force mosaic-stack-api && \
|
||||||
|
docker service update --with-registry-auth --force mosaic-stack-web && \
|
||||||
|
docker service update --with-registry-auth --force mosaic-stack-orchestrator && \
|
||||||
|
docker service update --with-registry-auth --force mosaic-stack-coordinator && \
|
||||||
|
echo '✅ All services updated'"
|
||||||
|
when:
|
||||||
|
- branch: [main]
|
||||||
|
event: [push, manual, tag]
|
||||||
|
depends_on:
|
||||||
|
- link-packages
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- MS21: Add admin, local auth, and invitation fields to users table
|
||||||
|
-- These columns were added to schema.prisma but never captured in a migration.
|
||||||
|
|
||||||
|
ALTER TABLE "users"
|
||||||
|
ADD COLUMN IF NOT EXISTS "deactivated_at" TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS "is_local_auth" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS "password_hash" TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS "invited_by" UUID,
|
||||||
|
ADD COLUMN IF NOT EXISTS "invitation_token" TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS "invited_at" TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "users_invitation_token_key" ON "users"("invitation_token");
|
||||||
@@ -1,14 +1,4 @@
|
|||||||
import {
|
import { Body, Controller, HttpException, Logger, Post, Req, Res, UseGuards } from "@nestjs/common";
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
HttpException,
|
|
||||||
Logger,
|
|
||||||
Post,
|
|
||||||
Req,
|
|
||||||
Res,
|
|
||||||
UnauthorizedException,
|
|
||||||
UseGuards,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import type { Response } from "express";
|
import type { Response } from "express";
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
import type { MaybeAuthenticatedRequest } from "../auth/types/better-auth-request.interface";
|
import type { MaybeAuthenticatedRequest } from "../auth/types/better-auth-request.interface";
|
||||||
@@ -16,16 +6,72 @@ import { ChatStreamDto } from "./chat-proxy.dto";
|
|||||||
import { ChatProxyService } from "./chat-proxy.service";
|
import { ChatProxyService } from "./chat-proxy.service";
|
||||||
|
|
||||||
@Controller("chat")
|
@Controller("chat")
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
export class ChatProxyController {
|
export class ChatProxyController {
|
||||||
private readonly logger = new Logger(ChatProxyController.name);
|
private readonly logger = new Logger(ChatProxyController.name);
|
||||||
|
|
||||||
constructor(private readonly chatProxyService: ChatProxyService) {}
|
constructor(private readonly chatProxyService: ChatProxyService) {}
|
||||||
|
|
||||||
|
// POST /api/chat/guest
|
||||||
|
// Guest chat endpoint - no authentication required
|
||||||
|
// Uses a shared LLM configuration for unauthenticated users
|
||||||
|
@Post("guest")
|
||||||
|
async guestChat(
|
||||||
|
@Body() body: ChatStreamDto,
|
||||||
|
@Req() req: MaybeAuthenticatedRequest,
|
||||||
|
@Res() res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
req.once("close", () => {
|
||||||
|
abortController.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", "text/event-stream");
|
||||||
|
res.setHeader("Cache-Control", "no-cache");
|
||||||
|
res.setHeader("Connection", "keep-alive");
|
||||||
|
res.setHeader("X-Accel-Buffering", "no");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const upstreamResponse = await this.chatProxyService.proxyGuestChat(
|
||||||
|
body.messages,
|
||||||
|
abortController.signal
|
||||||
|
);
|
||||||
|
|
||||||
|
const upstreamContentType = upstreamResponse.headers.get("content-type");
|
||||||
|
if (upstreamContentType) {
|
||||||
|
res.setHeader("Content-Type", upstreamContentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!upstreamResponse.body) {
|
||||||
|
throw new Error("LLM response did not include a stream body");
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (const chunk of upstreamResponse.body as unknown as AsyncIterable<Uint8Array>) {
|
||||||
|
if (res.writableEnded || res.destroyed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.write(Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.logStreamError(error);
|
||||||
|
|
||||||
|
if (!res.writableEnded && !res.destroyed) {
|
||||||
|
res.write("event: error\n");
|
||||||
|
res.write(`data: ${JSON.stringify({ error: this.toSafeClientMessage(error) })}\n\n`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!res.writableEnded && !res.destroyed) {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// POST /api/chat/stream
|
// POST /api/chat/stream
|
||||||
// Request: { messages: Array<{role, content}> }
|
// Request: { messages: Array<{role, content}> }
|
||||||
// Response: SSE stream of chat completion events
|
// Response: SSE stream of chat completion events
|
||||||
|
// Requires authentication - uses user's personal OpenClaw container
|
||||||
@Post("stream")
|
@Post("stream")
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
async streamChat(
|
async streamChat(
|
||||||
@Body() body: ChatStreamDto,
|
@Body() body: ChatStreamDto,
|
||||||
@Req() req: MaybeAuthenticatedRequest,
|
@Req() req: MaybeAuthenticatedRequest,
|
||||||
@@ -33,7 +79,8 @@ export class ChatProxyController {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const userId = req.user?.id;
|
const userId = req.user?.id;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new UnauthorizedException("No authenticated user found on request");
|
this.logger.warn("streamChat called without user ID after AuthGuard");
|
||||||
|
throw new HttpException("Authentication required", 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { ContainerLifecycleService } from "../container-lifecycle/container-lifecycle.service";
|
import { ContainerLifecycleService } from "../container-lifecycle/container-lifecycle.service";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import type { ChatMessage } from "./chat-proxy.dto";
|
import type { ChatMessage } from "./chat-proxy.dto";
|
||||||
|
|
||||||
const DEFAULT_OPENCLAW_MODEL = "openclaw:default";
|
const DEFAULT_OPENCLAW_MODEL = "openclaw:default";
|
||||||
|
const DEFAULT_GUEST_LLM_URL = "http://10.1.1.42:11434/v1";
|
||||||
|
const DEFAULT_GUEST_LLM_MODEL = "llama3.2";
|
||||||
|
|
||||||
interface ContainerConnection {
|
interface ContainerConnection {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -21,7 +24,8 @@ export class ChatProxyService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly containerLifecycle: ContainerLifecycleService
|
private readonly containerLifecycle: ContainerLifecycleService,
|
||||||
|
private readonly config: ConfigService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// Get the user's OpenClaw container URL and mark it active.
|
// Get the user's OpenClaw container URL and mark it active.
|
||||||
@@ -79,6 +83,65 @@ export class ChatProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy guest chat request to configured LLM endpoint.
|
||||||
|
* Uses environment variables for configuration:
|
||||||
|
* - GUEST_LLM_URL: OpenAI-compatible endpoint URL
|
||||||
|
* - GUEST_LLM_API_KEY: API key (optional, for cloud providers)
|
||||||
|
* - GUEST_LLM_MODEL: Model name to use
|
||||||
|
*/
|
||||||
|
async proxyGuestChat(messages: ChatMessage[], signal?: AbortSignal): Promise<Response> {
|
||||||
|
const llmUrl = this.config.get<string>("GUEST_LLM_URL") ?? DEFAULT_GUEST_LLM_URL;
|
||||||
|
const llmApiKey = this.config.get<string>("GUEST_LLM_API_KEY");
|
||||||
|
const llmModel = this.config.get<string>("GUEST_LLM_MODEL") ?? DEFAULT_GUEST_LLM_MODEL;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (llmApiKey) {
|
||||||
|
headers.Authorization = `Bearer ${llmApiKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestInit: RequestInit = {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
messages,
|
||||||
|
model: llmModel,
|
||||||
|
stream: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
requestInit.signal = signal;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.logger.debug(`Guest chat proxying to ${llmUrl} with model ${llmModel}`);
|
||||||
|
const response = await fetch(`${llmUrl}/chat/completions`, requestInit);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await this.readResponseText(response);
|
||||||
|
const status = `${String(response.status)} ${response.statusText}`.trim();
|
||||||
|
this.logger.warn(
|
||||||
|
detail ? `Guest LLM returned ${status}: ${detail}` : `Guest LLM returned ${status}`
|
||||||
|
);
|
||||||
|
throw new BadGatewayException(`Guest LLM returned ${status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof BadGatewayException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
this.logger.warn(`Failed to proxy guest chat request: ${message}`);
|
||||||
|
throw new ServiceUnavailableException("Failed to proxy guest chat to LLM");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async getContainerConnection(userId: string): Promise<ContainerConnection> {
|
private async getContainerConnection(userId: string): Promise<ContainerConnection> {
|
||||||
const connection = await this.containerLifecycle.ensureRunning(userId);
|
const connection = await this.containerLifecycle.ensureRunning(userId);
|
||||||
await this.containerLifecycle.touch(userId);
|
await this.containerLifecycle.touch(userId);
|
||||||
|
|||||||
@@ -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 { 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,6 +6,7 @@ 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;
|
||||||
@@ -15,6 +16,26 @@ 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 {
|
||||||
@@ -25,6 +46,81 @@ 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");
|
||||||
|
|||||||
@@ -601,9 +601,21 @@ class TestCoordinatorIntegration:
|
|||||||
coordinator = Coordinator(queue_manager=queue_manager, poll_interval=0.02)
|
coordinator = Coordinator(queue_manager=queue_manager, poll_interval=0.02)
|
||||||
|
|
||||||
task = asyncio.create_task(coordinator.start())
|
task = asyncio.create_task(coordinator.start())
|
||||||
await asyncio.sleep(0.5) # Allow time for processing
|
|
||||||
await coordinator.stop()
|
|
||||||
|
|
||||||
|
# Poll for completion with timeout instead of fixed sleep
|
||||||
|
deadline = asyncio.get_event_loop().time() + 5.0 # 5 second timeout
|
||||||
|
while asyncio.get_event_loop().time() < deadline:
|
||||||
|
all_completed = True
|
||||||
|
for i in range(157, 162):
|
||||||
|
item = queue_manager.get_item(i)
|
||||||
|
if item is None or item.status != QueueItemStatus.COMPLETED:
|
||||||
|
all_completed = False
|
||||||
|
break
|
||||||
|
if all_completed:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
await coordinator.stop()
|
||||||
task.cancel()
|
task.cancel()
|
||||||
try:
|
try:
|
||||||
await task
|
await task
|
||||||
|
|||||||
@@ -352,7 +352,7 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
<div className="mx-auto max-w-4xl px-4 py-4 lg:px-8">
|
<div className="mx-auto max-w-4xl px-4 py-4 lg:px-8">
|
||||||
<ChatInput
|
<ChatInput
|
||||||
onSend={handleSendMessage}
|
onSend={handleSendMessage}
|
||||||
disabled={isChatLoading || !user}
|
disabled={isChatLoading}
|
||||||
inputRef={inputRef}
|
inputRef={inputRef}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
onStopStreaming={abortStream}
|
onStopStreaming={abortStream}
|
||||||
|
|||||||
@@ -16,6 +16,21 @@ 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);
|
||||||
@@ -74,25 +89,20 @@ 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 => {
|
||||||
const statusLower = status.toLowerCase();
|
if (isWorking(status)) {
|
||||||
switch (statusLower) {
|
return <Activity className="w-4 h-4 text-blue-500 animate-pulse" />;
|
||||||
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 (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 => {
|
const getStatusText = (status: string): string => {
|
||||||
@@ -121,9 +131,9 @@ export function AgentStatusWidget({ id: _id, config: _config }: WidgetProps): Re
|
|||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
total: agents.length,
|
total: agents.length,
|
||||||
working: agents.filter((a) => a.status.toLowerCase() === "running").length,
|
working: agents.filter((a) => isWorking(a.status)).length,
|
||||||
idle: agents.filter((a) => a.status.toLowerCase() === "spawning").length,
|
idle: agents.filter((a) => isIdle(a.status)).length,
|
||||||
error: agents.filter((a) => a.status.toLowerCase() === "failed").length,
|
error: agents.filter((a) => isErrored(a.status)).length,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -176,9 +186,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 ${
|
||||||
agent.status.toLowerCase() === "failed"
|
isErrored(agent.status)
|
||||||
? "bg-red-50 border-red-200"
|
? "bg-red-50 border-red-200"
|
||||||
: agent.status.toLowerCase() === "running"
|
: isWorking(agent.status)
|
||||||
? "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,61 +4,43 @@
|
|||||||
|
|
||||||
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 } from "@mosaic/shared";
|
import type { WidgetProps, Event } 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(() => {
|
||||||
setIsLoading(true);
|
let isMounted = 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);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
const loadEvents = async (): Promise<void> => {
|
||||||
setEvents([
|
setIsLoading(true);
|
||||||
{
|
try {
|
||||||
id: "1",
|
const data = await fetchEvents();
|
||||||
title: "Team Standup",
|
if (isMounted) {
|
||||||
startTime: new Date(today.setHours(9, 0, 0, 0)).toISOString(),
|
setEvents(data);
|
||||||
endTime: new Date(today.setHours(9, 30, 0, 0)).toISOString(),
|
}
|
||||||
location: "Zoom",
|
} catch {
|
||||||
allDay: false,
|
if (isMounted) {
|
||||||
},
|
setEvents([]);
|
||||||
{
|
}
|
||||||
id: "2",
|
} finally {
|
||||||
title: "Project Review",
|
if (isMounted) {
|
||||||
startTime: new Date(today.setHours(14, 0, 0, 0)).toISOString(),
|
setIsLoading(false);
|
||||||
endTime: new Date(today.setHours(15, 0, 0, 0)).toISOString(),
|
}
|
||||||
location: "Conference Room A",
|
}
|
||||||
allDay: false,
|
};
|
||||||
},
|
|
||||||
{
|
void loadEvents();
|
||||||
id: "3",
|
|
||||||
title: "Sprint Planning",
|
return (): void => {
|
||||||
startTime: new Date(tomorrow.setHours(10, 0, 0, 0)).toISOString(),
|
isMounted = false;
|
||||||
endTime: new Date(tomorrow.setHours(12, 0, 0, 0)).toISOString(),
|
};
|
||||||
allDay: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
setIsLoading(false);
|
|
||||||
}, 500);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const formatTime = (dateString: string): string => {
|
const formatTime = (dateValue: Date | string): string => {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateValue);
|
||||||
return date.toLocaleTimeString("en-US", {
|
return date.toLocaleTimeString("en-US", {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
@@ -66,8 +48,8 @@ export function CalendarWidget({ id: _id, config: _config }: WidgetProps): React
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDay = (dateString: string): string => {
|
const formatDay = (dateValue: Date | string): string => {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateValue);
|
||||||
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,68 +4,56 @@
|
|||||||
|
|
||||||
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 type { WidgetProps } from "@mosaic/shared";
|
import { TaskPriority, TaskStatus, type WidgetProps, type Task } from "@mosaic/shared";
|
||||||
|
import { fetchTasks } from "@/lib/api/tasks";
|
||||||
|
|
||||||
interface Task {
|
export function TasksWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
||||||
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(() => {
|
||||||
setIsLoading(true);
|
let isMounted = true;
|
||||||
// Simulate API call
|
|
||||||
setTimeout(() => {
|
const loadTasks = async (): Promise<void> => {
|
||||||
setTasks([
|
setIsLoading(true);
|
||||||
{
|
try {
|
||||||
id: "1",
|
const data = await fetchTasks();
|
||||||
title: "Complete project documentation",
|
if (isMounted) {
|
||||||
status: "IN_PROGRESS",
|
setTasks(data);
|
||||||
priority: "HIGH",
|
}
|
||||||
dueDate: "2024-02-01",
|
} catch {
|
||||||
},
|
if (isMounted) {
|
||||||
{
|
setTasks([]);
|
||||||
id: "2",
|
}
|
||||||
title: "Review pull requests",
|
} finally {
|
||||||
status: "NOT_STARTED",
|
if (isMounted) {
|
||||||
priority: "MEDIUM",
|
setIsLoading(false);
|
||||||
dueDate: "2024-02-02",
|
}
|
||||||
},
|
}
|
||||||
{
|
};
|
||||||
id: "3",
|
|
||||||
title: "Update dependencies",
|
void loadTasks();
|
||||||
status: "COMPLETED",
|
|
||||||
priority: "LOW",
|
return (): void => {
|
||||||
dueDate: "2024-01-30",
|
isMounted = false;
|
||||||
},
|
};
|
||||||
]);
|
|
||||||
setIsLoading(false);
|
|
||||||
}, 500);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getPriorityIcon = (priority: string): React.JSX.Element => {
|
const getPriorityIcon = (priority: TaskPriority): React.JSX.Element => {
|
||||||
switch (priority) {
|
switch (priority) {
|
||||||
case "HIGH":
|
case TaskPriority.HIGH:
|
||||||
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
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" />;
|
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" />;
|
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: string): React.JSX.Element => {
|
const getStatusIcon = (status: TaskStatus): React.JSX.Element => {
|
||||||
return status === "COMPLETED" ? (
|
return status === TaskStatus.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" />
|
||||||
@@ -74,8 +62,8 @@ export function TasksWidget({}: WidgetProps): React.JSX.Element {
|
|||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
total: tasks.length,
|
total: tasks.length,
|
||||||
inProgress: tasks.filter((t) => t.status === "IN_PROGRESS").length,
|
inProgress: tasks.filter((t) => t.status === TaskStatus.IN_PROGRESS).length,
|
||||||
completed: tasks.filter((t) => t.status === "COMPLETED").length,
|
completed: tasks.filter((t) => t.status === TaskStatus.COMPLETED).length,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|||||||
@@ -1,16 +1,58 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
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 { 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 act(async () => {
|
await waitFor(() => {
|
||||||
await vi.advanceTimersByTimeAsync(500);
|
expect(screen.queryByText("Loading events...")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("CalendarWidget", (): void => {
|
describe("CalendarWidget", (): void => {
|
||||||
beforeEach((): void => {
|
beforeEach((): void => {
|
||||||
vi.useFakeTimers();
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(fetchEvents).mockResolvedValue(mockEvents);
|
||||||
vi.setSystemTime(new Date("2026-02-01T08:00:00Z"));
|
vi.setSystemTime(new Date("2026-02-01T08:00:00Z"));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -24,15 +66,15 @@ describe("CalendarWidget", (): void => {
|
|||||||
expect(screen.getByText("Loading events...")).toBeInTheDocument();
|
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" />);
|
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("Team Standup")).toBeInTheDocument();
|
expect(screen.getByText("API Planning")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Project Review")).toBeInTheDocument();
|
expect(screen.getByText("API Review")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Sprint Planning")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows relative day labels", async (): Promise<void> => {
|
it("shows relative day labels", async (): Promise<void> => {
|
||||||
@@ -50,6 +92,15 @@ describe("CalendarWidget", (): void => {
|
|||||||
await finishWidgetLoad();
|
await finishWidgetLoad();
|
||||||
|
|
||||||
expect(screen.getByText("Zoom")).toBeInTheDocument();
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +1,80 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { act, render, screen } from "@testing-library/react";
|
import { render, screen, waitFor } 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 act(async () => {
|
await waitFor(() => {
|
||||||
await vi.advanceTimersByTimeAsync(500);
|
expect(screen.queryByText("Loading tasks...")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("TasksWidget", (): void => {
|
describe("TasksWidget", (): void => {
|
||||||
beforeEach((): void => {
|
beforeEach((): void => {
|
||||||
vi.useFakeTimers();
|
vi.clearAllMocks();
|
||||||
});
|
vi.mocked(fetchTasks).mockResolvedValue(mockTasks);
|
||||||
|
|
||||||
afterEach((): void => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders loading state initially", (): void => {
|
it("renders loading state initially", (): void => {
|
||||||
@@ -23,25 +83,26 @@ describe("TasksWidget", (): void => {
|
|||||||
expect(screen.getByText("Loading tasks...")).toBeInTheDocument();
|
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" />);
|
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 default task rows", async (): Promise<void> => {
|
it("renders task rows from API response", async (): Promise<void> => {
|
||||||
render(<TasksWidget id="tasks-1" />);
|
render(<TasksWidget id="tasks-1" />);
|
||||||
|
|
||||||
await finishWidgetLoad();
|
await finishWidgetLoad();
|
||||||
|
|
||||||
expect(screen.getByText("Complete project documentation")).toBeInTheDocument();
|
expect(screen.getByText("API task one")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Review pull requests")).toBeInTheDocument();
|
expect(screen.getByText("API task two")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Update dependencies")).toBeInTheDocument();
|
expect(screen.getByText("API task three")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows due date labels for each task", async (): Promise<void> => {
|
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);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useState, useCallback, useRef } from "react";
|
|||||||
import {
|
import {
|
||||||
sendChatMessage,
|
sendChatMessage,
|
||||||
streamChatMessage,
|
streamChatMessage,
|
||||||
|
streamGuestChat,
|
||||||
type ChatMessage as ApiChatMessage,
|
type ChatMessage as ApiChatMessage,
|
||||||
} from "@/lib/api/chat";
|
} from "@/lib/api/chat";
|
||||||
import { createConversation, updateConversation, getIdea, type Idea } from "@/lib/api/ideas";
|
import { createConversation, updateConversation, getIdea, type Idea } from "@/lib/api/ideas";
|
||||||
@@ -278,68 +279,131 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Streaming failed — fall back to non-streaming
|
// Streaming failed - check if auth error, try guest mode
|
||||||
console.warn("Streaming failed, falling back to non-streaming", {
|
const isAuthError =
|
||||||
error: err instanceof Error ? err : new Error(String(err)),
|
err instanceof Error &&
|
||||||
});
|
(err.message.includes("403") ||
|
||||||
|
err.message.includes("401") ||
|
||||||
|
err.message.includes("auth") ||
|
||||||
|
err.message.includes("Forbidden"));
|
||||||
|
|
||||||
setMessages((prev) => {
|
if (isAuthError) {
|
||||||
const withoutPlaceholder = prev.filter((m) => m.id !== assistantMessageId);
|
console.warn("Auth failed, trying guest chat mode");
|
||||||
messagesRef.current = withoutPlaceholder;
|
|
||||||
return withoutPlaceholder;
|
|
||||||
});
|
|
||||||
setIsStreaming(false);
|
|
||||||
|
|
||||||
try {
|
// Try guest chat streaming
|
||||||
const response = await sendChatMessage(request);
|
try {
|
||||||
|
await new Promise<void>((guestResolve, guestReject) => {
|
||||||
|
let hasReceivedData = false;
|
||||||
|
|
||||||
const assistantMessage: Message = {
|
streamGuestChat(
|
||||||
id: `assistant-${Date.now().toString()}`,
|
request,
|
||||||
role: "assistant",
|
(chunk: string) => {
|
||||||
content: response.message.content,
|
if (!hasReceivedData) {
|
||||||
createdAt: new Date().toISOString(),
|
hasReceivedData = true;
|
||||||
model: response.model,
|
setIsLoading(false);
|
||||||
promptTokens: response.promptEvalCount ?? 0,
|
setIsStreaming(true);
|
||||||
completionTokens: response.evalCount ?? 0,
|
setMessages((prev) => {
|
||||||
totalTokens: (response.promptEvalCount ?? 0) + (response.evalCount ?? 0),
|
const updated = [...prev, { ...placeholderMessage }];
|
||||||
};
|
messagesRef.current = updated;
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages((prev) => {
|
||||||
|
const updated = prev.map((msg) =>
|
||||||
|
msg.id === assistantMessageId ? { ...msg, content: msg.content + chunk } : msg
|
||||||
|
);
|
||||||
|
messagesRef.current = updated;
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
streamingSucceeded = true;
|
||||||
|
setIsStreaming(false);
|
||||||
|
guestResolve();
|
||||||
|
},
|
||||||
|
(guestErr: Error) => {
|
||||||
|
guestReject(guestErr);
|
||||||
|
},
|
||||||
|
controller.signal
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (guestErr: unknown) {
|
||||||
|
// Guest also failed
|
||||||
|
setMessages((prev) => {
|
||||||
|
const withoutPlaceholder = prev.filter((m) => m.id !== assistantMessageId);
|
||||||
|
messagesRef.current = withoutPlaceholder;
|
||||||
|
return withoutPlaceholder;
|
||||||
|
});
|
||||||
|
const errorMsg = guestErr instanceof Error ? guestErr.message : "Chat unavailable";
|
||||||
|
setError(`Unable to connect to chat: ${errorMsg}`);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Streaming failed — fall back to non-streaming
|
||||||
|
console.warn("Streaming failed, falling back to non-streaming", {
|
||||||
|
error: err instanceof Error ? err : new Error(String(err)),
|
||||||
|
});
|
||||||
|
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
const updated = [...prev, assistantMessage];
|
const withoutPlaceholder = prev.filter((m) => m.id !== assistantMessageId);
|
||||||
messagesRef.current = updated;
|
messagesRef.current = withoutPlaceholder;
|
||||||
return updated;
|
return withoutPlaceholder;
|
||||||
});
|
});
|
||||||
|
setIsStreaming(false);
|
||||||
|
|
||||||
streamingSucceeded = true;
|
try {
|
||||||
} catch (fallbackErr: unknown) {
|
const response = await sendChatMessage(request);
|
||||||
const errorMsg =
|
|
||||||
fallbackErr instanceof Error ? fallbackErr.message : "Failed to send message";
|
|
||||||
setError("Unable to send message. Please try again.");
|
|
||||||
onError?.(fallbackErr instanceof Error ? fallbackErr : new Error(errorMsg));
|
|
||||||
console.error("Failed to send chat message", {
|
|
||||||
error: fallbackErr,
|
|
||||||
errorType: "LLM_ERROR",
|
|
||||||
conversationId: conversationIdRef.current,
|
|
||||||
messageLength: content.length,
|
|
||||||
messagePreview: content.substring(0, 50),
|
|
||||||
model,
|
|
||||||
messageCount: messagesRef.current.length,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const errorMessage: Message = {
|
const assistantMessage: Message = {
|
||||||
id: `error-${String(Date.now())}`,
|
id: `assistant-${Date.now().toString()}`,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "Something went wrong. Please try again.",
|
content: response.message.content,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
model: response.model,
|
||||||
setMessages((prev) => {
|
promptTokens: response.promptEvalCount ?? 0,
|
||||||
const updated = [...prev, errorMessage];
|
completionTokens: response.evalCount ?? 0,
|
||||||
messagesRef.current = updated;
|
totalTokens: (response.promptEvalCount ?? 0) + (response.evalCount ?? 0),
|
||||||
return updated;
|
};
|
||||||
});
|
|
||||||
setIsLoading(false);
|
setMessages((prev) => {
|
||||||
return;
|
const updated = [...prev, assistantMessage];
|
||||||
|
messagesRef.current = updated;
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
streamingSucceeded = true;
|
||||||
|
} catch (fallbackErr: unknown) {
|
||||||
|
const errorMsg =
|
||||||
|
fallbackErr instanceof Error ? fallbackErr.message : "Failed to send message";
|
||||||
|
setError("Unable to send message. Please try again.");
|
||||||
|
onError?.(fallbackErr instanceof Error ? fallbackErr : new Error(errorMsg));
|
||||||
|
console.error("Failed to send chat message", {
|
||||||
|
error: fallbackErr,
|
||||||
|
errorType: "LLM_ERROR",
|
||||||
|
conversationId: conversationIdRef.current,
|
||||||
|
messageLength: content.length,
|
||||||
|
messagePreview: content.substring(0, 50),
|
||||||
|
model,
|
||||||
|
messageCount: messagesRef.current.length,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorMessage: Message = {
|
||||||
|
id: `error-${String(Date.now())}`,
|
||||||
|
role: "assistant",
|
||||||
|
content: "Something went wrong. Please try again.",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
setMessages((prev) => {
|
||||||
|
const updated = [...prev, errorMessage];
|
||||||
|
messagesRef.current = updated;
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,141 @@ async function ensureCsrfTokenForStream(): Promise<string> {
|
|||||||
return fetchCsrfToken();
|
return fetchCsrfToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream a guest chat message (no authentication required).
|
||||||
|
* Uses /api/chat/guest endpoint with shared LLM configuration.
|
||||||
|
*
|
||||||
|
* @param request - Chat request
|
||||||
|
* @param onChunk - Called with each token string as it arrives
|
||||||
|
* @param onComplete - Called when the stream finishes successfully
|
||||||
|
* @param onError - Called if the stream encounters an error
|
||||||
|
* @param signal - Optional AbortSignal for cancellation
|
||||||
|
*/
|
||||||
|
export function streamGuestChat(
|
||||||
|
request: ChatRequest,
|
||||||
|
onChunk: (chunk: string) => void,
|
||||||
|
onComplete: () => void,
|
||||||
|
onError: (error: Error) => void,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): void {
|
||||||
|
void (async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/chat/guest`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ messages: request.messages, stream: true }),
|
||||||
|
signal: signal ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text().catch(() => response.statusText);
|
||||||
|
throw new Error(`Guest chat failed: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error("Response body is not readable");
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
let readerDone = false;
|
||||||
|
while (!readerDone) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
readerDone = done;
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
// SSE messages are separated by double newlines
|
||||||
|
const parts = buffer.split("\n\n");
|
||||||
|
buffer = parts.pop() ?? "";
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const trimmed = part.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
|
||||||
|
// Handle event: error format
|
||||||
|
const eventMatch = /^event:\s*(\S+)\n/i.exec(trimmed);
|
||||||
|
const dataMatch = /^data:\s*(.+)$/im.exec(trimmed);
|
||||||
|
|
||||||
|
if (eventMatch?.[1] === "error" && dataMatch?.[1]) {
|
||||||
|
try {
|
||||||
|
const errorData = JSON.parse(dataMatch[1].trim()) as {
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
throw new Error(errorData.error ?? "Stream error occurred");
|
||||||
|
} catch (parseErr) {
|
||||||
|
if (parseErr instanceof SyntaxError) {
|
||||||
|
throw new Error("Stream error occurred");
|
||||||
|
}
|
||||||
|
throw parseErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard SSE format: data: {...}
|
||||||
|
for (const line of trimmed.split("\n")) {
|
||||||
|
if (!line.startsWith("data: ")) continue;
|
||||||
|
|
||||||
|
const data = line.slice("data: ".length).trim();
|
||||||
|
|
||||||
|
if (data === "[DONE]") {
|
||||||
|
onComplete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(data);
|
||||||
|
|
||||||
|
// Handle OpenAI format
|
||||||
|
const openAiChunk = parsed as OpenAiSseChunk;
|
||||||
|
if (openAiChunk.choices?.[0]?.delta?.content) {
|
||||||
|
onChunk(openAiChunk.choices[0].delta.content);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle simple token format
|
||||||
|
const simpleChunk = parsed as SimpleTokenChunk;
|
||||||
|
if (simpleChunk.token) {
|
||||||
|
onChunk(simpleChunk.token);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (simpleChunk.done === true) {
|
||||||
|
onComplete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = openAiChunk.error ?? simpleChunk.error;
|
||||||
|
if (error) {
|
||||||
|
throw new Error(error);
|
||||||
|
}
|
||||||
|
} catch (parseErr) {
|
||||||
|
if (parseErr instanceof SyntaxError) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw parseErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onComplete();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof DOMException && err.name === "AbortError") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onError(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream a chat message from the LLM using SSE over fetch.
|
* Stream a chat message from the LLM using SSE over fetch.
|
||||||
*
|
*
|
||||||
|
|||||||
53
apps/web/src/lib/api/telemetry.test.ts
Normal file
53
apps/web/src/lib/api/telemetry.test.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* 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";
|
||||||
@@ -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[] {
|
interface ModelUsageAnalyticsItem {
|
||||||
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
model: string;
|
||||||
const dates: string[] = [];
|
calls: number;
|
||||||
const now = new Date();
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
costCents: number;
|
||||||
|
averageDurationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = days - 1; i >= 0; i--) {
|
interface TaskTypeUsageAnalyticsItem {
|
||||||
const d = new Date(now);
|
taskType: string;
|
||||||
d.setDate(d.getDate() - i);
|
calls: number;
|
||||||
dates.push(d.toISOString().split("T")[0] ?? "");
|
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[] {
|
analyticsRequestCache.set(timeRange, request);
|
||||||
const dates = generateDateRange(range);
|
return request;
|
||||||
|
|
||||||
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 ───────────────────────────────────────────────────
|
// ─── API Functions ───────────────────────────────────────────────────
|
||||||
@@ -127,47 +142,54 @@ function generateMockTaskOutcomes(): TaskOutcomeItem[] {
|
|||||||
* 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> {
|
||||||
// TODO: Replace with real API call when backend aggregation endpoints are ready
|
const analytics = await fetchUsageAnalytics(timeRange);
|
||||||
// const response = await apiGet<ApiResponse<UsageSummary>>(`/api/telemetry/summary?range=${timeRange}`);
|
|
||||||
// return response.data;
|
return {
|
||||||
void apiGet; // suppress unused import warning in the meantime
|
totalTokens: analytics.totalTokens,
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
totalCost: analytics.totalCostCents / 100,
|
||||||
return generateMockSummary(timeRange);
|
taskCount: analytics.totalCalls,
|
||||||
|
avgQualityGatePassRate: 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch token usage time series for charts
|
* Fetch token usage time series for charts
|
||||||
*/
|
*/
|
||||||
export async function fetchTokenUsage(timeRange: TimeRange): Promise<TokenUsagePoint[]> {
|
export function fetchTokenUsage(timeRange: TimeRange): Promise<TokenUsagePoint[]> {
|
||||||
// TODO: Replace with real API call
|
void timeRange;
|
||||||
// const response = await apiGet<ApiResponse<TokenUsagePoint[]>>(`/api/telemetry/tokens?range=${timeRange}`);
|
return Promise.resolve([]);
|
||||||
// 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[]> {
|
||||||
// TODO: Replace with real API call
|
const analytics = await fetchUsageAnalytics(timeRange);
|
||||||
// const response = await apiGet<ApiResponse<CostBreakdownItem[]>>(`/api/telemetry/costs?range=${timeRange}`);
|
|
||||||
// return response.data;
|
return analytics.byModel
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
.filter((item) => item.calls > 0)
|
||||||
void timeRange;
|
.sort((a, b) => b.costCents - a.costCents)
|
||||||
return generateMockCostBreakdown();
|
.map((item) => ({
|
||||||
|
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[]> {
|
||||||
// TODO: Replace with real API call
|
const analytics = await fetchUsageAnalytics(timeRange);
|
||||||
// const response = await apiGet<ApiResponse<TaskOutcomeItem[]>>(`/api/telemetry/outcomes?range=${timeRange}`);
|
|
||||||
// return response.data;
|
return analytics.byTaskType
|
||||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
.filter((item) => item.calls > 0)
|
||||||
void timeRange;
|
.map((item, index) => ({
|
||||||
return generateMockTaskOutcomes();
|
outcome: item.taskType,
|
||||||
|
count: item.calls,
|
||||||
|
color: TASK_OUTCOME_COLORS[index % TASK_OUTCOME_COLORS.length] ?? "#94A3B8",
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
# - OpenBao: Standalone container (see docker-compose.openbao.yml)
|
# - OpenBao: Standalone container (see docker-compose.openbao.yml)
|
||||||
# - Authentik: External OIDC provider
|
# - Authentik: External OIDC provider
|
||||||
# - Ollama: External AI inference
|
# - Ollama: External AI inference
|
||||||
|
# - PostgreSQL: Provided by the openbrain stack (openbrain_brain-db)
|
||||||
|
# Deploy openbrain stack before this stack.
|
||||||
#
|
#
|
||||||
# Usage (Portainer):
|
# Usage (Portainer):
|
||||||
# 1. Stacks -> Add Stack -> Upload or paste
|
# 1. Stacks -> Add Stack -> Upload or paste
|
||||||
@@ -36,37 +38,75 @@
|
|||||||
# Required vars use plain ${VAR} — the app validates at startup.
|
# Required vars use plain ${VAR} — the app validates at startup.
|
||||||
#
|
#
|
||||||
# ==============================================
|
# ==============================================
|
||||||
|
# DATABASE (openbrain_brain-db — external)
|
||||||
|
# ==============================================
|
||||||
|
#
|
||||||
|
# This stack uses the PostgreSQL instance from the openbrain stack.
|
||||||
|
# The openbrain stack must be deployed first and its brain-internal
|
||||||
|
# overlay network must exist.
|
||||||
|
#
|
||||||
|
# Required env vars for DB access:
|
||||||
|
# BRAIN_DB_ADMIN_USER — openbrain superuser (default: openbrain)
|
||||||
|
# BRAIN_DB_ADMIN_PASSWORD — openbrain superuser password
|
||||||
|
# (must match openbrain stack POSTGRES_PASSWORD)
|
||||||
|
# POSTGRES_USER — mosaic application DB user (created by mosaic-db-init)
|
||||||
|
# POSTGRES_PASSWORD — mosaic application DB password
|
||||||
|
# POSTGRES_DB — mosaic application database name (default: mosaic)
|
||||||
|
#
|
||||||
|
# ==============================================
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# ============================================
|
# ============================================
|
||||||
# CORE INFRASTRUCTURE
|
# DATABASE INIT
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# ======================
|
# ======================
|
||||||
# PostgreSQL Database
|
# Mosaic Database Init
|
||||||
# ======================
|
# ======================
|
||||||
postgres:
|
# Creates the mosaic application user and database in the shared
|
||||||
image: git.mosaicstack.dev/mosaic/stack-postgres:${IMAGE_TAG:-latest}
|
# openbrain PostgreSQL instance (openbrain_brain-db).
|
||||||
|
# Runs once and exits. Idempotent — safe to run on every deploy.
|
||||||
|
mosaic-db-init:
|
||||||
|
image: postgres:17-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
PGHOST: openbrain_brain-db
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
PGPORT: 5432
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
PGUSER: ${BRAIN_DB_ADMIN_USER:-openbrain}
|
||||||
POSTGRES_SHARED_BUFFERS: ${POSTGRES_SHARED_BUFFERS:-256MB}
|
PGPASSWORD: ${BRAIN_DB_ADMIN_PASSWORD}
|
||||||
POSTGRES_EFFECTIVE_CACHE_SIZE: ${POSTGRES_EFFECTIVE_CACHE_SIZE:-1GB}
|
MOSAIC_USER: ${POSTGRES_USER}
|
||||||
POSTGRES_MAX_CONNECTIONS: ${POSTGRES_MAX_CONNECTIONS:-100}
|
MOSAIC_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
volumes:
|
MOSAIC_DB: ${POSTGRES_DB:-mosaic}
|
||||||
- postgres_data:/var/lib/postgresql/data
|
entrypoint: ["sh", "-c"]
|
||||||
healthcheck:
|
command:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
- |
|
||||||
interval: 10s
|
until pg_isready -h openbrain_brain-db -p 5432 -U $${PGUSER}; do
|
||||||
timeout: 5s
|
echo "Waiting for openbrain_brain-db..."
|
||||||
retries: 5
|
sleep 2
|
||||||
start_period: 30s
|
done
|
||||||
|
echo "Database ready. Creating mosaic user and database..."
|
||||||
|
|
||||||
|
psql -h openbrain_brain-db -U $${PGUSER} -tc "SELECT 1 FROM pg_roles WHERE rolname='$${MOSAIC_USER}'" | grep -q 1 || \
|
||||||
|
psql -h openbrain_brain-db -U $${PGUSER} -c "CREATE USER $${MOSAIC_USER} WITH PASSWORD '$${MOSAIC_PASSWORD}';"
|
||||||
|
|
||||||
|
psql -h openbrain_brain-db -U $${PGUSER} -tc "SELECT 1 FROM pg_database WHERE datname='$${MOSAIC_DB}'" | grep -q 1 || \
|
||||||
|
psql -h openbrain_brain-db -U $${PGUSER} -c "CREATE DATABASE $${MOSAIC_DB} OWNER $${MOSAIC_USER} ENCODING 'UTF8' LC_COLLATE='C' LC_CTYPE='C' TEMPLATE template0;"
|
||||||
|
|
||||||
|
echo "Enabling required extensions in $${MOSAIC_DB}..."
|
||||||
|
psql -h openbrain_brain-db -U $${PGUSER} -d $${MOSAIC_DB} -c "CREATE EXTENSION IF NOT EXISTS vector;"
|
||||||
|
psql -h openbrain_brain-db -U $${PGUSER} -d $${MOSAIC_DB} -c "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";"
|
||||||
|
|
||||||
|
echo "Mosaic database ready: $${MOSAIC_DB}"
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- openbrain-brain-internal
|
||||||
deploy:
|
deploy:
|
||||||
restart_policy:
|
restart_policy:
|
||||||
condition: on-failure
|
condition: on-failure
|
||||||
|
delay: 5s
|
||||||
|
max_attempts: 5
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# CORE INFRASTRUCTURE
|
||||||
|
# ============================================
|
||||||
|
|
||||||
# ======================
|
# ======================
|
||||||
# Valkey Cache
|
# Valkey Cache
|
||||||
@@ -105,7 +145,7 @@ services:
|
|||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: ${API_PORT:-3001}
|
PORT: ${API_PORT:-3001}
|
||||||
API_HOST: ${API_HOST:-0.0.0.0}
|
API_HOST: ${API_HOST:-0.0.0.0}
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@openbrain_brain-db:5432/${POSTGRES_DB:-mosaic}
|
||||||
VALKEY_URL: redis://valkey:6379
|
VALKEY_URL: redis://valkey:6379
|
||||||
# Auth (external Authentik)
|
# Auth (external Authentik)
|
||||||
OIDC_ENABLED: ${OIDC_ENABLED:-false}
|
OIDC_ENABLED: ${OIDC_ENABLED:-false}
|
||||||
@@ -163,6 +203,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
- traefik-public
|
- traefik-public
|
||||||
|
- openbrain-brain-internal
|
||||||
deploy:
|
deploy:
|
||||||
restart_policy:
|
restart_policy:
|
||||||
condition: on-failure
|
condition: on-failure
|
||||||
@@ -307,36 +348,36 @@ services:
|
|||||||
# ======================
|
# ======================
|
||||||
# Synapse Database Init
|
# Synapse Database Init
|
||||||
# ======================
|
# ======================
|
||||||
# Creates the 'synapse' database in the shared PostgreSQL instance.
|
# Creates the 'synapse' database in the shared openbrain PostgreSQL instance.
|
||||||
# Runs once and exits. Idempotent — safe to run on every deploy.
|
# Runs once and exits. Idempotent — safe to run on every deploy.
|
||||||
synapse-db-init:
|
synapse-db-init:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
environment:
|
environment:
|
||||||
PGHOST: postgres
|
PGHOST: openbrain_brain-db
|
||||||
PGPORT: 5432
|
PGPORT: 5432
|
||||||
PGUSER: ${POSTGRES_USER}
|
PGUSER: ${BRAIN_DB_ADMIN_USER:-openbrain}
|
||||||
PGPASSWORD: ${POSTGRES_PASSWORD}
|
PGPASSWORD: ${BRAIN_DB_ADMIN_PASSWORD}
|
||||||
SYNAPSE_DB: ${SYNAPSE_POSTGRES_DB}
|
SYNAPSE_DB: ${SYNAPSE_POSTGRES_DB}
|
||||||
SYNAPSE_USER: ${SYNAPSE_POSTGRES_USER}
|
SYNAPSE_USER: ${SYNAPSE_POSTGRES_USER}
|
||||||
SYNAPSE_PASSWORD: ${SYNAPSE_POSTGRES_PASSWORD}
|
SYNAPSE_PASSWORD: ${SYNAPSE_POSTGRES_PASSWORD}
|
||||||
entrypoint: ["sh", "-c"]
|
entrypoint: ["sh", "-c"]
|
||||||
command:
|
command:
|
||||||
- |
|
- |
|
||||||
until pg_isready -h postgres -p 5432 -U $${PGUSER}; do
|
until pg_isready -h openbrain_brain-db -p 5432 -U $${PGUSER}; do
|
||||||
echo "Waiting for PostgreSQL..."
|
echo "Waiting for openbrain_brain-db..."
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
echo "PostgreSQL is ready. Creating Synapse database and user..."
|
echo "Database ready. Creating Synapse user and database..."
|
||||||
|
|
||||||
psql -h postgres -U $${PGUSER} -tc "SELECT 1 FROM pg_roles WHERE rolname='$${SYNAPSE_USER}'" | grep -q 1 || \
|
psql -h openbrain_brain-db -U $${PGUSER} -tc "SELECT 1 FROM pg_roles WHERE rolname='$${SYNAPSE_USER}'" | grep -q 1 || \
|
||||||
psql -h postgres -U $${PGUSER} -c "CREATE USER $${SYNAPSE_USER} WITH PASSWORD '$${SYNAPSE_PASSWORD}';"
|
psql -h openbrain_brain-db -U $${PGUSER} -c "CREATE USER $${SYNAPSE_USER} WITH PASSWORD '$${SYNAPSE_PASSWORD}';"
|
||||||
|
|
||||||
psql -h postgres -U $${PGUSER} -tc "SELECT 1 FROM pg_database WHERE datname='$${SYNAPSE_DB}'" | grep -q 1 || \
|
psql -h openbrain_brain-db -U $${PGUSER} -tc "SELECT 1 FROM pg_database WHERE datname='$${SYNAPSE_DB}'" | grep -q 1 || \
|
||||||
psql -h postgres -U $${PGUSER} -c "CREATE DATABASE $${SYNAPSE_DB} OWNER $${SYNAPSE_USER} ENCODING 'UTF8' LC_COLLATE='C' LC_CTYPE='C' TEMPLATE template0;"
|
psql -h openbrain_brain-db -U $${PGUSER} -c "CREATE DATABASE $${SYNAPSE_DB} OWNER $${SYNAPSE_USER} ENCODING 'UTF8' LC_COLLATE='C' LC_CTYPE='C' TEMPLATE template0;"
|
||||||
|
|
||||||
echo "Synapse database ready: $${SYNAPSE_DB}"
|
echo "Synapse database ready: $${SYNAPSE_DB}"
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- openbrain-brain-internal
|
||||||
deploy:
|
deploy:
|
||||||
restart_policy:
|
restart_policy:
|
||||||
condition: on-failure
|
condition: on-failure
|
||||||
@@ -451,7 +492,6 @@ services:
|
|||||||
# Volumes
|
# Volumes
|
||||||
# ======================
|
# ======================
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
|
||||||
valkey_data:
|
valkey_data:
|
||||||
orchestrator_workspace:
|
orchestrator_workspace:
|
||||||
speaches_models:
|
speaches_models:
|
||||||
@@ -464,3 +504,6 @@ networks:
|
|||||||
driver: overlay
|
driver: overlay
|
||||||
traefik-public:
|
traefik-public:
|
||||||
external: true
|
external: true
|
||||||
|
openbrain-brain-internal:
|
||||||
|
external: true
|
||||||
|
name: openbrain_brain-internal
|
||||||
|
|||||||
Reference in New Issue
Block a user