Compare commits
1 Commits
main
...
feat/usage
| Author | SHA1 | Date | |
|---|---|---|---|
| 79ceddd85c |
2
.npmrc
2
.npmrc
@@ -1,3 +1 @@
|
||||
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaic/npm/
|
||||
supportedArchitectures[libc][]=glibc
|
||||
supportedArchitectures[cpu][]=x64
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
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
|
||||
@@ -29,11 +29,9 @@ when:
|
||||
- ".trivyignore"
|
||||
|
||||
variables:
|
||||
- &node_image "node:24-slim"
|
||||
- &node_image "node:24-alpine"
|
||||
- &install_deps |
|
||||
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
|
||||
- &use_deps |
|
||||
corepack enable
|
||||
@@ -170,7 +168,7 @@ steps:
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:latest"
|
||||
fi
|
||||
/kaniko/executor --context . --dockerfile apps/api/Dockerfile --snapshot-mode=redo --cache=true --cache-repo git.mosaicstack.dev/mosaic/stack-api/cache $DESTINATIONS
|
||||
/kaniko/executor --context . --dockerfile apps/api/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
||||
when:
|
||||
- branch: [main]
|
||||
event: [push, manual, tag]
|
||||
@@ -195,7 +193,7 @@ steps:
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:latest"
|
||||
fi
|
||||
/kaniko/executor --context . --dockerfile apps/orchestrator/Dockerfile --snapshot-mode=redo --cache=true --cache-repo git.mosaicstack.dev/mosaic/stack-orchestrator/cache $DESTINATIONS
|
||||
/kaniko/executor --context . --dockerfile apps/orchestrator/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
||||
when:
|
||||
- branch: [main]
|
||||
event: [push, manual, tag]
|
||||
@@ -220,7 +218,7 @@ steps:
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:latest"
|
||||
fi
|
||||
/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
|
||||
/kaniko/executor --context . --dockerfile apps/web/Dockerfile --snapshot-mode=redo --build-arg NEXT_PUBLIC_API_URL=https://api.mosaicstack.dev $DESTINATIONS
|
||||
when:
|
||||
- branch: [main]
|
||||
event: [push, manual, tag]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Base image for all stages
|
||||
# Uses Debian slim (glibc) instead of Alpine (musl) because native Node.js addons
|
||||
# (matrix-sdk-crypto-nodejs, Prisma engines) require glibc-compatible binaries.
|
||||
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS base
|
||||
FROM node:24-slim AS base
|
||||
|
||||
# Install pnpm globally
|
||||
RUN corepack enable && corepack prepare pnpm@10.27.0 --activate
|
||||
@@ -19,9 +19,9 @@ COPY turbo.json ./
|
||||
FROM base AS deps
|
||||
|
||||
# Install build tools for native addons (node-pty requires node-gyp compilation)
|
||||
# Note: openssl and ca-certificates pre-installed in base image
|
||||
# and OpenSSL for Prisma engine detection
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 make g++ \
|
||||
python3 make g++ openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy all package.json files for workspace resolution
|
||||
@@ -30,9 +30,6 @@ COPY packages/ui/package.json ./packages/ui/
|
||||
COPY packages/config/package.json ./packages/config/
|
||||
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)
|
||||
# Then explicitly rebuild node-pty from source since pnpm may skip postinstall
|
||||
# scripts or fail to find prebuilt binaries for this Node.js version
|
||||
@@ -64,14 +61,19 @@ RUN pnpm turbo build --filter=@mosaic/api --force
|
||||
# ======================
|
||||
# Production stage
|
||||
# ======================
|
||||
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS production
|
||||
FROM node:24-slim AS production
|
||||
|
||||
# dumb-init, openssl, ca-certificates pre-installed in base image
|
||||
# Install dumb-init for proper signal handling (static binary from GitHub,
|
||||
# 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)
|
||||
# - 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 \
|
||||
# - openssl: Prisma engine detection requires libssl
|
||||
# - No build tools needed here — native addons are compiled in the deps stage
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
|
||||
&& 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
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -384,18 +384,10 @@ describe("ActivityLoggingInterceptor", () => {
|
||||
const context = createMockExecutionContext("POST", {}, body, user);
|
||||
const next = createMockCallHandler(result);
|
||||
|
||||
mockActivityService.logActivity.mockResolvedValue({
|
||||
id: "activity-123",
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe(() => {
|
||||
// workspaceId is now optional, so logActivity should be called without it
|
||||
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();
|
||||
// Should not call logActivity when workspaceId is missing
|
||||
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
@@ -420,18 +412,10 @@ describe("ActivityLoggingInterceptor", () => {
|
||||
const context = createMockExecutionContext("POST", {}, body, user);
|
||||
const next = createMockCallHandler(result);
|
||||
|
||||
mockActivityService.logActivity.mockResolvedValue({
|
||||
id: "activity-123",
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe(() => {
|
||||
// workspaceId is now optional, so logActivity should be called without it
|
||||
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();
|
||||
// Should not call logActivity when workspaceId is missing
|
||||
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Base image for all stages
|
||||
# Uses Debian slim (glibc) instead of Alpine (musl) for native addon compatibility.
|
||||
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS base
|
||||
FROM node:24-slim AS base
|
||||
|
||||
# Install pnpm globally
|
||||
RUN corepack enable && corepack prepare pnpm@10.27.0 --activate
|
||||
@@ -22,9 +22,6 @@ COPY packages/shared/package.json ./packages/shared/
|
||||
COPY packages/config/package.json ./packages/config/
|
||||
COPY apps/orchestrator/package.json ./apps/orchestrator/
|
||||
|
||||
# Copy npm configuration for native binary architecture hints
|
||||
COPY .npmrc ./
|
||||
|
||||
# Install ALL dependencies (not just production)
|
||||
# No cache mount — Kaniko builds are ephemeral in CI
|
||||
RUN pnpm install --frozen-lockfile
|
||||
@@ -57,7 +54,7 @@ RUN find ./apps/orchestrator/dist \( -name '*.spec.js' -o -name '*.spec.js.map'
|
||||
# ======================
|
||||
# Production stage
|
||||
# ======================
|
||||
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS production
|
||||
FROM node:24-slim AS production
|
||||
|
||||
# Add metadata labels
|
||||
LABEL maintainer="mosaic-team@mosaicstack.dev"
|
||||
@@ -68,12 +65,13 @@ LABEL org.opencontainers.image.vendor="Mosaic Stack"
|
||||
LABEL org.opencontainers.image.title="Mosaic Orchestrator"
|
||||
LABEL org.opencontainers.image.description="Agent orchestration service for Mosaic Stack"
|
||||
|
||||
# dumb-init, ca-certificates pre-installed in base image
|
||||
# Install dumb-init for proper signal handling (static binary from GitHub,
|
||||
# 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)
|
||||
# - 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 \
|
||||
&& chmod 755 /usr/local/bin/dumb-init \
|
||||
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Base image for all stages
|
||||
# Uses Debian slim (glibc) for consistency with API/orchestrator and to prevent
|
||||
# future native addon compatibility issues with Alpine's musl libc.
|
||||
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS base
|
||||
FROM node:24-slim AS base
|
||||
|
||||
# Install pnpm globally
|
||||
RUN corepack enable && corepack prepare pnpm@10.27.0 --activate
|
||||
@@ -24,9 +24,6 @@ COPY packages/ui/package.json ./packages/ui/
|
||||
COPY packages/config/package.json ./packages/config/
|
||||
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)
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
@@ -41,9 +38,6 @@ COPY packages/ui/package.json ./packages/ui/
|
||||
COPY packages/config/package.json ./packages/config/
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
|
||||
# Copy npm configuration for native binary architecture hints
|
||||
COPY .npmrc ./
|
||||
|
||||
# Install production dependencies only
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
|
||||
@@ -93,14 +87,15 @@ RUN mkdir -p ./apps/web/public
|
||||
# ======================
|
||||
# Production stage
|
||||
# ======================
|
||||
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS production
|
||||
FROM node:24-slim AS production
|
||||
|
||||
# dumb-init, ca-certificates pre-installed in base image
|
||||
# Install dumb-init for proper signal handling (static binary from GitHub,
|
||||
# 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)
|
||||
# - 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 \
|
||||
&& chmod 755 /usr/local/bin/dumb-init \
|
||||
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nextjs
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -184,11 +184,10 @@ function TaskCard({ task, provided, snapshot, columnAccent }: TaskCardProps): Re
|
||||
interface KanbanColumnProps {
|
||||
config: ColumnConfig;
|
||||
tasks: Task[];
|
||||
onAddTask: (status: TaskStatus, title: string, projectId?: string) => Promise<void>;
|
||||
projectId?: string;
|
||||
onAddTask: (status: TaskStatus, title: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function KanbanColumn({ config, tasks, onAddTask, projectId }: KanbanColumnProps): ReactElement {
|
||||
function KanbanColumn({ config, tasks, onAddTask }: KanbanColumnProps): ReactElement {
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -209,7 +208,7 @@ function KanbanColumn({ config, tasks, onAddTask, projectId }: KanbanColumnProps
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onAddTask(config.status, inputValue.trim(), projectId);
|
||||
await onAddTask(config.status, inputValue.trim());
|
||||
setInputValue("");
|
||||
setShowAddForm(false);
|
||||
} catch (err) {
|
||||
@@ -363,45 +362,6 @@ function KanbanColumn({ config, tasks, onAddTask, projectId }: KanbanColumnProps
|
||||
}}
|
||||
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)" }}>
|
||||
Press{" "}
|
||||
<kbd
|
||||
@@ -785,17 +745,10 @@ export default function KanbanPage(): ReactElement {
|
||||
/* --- add task handler --- */
|
||||
|
||||
const handleAddTask = useCallback(
|
||||
async (status: TaskStatus, title: string, projectId?: string) => {
|
||||
async (status: TaskStatus, title: string) => {
|
||||
try {
|
||||
const wsId = workspaceId ?? undefined;
|
||||
const taskData: { title: string; status: TaskStatus; projectId?: string } = {
|
||||
title,
|
||||
status,
|
||||
};
|
||||
if (projectId) {
|
||||
taskData.projectId = projectId;
|
||||
}
|
||||
const newTask = await createTask(taskData, wsId);
|
||||
const newTask = await createTask({ title, status }, wsId);
|
||||
// Optimistically add to local state
|
||||
setTasks((prev) => [...prev, newTask]);
|
||||
} catch (err: unknown) {
|
||||
@@ -913,8 +866,23 @@ export default function KanbanPage(): ReactElement {
|
||||
Clear filters
|
||||
</button>
|
||||
</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 (always render columns to allow adding first task) */
|
||||
/* Board */
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<div
|
||||
style={{
|
||||
@@ -931,7 +899,6 @@ export default function KanbanPage(): ReactElement {
|
||||
config={col}
|
||||
tasks={grouped[col.status]}
|
||||
onAddTask={handleAddTask}
|
||||
projectId={filterProject}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,43 +4,61 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Calendar as CalendarIcon, Clock, MapPin } from "lucide-react";
|
||||
import type { WidgetProps, Event } from "@mosaic/shared";
|
||||
import { fetchEvents } from "@/lib/api/events";
|
||||
import type { WidgetProps } from "@mosaic/shared";
|
||||
|
||||
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 {
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Mock data for now - will fetch from API later
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
setIsLoading(true);
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
const loadEvents = async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchEvents();
|
||||
if (isMounted) {
|
||||
setEvents(data);
|
||||
}
|
||||
} catch {
|
||||
if (isMounted) {
|
||||
setEvents([]);
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadEvents();
|
||||
|
||||
return (): void => {
|
||||
isMounted = false;
|
||||
};
|
||||
setTimeout(() => {
|
||||
setEvents([
|
||||
{
|
||||
id: "1",
|
||||
title: "Team Standup",
|
||||
startTime: new Date(today.setHours(9, 0, 0, 0)).toISOString(),
|
||||
endTime: new Date(today.setHours(9, 30, 0, 0)).toISOString(),
|
||||
location: "Zoom",
|
||||
allDay: false,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Project Review",
|
||||
startTime: new Date(today.setHours(14, 0, 0, 0)).toISOString(),
|
||||
endTime: new Date(today.setHours(15, 0, 0, 0)).toISOString(),
|
||||
location: "Conference Room A",
|
||||
allDay: false,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "Sprint Planning",
|
||||
startTime: new Date(tomorrow.setHours(10, 0, 0, 0)).toISOString(),
|
||||
endTime: new Date(tomorrow.setHours(12, 0, 0, 0)).toISOString(),
|
||||
allDay: false,
|
||||
},
|
||||
]);
|
||||
setIsLoading(false);
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
const formatTime = (dateValue: Date | string): string => {
|
||||
const date = new Date(dateValue);
|
||||
const formatTime = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
@@ -48,8 +66,8 @@ export function CalendarWidget({ id: _id, config: _config }: WidgetProps): React
|
||||
});
|
||||
};
|
||||
|
||||
const formatDay = (dateValue: Date | string): string => {
|
||||
const date = new Date(dateValue);
|
||||
const formatDay = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
const today = new Date();
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
@@ -4,56 +4,68 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { CheckCircle, Circle, Clock, AlertCircle } from "lucide-react";
|
||||
import { TaskPriority, TaskStatus, type WidgetProps, type Task } from "@mosaic/shared";
|
||||
import { fetchTasks } from "@/lib/api/tasks";
|
||||
import type { WidgetProps } from "@mosaic/shared";
|
||||
|
||||
export function TasksWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
dueDate?: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
export function TasksWidget({}: WidgetProps): React.JSX.Element {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Mock data for now - will fetch from API later
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadTasks = async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await fetchTasks();
|
||||
if (isMounted) {
|
||||
setTasks(data);
|
||||
}
|
||||
} catch {
|
||||
if (isMounted) {
|
||||
setTasks([]);
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadTasks();
|
||||
|
||||
return (): void => {
|
||||
isMounted = false;
|
||||
};
|
||||
setIsLoading(true);
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
setTasks([
|
||||
{
|
||||
id: "1",
|
||||
title: "Complete project documentation",
|
||||
status: "IN_PROGRESS",
|
||||
priority: "HIGH",
|
||||
dueDate: "2024-02-01",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Review pull requests",
|
||||
status: "NOT_STARTED",
|
||||
priority: "MEDIUM",
|
||||
dueDate: "2024-02-02",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "Update dependencies",
|
||||
status: "COMPLETED",
|
||||
priority: "LOW",
|
||||
dueDate: "2024-01-30",
|
||||
},
|
||||
]);
|
||||
setIsLoading(false);
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
const getPriorityIcon = (priority: TaskPriority): React.JSX.Element => {
|
||||
const getPriorityIcon = (priority: string): React.JSX.Element => {
|
||||
switch (priority) {
|
||||
case TaskPriority.HIGH:
|
||||
case "HIGH":
|
||||
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
||||
case TaskPriority.MEDIUM:
|
||||
case "MEDIUM":
|
||||
return <Clock className="w-4 h-4 text-yellow-500" />;
|
||||
case TaskPriority.LOW:
|
||||
case "LOW":
|
||||
return <Circle className="w-4 h-4 text-gray-400" />;
|
||||
default:
|
||||
return <Circle className="w-4 h-4 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: TaskStatus): React.JSX.Element => {
|
||||
return status === TaskStatus.COMPLETED ? (
|
||||
const getStatusIcon = (status: string): React.JSX.Element => {
|
||||
return status === "COMPLETED" ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<Circle className="w-4 h-4 text-gray-400" />
|
||||
@@ -62,8 +74,8 @@ export function TasksWidget({ id: _id, config: _config }: WidgetProps): React.JS
|
||||
|
||||
const stats = {
|
||||
total: tasks.length,
|
||||
inProgress: tasks.filter((t) => t.status === TaskStatus.IN_PROGRESS).length,
|
||||
completed: tasks.filter((t) => t.status === TaskStatus.COMPLETED).length,
|
||||
inProgress: tasks.filter((t) => t.status === "IN_PROGRESS").length,
|
||||
completed: tasks.filter((t) => t.status === "COMPLETED").length,
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
|
||||
@@ -1,58 +1,16 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import type { Event } from "@mosaic/shared";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { CalendarWidget } from "../CalendarWidget";
|
||||
import { fetchEvents } from "@/lib/api/events";
|
||||
|
||||
vi.mock("@/lib/api/events", () => ({
|
||||
fetchEvents: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockEvents: Event[] = [
|
||||
{
|
||||
id: "event-1",
|
||||
title: "API Planning",
|
||||
description: null,
|
||||
startTime: new Date("2026-02-01T09:00:00Z"),
|
||||
endTime: new Date("2026-02-01T09:30:00Z"),
|
||||
allDay: false,
|
||||
location: "Zoom",
|
||||
recurrence: null,
|
||||
creatorId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
metadata: {},
|
||||
createdAt: new Date("2026-01-30T09:00:00Z"),
|
||||
updatedAt: new Date("2026-01-30T09:00:00Z"),
|
||||
},
|
||||
{
|
||||
id: "event-2",
|
||||
title: "API Review",
|
||||
description: null,
|
||||
startTime: new Date("2026-02-02T10:00:00Z"),
|
||||
endTime: new Date("2026-02-02T11:00:00Z"),
|
||||
allDay: false,
|
||||
location: "Room 1",
|
||||
recurrence: null,
|
||||
creatorId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
metadata: {},
|
||||
createdAt: new Date("2026-01-30T09:00:00Z"),
|
||||
updatedAt: new Date("2026-01-30T09:00:00Z"),
|
||||
},
|
||||
];
|
||||
|
||||
async function finishWidgetLoad(): Promise<void> {
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Loading events...")).not.toBeInTheDocument();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
});
|
||||
}
|
||||
|
||||
describe("CalendarWidget", (): void => {
|
||||
beforeEach((): void => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fetchEvents).mockResolvedValue(mockEvents);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-01T08:00:00Z"));
|
||||
});
|
||||
|
||||
@@ -66,15 +24,15 @@ describe("CalendarWidget", (): void => {
|
||||
expect(screen.getByText("Loading events...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fetches and renders upcoming events after loading", async (): Promise<void> => {
|
||||
it("renders upcoming events after loading", async (): Promise<void> => {
|
||||
render(<CalendarWidget id="calendar-1" />);
|
||||
|
||||
await finishWidgetLoad();
|
||||
|
||||
expect(fetchEvents).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText("Upcoming Events")).toBeInTheDocument();
|
||||
expect(screen.getByText("API Planning")).toBeInTheDocument();
|
||||
expect(screen.getByText("API Review")).toBeInTheDocument();
|
||||
expect(screen.getByText("Team Standup")).toBeInTheDocument();
|
||||
expect(screen.getByText("Project Review")).toBeInTheDocument();
|
||||
expect(screen.getByText("Sprint Planning")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows relative day labels", async (): Promise<void> => {
|
||||
@@ -92,15 +50,6 @@ describe("CalendarWidget", (): void => {
|
||||
await finishWidgetLoad();
|
||||
|
||||
expect(screen.getByText("Zoom")).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();
|
||||
expect(screen.getByText("Conference Room A")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,80 +1,20 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { TaskStatus, TaskPriority, type Task } from "@mosaic/shared";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { TasksWidget } from "../TasksWidget";
|
||||
import { fetchTasks } from "@/lib/api/tasks";
|
||||
|
||||
vi.mock("@/lib/api/tasks", () => ({
|
||||
fetchTasks: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockTasks: Task[] = [
|
||||
{
|
||||
id: "task-1",
|
||||
title: "API task one",
|
||||
description: null,
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
priority: TaskPriority.HIGH,
|
||||
dueDate: new Date("2026-02-03T09:00:00Z"),
|
||||
creatorId: "user-1",
|
||||
assigneeId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
metadata: {},
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-02-01T09:00:00Z"),
|
||||
updatedAt: new Date("2026-02-01T09:00:00Z"),
|
||||
},
|
||||
{
|
||||
id: "task-2",
|
||||
title: "API task two",
|
||||
description: null,
|
||||
status: TaskStatus.NOT_STARTED,
|
||||
priority: TaskPriority.MEDIUM,
|
||||
dueDate: new Date("2026-02-04T09:00:00Z"),
|
||||
creatorId: "user-1",
|
||||
assigneeId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
sortOrder: 1,
|
||||
metadata: {},
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-02-01T09:00:00Z"),
|
||||
updatedAt: new Date("2026-02-01T09:00:00Z"),
|
||||
},
|
||||
{
|
||||
id: "task-3",
|
||||
title: "API task three",
|
||||
description: null,
|
||||
status: TaskStatus.COMPLETED,
|
||||
priority: TaskPriority.LOW,
|
||||
dueDate: new Date("2026-02-05T09:00:00Z"),
|
||||
creatorId: "user-1",
|
||||
assigneeId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
sortOrder: 2,
|
||||
metadata: {},
|
||||
completedAt: new Date("2026-02-02T09:00:00Z"),
|
||||
createdAt: new Date("2026-02-01T09:00:00Z"),
|
||||
updatedAt: new Date("2026-02-02T09:00:00Z"),
|
||||
},
|
||||
];
|
||||
|
||||
async function finishWidgetLoad(): Promise<void> {
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Loading tasks...")).not.toBeInTheDocument();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
});
|
||||
}
|
||||
|
||||
describe("TasksWidget", (): void => {
|
||||
beforeEach((): void => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fetchTasks).mockResolvedValue(mockTasks);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach((): void => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders loading state initially", (): void => {
|
||||
@@ -83,26 +23,25 @@ describe("TasksWidget", (): void => {
|
||||
expect(screen.getByText("Loading tasks...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fetches tasks and renders summary stats", async (): Promise<void> => {
|
||||
it("renders default summary stats", async (): Promise<void> => {
|
||||
render(<TasksWidget id="tasks-1" />);
|
||||
|
||||
await finishWidgetLoad();
|
||||
|
||||
expect(fetchTasks).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText("Total")).toBeInTheDocument();
|
||||
expect(screen.getByText("In Progress")).toBeInTheDocument();
|
||||
expect(screen.getByText("Done")).toBeInTheDocument();
|
||||
expect(screen.getByText("3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders task rows from API response", async (): Promise<void> => {
|
||||
it("renders default task rows", async (): Promise<void> => {
|
||||
render(<TasksWidget id="tasks-1" />);
|
||||
|
||||
await finishWidgetLoad();
|
||||
|
||||
expect(screen.getByText("API task one")).toBeInTheDocument();
|
||||
expect(screen.getByText("API task two")).toBeInTheDocument();
|
||||
expect(screen.getByText("API task three")).toBeInTheDocument();
|
||||
expect(screen.getByText("Complete project documentation")).toBeInTheDocument();
|
||||
expect(screen.getByText("Review pull requests")).toBeInTheDocument();
|
||||
expect(screen.getByText("Update dependencies")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows due date labels for each task", async (): Promise<void> => {
|
||||
@@ -112,13 +51,4 @@ describe("TasksWidget", (): void => {
|
||||
|
||||
expect(screen.getAllByText(/Due:/).length).toBe(3);
|
||||
});
|
||||
|
||||
it("shows empty state when API returns no tasks", async (): Promise<void> => {
|
||||
vi.mocked(fetchTasks).mockResolvedValueOnce([]);
|
||||
|
||||
render(<TasksWidget id="tasks-1" />);
|
||||
await finishWidgetLoad();
|
||||
|
||||
expect(screen.getByText("No tasks yet")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Chat API client
|
||||
* Handles LLM chat interactions via /api/chat/stream (streaming) and /api/llm/chat (fallback)
|
||||
* Handles LLM chat interactions via /api/llm/chat
|
||||
*/
|
||||
|
||||
import { apiPost, fetchCsrfToken, getCsrfToken } from "./client";
|
||||
@@ -33,28 +33,9 @@ export interface ChatResponse {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed SSE data chunk from OpenAI-compatible stream
|
||||
* Parsed SSE data chunk from the LLM stream
|
||||
*/
|
||||
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 {
|
||||
interface SseChunk {
|
||||
error?: string;
|
||||
message?: {
|
||||
role: string;
|
||||
@@ -65,17 +46,7 @@ interface LegacySseChunk {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Send a chat message to the LLM
|
||||
*/
|
||||
export async function sendChatMessage(request: ChatRequest): Promise<ChatResponse> {
|
||||
return apiPost<ChatResponse>("/api/llm/chat", request);
|
||||
@@ -95,20 +66,11 @@ async function ensureCsrfTokenForStream(): Promise<string> {
|
||||
/**
|
||||
* Stream a chat message from the LLM using SSE over fetch.
|
||||
*
|
||||
* Uses /api/chat/stream endpoint which proxies to OpenClaw.
|
||||
* The backend responds with Server-Sent Events in one of these formats:
|
||||
*
|
||||
* OpenAI-compatible format:
|
||||
* 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
|
||||
* The backend accepts stream: true in the request body and responds with
|
||||
* Server-Sent Events:
|
||||
* data: {"message":{"content":"token"},...}\n\n for each token
|
||||
* data: [DONE]\n\n when the stream is complete
|
||||
* data: {"error":"message"}\n\n on error
|
||||
*
|
||||
* @param request - Chat request (stream field will be forced to true)
|
||||
* @param onChunk - Called with each token string as it arrives
|
||||
@@ -127,14 +89,14 @@ export function streamChatMessage(
|
||||
try {
|
||||
const csrfToken = await ensureCsrfTokenForStream();
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/chat/stream`, {
|
||||
const response = await fetch(`${API_BASE_URL}/api/llm/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": csrfToken,
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ messages: request.messages, stream: true }),
|
||||
body: JSON.stringify({ ...request, stream: true }),
|
||||
signal: signal ?? null,
|
||||
});
|
||||
|
||||
@@ -170,25 +132,6 @@ export function streamChatMessage(
|
||||
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;
|
||||
|
||||
@@ -200,39 +143,14 @@ export function streamChatMessage(
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(data);
|
||||
const parsed = JSON.parse(data) as SseChunk;
|
||||
|
||||
// Handle OpenAI format (from /api/chat/stream via OpenClaw)
|
||||
const openAiChunk = parsed as OpenAiSseChunk;
|
||||
if (openAiChunk.choices?.[0]?.delta?.content) {
|
||||
onChunk(openAiChunk.choices[0].delta.content);
|
||||
continue;
|
||||
if (parsed.error) {
|
||||
throw new Error(parsed.error);
|
||||
}
|
||||
|
||||
// Handle legacy format (from /api/llm/chat)
|
||||
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);
|
||||
if (parsed.message?.content) {
|
||||
onChunk(parsed.message.content);
|
||||
}
|
||||
} catch (parseErr) {
|
||||
if (parseErr instanceof SyntaxError) {
|
||||
@@ -244,7 +162,7 @@ export function streamChatMessage(
|
||||
}
|
||||
}
|
||||
|
||||
// Natural end of stream without [DONE] or done flag
|
||||
// Natural end of stream without [DONE]
|
||||
onComplete();
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { fetchUsageSummary } from "./telemetry";
|
||||
|
||||
vi.mock("./client", () => ({
|
||||
apiGet: vi.fn(),
|
||||
}));
|
||||
|
||||
const { apiGet } = await import("./client");
|
||||
|
||||
describe("Telemetry API Client", (): void => {
|
||||
beforeEach((): void => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-02T12:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach((): void => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("fetches usage summary from llm usage analytics endpoint", async (): Promise<void> => {
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({
|
||||
data: {
|
||||
totalCalls: 47,
|
||||
totalPromptTokens: 120000,
|
||||
totalCompletionTokens: 125800,
|
||||
totalTokens: 245800,
|
||||
totalCostCents: 342,
|
||||
averageDurationMs: 3200,
|
||||
byProvider: [],
|
||||
byModel: [],
|
||||
byTaskType: [],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await fetchUsageSummary("30d");
|
||||
|
||||
const calledEndpoint = vi.mocked(apiGet).mock.calls[0]?.[0];
|
||||
expect(calledEndpoint).toMatch(/^\/api\/llm-usage\/analytics\?/);
|
||||
|
||||
const queryString = calledEndpoint?.split("?")[1] ?? "";
|
||||
const params = new URLSearchParams(queryString);
|
||||
expect(params.get("startDate")).toBeTruthy();
|
||||
expect(params.get("endDate")).toBeTruthy();
|
||||
|
||||
expect(result).toEqual({
|
||||
totalTokens: 245800,
|
||||
totalCost: 3.42,
|
||||
taskCount: 47,
|
||||
avgQualityGatePassRate: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
/**
|
||||
* Telemetry API Client
|
||||
* 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";
|
||||
@@ -56,84 +60,65 @@ export interface EstimateResponse {
|
||||
};
|
||||
}
|
||||
|
||||
interface ProviderUsageAnalyticsItem {
|
||||
provider: string;
|
||||
calls: number;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
costCents: number;
|
||||
averageDurationMs: number;
|
||||
}
|
||||
// ─── Mock Data Generators ────────────────────────────────────────────
|
||||
|
||||
interface ModelUsageAnalyticsItem {
|
||||
model: string;
|
||||
calls: number;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
costCents: number;
|
||||
averageDurationMs: number;
|
||||
}
|
||||
function generateDateRange(range: TimeRange): string[] {
|
||||
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
||||
const dates: string[] = [];
|
||||
const now = new Date();
|
||||
|
||||
interface TaskTypeUsageAnalyticsItem {
|
||||
taskType: string;
|
||||
calls: number;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
costCents: number;
|
||||
averageDurationMs: number;
|
||||
}
|
||||
|
||||
interface UsageAnalyticsResponse {
|
||||
totalCalls: number;
|
||||
totalPromptTokens: number;
|
||||
totalCompletionTokens: number;
|
||||
totalTokens: number;
|
||||
totalCostCents: number;
|
||||
averageDurationMs: number;
|
||||
byProvider: ProviderUsageAnalyticsItem[];
|
||||
byModel: ModelUsageAnalyticsItem[];
|
||||
byTaskType: TaskTypeUsageAnalyticsItem[];
|
||||
}
|
||||
|
||||
const TASK_OUTCOME_COLORS = ["#6EBF8B", "#F5C862", "#94A3B8", "#C4A5DE", "#7AA2F7"];
|
||||
const DAYS_BY_RANGE: Record<TimeRange, number> = {
|
||||
"7d": 7,
|
||||
"30d": 30,
|
||||
"90d": 90,
|
||||
};
|
||||
const analyticsRequestCache = new Map<TimeRange, Promise<UsageAnalyticsResponse>>();
|
||||
|
||||
function buildAnalyticsEndpoint(timeRange: TimeRange): string {
|
||||
const endDate = new Date();
|
||||
const startDate = new Date(endDate);
|
||||
startDate.setDate(startDate.getDate() - (DAYS_BY_RANGE[timeRange] - 1));
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const query = new URLSearchParams({
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
}).toString();
|
||||
|
||||
return `/api/llm-usage/analytics?${query}`;
|
||||
}
|
||||
|
||||
async function fetchUsageAnalytics(timeRange: TimeRange): Promise<UsageAnalyticsResponse> {
|
||||
const cachedRequest = analyticsRequestCache.get(timeRange);
|
||||
if (cachedRequest) {
|
||||
return cachedRequest;
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const d = new Date(now);
|
||||
d.setDate(d.getDate() - i);
|
||||
dates.push(d.toISOString().split("T")[0] ?? "");
|
||||
}
|
||||
|
||||
const request = apiGet<ApiResponse<UsageAnalyticsResponse>>(buildAnalyticsEndpoint(timeRange))
|
||||
.then((response) => response.data)
|
||||
.finally(() => {
|
||||
analyticsRequestCache.delete(timeRange);
|
||||
});
|
||||
return dates;
|
||||
}
|
||||
|
||||
analyticsRequestCache.set(timeRange, request);
|
||||
return request;
|
||||
function generateMockTokenUsage(range: TimeRange): TokenUsagePoint[] {
|
||||
const dates = generateDateRange(range);
|
||||
|
||||
return dates.map((date) => {
|
||||
const baseInput = 8000 + Math.floor(Math.random() * 12000);
|
||||
const baseOutput = 3000 + Math.floor(Math.random() * 7000);
|
||||
return {
|
||||
date,
|
||||
inputTokens: baseInput,
|
||||
outputTokens: baseOutput,
|
||||
totalTokens: baseInput + baseOutput,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function generateMockSummary(range: TimeRange): UsageSummary {
|
||||
const multiplier = range === "7d" ? 1 : range === "30d" ? 4 : 12;
|
||||
return {
|
||||
totalTokens: 245_800 * multiplier,
|
||||
totalCost: 3.42 * multiplier,
|
||||
taskCount: 47 * multiplier,
|
||||
avgQualityGatePassRate: 0.87,
|
||||
};
|
||||
}
|
||||
|
||||
function generateMockCostBreakdown(): CostBreakdownItem[] {
|
||||
return [
|
||||
{ model: "claude-sonnet-4-5", provider: "anthropic", cost: 18.5, taskCount: 124 },
|
||||
{ model: "gpt-4o", provider: "openai", cost: 12.3, taskCount: 89 },
|
||||
{ model: "claude-haiku-3.5", provider: "anthropic", cost: 4.2, taskCount: 156 },
|
||||
{ model: "llama-3.3-70b", provider: "ollama", cost: 0, taskCount: 67 },
|
||||
{ model: "gemini-2.0-flash", provider: "google", cost: 2.8, taskCount: 42 },
|
||||
];
|
||||
}
|
||||
|
||||
// PDA-friendly colors: calm, no aggressive reds
|
||||
function generateMockTaskOutcomes(): TaskOutcomeItem[] {
|
||||
return [
|
||||
{ outcome: "Success", count: 312, color: "#6EBF8B" },
|
||||
{ outcome: "Partial", count: 48, color: "#F5C862" },
|
||||
{ outcome: "Timeout", count: 18, color: "#94A3B8" },
|
||||
{ outcome: "Incomplete", count: 22, color: "#C4A5DE" },
|
||||
];
|
||||
}
|
||||
|
||||
// ─── API Functions ───────────────────────────────────────────────────
|
||||
@@ -142,54 +127,47 @@ async function fetchUsageAnalytics(timeRange: TimeRange): Promise<UsageAnalytics
|
||||
* Fetch usage summary data (total tokens, cost, task count, quality rate)
|
||||
*/
|
||||
export async function fetchUsageSummary(timeRange: TimeRange): Promise<UsageSummary> {
|
||||
const analytics = await fetchUsageAnalytics(timeRange);
|
||||
|
||||
return {
|
||||
totalTokens: analytics.totalTokens,
|
||||
totalCost: analytics.totalCostCents / 100,
|
||||
taskCount: analytics.totalCalls,
|
||||
avgQualityGatePassRate: 0,
|
||||
};
|
||||
// TODO: Replace with real API call when backend aggregation endpoints are ready
|
||||
// const response = await apiGet<ApiResponse<UsageSummary>>(`/api/telemetry/summary?range=${timeRange}`);
|
||||
// return response.data;
|
||||
void apiGet; // suppress unused import warning in the meantime
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
return generateMockSummary(timeRange);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch token usage time series for charts
|
||||
*/
|
||||
export function fetchTokenUsage(timeRange: TimeRange): Promise<TokenUsagePoint[]> {
|
||||
void timeRange;
|
||||
return Promise.resolve([]);
|
||||
export async function fetchTokenUsage(timeRange: TimeRange): Promise<TokenUsagePoint[]> {
|
||||
// TODO: Replace with real API call
|
||||
// const response = await apiGet<ApiResponse<TokenUsagePoint[]>>(`/api/telemetry/tokens?range=${timeRange}`);
|
||||
// return response.data;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
return generateMockTokenUsage(timeRange);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch cost breakdown by model
|
||||
*/
|
||||
export async function fetchCostBreakdown(timeRange: TimeRange): Promise<CostBreakdownItem[]> {
|
||||
const analytics = await fetchUsageAnalytics(timeRange);
|
||||
|
||||
return analytics.byModel
|
||||
.filter((item) => item.calls > 0)
|
||||
.sort((a, b) => b.costCents - a.costCents)
|
||||
.map((item) => ({
|
||||
model: item.model,
|
||||
provider: "unknown",
|
||||
cost: item.costCents / 100,
|
||||
taskCount: item.calls,
|
||||
}));
|
||||
// TODO: Replace with real API call
|
||||
// const response = await apiGet<ApiResponse<CostBreakdownItem[]>>(`/api/telemetry/costs?range=${timeRange}`);
|
||||
// return response.data;
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
void timeRange;
|
||||
return generateMockCostBreakdown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch task outcome distribution
|
||||
*/
|
||||
export async function fetchTaskOutcomes(timeRange: TimeRange): Promise<TaskOutcomeItem[]> {
|
||||
const analytics = await fetchUsageAnalytics(timeRange);
|
||||
|
||||
return analytics.byTaskType
|
||||
.filter((item) => item.calls > 0)
|
||||
.map((item, index) => ({
|
||||
outcome: item.taskType,
|
||||
count: item.calls,
|
||||
color: TASK_OUTCOME_COLORS[index % TASK_OUTCOME_COLORS.length] ?? "#94A3B8",
|
||||
}));
|
||||
// TODO: Replace with real API call
|
||||
// const response = await apiGet<ApiResponse<TaskOutcomeItem[]>>(`/api/telemetry/outcomes?range=${timeRange}`);
|
||||
// return response.data;
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
void timeRange;
|
||||
return generateMockTaskOutcomes();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
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
|
||||
Reference in New Issue
Block a user