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..58f4454 --- /dev/null +++ b/apps/api/src/dashboard/dashboard.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Get, UseGuards } 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"; + +/** + * 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) { + 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..96ae855 --- /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, + 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..9feef5c --- /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!: unknown; + 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";