diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index ee50a1e..23c4cd2 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -39,6 +39,7 @@ import { FederationModule } from "./federation/federation.module"; import { CredentialsModule } from "./credentials/credentials.module"; import { MosaicTelemetryModule } from "./mosaic-telemetry"; import { SpeechModule } from "./speech/speech.module"; +import { DashboardModule } from "./dashboard/dashboard.module"; import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor"; @Module({ @@ -101,6 +102,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce CredentialsModule, MosaicTelemetryModule, SpeechModule, + DashboardModule, ], controllers: [AppController, CsrfController], providers: [ diff --git a/apps/api/src/dashboard/dashboard.controller.spec.ts b/apps/api/src/dashboard/dashboard.controller.spec.ts new file mode 100644 index 0000000..8b3f15c --- /dev/null +++ b/apps/api/src/dashboard/dashboard.controller.spec.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { DashboardController } from "./dashboard.controller"; +import { DashboardService } from "./dashboard.service"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import { WorkspaceGuard } from "../common/guards/workspace.guard"; +import { PermissionGuard } from "../common/guards/permission.guard"; +import type { DashboardSummaryDto } from "./dto"; + +describe("DashboardController", () => { + let controller: DashboardController; + let service: DashboardService; + + const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001"; + + const mockSummary: DashboardSummaryDto = { + metrics: { + activeAgents: 3, + tasksCompleted: 12, + totalTasks: 25, + tasksInProgress: 5, + activeProjects: 4, + errorRate: 2.5, + }, + recentActivity: [ + { + id: "550e8400-e29b-41d4-a716-446655440010", + action: "CREATED", + entityType: "TASK", + entityId: "550e8400-e29b-41d4-a716-446655440011", + details: { title: "New task" }, + userId: "550e8400-e29b-41d4-a716-446655440002", + createdAt: "2026-02-22T12:00:00.000Z", + }, + ], + activeJobs: [ + { + id: "550e8400-e29b-41d4-a716-446655440020", + type: "code-task", + status: "RUNNING", + progressPercent: 45, + createdAt: "2026-02-22T11:00:00.000Z", + updatedAt: "2026-02-22T11:30:00.000Z", + steps: [ + { + id: "550e8400-e29b-41d4-a716-446655440030", + name: "Setup", + status: "COMPLETED", + phase: "SETUP", + }, + ], + }, + ], + tokenBudget: [ + { + model: "agent-1", + used: 5000, + limit: 10000, + }, + ], + }; + + const mockDashboardService = { + getSummary: vi.fn(), + }; + + const mockAuthGuard = { + canActivate: vi.fn(() => true), + }; + + const mockWorkspaceGuard = { + canActivate: vi.fn(() => true), + }; + + const mockPermissionGuard = { + canActivate: vi.fn(() => true), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [DashboardController], + providers: [ + { + provide: DashboardService, + useValue: mockDashboardService, + }, + ], + }) + .overrideGuard(AuthGuard) + .useValue(mockAuthGuard) + .overrideGuard(WorkspaceGuard) + .useValue(mockWorkspaceGuard) + .overrideGuard(PermissionGuard) + .useValue(mockPermissionGuard) + .compile(); + + controller = module.get(DashboardController); + service = module.get(DashboardService); + + vi.clearAllMocks(); + }); + + it("should be defined", () => { + expect(controller).toBeDefined(); + }); + + describe("getSummary", () => { + it("should return dashboard summary for workspace", async () => { + mockDashboardService.getSummary.mockResolvedValue(mockSummary); + + const result = await controller.getSummary(mockWorkspaceId); + + expect(result).toEqual(mockSummary); + expect(service.getSummary).toHaveBeenCalledWith(mockWorkspaceId); + }); + + it("should return empty arrays when no data exists", async () => { + const emptySummary: DashboardSummaryDto = { + metrics: { + activeAgents: 0, + tasksCompleted: 0, + totalTasks: 0, + tasksInProgress: 0, + activeProjects: 0, + errorRate: 0, + }, + recentActivity: [], + activeJobs: [], + tokenBudget: [], + }; + + mockDashboardService.getSummary.mockResolvedValue(emptySummary); + + const result = await controller.getSummary(mockWorkspaceId); + + expect(result).toEqual(emptySummary); + expect(result.metrics.errorRate).toBe(0); + expect(result.recentActivity).toHaveLength(0); + expect(result.activeJobs).toHaveLength(0); + expect(result.tokenBudget).toHaveLength(0); + }); + }); +}); diff --git a/apps/api/src/dashboard/dashboard.controller.ts b/apps/api/src/dashboard/dashboard.controller.ts new file mode 100644 index 0000000..57515ca --- /dev/null +++ b/apps/api/src/dashboard/dashboard.controller.ts @@ -0,0 +1,35 @@ +import { Controller, Get, UseGuards, BadRequestException } from "@nestjs/common"; +import { DashboardService } from "./dashboard.service"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import { WorkspaceGuard, PermissionGuard } from "../common/guards"; +import { Workspace, Permission, RequirePermission } from "../common/decorators"; +import type { DashboardSummaryDto } from "./dto"; + +/** + * Controller for dashboard endpoints. + * Returns aggregated summary data for the workspace dashboard. + * + * Guards are applied in order: + * 1. AuthGuard - Verifies user authentication + * 2. WorkspaceGuard - Validates workspace access and sets RLS context + * 3. PermissionGuard - Checks role-based permissions + */ +@Controller("dashboard") +@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard) +export class DashboardController { + constructor(private readonly dashboardService: DashboardService) {} + + /** + * GET /api/dashboard/summary + * Returns aggregated metrics, recent activity, active jobs, and token budgets + * Requires: Any workspace member (including GUEST) + */ + @Get("summary") + @RequirePermission(Permission.WORKSPACE_ANY) + async getSummary(@Workspace() workspaceId: string | undefined): Promise { + if (!workspaceId) { + throw new BadRequestException("Workspace context required"); + } + return this.dashboardService.getSummary(workspaceId); + } +} diff --git a/apps/api/src/dashboard/dashboard.module.ts b/apps/api/src/dashboard/dashboard.module.ts new file mode 100644 index 0000000..9d54cba --- /dev/null +++ b/apps/api/src/dashboard/dashboard.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { DashboardController } from "./dashboard.controller"; +import { DashboardService } from "./dashboard.service"; +import { PrismaModule } from "../prisma/prisma.module"; +import { AuthModule } from "../auth/auth.module"; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [DashboardController], + providers: [DashboardService], + exports: [DashboardService], +}) +export class DashboardModule {} diff --git a/apps/api/src/dashboard/dashboard.service.ts b/apps/api/src/dashboard/dashboard.service.ts new file mode 100644 index 0000000..049d6f1 --- /dev/null +++ b/apps/api/src/dashboard/dashboard.service.ts @@ -0,0 +1,187 @@ +import { Injectable } from "@nestjs/common"; +import { AgentStatus, ProjectStatus, RunnerJobStatus, TaskStatus } from "@prisma/client"; +import { PrismaService } from "../prisma/prisma.service"; +import type { + DashboardSummaryDto, + ActiveJobDto, + RecentActivityDto, + TokenBudgetEntryDto, +} from "./dto"; + +/** + * Service for aggregating dashboard summary data. + * Executes all queries in parallel to minimize latency. + */ +@Injectable() +export class DashboardService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Get aggregated dashboard summary for a workspace + */ + async getSummary(workspaceId: string): Promise { + const now = new Date(); + const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + // Execute all queries in parallel + const [ + activeAgents, + tasksCompleted, + totalTasks, + tasksInProgress, + activeProjects, + failedJobsLast24h, + totalJobsLast24h, + recentActivityRows, + activeJobRows, + tokenBudgetRows, + ] = await Promise.all([ + // Active agents: IDLE, WORKING, WAITING + this.prisma.agent.count({ + where: { + workspaceId, + status: { in: [AgentStatus.IDLE, AgentStatus.WORKING, AgentStatus.WAITING] }, + }, + }), + + // Tasks completed + this.prisma.task.count({ + where: { + workspaceId, + status: TaskStatus.COMPLETED, + }, + }), + + // Total tasks + this.prisma.task.count({ + where: { workspaceId }, + }), + + // Tasks in progress + this.prisma.task.count({ + where: { + workspaceId, + status: TaskStatus.IN_PROGRESS, + }, + }), + + // Active projects + this.prisma.project.count({ + where: { + workspaceId, + status: ProjectStatus.ACTIVE, + }, + }), + + // Failed jobs in last 24h (for error rate) + this.prisma.runnerJob.count({ + where: { + workspaceId, + status: RunnerJobStatus.FAILED, + createdAt: { gte: oneDayAgo }, + }, + }), + + // Total jobs in last 24h (for error rate) + this.prisma.runnerJob.count({ + where: { + workspaceId, + createdAt: { gte: oneDayAgo }, + }, + }), + + // Recent activity: last 10 entries + this.prisma.activityLog.findMany({ + where: { workspaceId }, + orderBy: { createdAt: "desc" }, + take: 10, + }), + + // Active jobs: PENDING, QUEUED, RUNNING with steps + this.prisma.runnerJob.findMany({ + where: { + workspaceId, + status: { + in: [RunnerJobStatus.PENDING, RunnerJobStatus.QUEUED, RunnerJobStatus.RUNNING], + }, + }, + include: { + steps: { + select: { + id: true, + name: true, + status: true, + phase: true, + }, + orderBy: { ordinal: "asc" }, + }, + }, + orderBy: { createdAt: "desc" }, + }), + + // Token budgets for workspace (active, not yet completed) + this.prisma.tokenBudget.findMany({ + where: { + workspaceId, + completedAt: null, + }, + select: { + agentId: true, + totalTokensUsed: true, + allocatedTokens: true, + }, + }), + ]); + + // Compute error rate + const errorRate = totalJobsLast24h > 0 ? (failedJobsLast24h / totalJobsLast24h) * 100 : 0; + + // Map recent activity + const recentActivity: RecentActivityDto[] = recentActivityRows.map((row) => ({ + id: row.id, + action: row.action, + entityType: row.entityType, + entityId: row.entityId, + details: row.details as Record | null, + userId: row.userId, + createdAt: row.createdAt.toISOString(), + })); + + // Map active jobs (RunnerJob lacks updatedAt; use startedAt or createdAt as proxy) + const activeJobs: ActiveJobDto[] = activeJobRows.map((row) => ({ + id: row.id, + type: row.type, + status: row.status, + progressPercent: row.progressPercent, + createdAt: row.createdAt.toISOString(), + updatedAt: (row.startedAt ?? row.createdAt).toISOString(), + steps: row.steps.map((step) => ({ + id: step.id, + name: step.name, + status: step.status, + phase: step.phase, + })), + })); + + // Map token budget entries + const tokenBudget: TokenBudgetEntryDto[] = tokenBudgetRows.map((row) => ({ + model: row.agentId, + used: row.totalTokensUsed, + limit: row.allocatedTokens, + })); + + return { + metrics: { + activeAgents, + tasksCompleted, + totalTasks, + tasksInProgress, + activeProjects, + errorRate: Math.round(errorRate * 100) / 100, + }, + recentActivity, + activeJobs, + tokenBudget, + }; + } +} diff --git a/apps/api/src/dashboard/dto/dashboard-summary.dto.ts b/apps/api/src/dashboard/dto/dashboard-summary.dto.ts new file mode 100644 index 0000000..502f7ec --- /dev/null +++ b/apps/api/src/dashboard/dto/dashboard-summary.dto.ts @@ -0,0 +1,53 @@ +/** + * Dashboard Summary DTO + * Defines the response shape for the dashboard summary endpoint. + */ + +export class DashboardMetricsDto { + activeAgents!: number; + tasksCompleted!: number; + totalTasks!: number; + tasksInProgress!: number; + activeProjects!: number; + errorRate!: number; +} + +export class RecentActivityDto { + id!: string; + action!: string; + entityType!: string; + entityId!: string; + details!: Record | null; + userId!: string; + createdAt!: string; +} + +export class ActiveJobStepDto { + id!: string; + name!: string; + status!: string; + phase!: string; +} + +export class ActiveJobDto { + id!: string; + type!: string; + status!: string; + progressPercent!: number; + createdAt!: string; + updatedAt!: string; + steps!: ActiveJobStepDto[]; +} + +export class TokenBudgetEntryDto { + model!: string; + used!: number; + limit!: number; +} + +export class DashboardSummaryDto { + metrics!: DashboardMetricsDto; + recentActivity!: RecentActivityDto[]; + activeJobs!: ActiveJobDto[]; + tokenBudget!: TokenBudgetEntryDto[]; +} diff --git a/apps/api/src/dashboard/dto/index.ts b/apps/api/src/dashboard/dto/index.ts new file mode 100644 index 0000000..bc6cb94 --- /dev/null +++ b/apps/api/src/dashboard/dto/index.ts @@ -0,0 +1 @@ +export * from "./dashboard-summary.dto"; diff --git a/apps/web/src/app/(authenticated)/page.test.tsx b/apps/web/src/app/(authenticated)/page.test.tsx index 98da381..3d72378 100644 --- a/apps/web/src/app/(authenticated)/page.test.tsx +++ b/apps/web/src/app/(authenticated)/page.test.tsx @@ -1,6 +1,7 @@ -import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; import DashboardPage from "./page"; +import { fetchDashboardSummary } from "@/lib/api/dashboard"; // Mock Phase 3 dashboard widgets vi.mock("@/components/dashboard/DashboardMetrics", () => ({ @@ -27,29 +28,85 @@ vi.mock("@/components/dashboard/TokenBudget", () => ({ TokenBudget: (): React.JSX.Element =>
Token Budget
, })); +// Mock hooks and API calls +vi.mock("@/lib/hooks", () => ({ + useWorkspaceId: (): string | null => "ws-test-123", +})); + +vi.mock("@/lib/api/dashboard", () => ({ + fetchDashboardSummary: vi.fn().mockResolvedValue({ + metrics: { + activeAgents: 5, + tasksCompleted: 42, + totalTasks: 100, + tasksInProgress: 10, + activeProjects: 3, + errorRate: 0.5, + }, + recentActivity: [], + activeJobs: [], + tokenBudget: [], + }), +})); + describe("DashboardPage", (): void => { - it("should render the DashboardMetrics widget", (): void => { - render(); - expect(screen.getByTestId("dashboard-metrics")).toBeInTheDocument(); + beforeEach((): void => { + vi.clearAllMocks(); + vi.mocked(fetchDashboardSummary).mockResolvedValue({ + metrics: { + activeAgents: 5, + tasksCompleted: 42, + totalTasks: 100, + tasksInProgress: 10, + activeProjects: 3, + errorRate: 0.5, + }, + recentActivity: [], + activeJobs: [], + tokenBudget: [], + }); }); - it("should render the OrchestratorSessions widget", (): void => { + it("should render the DashboardMetrics widget", async (): Promise => { render(); - expect(screen.getByTestId("orchestrator-sessions")).toBeInTheDocument(); + await waitFor((): void => { + expect(screen.getByTestId("dashboard-metrics")).toBeInTheDocument(); + }); }); - it("should render the QuickActions widget", (): void => { + it("should render the OrchestratorSessions widget", async (): Promise => { render(); - expect(screen.getByTestId("quick-actions")).toBeInTheDocument(); + await waitFor((): void => { + expect(screen.getByTestId("orchestrator-sessions")).toBeInTheDocument(); + }); }); - it("should render the ActivityFeed widget", (): void => { + it("should render the QuickActions widget", async (): Promise => { render(); - expect(screen.getByTestId("activity-feed")).toBeInTheDocument(); + await waitFor((): void => { + expect(screen.getByTestId("quick-actions")).toBeInTheDocument(); + }); }); - it("should render the TokenBudget widget", (): void => { + it("should render the ActivityFeed widget", async (): Promise => { render(); - expect(screen.getByTestId("token-budget")).toBeInTheDocument(); + await waitFor((): void => { + expect(screen.getByTestId("activity-feed")).toBeInTheDocument(); + }); + }); + + it("should render the TokenBudget widget", async (): Promise => { + render(); + await waitFor((): void => { + expect(screen.getByTestId("token-budget")).toBeInTheDocument(); + }); + }); + + it("should render error state when API fails", async (): Promise => { + vi.mocked(fetchDashboardSummary).mockRejectedValueOnce(new Error("Network error")); + render(); + await waitFor((): void => { + expect(screen.getByText("Failed to load dashboard data")).toBeInTheDocument(); + }); }); }); diff --git a/apps/web/src/app/(authenticated)/page.tsx b/apps/web/src/app/(authenticated)/page.tsx index bf09ec1..2105b48 100644 --- a/apps/web/src/app/(authenticated)/page.tsx +++ b/apps/web/src/app/(authenticated)/page.tsx @@ -1,24 +1,102 @@ "use client"; +import { useState, useEffect } from "react"; import type { ReactElement } from "react"; import { DashboardMetrics } from "@/components/dashboard/DashboardMetrics"; import { OrchestratorSessions } from "@/components/dashboard/OrchestratorSessions"; import { QuickActions } from "@/components/dashboard/QuickActions"; import { ActivityFeed } from "@/components/dashboard/ActivityFeed"; import { TokenBudget } from "@/components/dashboard/TokenBudget"; +import { fetchDashboardSummary } from "@/lib/api/dashboard"; +import type { DashboardSummaryResponse } from "@/lib/api/dashboard"; +import { useWorkspaceId } from "@/lib/hooks"; export default function DashboardPage(): ReactElement { + const workspaceId = useWorkspaceId(); + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!workspaceId) { + setIsLoading(false); + return; + } + + const wsId = workspaceId; + let cancelled = false; + setError(null); + setIsLoading(true); + + async function loadSummary(): Promise { + try { + const summary = await fetchDashboardSummary(wsId); + if (!cancelled) { + setData(summary); + } + } catch (err: unknown) { + console.error("[Dashboard] Failed to fetch summary:", err); + if (!cancelled) { + setError("Failed to load dashboard data"); + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + } + + void loadSummary(); + + return (): void => { + cancelled = true; + }; + }, [workspaceId]); + + if (isLoading) { + return ( +
+ +
+
+ + +
+
+ + +
+
+
+ ); + } + return (
- + {error && ( +
+ {error} +
+ )} +
- +
- - + +
diff --git a/apps/web/src/components/dashboard/ActivityFeed.tsx b/apps/web/src/components/dashboard/ActivityFeed.tsx index 3a623bf..a151a10 100644 --- a/apps/web/src/components/dashboard/ActivityFeed.tsx +++ b/apps/web/src/components/dashboard/ActivityFeed.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from "react"; import { Card, SectionHeader, Badge } from "@mosaic/ui"; +import type { RecentActivity } from "@/lib/api/dashboard"; type BadgeVariantType = | "badge-amber" @@ -10,7 +11,7 @@ type BadgeVariantType = | "badge-purple" | "badge-pulse"; -interface ActivityItem { +interface ActivityDisplayItem { id: string; icon: string; iconBg: string; @@ -18,82 +19,91 @@ interface ActivityItem { highlight: string; rest: string; timestamp: string; - badge?: { - text: string; - variant: BadgeVariantType; + badge?: + | { + text: string; + variant: BadgeVariantType; + } + | undefined; +} + +export interface ActivityFeedProps { + items?: RecentActivity[] | undefined; +} + +/* ------------------------------------------------------------------ */ +/* Mapping helpers */ +/* ------------------------------------------------------------------ */ + +function getIconForAction(action: string): { icon: string; iconBg: string } { + const lower = action.toLowerCase(); + if (lower.includes("complet") || lower.includes("finish") || lower.includes("success")) { + return { icon: "\u2713", iconBg: "rgba(20,184,166,0.15)" }; + } + if (lower.includes("fail") || lower.includes("error")) { + return { icon: "\u2717", iconBg: "rgba(229,72,77,0.15)" }; + } + if (lower.includes("warn") || lower.includes("limit")) { + return { icon: "\u26A0", iconBg: "rgba(245,158,11,0.15)" }; + } + if (lower.includes("start") || lower.includes("creat")) { + return { icon: "\u2191", iconBg: "rgba(47,128,255,0.15)" }; + } + if (lower.includes("update") || lower.includes("modif")) { + return { icon: "\u21BB", iconBg: "rgba(139,92,246,0.15)" }; + } + return { icon: "\u2022", iconBg: "rgba(100,116,139,0.15)" }; +} + +function getBadgeForAction(action: string): ActivityDisplayItem["badge"] { + const lower = action.toLowerCase(); + if (lower.includes("fail") || lower.includes("error")) { + return { text: "error", variant: "badge-red" }; + } + if (lower.includes("warn") || lower.includes("limit")) { + return { text: "warn", variant: "badge-amber" }; + } + return undefined; +} + +function formatRelativeTime(isoDate: string): string { + const now = Date.now(); + const then = new Date(isoDate).getTime(); + const diffMs = now - then; + + if (Number.isNaN(diffMs) || diffMs < 0) return "just now"; + + const minutes = Math.floor(diffMs / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${String(minutes)}m ago`; + + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${String(hours)}h ago`; + + const days = Math.floor(hours / 24); + return `${String(days)}d ago`; +} + +function mapActivityToDisplay(activity: RecentActivity): ActivityDisplayItem { + const { icon, iconBg } = getIconForAction(activity.action); + return { + id: activity.id, + icon, + iconBg, + title: "", + highlight: activity.entityType, + rest: ` ${activity.action} (${activity.entityId})`, + timestamp: formatRelativeTime(activity.createdAt), + badge: getBadgeForAction(activity.action), }; } -const activityItems: ActivityItem[] = [ - { - id: "act-1", - icon: "✓", - iconBg: "rgba(20,184,166,0.15)", - title: "", - highlight: "planner-agent", - rest: " completed task analysis for infra-refactor", - timestamp: "2m ago", - }, - { - id: "act-2", - icon: "⚠", - iconBg: "rgba(245,158,11,0.15)", - title: "", - highlight: "executor-agent", - rest: " hit rate limit on Terraform API", - timestamp: "5m ago", - badge: { text: "warn", variant: "badge-amber" }, - }, - { - id: "act-3", - icon: "↑", - iconBg: "rgba(47,128,255,0.15)", - title: "", - highlight: "ORCH-002", - rest: " session started for api-v3-migration", - timestamp: "12m ago", - }, - { - id: "act-4", - icon: "✗", - iconBg: "rgba(229,72,77,0.15)", - title: "", - highlight: "migrator-agent", - rest: " failed to connect to staging database", - timestamp: "18m ago", - badge: { text: "error", variant: "badge-red" }, - }, - { - id: "act-5", - icon: "✓", - iconBg: "rgba(20,184,166,0.15)", - title: "", - highlight: "reviewer-agent", - rest: " approved PR #214 in infra-refactor", - timestamp: "34m ago", - }, - { - id: "act-6", - icon: "⟳", - iconBg: "rgba(139,92,246,0.15)", - title: "Token budget reset for ", - highlight: "gpt-4o", - rest: " model", - timestamp: "1h ago", - }, - { - id: "act-7", - icon: "★", - iconBg: "rgba(20,184,166,0.15)", - title: "Project ", - highlight: "data-pipeline", - rest: " marked as completed", - timestamp: "2h ago", - }, -]; +/* ------------------------------------------------------------------ */ +/* Components */ +/* ------------------------------------------------------------------ */ interface ActivityItemRowProps { - item: ActivityItem; + item: ActivityDisplayItem; } function ActivityItemRow({ item }: ActivityItemRowProps): ReactElement { @@ -155,14 +165,27 @@ function ActivityItemRow({ item }: ActivityItemRowProps): ReactElement { ); } -export function ActivityFeed(): ReactElement { +export function ActivityFeed({ items }: ActivityFeedProps): ReactElement { + const displayItems = items ? items.map(mapActivityToDisplay) : []; + return (
- {activityItems.map((item) => ( - - ))} + {displayItems.length > 0 ? ( + displayItems.map((item) => ) + ) : ( +
+ No recent activity +
+ )}
); diff --git a/apps/web/src/components/dashboard/DashboardMetrics.tsx b/apps/web/src/components/dashboard/DashboardMetrics.tsx index 16a5945..94e28ef 100644 --- a/apps/web/src/components/dashboard/DashboardMetrics.tsx +++ b/apps/web/src/components/dashboard/DashboardMetrics.tsx @@ -1,45 +1,69 @@ import type { ReactElement } from "react"; import { MetricsStrip, type MetricCell } from "@mosaic/ui"; +import type { DashboardMetrics as DashboardMetricsData } from "@/lib/api/dashboard"; -const cells: MetricCell[] = [ - { - label: "Active Agents", - value: "47", - color: "var(--ms-blue-400)", - trend: { direction: "up", text: "↑ +3 from yesterday" }, - }, - { - label: "Tasks Completed", - value: "1,284", - color: "var(--ms-teal-400)", - trend: { direction: "up", text: "↑ +128 today" }, - }, - { - label: "Avg Response Time", - value: "2.4s", - color: "var(--ms-purple-400)", - trend: { direction: "down", text: "↓ -0.3s improved" }, - }, - { - label: "Token Usage", - value: "3.2M", - color: "var(--ms-amber-400)", - trend: { direction: "neutral", text: "78% of budget" }, - }, - { - label: "Error Rate", - value: "0.4%", - color: "var(--ms-red-400)", - trend: { direction: "down", text: "↓ -0.1% improved" }, - }, - { - label: "Active Projects", - value: "8", - color: "var(--ms-cyan-500)", - trend: { direction: "neutral", text: "2 deploying" }, - }, +export interface DashboardMetricsProps { + metrics?: DashboardMetricsData | undefined; +} + +function formatNumber(n: number): string { + return n.toLocaleString(); +} + +function buildCells(metrics: DashboardMetricsData): MetricCell[] { + return [ + { + label: "Active Agents", + value: formatNumber(metrics.activeAgents), + color: "var(--ms-blue-400)", + trend: { direction: "neutral", text: "currently active" }, + }, + { + label: "Tasks Completed", + value: formatNumber(metrics.tasksCompleted), + color: "var(--ms-teal-400)", + trend: { direction: "neutral", text: `of ${formatNumber(metrics.totalTasks)} total` }, + }, + { + label: "Total Tasks", + value: formatNumber(metrics.totalTasks), + color: "var(--ms-purple-400)", + trend: { direction: "neutral", text: "across workspace" }, + }, + { + label: "In Progress", + value: formatNumber(metrics.tasksInProgress), + color: "var(--ms-amber-400)", + trend: { direction: "neutral", text: "tasks running" }, + }, + { + label: "Error Rate", + value: `${String(metrics.errorRate)}%`, + color: "var(--ms-red-400)", + trend: { + direction: metrics.errorRate > 1 ? "up" : "down", + text: metrics.errorRate > 1 ? "above threshold" : "within threshold", + }, + }, + { + label: "Active Projects", + value: formatNumber(metrics.activeProjects), + color: "var(--ms-cyan-500)", + trend: { direction: "neutral", text: "in workspace" }, + }, + ]; +} + +const EMPTY_CELLS: MetricCell[] = [ + { label: "Active Agents", value: "0", color: "var(--ms-blue-400)" }, + { label: "Tasks Completed", value: "0", color: "var(--ms-teal-400)" }, + { label: "Total Tasks", value: "0", color: "var(--ms-purple-400)" }, + { label: "In Progress", value: "0", color: "var(--ms-amber-400)" }, + { label: "Error Rate", value: "0%", color: "var(--ms-red-400)" }, + { label: "Active Projects", value: "0", color: "var(--ms-cyan-500)" }, ]; -export function DashboardMetrics(): ReactElement { +export function DashboardMetrics({ metrics }: DashboardMetricsProps): ReactElement { + const cells = metrics ? buildCells(metrics) : EMPTY_CELLS; return ; } diff --git a/apps/web/src/components/dashboard/OrchestratorSessions.tsx b/apps/web/src/components/dashboard/OrchestratorSessions.tsx index c9e9732..e654163 100644 --- a/apps/web/src/components/dashboard/OrchestratorSessions.tsx +++ b/apps/web/src/components/dashboard/OrchestratorSessions.tsx @@ -3,6 +3,22 @@ import { useState } from "react"; import type { ReactElement } from "react"; import { Card, SectionHeader, Badge, Dot } from "@mosaic/ui"; +import type { ActiveJob } from "@/lib/api/dashboard"; + +/* ------------------------------------------------------------------ */ +/* Internal display types */ +/* ------------------------------------------------------------------ */ + +type DotVariant = "teal" | "blue" | "amber" | "red" | "muted"; + +type BadgeVariant = + | "badge-teal" + | "badge-amber" + | "badge-red" + | "badge-blue" + | "badge-muted" + | "badge-purple" + | "badge-pulse"; interface AgentNode { id: string; @@ -10,7 +26,7 @@ interface AgentNode { avatarColor: string; name: string; task: string; - status: "teal" | "blue" | "amber" | "red" | "muted"; + status: DotVariant; } interface OrchestratorSession { @@ -18,73 +34,94 @@ interface OrchestratorSession { orchId: string; name: string; badge: string; - badgeVariant: - | "badge-teal" - | "badge-amber" - | "badge-red" - | "badge-blue" - | "badge-muted" - | "badge-purple" - | "badge-pulse"; + badgeVariant: BadgeVariant; duration: string; agents: AgentNode[]; } -const sessions: OrchestratorSession[] = [ - { - id: "s1", - orchId: "ORCH-001", - name: "infra-refactor", - badge: "running", - badgeVariant: "badge-teal", - duration: "2h 14m", - agents: [ - { - id: "a1", - initials: "PL", - avatarColor: "rgba(47,128,255,0.15)", - name: "planner-agent", - task: "Analyzing network topology", - status: "blue", - }, - { - id: "a2", - initials: "EX", - avatarColor: "rgba(20,184,166,0.15)", - name: "executor-agent", - task: "Applying Terraform modules", - status: "teal", - }, - { - id: "a3", - initials: "QA", - avatarColor: "rgba(245,158,11,0.15)", - name: "reviewer-agent", - task: "Waiting for executor output", - status: "amber", - }, - ], - }, - { - id: "s2", - orchId: "ORCH-002", - name: "api-v3-migration", - badge: "running", - badgeVariant: "badge-teal", - duration: "45m", - agents: [ - { - id: "a4", - initials: "MG", - avatarColor: "rgba(139,92,246,0.15)", - name: "migrator-agent", - task: "Rewriting endpoint handlers", - status: "blue", - }, - ], - }, +export interface OrchestratorSessionsProps { + jobs?: ActiveJob[] | undefined; +} + +/* ------------------------------------------------------------------ */ +/* Mapping helpers */ +/* ------------------------------------------------------------------ */ + +const STEP_COLORS: string[] = [ + "rgba(47,128,255,0.15)", + "rgba(20,184,166,0.15)", + "rgba(245,158,11,0.15)", + "rgba(139,92,246,0.15)", + "rgba(229,72,77,0.15)", ]; +function statusToDotVariant(status: string): DotVariant { + const lower = status.toLowerCase(); + if (lower === "running" || lower === "active" || lower === "completed") return "teal"; + if (lower === "pending" || lower === "queued") return "blue"; + if (lower === "waiting" || lower === "paused") return "amber"; + if (lower === "failed" || lower === "error") return "red"; + return "muted"; +} + +function statusToBadgeVariant(status: string): BadgeVariant { + const lower = status.toLowerCase(); + if (lower === "running" || lower === "active") return "badge-teal"; + if (lower === "pending" || lower === "queued") return "badge-blue"; + if (lower === "waiting" || lower === "paused") return "badge-amber"; + if (lower === "failed" || lower === "error") return "badge-red"; + if (lower === "completed") return "badge-purple"; + return "badge-muted"; +} + +function formatDuration(isoDate: string): string { + const now = Date.now(); + const start = new Date(isoDate).getTime(); + const diffMs = now - start; + + if (Number.isNaN(diffMs) || diffMs < 0) return "0m"; + + const totalMinutes = Math.floor(diffMs / 60_000); + if (totalMinutes < 60) return `${String(totalMinutes)}m`; + + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return `${String(hours)}h ${String(minutes)}m`; +} + +function initials(name: string): string { + return name + .split(/[\s\-_]+/) + .slice(0, 2) + .map((w) => w.charAt(0).toUpperCase()) + .join(""); +} + +function mapJobToSession(job: ActiveJob): OrchestratorSession { + const agents: AgentNode[] = job.steps.map((step, idx) => ({ + id: step.id, + initials: initials(step.name), + avatarColor: STEP_COLORS[idx % STEP_COLORS.length] ?? "rgba(100,116,139,0.15)", + name: step.name, + task: `Phase: ${step.phase}`, + status: statusToDotVariant(step.status), + })); + + return { + id: job.id, + orchId: job.id.length > 10 ? job.id.slice(0, 10).toUpperCase() : job.id.toUpperCase(), + name: job.type, + badge: job.status, + badgeVariant: statusToBadgeVariant(job.status), + duration: formatDuration(job.createdAt), + agents, + }; +} + +/* ------------------------------------------------------------------ */ +/* Sub-components */ +/* ------------------------------------------------------------------ */ + interface AgentNodeItemProps { agent: AgentNode; } @@ -182,7 +219,7 @@ function OrchCard({ session }: OrchCardProps): ReactElement { marginBottom: 10, }} > - + j.status.toLowerCase() === "running" || j.status.toLowerCase() === "active" + ).length + : 0; + return ( 3 active} + subtitle={ + sessions.length > 0 + ? `${String(activeCount)} of ${String(sessions.length)} jobs running` + : "No active sessions" + } + actions={ + sessions.length > 0 ? ( + {String(activeCount)} active + ) : undefined + } />
- {sessions.map((session) => ( - - ))} + {sessions.length > 0 ? ( + sessions.map((session) => ) + ) : ( +
+ No active sessions +
+ )}
); diff --git a/apps/web/src/components/dashboard/TokenBudget.tsx b/apps/web/src/components/dashboard/TokenBudget.tsx index 7aad59d..2e434cb 100644 --- a/apps/web/src/components/dashboard/TokenBudget.tsx +++ b/apps/web/src/components/dashboard/TokenBudget.tsx @@ -1,7 +1,24 @@ import type { ReactElement } from "react"; import { Card, SectionHeader, ProgressBar, type ProgressBarVariant } from "@mosaic/ui"; +import type { TokenBudgetEntry } from "@/lib/api/dashboard"; -interface ModelBudget { +export interface TokenBudgetProps { + budgets?: TokenBudgetEntry[] | undefined; +} + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +const VARIANT_CYCLE: ProgressBarVariant[] = ["blue", "teal", "purple", "amber"]; + +function formatTokenCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`; + return String(n); +} + +interface ModelBudgetDisplay { id: string; label: string; usage: string; @@ -9,39 +26,28 @@ interface ModelBudget { variant: ProgressBarVariant; } -const models: ModelBudget[] = [ - { - id: "sonnet", - label: "claude-3-5-sonnet", - usage: "2.1M / 3M", - value: 70, - variant: "blue", - }, - { - id: "haiku", - label: "claude-3-haiku", - usage: "890K / 5M", - value: 18, - variant: "teal", - }, - { - id: "gpt4o", - label: "gpt-4o", - usage: "320K / 1M", - value: 32, - variant: "purple", - }, - { - id: "llama", - label: "local/llama-3.3", - usage: "unlimited", - value: 55, - variant: "amber", - }, -]; +function mapBudgetToDisplay(entry: TokenBudgetEntry, index: number): ModelBudgetDisplay { + const percent = entry.limit > 0 ? Math.round((entry.used / entry.limit) * 100) : 0; + const usage = + entry.limit > 0 + ? `${formatTokenCount(entry.used)} / ${formatTokenCount(entry.limit)}` + : "unlimited"; + + return { + id: entry.model, + label: entry.model, + usage, + value: percent, + variant: VARIANT_CYCLE[index % VARIANT_CYCLE.length] ?? "blue", + }; +} + +/* ------------------------------------------------------------------ */ +/* Components */ +/* ------------------------------------------------------------------ */ interface ModelRowProps { - model: ModelBudget; + model: ModelBudgetDisplay; } function ModelRow({ model }: ModelRowProps): ReactElement { @@ -84,14 +90,27 @@ function ModelRow({ model }: ModelRowProps): ReactElement { ); } -export function TokenBudget(): ReactElement { +export function TokenBudget({ budgets }: TokenBudgetProps): ReactElement { + const displayModels = budgets ? budgets.map(mapBudgetToDisplay) : []; + return (
- {models.map((model) => ( - - ))} + {displayModels.length > 0 ? ( + displayModels.map((model) => ) + ) : ( +
+ No budget data +
+ )}
); diff --git a/apps/web/src/lib/api/dashboard.ts b/apps/web/src/lib/api/dashboard.ts new file mode 100644 index 0000000..6994bd5 --- /dev/null +++ b/apps/web/src/lib/api/dashboard.ts @@ -0,0 +1,74 @@ +/** + * Dashboard API Client + * Handles dashboard summary data fetching + */ + +import { apiGet } from "./client"; + +/* ------------------------------------------------------------------ */ +/* Type definitions matching backend DashboardSummaryDto */ +/* ------------------------------------------------------------------ */ + +export interface DashboardMetrics { + activeAgents: number; + tasksCompleted: number; + totalTasks: number; + tasksInProgress: number; + activeProjects: number; + errorRate: number; +} + +export interface RecentActivity { + id: string; + action: string; + entityType: string; + entityId: string; + details: Record | null; + userId: string; + createdAt: string; +} + +export interface ActiveJobStep { + id: string; + name: string; + status: string; + phase: string; +} + +export interface ActiveJob { + id: string; + type: string; + status: string; + progressPercent: number; + createdAt: string; + updatedAt: string; + steps: ActiveJobStep[]; +} + +export interface TokenBudgetEntry { + model: string; + used: number; + limit: number; +} + +export interface DashboardSummaryResponse { + metrics: DashboardMetrics; + recentActivity: RecentActivity[]; + activeJobs: ActiveJob[]; + tokenBudget: TokenBudgetEntry[]; +} + +/* ------------------------------------------------------------------ */ +/* API function */ +/* ------------------------------------------------------------------ */ + +/** + * Fetch dashboard summary data for the given workspace. + * + * @param workspaceId - Optional workspace ID sent via X-Workspace-Id header + */ +export async function fetchDashboardSummary( + workspaceId?: string +): Promise { + return apiGet("/api/dashboard/summary", workspaceId ?? undefined); +} diff --git a/apps/web/src/lib/api/index.ts b/apps/web/src/lib/api/index.ts index 5877de4..3d97eae 100644 --- a/apps/web/src/lib/api/index.ts +++ b/apps/web/src/lib/api/index.ts @@ -13,3 +13,4 @@ export * from "./domains"; export * from "./teams"; export * from "./personalities"; export * from "./telemetry"; +export * from "./dashboard"; diff --git a/docs/MISSION-MANIFEST.md b/docs/MISSION-MANIFEST.md index 724c6b1..7e0a815 100644 --- a/docs/MISSION-MANIFEST.md +++ b/docs/MISSION-MANIFEST.md @@ -8,10 +8,10 @@ **ID:** mosaic-stack-go-live-mvp-20260222 **Statement:** Ship Mosaic Stack MVP: operational dashboard with theming, task ingestion, one visible agent cycle, deployed and smoke-tested. Unblocks SagePHR, DYOR, Calibr, and downstream projects. **Phase:** Execution -**Current Milestone:** phase-1 (Dashboard Polish + Theming) -**Progress:** 0 / 4 milestones +**Current Milestone:** phase-2 (Task Ingestion Pipeline) +**Progress:** 1 / 4 milestones **Status:** active -**Last Updated:** 2026-02-22 23:51 UTC +**Last Updated:** 2026-02-23 00:20 UTC ## Success Criteria @@ -34,12 +34,12 @@ This mission continues from that foundation. ## Milestones -| # | ID | Name | Status | Branch | Issue | Started | Completed | -| --- | ------- | -------------------------- | ----------- | ------------------- | ----- | ---------- | --------- | -| 1 | phase-1 | Dashboard Polish + Theming | in-progress | feat/phase-1-polish | #457 | 2026-02-22 | — | -| 2 | phase-2 | Task Ingestion Pipeline | pending | — | — | — | — | -| 3 | phase-3 | Agent Cycle Visibility | pending | — | — | — | — | -| 4 | phase-4 | Deploy + Smoke Test | pending | — | — | — | — | +| # | ID | Name | Status | Branch | Issue | Started | Completed | +| --- | ------- | -------------------------- | ----------- | ---------------------- | ----- | ---------- | ---------- | +| 1 | phase-1 | Dashboard Polish + Theming | completed | feat/phase-1-polish | #457 | 2026-02-22 | 2026-02-23 | +| 2 | phase-2 | Task Ingestion Pipeline | in-progress | feat/phase-2-ingestion | #459 | 2026-02-23 | — | +| 3 | phase-3 | Agent Cycle Visibility | pending | — | — | — | — | +| 4 | phase-4 | Deploy + Smoke Test | pending | — | — | — | — | ## Deployment diff --git a/docs/TASKS.md b/docs/TASKS.md index 89b064c..09eb59c 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -2,9 +2,12 @@ > Single-writer: orchestrator only. Workers read but never modify. -| id | status | milestone | description | pr | notes | -| --------- | ------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------ | --- | -------------------------------------------------------- | -| MS-P1-001 | done | phase-1 | Fix broken test suites: Button.test.tsx (4 fails, old Tailwind classes) + page.test.tsx (5 fails, old widget refs) | — | issue #457, commit 8fa0b30 | -| MS-P1-002 | done | phase-1 | Remove legacy unused dashboard widgets: DomainOverviewWidget, RecentTasksWidget, UpcomingEventsWidget, QuickCaptureWidget | — | issue #457, commit 8fa0b30, 5 files deleted | -| MS-P1-003 | done | phase-1 | Visual + theme polish: audit current vs design reference, fix gaps, verify dark/light across all components, responsive verification | — | issue #457, commit d97a98b, review: approve (0 blockers) | -| MS-P1-004 | done | phase-1 | Phase verification: all quality gates pass (lint 8/8, typecheck 7/7, test 8/8) | — | issue #457, all gates green (forced, no cache) | +| id | status | milestone | description | pr | notes | +| --------- | ------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------ | ---- | ---------------------------------------------- | +| MS-P1-001 | done | phase-1 | Fix broken test suites: Button.test.tsx (4 fails, old Tailwind classes) + page.test.tsx (5 fails, old widget refs) | #458 | issue #457, commit 8fa0b30 | +| MS-P1-002 | done | phase-1 | Remove legacy unused dashboard widgets: DomainOverviewWidget, RecentTasksWidget, UpcomingEventsWidget, QuickCaptureWidget | #458 | issue #457, commit 8fa0b30, 5 files deleted | +| MS-P1-003 | done | phase-1 | Visual + theme polish: audit current vs design reference, fix gaps, verify dark/light across all components, responsive verification | #458 | issue #457, commit d97a98b, review: approve | +| MS-P1-004 | done | phase-1 | Phase verification: all quality gates pass (lint 8/8, typecheck 7/7, test 8/8) | #458 | issue #457, merged 07f5225, issue closed | +| MS-P2-001 | done | phase-2 | Create dashboard summary API endpoint: aggregate task counts, project counts, recent activity, active jobs in single call | — | issue #459, commit e38aaa9, 7 files +430 lines | +| MS-P2-002 | done | phase-2 | Wire dashboard widgets to real API data: ActivityFeed, DashboardMetrics, OrchestratorSessions replace mock with API calls | — | issue #459, commit 7c762e6 + remediation | +| MS-P2-003 | done | phase-2 | Phase verification: create task via API, confirm visible in dashboard, all quality gates pass | — | issue #459, lint 8/8 typecheck 7/7 test 8/8 | diff --git a/docs/scratchpads/mosaic-stack-go-live-mvp-20260222.md b/docs/scratchpads/mosaic-stack-go-live-mvp-20260222.md index 7ce802c..1529d7e 100644 --- a/docs/scratchpads/mosaic-stack-go-live-mvp-20260222.md +++ b/docs/scratchpads/mosaic-stack-go-live-mvp-20260222.md @@ -33,9 +33,18 @@ Estimated total: ~50K tokens ## Session Log -| Session | Date | Milestone | Tasks Done | Outcome | -| ------- | ---------- | --------- | ---------- | ------------------------------------------- | -| S1 | 2026-02-22 | phase-1 | 0/4 | In progress — bootstrap complete, executing | +| Session | Date | Milestone | Tasks Done | Outcome | +| ------- | ---------- | --------- | ---------- | ------------------------------------------------------ | +| S1 | 2026-02-22 | phase-1 | 4/4 | COMPLETE — PR #458 merged (07f5225), issue #457 closed | + +### 2026-02-23: Phase-1 Completion Summary + +- PR #458 merged to main (squash), commit 07f5225 +- Issue #457 closed +- 4/4 tasks done, all quality gates green +- Pre-existing bug noted: Toast.tsx var(--info) undefined (not in scope) +- Net: -373 lines (legacy cleanup + responsive CSS additions) +- Review: approve (0 blockers, 0 critical security) ## Open Questions