Compare commits

...

14 Commits

Author SHA1 Message Date
7f94ecdc7a fix: add missing orchestrator endpoints + fix AgentStatusWidget (#657)
All checks were successful
ci/woodpecker/manual/base-image Pipeline was successful
ci/woodpecker/manual/infra Pipeline was successful
ci/woodpecker/manual/coordinator Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/manual/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-02 16:43:51 +00:00
5b77774d91 fix(web): remove mock data from dashboard telemetry/tasks/calendar (#656)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-02 14:19:27 +00:00
a16371c6f9 fix(ci): use node:24-slim (glibc) instead of Alpine (musl) (#655)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/manual/base-image Pipeline was successful
ci/woodpecker/manual/coordinator Pipeline was successful
ci/woodpecker/manual/infra Pipeline was successful
ci/woodpecker/manual/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-02 01:40:37 +00:00
51d46b2e4a fix(ci): copy .npmrc before pnpm install in all Dockerfiles (#654)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/manual/base-image Pipeline was successful
ci/woodpecker/manual/infra Pipeline was successful
ci/woodpecker/manual/coordinator Pipeline was successful
ci/woodpecker/manual/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-02 01:09:22 +00:00
6582785ddd fix: matrix native binary + Dockerfile audit (#653)
All checks were successful
ci/woodpecker/manual/base-image Pipeline was successful
ci/woodpecker/manual/infra Pipeline was successful
ci/woodpecker/manual/coordinator Pipeline was successful
ci/woodpecker/manual/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-02 00:19:41 +00:00
ae0bebe2e0 ci: enable Kaniko layer caching (#652)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-02 00:08:15 +00:00
173b429c62 fix(ci): Kaniko for base image build (#651)
All checks were successful
ci/woodpecker/manual/base-image Pipeline was successful
ci/woodpecker/manual/infra Pipeline was successful
ci/woodpecker/manual/coordinator Pipeline was successful
ci/woodpecker/manual/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 23:41:46 +00:00
7d505e75f8 feat: custom node base image (#649)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 23:39:41 +00:00
cd1c52c506 ci: pnpm store cache (#648)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 23:26:51 +00:00
a00f1e1fd7 fix(api): activity interceptor tests (#647)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 23:15:16 +00:00
9305cacd4a fix(web): kanban add-task tests (#645)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 23:03:21 +00:00
0d5aa5c3ae feat: wire chat to backend (#644)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 22:54:48 +00:00
eb34eb8104 feat: compact usage widget in header (#643)
Some checks failed
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/coordinator Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 22:53:31 +00:00
5165a30fad feat: compact usage widget in header (#642)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 22:51:50 +00:00
20 changed files with 1092 additions and 296 deletions

2
.npmrc
View File

@@ -1 +1,3 @@
@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

View File

@@ -0,0 +1,27 @@
when:
- event: manual
- event: cron
cron: weekly-base-image
variables:
- &kaniko_setup |
mkdir -p /kaniko/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
steps:
build-base:
image: gcr.io/kaniko-project/executor:debug
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
commands:
- *kaniko_setup
- /kaniko/executor
--context .
--dockerfile docker/base.Dockerfile
--destination git.mosaicstack.dev/mosaic/node-base:24-slim
--destination git.mosaicstack.dev/mosaic/node-base:latest
--cache=true
--cache-repo git.mosaicstack.dev/mosaic/node-base/cache

View File

@@ -29,9 +29,11 @@ when:
- ".trivyignore" - ".trivyignore"
variables: variables:
- &node_image "node:24-alpine" - &node_image "node:24-slim"
- &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 install --frozen-lockfile pnpm install --frozen-lockfile
- &use_deps | - &use_deps |
corepack enable corepack enable
@@ -168,7 +170,7 @@ steps:
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:latest" DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:latest"
fi fi
/kaniko/executor --context . --dockerfile apps/api/Dockerfile --snapshot-mode=redo $DESTINATIONS /kaniko/executor --context . --dockerfile apps/api/Dockerfile --snapshot-mode=redo --cache=true --cache-repo git.mosaicstack.dev/mosaic/stack-api/cache $DESTINATIONS
when: when:
- branch: [main] - branch: [main]
event: [push, manual, tag] event: [push, manual, tag]
@@ -193,7 +195,7 @@ steps:
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:latest" DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:latest"
fi fi
/kaniko/executor --context . --dockerfile apps/orchestrator/Dockerfile --snapshot-mode=redo $DESTINATIONS /kaniko/executor --context . --dockerfile apps/orchestrator/Dockerfile --snapshot-mode=redo --cache=true --cache-repo git.mosaicstack.dev/mosaic/stack-orchestrator/cache $DESTINATIONS
when: when:
- branch: [main] - branch: [main]
event: [push, manual, tag] event: [push, manual, tag]
@@ -218,7 +220,7 @@ steps:
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:latest" DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:latest"
fi fi
/kaniko/executor --context . --dockerfile apps/web/Dockerfile --snapshot-mode=redo --build-arg NEXT_PUBLIC_API_URL=https://api.mosaicstack.dev $DESTINATIONS /kaniko/executor --context . --dockerfile apps/web/Dockerfile --snapshot-mode=redo --cache=true --cache-repo git.mosaicstack.dev/mosaic/stack-web/cache --build-arg NEXT_PUBLIC_API_URL=https://api.mosaicstack.dev $DESTINATIONS
when: when:
- branch: [main] - branch: [main]
event: [push, manual, tag] event: [push, manual, tag]

View File

@@ -1,7 +1,7 @@
# Base image for all stages # Base image for all stages
# Uses Debian slim (glibc) instead of Alpine (musl) because native Node.js addons # Uses Debian slim (glibc) instead of Alpine (musl) because native Node.js addons
# (matrix-sdk-crypto-nodejs, Prisma engines) require glibc-compatible binaries. # (matrix-sdk-crypto-nodejs, Prisma engines) require glibc-compatible binaries.
FROM node:24-slim AS base FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS base
# Install pnpm globally # Install pnpm globally
RUN corepack enable && corepack prepare pnpm@10.27.0 --activate RUN corepack enable && corepack prepare pnpm@10.27.0 --activate
@@ -19,9 +19,9 @@ COPY turbo.json ./
FROM base AS deps FROM base AS deps
# Install build tools for native addons (node-pty requires node-gyp compilation) # Install build tools for native addons (node-pty requires node-gyp compilation)
# and OpenSSL for Prisma engine detection # Note: openssl and ca-certificates pre-installed in base image
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ openssl \ python3 make g++ \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Copy all package.json files for workspace resolution # Copy all package.json files for workspace resolution
@@ -30,6 +30,9 @@ 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
@@ -61,19 +64,14 @@ RUN pnpm turbo build --filter=@mosaic/api --force
# ====================== # ======================
# Production stage # Production stage
# ====================== # ======================
FROM node:24-slim AS production FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS production
# Install dumb-init for proper signal handling (static binary from GitHub, # dumb-init, openssl, ca-certificates pre-installed in base image
# avoids apt-get which fails under Kaniko with bookworm GPG signature errors)
ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64 /usr/local/bin/dumb-init
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot) # Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
# - openssl: Prisma engine detection requires libssl # - Remove npm/npx to reduce image size (not used in production)
# - No build tools needed here — native addons are compiled in the deps stage # - Create non-root user
RUN apt-get update && apt-get install -y --no-install-recommends openssl \ RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
&& rm -rf /var/lib/apt/lists/* \
&& rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
&& chmod 755 /usr/local/bin/dumb-init \
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs && groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs
WORKDIR /app WORKDIR /app

View File

@@ -384,10 +384,18 @@ describe("ActivityLoggingInterceptor", () => {
const context = createMockExecutionContext("POST", {}, body, user); const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result); const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-123",
});
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => { interceptor.intercept(context, next).subscribe(() => {
// Should not call logActivity when workspaceId is missing // workspaceId is now optional, so logActivity should be called without it
expect(mockActivityService.logActivity).not.toHaveBeenCalled(); expect(mockActivityService.logActivity).toHaveBeenCalled();
const callArgs = mockActivityService.logActivity.mock.calls[0][0];
expect(callArgs.userId).toBe("user-123");
expect(callArgs.entityId).toBe("task-123");
expect(callArgs.workspaceId).toBeUndefined();
resolve(); resolve();
}); });
}); });
@@ -412,10 +420,18 @@ describe("ActivityLoggingInterceptor", () => {
const context = createMockExecutionContext("POST", {}, body, user); const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result); const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-123",
});
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => { interceptor.intercept(context, next).subscribe(() => {
// Should not call logActivity when workspaceId is missing // workspaceId is now optional, so logActivity should be called without it
expect(mockActivityService.logActivity).not.toHaveBeenCalled(); expect(mockActivityService.logActivity).toHaveBeenCalled();
const callArgs = mockActivityService.logActivity.mock.calls[0][0];
expect(callArgs.userId).toBe("user-123");
expect(callArgs.entityId).toBe("task-123");
expect(callArgs.workspaceId).toBeUndefined();
resolve(); resolve();
}); });
}); });

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Res, UseGuards } from "@nestjs/common"; import { Controller, Get, Query, Res, UseGuards } from "@nestjs/common";
import { AgentStatus } from "@prisma/client"; import { 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");

View File

@@ -1,6 +1,6 @@
# Base image for all stages # Base image for all stages
# Uses Debian slim (glibc) instead of Alpine (musl) for native addon compatibility. # Uses Debian slim (glibc) instead of Alpine (musl) for native addon compatibility.
FROM node:24-slim AS base FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS base
# Install pnpm globally # Install pnpm globally
RUN corepack enable && corepack prepare pnpm@10.27.0 --activate RUN corepack enable && corepack prepare pnpm@10.27.0 --activate
@@ -22,6 +22,9 @@ 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
@@ -54,7 +57,7 @@ RUN find ./apps/orchestrator/dist \( -name '*.spec.js' -o -name '*.spec.js.map'
# ====================== # ======================
# Production stage # Production stage
# ====================== # ======================
FROM node:24-slim AS production FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS production
# Add metadata labels # Add metadata labels
LABEL maintainer="mosaic-team@mosaicstack.dev" LABEL maintainer="mosaic-team@mosaicstack.dev"
@@ -65,13 +68,12 @@ LABEL org.opencontainers.image.vendor="Mosaic Stack"
LABEL org.opencontainers.image.title="Mosaic Orchestrator" LABEL org.opencontainers.image.title="Mosaic Orchestrator"
LABEL org.opencontainers.image.description="Agent orchestration service for Mosaic Stack" LABEL org.opencontainers.image.description="Agent orchestration service for Mosaic Stack"
# Install dumb-init for proper signal handling (static binary from GitHub, # dumb-init, ca-certificates pre-installed in base image
# avoids apt-get which fails under Kaniko with bookworm GPG signature errors)
ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64 /usr/local/bin/dumb-init
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot) # Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
# - Remove npm/npx to reduce image size (not used in production)
# - Create non-root user
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \ RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
&& chmod 755 /usr/local/bin/dumb-init \
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs && groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs
WORKDIR /app WORKDIR /app

View File

@@ -1,7 +1,7 @@
# Base image for all stages # Base image for all stages
# Uses Debian slim (glibc) for consistency with API/orchestrator and to prevent # Uses Debian slim (glibc) for consistency with API/orchestrator and to prevent
# future native addon compatibility issues with Alpine's musl libc. # future native addon compatibility issues with Alpine's musl libc.
FROM node:24-slim AS base FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS base
# Install pnpm globally # Install pnpm globally
RUN corepack enable && corepack prepare pnpm@10.27.0 --activate RUN corepack enable && corepack prepare pnpm@10.27.0 --activate
@@ -24,6 +24,9 @@ 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
@@ -38,6 +41,9 @@ 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
@@ -87,15 +93,14 @@ RUN mkdir -p ./apps/web/public
# ====================== # ======================
# Production stage # Production stage
# ====================== # ======================
FROM node:24-slim AS production FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS production
# Install dumb-init for proper signal handling (static binary from GitHub, # dumb-init, ca-certificates pre-installed in base image
# avoids apt-get which fails under Kaniko with bookworm GPG signature errors)
ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64 /usr/local/bin/dumb-init
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot) # Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
# - Remove npm/npx to reduce image size (not used in production)
# - Create non-root user
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \ RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
&& chmod 755 /usr/local/bin/dumb-init \
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nextjs && groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nextjs
WORKDIR /app WORKDIR /app

View File

@@ -184,10 +184,11 @@ function TaskCard({ task, provided, snapshot, columnAccent }: TaskCardProps): Re
interface KanbanColumnProps { interface KanbanColumnProps {
config: ColumnConfig; config: ColumnConfig;
tasks: Task[]; tasks: Task[];
onAddTask: (status: TaskStatus, title: string) => Promise<void>; onAddTask: (status: TaskStatus, title: string, projectId?: string) => Promise<void>;
projectId?: string;
} }
function KanbanColumn({ config, tasks, onAddTask }: KanbanColumnProps): ReactElement { function KanbanColumn({ config, tasks, onAddTask, projectId }: KanbanColumnProps): ReactElement {
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@@ -208,7 +209,7 @@ function KanbanColumn({ config, tasks, onAddTask }: KanbanColumnProps): ReactEle
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await onAddTask(config.status, inputValue.trim()); await onAddTask(config.status, inputValue.trim(), projectId);
setInputValue(""); setInputValue("");
setShowAddForm(false); setShowAddForm(false);
} catch (err) { } catch (err) {
@@ -362,6 +363,45 @@ function KanbanColumn({ config, tasks, onAddTask }: KanbanColumnProps): ReactEle
}} }}
autoFocus autoFocus
/> />
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
<button
type="submit"
disabled={isSubmitting || !inputValue.trim()}
style={{
padding: "6px 12px",
borderRadius: "var(--r)",
border: "1px solid var(--primary)",
background: "var(--primary)",
color: "#fff",
fontSize: "0.8rem",
fontWeight: 500,
cursor: isSubmitting || !inputValue.trim() ? "not-allowed" : "pointer",
opacity: isSubmitting || !inputValue.trim() ? 0.5 : 1,
}}
>
Add
</button>
<button
type="button"
onClick={() => {
setShowAddForm(false);
setInputValue("");
}}
disabled={isSubmitting}
style={{
padding: "6px 12px",
borderRadius: "var(--r)",
border: "1px solid var(--border)",
background: "transparent",
color: "var(--muted)",
fontSize: "0.8rem",
cursor: isSubmitting ? "not-allowed" : "pointer",
opacity: isSubmitting ? 0.5 : 1,
}}
>
Cancel
</button>
</div>
<div style={{ marginTop: 6, fontSize: "0.75rem", color: "var(--muted)" }}> <div style={{ marginTop: 6, fontSize: "0.75rem", color: "var(--muted)" }}>
Press{" "} Press{" "}
<kbd <kbd
@@ -745,10 +785,17 @@ export default function KanbanPage(): ReactElement {
/* --- add task handler --- */ /* --- add task handler --- */
const handleAddTask = useCallback( const handleAddTask = useCallback(
async (status: TaskStatus, title: string) => { async (status: TaskStatus, title: string, projectId?: string) => {
try { try {
const wsId = workspaceId ?? undefined; const wsId = workspaceId ?? undefined;
const newTask = await createTask({ title, status }, wsId); const taskData: { title: string; status: TaskStatus; projectId?: string } = {
title,
status,
};
if (projectId) {
taskData.projectId = projectId;
}
const newTask = await createTask(taskData, wsId);
// Optimistically add to local state // Optimistically add to local state
setTasks((prev) => [...prev, newTask]); setTasks((prev) => [...prev, newTask]);
} catch (err: unknown) { } catch (err: unknown) {
@@ -866,23 +913,8 @@ export default function KanbanPage(): ReactElement {
Clear filters Clear filters
</button> </button>
</div> </div>
) : tasks.length === 0 ? (
/* Empty state */
<div
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--r-lg)",
padding: 48,
textAlign: "center",
}}
>
<p style={{ color: "var(--muted)", margin: 0, fontSize: "0.9rem" }}>
No tasks yet. Create some tasks to see them here.
</p>
</div>
) : ( ) : (
/* Board */ /* Board (always render columns to allow adding first task) */
<DragDropContext onDragEnd={handleDragEnd}> <DragDropContext onDragEnd={handleDragEnd}>
<div <div
style={{ style={{
@@ -899,6 +931,7 @@ export default function KanbanPage(): ReactElement {
config={col} config={col}
tasks={grouped[col.status]} tasks={grouped[col.status]}
onAddTask={handleAddTask} onAddTask={handleAddTask}
projectId={filterProject}
/> />
))} ))}
</div> </div>

View File

@@ -5,6 +5,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useAuth } from "@/lib/auth/auth-context"; import { useAuth } from "@/lib/auth/auth-context";
import { ThemeToggle } from "./ThemeToggle"; import { ThemeToggle } from "./ThemeToggle";
import { UsageWidget } from "@/components/ui/UsageWidget";
import { useSidebar } from "./SidebarContext"; import { useSidebar } from "./SidebarContext";
/** /**
@@ -350,6 +351,9 @@ export function AppHeader(): React.JSX.Element {
{/* Theme Toggle */} {/* Theme Toggle */}
<ThemeToggle /> <ThemeToggle />
{/* Usage Widget */}
<UsageWidget />
{/* User Avatar + Dropdown */} {/* User Avatar + Dropdown */}
<div ref={dropdownRef} style={{ position: "relative", flexShrink: 0 }}> <div ref={dropdownRef} style={{ position: "relative", flexShrink: 0 }}>
<button <button

View File

@@ -0,0 +1,337 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { fetchUsageSummary, type UsageSummary } from "@/lib/api/telemetry";
// ─── Types ───────────────────────────────────────────────────────────
interface UsageTier {
name: string;
tokens: number;
limit: number;
percentage: number;
}
// ─── Helpers ─────────────────────────────────────────────────────────
function getUsageColor(percentage: number): string {
if (percentage < 60) return "var(--success)";
if (percentage < 80) return "var(--warn)";
return "var(--danger)";
}
function formatTokens(value: number): string {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
return value.toFixed(0);
}
// ─── Component ───────────────────────────────────────────────────────
export function UsageWidget(): React.JSX.Element {
const [summary, setSummary] = useState<UsageSummary | null>(null);
const [popoverOpen, setPopoverOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const popoverRef = useRef<HTMLDivElement>(null);
const tiers: UsageTier[] = summary
? [
{
name: "Session",
tokens: summary.totalTokens,
limit: 100_000,
percentage: (summary.totalTokens / 100_000) * 100,
},
{
name: "Daily",
tokens: summary.totalTokens,
limit: 500_000,
percentage: (summary.totalTokens / 500_000) * 100,
},
{
name: "Monthly",
tokens: summary.totalTokens,
limit: 2_000_000,
percentage: (summary.totalTokens / 2_000_000) * 100,
},
]
: [];
const currentTier = tiers[0];
const usageColor = currentTier ? getUsageColor(currentTier.percentage) : "var(--muted)";
const loadSummary = useCallback(async () => {
try {
const data = await fetchUsageSummary("30d");
setSummary(data);
} catch (err) {
console.error("Failed to load usage summary:", err);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void loadSummary();
}, [loadSummary]);
useEffect(() => {
function handleClickOutside(event: MouseEvent): void {
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
setPopoverOpen(false);
}
}
if (!popoverOpen) {
return;
}
document.addEventListener("mousedown", handleClickOutside);
return (): void => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [popoverOpen]);
const pct = currentTier ? Math.min(currentTier.percentage, 100) : 0;
return (
<div ref={popoverRef} style={{ position: "relative" }}>
<button
onClick={(): void => {
setPopoverOpen((prev) => !prev);
}}
aria-label="Usage widget"
aria-expanded={popoverOpen}
aria-haspopup="true"
className="hidden lg:flex items-center"
style={{
gap: 6,
padding: "5px 10px",
borderRadius: 6,
background: "var(--surface)",
border: `1px solid ${popoverOpen ? usageColor : "var(--border)"}`,
fontSize: "0.75rem",
fontFamily: "var(--mono)",
color: "var(--text-2)",
cursor: "pointer",
transition: "border-color 0.15s, color 0.15s",
flexShrink: 0,
}}
onMouseEnter={(e): void => {
(e.currentTarget as HTMLButtonElement).style.borderColor = usageColor;
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
}}
onMouseLeave={(e): void => {
if (!popoverOpen) {
(e.currentTarget as HTMLButtonElement).style.borderColor = "var(--border)";
(e.currentTarget as HTMLButtonElement).style.color = "var(--text-2)";
}
}}
>
<svg
width="12"
height="12"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ color: usageColor, flexShrink: 0 }}
aria-hidden="true"
>
<path d="M9 1L3 9h5l-1 6 6-8H8l1-6z" />
</svg>
<span style={{ fontWeight: 500, color: "var(--text-2)" }}>
{isLoading ? "..." : summary ? formatTokens(summary.totalTokens) : "0"}
</span>
{!isLoading && currentTier && (
<div
style={{
width: 24,
height: 4,
borderRadius: 2,
background: "var(--bg-mid)",
overflow: "hidden",
flexShrink: 0,
}}
aria-hidden="true"
>
<div
style={{
width: `${String(pct)}%`,
height: "100%",
background: usageColor,
borderRadius: 2,
transition: "width 0.3s ease-out",
}}
/>
</div>
)}
{!isLoading && currentTier && (
<span style={{ fontWeight: 600, color: usageColor, minWidth: 32, textAlign: "right" }}>
{Math.round(currentTier.percentage)}%
</span>
)}
</button>
{popoverOpen && (
<div
role="dialog"
aria-label="Usage details"
style={{
position: "absolute",
top: "calc(100% + 8px)",
right: 0,
width: 280,
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: 8,
padding: 12,
boxShadow: "0 8px 32px rgba(0,0,0,0.3)",
zIndex: 200,
}}
>
<div
style={{
fontSize: "0.83rem",
fontWeight: 600,
color: "var(--text)",
marginBottom: 12,
paddingBottom: 8,
borderBottom: "1px solid var(--border)",
}}
>
Token Usage
</div>
{isLoading ? (
<div
style={{
textAlign: "center",
padding: "20px 0",
color: "var(--muted)",
fontSize: "0.75rem",
}}
>
Loading usage data
</div>
) : summary ? (
<>
<div style={{ marginBottom: 12, display: "flex", flexDirection: "column", gap: 8 }}>
<div
style={{ display: "flex", justifyContent: "space-between", fontSize: "0.75rem" }}
>
<span style={{ color: "var(--muted)" }}>Total Tokens</span>
<span style={{ color: "var(--text)", fontFamily: "var(--mono)" }}>
{formatTokens(summary.totalTokens)}
</span>
</div>
<div
style={{ display: "flex", justifyContent: "space-between", fontSize: "0.75rem" }}
>
<span style={{ color: "var(--muted)" }}>Estimated Cost</span>
<span style={{ color: "var(--text)", fontFamily: "var(--mono)" }}>
${summary.totalCost.toFixed(2)}
</span>
</div>
<div
style={{ display: "flex", justifyContent: "space-between", fontSize: "0.75rem" }}
>
<span style={{ color: "var(--muted)" }}>Tasks</span>
<span style={{ color: "var(--text)", fontFamily: "var(--mono)" }}>
{summary.taskCount}
</span>
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{tiers.map((tier) => {
const tierPct = Math.min(tier.percentage, 100);
return (
<div key={tier.name}>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: "0.75rem",
marginBottom: 4,
}}
>
<span style={{ color: "var(--text-2)" }}>{tier.name}</span>
<span
style={{
color: getUsageColor(tier.percentage),
fontFamily: "var(--mono)",
fontWeight: 500,
}}
>
{formatTokens(tier.tokens)} / {formatTokens(tier.limit)}
</span>
</div>
<div
style={{
width: "100%",
height: 6,
borderRadius: 3,
background: "var(--bg-mid)",
overflow: "hidden",
}}
>
<div
style={{
width: `${String(tierPct)}%`,
height: "100%",
background: getUsageColor(tier.percentage),
borderRadius: 3,
transition: "width 0.3s ease-out",
}}
/>
</div>
</div>
);
})}
</div>
<a
href="/usage"
onClick={(): void => {
setPopoverOpen(false);
}}
style={{
display: "block",
marginTop: 12,
paddingTop: 8,
borderTop: "1px solid var(--border)",
fontSize: "0.75rem",
color: "var(--primary)",
textDecoration: "none",
textAlign: "center",
}}
onMouseEnter={(e): void => {
(e.currentTarget as HTMLAnchorElement).style.textDecoration = "underline";
}}
onMouseLeave={(e): void => {
(e.currentTarget as HTMLAnchorElement).style.textDecoration = "none";
}}
>
View detailed usage
</a>
</>
) : (
<div
style={{
textAlign: "center",
padding: "20px 0",
color: "var(--muted)",
fontSize: "0.75rem",
}}
>
No usage data available
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -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) {
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":
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"
}`} }`}

View File

@@ -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(),
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 = (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);

View File

@@ -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(() => {
let isMounted = true;
const loadTasks = async (): Promise<void> => {
setIsLoading(true); setIsLoading(true);
// Simulate API call try {
setTimeout(() => { const data = await fetchTasks();
setTasks([ if (isMounted) {
{ setTasks(data);
id: "1", }
title: "Complete project documentation", } catch {
status: "IN_PROGRESS", if (isMounted) {
priority: "HIGH", setTasks([]);
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: 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) {

View File

@@ -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();
}); });
}); });

View File

@@ -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();
});
}); });

View File

@@ -1,6 +1,6 @@
/** /**
* Chat API client * Chat API client
* Handles LLM chat interactions via /api/llm/chat * Handles LLM chat interactions via /api/chat/stream (streaming) and /api/llm/chat (fallback)
*/ */
import { apiPost, fetchCsrfToken, getCsrfToken } from "./client"; import { apiPost, fetchCsrfToken, getCsrfToken } from "./client";
@@ -33,9 +33,28 @@ export interface ChatResponse {
} }
/** /**
* Parsed SSE data chunk from the LLM stream * Parsed SSE data chunk from OpenAI-compatible stream
*/ */
interface SseChunk { interface OpenAiSseChunk {
id?: string;
object?: string;
created?: number;
model?: string;
choices?: {
index: number;
delta?: {
role?: string;
content?: string;
};
finish_reason?: string | null;
}[];
error?: string;
}
/**
* Parsed SSE data chunk from legacy /api/llm/chat stream
*/
interface LegacySseChunk {
error?: string; error?: string;
message?: { message?: {
role: string; role: string;
@@ -46,7 +65,17 @@ interface SseChunk {
} }
/** /**
* Send a chat message to the LLM * Parsed SSE data chunk with simple token format
*/
interface SimpleTokenChunk {
token?: string;
done?: boolean;
error?: string;
}
/**
* Send a chat message to the LLM (non-streaming fallback)
* Uses /api/llm/chat endpoint which supports both streaming and non-streaming
*/ */
export async function sendChatMessage(request: ChatRequest): Promise<ChatResponse> { export async function sendChatMessage(request: ChatRequest): Promise<ChatResponse> {
return apiPost<ChatResponse>("/api/llm/chat", request); return apiPost<ChatResponse>("/api/llm/chat", request);
@@ -66,11 +95,20 @@ async function ensureCsrfTokenForStream(): Promise<string> {
/** /**
* Stream a chat message from the LLM using SSE over fetch. * Stream a chat message from the LLM using SSE over fetch.
* *
* The backend accepts stream: true in the request body and responds with * Uses /api/chat/stream endpoint which proxies to OpenClaw.
* Server-Sent Events: * The backend responds with Server-Sent Events in one of these formats:
* data: {"message":{"content":"token"},...}\n\n for each token *
* data: [DONE]\n\n when the stream is complete * OpenAI-compatible format:
* data: {"error":"message"}\n\n on error * data: {"choices":[{"delta":{"content":"token"}}],...}\n\n
* data: [DONE]\n\n
*
* Legacy format (from /api/llm/chat):
* data: {"message":{"content":"token"},...}\n\n
* data: [DONE]\n\n
*
* Simple token format:
* data: {"token":"..."}\n\n
* data: {"done":true}\n\n
* *
* @param request - Chat request (stream field will be forced to true) * @param request - Chat request (stream field will be forced to true)
* @param onChunk - Called with each token string as it arrives * @param onChunk - Called with each token string as it arrives
@@ -89,14 +127,14 @@ export function streamChatMessage(
try { try {
const csrfToken = await ensureCsrfTokenForStream(); const csrfToken = await ensureCsrfTokenForStream();
const response = await fetch(`${API_BASE_URL}/api/llm/chat`, { const response = await fetch(`${API_BASE_URL}/api/chat/stream`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"X-CSRF-Token": csrfToken, "X-CSRF-Token": csrfToken,
}, },
credentials: "include", credentials: "include",
body: JSON.stringify({ ...request, stream: true }), body: JSON.stringify({ messages: request.messages, stream: true }),
signal: signal ?? null, signal: signal ?? null,
}); });
@@ -132,6 +170,25 @@ export function streamChatMessage(
const trimmed = part.trim(); const trimmed = part.trim();
if (!trimmed) continue; 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")) { for (const line of trimmed.split("\n")) {
if (!line.startsWith("data: ")) continue; if (!line.startsWith("data: ")) continue;
@@ -143,14 +200,39 @@ export function streamChatMessage(
} }
try { try {
const parsed = JSON.parse(data) as SseChunk; const parsed: unknown = JSON.parse(data);
if (parsed.error) { // Handle OpenAI format (from /api/chat/stream via OpenClaw)
throw new Error(parsed.error); const openAiChunk = parsed as OpenAiSseChunk;
if (openAiChunk.choices?.[0]?.delta?.content) {
onChunk(openAiChunk.choices[0].delta.content);
continue;
} }
if (parsed.message?.content) { // Handle legacy format (from /api/llm/chat)
onChunk(parsed.message.content); const legacyChunk = parsed as LegacySseChunk;
if (legacyChunk.message?.content) {
onChunk(legacyChunk.message.content);
continue;
}
// Handle simple token format
const simpleChunk = parsed as SimpleTokenChunk;
if (simpleChunk.token) {
onChunk(simpleChunk.token);
continue;
}
// Handle done flag in simple format
if (simpleChunk.done === true) {
onComplete();
return;
}
// Handle error in any format
const error = openAiChunk.error ?? legacyChunk.error ?? simpleChunk.error;
if (error) {
throw new Error(error);
} }
} catch (parseErr) { } catch (parseErr) {
if (parseErr instanceof SyntaxError) { if (parseErr instanceof SyntaxError) {
@@ -162,7 +244,7 @@ export function streamChatMessage(
} }
} }
// Natural end of stream without [DONE] // Natural end of stream without [DONE] or done flag
onComplete(); onComplete();
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof DOMException && err.name === "AbortError") { if (err instanceof DOMException && err.name === "AbortError") {

View File

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

View File

@@ -1,10 +1,6 @@
/** /**
* Telemetry API Client * 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;
function generateDateRange(range: TimeRange): string[] { calls: number;
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; promptTokens: number;
const dates: string[] = []; completionTokens: number;
const now = new Date(); totalTokens: number;
costCents: number;
for (let i = days - 1; i >= 0; i--) { averageDurationMs: number;
const d = new Date(now);
d.setDate(d.getDate() - i);
dates.push(d.toISOString().split("T")[0] ?? "");
} }
return dates; interface ModelUsageAnalyticsItem {
model: string;
calls: number;
promptTokens: number;
completionTokens: number;
totalTokens: number;
costCents: number;
averageDurationMs: number;
} }
function generateMockTokenUsage(range: TimeRange): TokenUsagePoint[] { interface TaskTypeUsageAnalyticsItem {
const dates = generateDateRange(range); taskType: string;
calls: number;
promptTokens: number;
completionTokens: number;
totalTokens: number;
costCents: number;
averageDurationMs: number;
}
return dates.map((date) => { interface UsageAnalyticsResponse {
const baseInput = 8000 + Math.floor(Math.random() * 12000); totalCalls: number;
const baseOutput = 3000 + Math.floor(Math.random() * 7000); totalPromptTokens: number;
return { totalCompletionTokens: number;
date, totalTokens: number;
inputTokens: baseInput, totalCostCents: number;
outputTokens: baseOutput, averageDurationMs: number;
totalTokens: baseInput + baseOutput, 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;
}
const request = apiGet<ApiResponse<UsageAnalyticsResponse>>(buildAnalyticsEndpoint(timeRange))
.then((response) => response.data)
.finally(() => {
analyticsRequestCache.delete(timeRange);
}); });
}
function generateMockSummary(range: TimeRange): UsageSummary { analyticsRequestCache.set(timeRange, request);
const multiplier = range === "7d" ? 1 : range === "30d" ? 4 : 12; return request;
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",
}));
} }
/** /**

16
docker/base.Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM node:24-slim AS base
# Pre-bake OS updates and common packages shared across all apps.
# Rebuild this image weekly or when base packages change.
# Push to: git.mosaicstack.dev/mosaic/node-base:24-slim
RUN apt-get update && apt-get upgrade -y --no-install-recommends \
&& apt-get install -y --no-install-recommends \
openssl \
ca-certificates \
curl \
dumb-init \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Enable corepack for pnpm
RUN corepack enable