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:
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user