feat(api): add dashboard summary endpoint (#459)
All checks were successful
ci/woodpecker/push/api Pipeline was successful
All checks were successful
ci/woodpecker/push/api Pipeline was successful
Create GET /api/dashboard/summary aggregating 10 parallel Prisma queries: task metrics, active agents, project counts, error rate, recent activity, active runner jobs with steps, token budget entries. Guarded by AuthGuard + WorkspaceGuard + PermissionGuard. Includes DTO classes and unit tests. Task: MS-P2-001 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,7 @@ import { FederationModule } from "./federation/federation.module";
|
|||||||
import { CredentialsModule } from "./credentials/credentials.module";
|
import { CredentialsModule } from "./credentials/credentials.module";
|
||||||
import { MosaicTelemetryModule } from "./mosaic-telemetry";
|
import { MosaicTelemetryModule } from "./mosaic-telemetry";
|
||||||
import { SpeechModule } from "./speech/speech.module";
|
import { SpeechModule } from "./speech/speech.module";
|
||||||
|
import { DashboardModule } from "./dashboard/dashboard.module";
|
||||||
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -101,6 +102,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
|
|||||||
CredentialsModule,
|
CredentialsModule,
|
||||||
MosaicTelemetryModule,
|
MosaicTelemetryModule,
|
||||||
SpeechModule,
|
SpeechModule,
|
||||||
|
DashboardModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController, CsrfController],
|
controllers: [AppController, CsrfController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
143
apps/api/src/dashboard/dashboard.controller.spec.ts
Normal file
143
apps/api/src/dashboard/dashboard.controller.spec.ts
Normal file
@@ -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>(DashboardController);
|
||||||
|
service = module.get<DashboardService>(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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
31
apps/api/src/dashboard/dashboard.controller.ts
Normal file
31
apps/api/src/dashboard/dashboard.controller.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/api/src/dashboard/dashboard.module.ts
Normal file
13
apps/api/src/dashboard/dashboard.module.ts
Normal file
@@ -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 {}
|
||||||
187
apps/api/src/dashboard/dashboard.service.ts
Normal file
187
apps/api/src/dashboard/dashboard.service.ts
Normal file
@@ -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<DashboardSummaryDto> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
53
apps/api/src/dashboard/dto/dashboard-summary.dto.ts
Normal file
53
apps/api/src/dashboard/dto/dashboard-summary.dto.ts
Normal file
@@ -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[];
|
||||||
|
}
|
||||||
1
apps/api/src/dashboard/dto/index.ts
Normal file
1
apps/api/src/dashboard/dto/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./dashboard-summary.dto";
|
||||||
Reference in New Issue
Block a user