Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #460.
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
35
apps/api/src/dashboard/dashboard.controller.ts
Normal file
35
apps/api/src/dashboard/dashboard.controller.ts
Normal file
@@ -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<DashboardSummaryDto> {
|
||||||
|
if (!workspaceId) {
|
||||||
|
throw new BadRequestException("Workspace context required");
|
||||||
|
}
|
||||||
|
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 as Record<string, unknown> | 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
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!: Record<string, unknown> | 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[];
|
||||||
|
}
|
||||||
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";
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import DashboardPage from "./page";
|
import DashboardPage from "./page";
|
||||||
|
import { fetchDashboardSummary } from "@/lib/api/dashboard";
|
||||||
|
|
||||||
// Mock Phase 3 dashboard widgets
|
// Mock Phase 3 dashboard widgets
|
||||||
vi.mock("@/components/dashboard/DashboardMetrics", () => ({
|
vi.mock("@/components/dashboard/DashboardMetrics", () => ({
|
||||||
@@ -27,29 +28,85 @@ vi.mock("@/components/dashboard/TokenBudget", () => ({
|
|||||||
TokenBudget: (): React.JSX.Element => <div data-testid="token-budget">Token Budget</div>,
|
TokenBudget: (): React.JSX.Element => <div data-testid="token-budget">Token Budget</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 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 => {
|
describe("DashboardPage", (): void => {
|
||||||
it("should render the DashboardMetrics widget", (): void => {
|
beforeEach((): void => {
|
||||||
render(<DashboardPage />);
|
vi.clearAllMocks();
|
||||||
expect(screen.getByTestId("dashboard-metrics")).toBeInTheDocument();
|
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<void> => {
|
||||||
render(<DashboardPage />);
|
render(<DashboardPage />);
|
||||||
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<void> => {
|
||||||
render(<DashboardPage />);
|
render(<DashboardPage />);
|
||||||
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<void> => {
|
||||||
render(<DashboardPage />);
|
render(<DashboardPage />);
|
||||||
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<void> => {
|
||||||
render(<DashboardPage />);
|
render(<DashboardPage />);
|
||||||
expect(screen.getByTestId("token-budget")).toBeInTheDocument();
|
await waitFor((): void => {
|
||||||
|
expect(screen.getByTestId("activity-feed")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render the TokenBudget widget", async (): Promise<void> => {
|
||||||
|
render(<DashboardPage />);
|
||||||
|
await waitFor((): void => {
|
||||||
|
expect(screen.getByTestId("token-budget")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render error state when API fails", async (): Promise<void> => {
|
||||||
|
vi.mocked(fetchDashboardSummary).mockRejectedValueOnce(new Error("Network error"));
|
||||||
|
render(<DashboardPage />);
|
||||||
|
await waitFor((): void => {
|
||||||
|
expect(screen.getByText("Failed to load dashboard data")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,24 +1,102 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { DashboardMetrics } from "@/components/dashboard/DashboardMetrics";
|
import { DashboardMetrics } from "@/components/dashboard/DashboardMetrics";
|
||||||
import { OrchestratorSessions } from "@/components/dashboard/OrchestratorSessions";
|
import { OrchestratorSessions } from "@/components/dashboard/OrchestratorSessions";
|
||||||
import { QuickActions } from "@/components/dashboard/QuickActions";
|
import { QuickActions } from "@/components/dashboard/QuickActions";
|
||||||
import { ActivityFeed } from "@/components/dashboard/ActivityFeed";
|
import { ActivityFeed } from "@/components/dashboard/ActivityFeed";
|
||||||
import { TokenBudget } from "@/components/dashboard/TokenBudget";
|
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 {
|
export default function DashboardPage(): ReactElement {
|
||||||
|
const workspaceId = useWorkspaceId();
|
||||||
|
const [data, setData] = useState<DashboardSummaryResponse | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workspaceId) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsId = workspaceId;
|
||||||
|
let cancelled = false;
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
async function loadSummary(): Promise<void> {
|
||||||
|
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 (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
|
<DashboardMetrics />
|
||||||
|
<div className="dash-grid">
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 16, minWidth: 0 }}>
|
||||||
|
<OrchestratorSessions />
|
||||||
|
<QuickActions />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
|
<ActivityFeed />
|
||||||
|
<TokenBudget />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
<DashboardMetrics />
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "12px 16px",
|
||||||
|
marginBottom: 16,
|
||||||
|
background: "rgba(229,72,77,0.1)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
color: "var(--text)",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DashboardMetrics metrics={data?.metrics} />
|
||||||
<div className="dash-grid">
|
<div className="dash-grid">
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16, minWidth: 0 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 16, minWidth: 0 }}>
|
||||||
<OrchestratorSessions />
|
<OrchestratorSessions jobs={data?.activeJobs} />
|
||||||
<QuickActions />
|
<QuickActions />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
<ActivityFeed />
|
<ActivityFeed items={data?.recentActivity} />
|
||||||
<TokenBudget />
|
<TokenBudget budgets={data?.tokenBudget} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { Card, SectionHeader, Badge } from "@mosaic/ui";
|
import { Card, SectionHeader, Badge } from "@mosaic/ui";
|
||||||
|
import type { RecentActivity } from "@/lib/api/dashboard";
|
||||||
|
|
||||||
type BadgeVariantType =
|
type BadgeVariantType =
|
||||||
| "badge-amber"
|
| "badge-amber"
|
||||||
@@ -10,7 +11,7 @@ type BadgeVariantType =
|
|||||||
| "badge-purple"
|
| "badge-purple"
|
||||||
| "badge-pulse";
|
| "badge-pulse";
|
||||||
|
|
||||||
interface ActivityItem {
|
interface ActivityDisplayItem {
|
||||||
id: string;
|
id: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
iconBg: string;
|
iconBg: string;
|
||||||
@@ -18,82 +19,91 @@ interface ActivityItem {
|
|||||||
highlight: string;
|
highlight: string;
|
||||||
rest: string;
|
rest: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
badge?: {
|
badge?:
|
||||||
text: string;
|
| {
|
||||||
variant: BadgeVariantType;
|
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[] = [
|
/* ------------------------------------------------------------------ */
|
||||||
{
|
/* Components */
|
||||||
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",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface ActivityItemRowProps {
|
interface ActivityItemRowProps {
|
||||||
item: ActivityItem;
|
item: ActivityDisplayItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActivityItemRow({ item }: ActivityItemRowProps): ReactElement {
|
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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<SectionHeader title="Activity Feed" subtitle="Recent agent events" />
|
<SectionHeader title="Activity Feed" subtitle="Recent agent events" />
|
||||||
<div>
|
<div>
|
||||||
{activityItems.map((item) => (
|
{displayItems.length > 0 ? (
|
||||||
<ActivityItemRow key={item.id} item={item} />
|
displayItems.map((item) => <ActivityItemRow key={item.id} item={item} />)
|
||||||
))}
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "24px 0",
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No recent activity
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,45 +1,69 @@
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { MetricsStrip, type MetricCell } from "@mosaic/ui";
|
import { MetricsStrip, type MetricCell } from "@mosaic/ui";
|
||||||
|
import type { DashboardMetrics as DashboardMetricsData } from "@/lib/api/dashboard";
|
||||||
|
|
||||||
const cells: MetricCell[] = [
|
export interface DashboardMetricsProps {
|
||||||
{
|
metrics?: DashboardMetricsData | undefined;
|
||||||
label: "Active Agents",
|
}
|
||||||
value: "47",
|
|
||||||
color: "var(--ms-blue-400)",
|
function formatNumber(n: number): string {
|
||||||
trend: { direction: "up", text: "↑ +3 from yesterday" },
|
return n.toLocaleString();
|
||||||
},
|
}
|
||||||
{
|
|
||||||
label: "Tasks Completed",
|
function buildCells(metrics: DashboardMetricsData): MetricCell[] {
|
||||||
value: "1,284",
|
return [
|
||||||
color: "var(--ms-teal-400)",
|
{
|
||||||
trend: { direction: "up", text: "↑ +128 today" },
|
label: "Active Agents",
|
||||||
},
|
value: formatNumber(metrics.activeAgents),
|
||||||
{
|
color: "var(--ms-blue-400)",
|
||||||
label: "Avg Response Time",
|
trend: { direction: "neutral", text: "currently active" },
|
||||||
value: "2.4s",
|
},
|
||||||
color: "var(--ms-purple-400)",
|
{
|
||||||
trend: { direction: "down", text: "↓ -0.3s improved" },
|
label: "Tasks Completed",
|
||||||
},
|
value: formatNumber(metrics.tasksCompleted),
|
||||||
{
|
color: "var(--ms-teal-400)",
|
||||||
label: "Token Usage",
|
trend: { direction: "neutral", text: `of ${formatNumber(metrics.totalTasks)} total` },
|
||||||
value: "3.2M",
|
},
|
||||||
color: "var(--ms-amber-400)",
|
{
|
||||||
trend: { direction: "neutral", text: "78% of budget" },
|
label: "Total Tasks",
|
||||||
},
|
value: formatNumber(metrics.totalTasks),
|
||||||
{
|
color: "var(--ms-purple-400)",
|
||||||
label: "Error Rate",
|
trend: { direction: "neutral", text: "across workspace" },
|
||||||
value: "0.4%",
|
},
|
||||||
color: "var(--ms-red-400)",
|
{
|
||||||
trend: { direction: "down", text: "↓ -0.1% improved" },
|
label: "In Progress",
|
||||||
},
|
value: formatNumber(metrics.tasksInProgress),
|
||||||
{
|
color: "var(--ms-amber-400)",
|
||||||
label: "Active Projects",
|
trend: { direction: "neutral", text: "tasks running" },
|
||||||
value: "8",
|
},
|
||||||
color: "var(--ms-cyan-500)",
|
{
|
||||||
trend: { direction: "neutral", text: "2 deploying" },
|
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 <MetricsStrip cells={cells} />;
|
return <MetricsStrip cells={cells} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,22 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { Card, SectionHeader, Badge, Dot } from "@mosaic/ui";
|
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 {
|
interface AgentNode {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -10,7 +26,7 @@ interface AgentNode {
|
|||||||
avatarColor: string;
|
avatarColor: string;
|
||||||
name: string;
|
name: string;
|
||||||
task: string;
|
task: string;
|
||||||
status: "teal" | "blue" | "amber" | "red" | "muted";
|
status: DotVariant;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OrchestratorSession {
|
interface OrchestratorSession {
|
||||||
@@ -18,73 +34,94 @@ interface OrchestratorSession {
|
|||||||
orchId: string;
|
orchId: string;
|
||||||
name: string;
|
name: string;
|
||||||
badge: string;
|
badge: string;
|
||||||
badgeVariant:
|
badgeVariant: BadgeVariant;
|
||||||
| "badge-teal"
|
|
||||||
| "badge-amber"
|
|
||||||
| "badge-red"
|
|
||||||
| "badge-blue"
|
|
||||||
| "badge-muted"
|
|
||||||
| "badge-purple"
|
|
||||||
| "badge-pulse";
|
|
||||||
duration: string;
|
duration: string;
|
||||||
agents: AgentNode[];
|
agents: AgentNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessions: OrchestratorSession[] = [
|
export interface OrchestratorSessionsProps {
|
||||||
{
|
jobs?: ActiveJob[] | undefined;
|
||||||
id: "s1",
|
}
|
||||||
orchId: "ORCH-001",
|
|
||||||
name: "infra-refactor",
|
/* ------------------------------------------------------------------ */
|
||||||
badge: "running",
|
/* Mapping helpers */
|
||||||
badgeVariant: "badge-teal",
|
/* ------------------------------------------------------------------ */
|
||||||
duration: "2h 14m",
|
|
||||||
agents: [
|
const STEP_COLORS: string[] = [
|
||||||
{
|
"rgba(47,128,255,0.15)",
|
||||||
id: "a1",
|
"rgba(20,184,166,0.15)",
|
||||||
initials: "PL",
|
"rgba(245,158,11,0.15)",
|
||||||
avatarColor: "rgba(47,128,255,0.15)",
|
"rgba(139,92,246,0.15)",
|
||||||
name: "planner-agent",
|
"rgba(229,72,77,0.15)",
|
||||||
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",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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 {
|
interface AgentNodeItemProps {
|
||||||
agent: AgentNode;
|
agent: AgentNode;
|
||||||
}
|
}
|
||||||
@@ -182,7 +219,7 @@ function OrchCard({ session }: OrchCardProps): ReactElement {
|
|||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Dot variant="teal" />
|
<Dot variant={statusToDotVariant(session.badge)} />
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "var(--mono)",
|
fontFamily: "var(--mono)",
|
||||||
@@ -223,18 +260,48 @@ function OrchCard({ session }: OrchCardProps): ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OrchestratorSessions(): ReactElement {
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Main export */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export function OrchestratorSessions({ jobs }: OrchestratorSessionsProps): ReactElement {
|
||||||
|
const sessions = jobs ? jobs.map(mapJobToSession) : [];
|
||||||
|
const activeCount = jobs
|
||||||
|
? jobs.filter(
|
||||||
|
(j) => j.status.toLowerCase() === "running" || j.status.toLowerCase() === "active"
|
||||||
|
).length
|
||||||
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Active Orchestrator Sessions"
|
title="Active Orchestrator Sessions"
|
||||||
subtitle="3 of 8 projects running"
|
subtitle={
|
||||||
actions={<Badge variant="badge-teal">3 active</Badge>}
|
sessions.length > 0
|
||||||
|
? `${String(activeCount)} of ${String(sessions.length)} jobs running`
|
||||||
|
: "No active sessions"
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
sessions.length > 0 ? (
|
||||||
|
<Badge variant="badge-teal">{String(activeCount)} active</Badge>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
{sessions.map((session) => (
|
{sessions.length > 0 ? (
|
||||||
<OrchCard key={session.id} session={session} />
|
sessions.map((session) => <OrchCard key={session.id} session={session} />)
|
||||||
))}
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "24px 0",
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No active sessions
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,24 @@
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { Card, SectionHeader, ProgressBar, type ProgressBarVariant } from "@mosaic/ui";
|
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;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
usage: string;
|
usage: string;
|
||||||
@@ -9,39 +26,28 @@ interface ModelBudget {
|
|||||||
variant: ProgressBarVariant;
|
variant: ProgressBarVariant;
|
||||||
}
|
}
|
||||||
|
|
||||||
const models: ModelBudget[] = [
|
function mapBudgetToDisplay(entry: TokenBudgetEntry, index: number): ModelBudgetDisplay {
|
||||||
{
|
const percent = entry.limit > 0 ? Math.round((entry.used / entry.limit) * 100) : 0;
|
||||||
id: "sonnet",
|
const usage =
|
||||||
label: "claude-3-5-sonnet",
|
entry.limit > 0
|
||||||
usage: "2.1M / 3M",
|
? `${formatTokenCount(entry.used)} / ${formatTokenCount(entry.limit)}`
|
||||||
value: 70,
|
: "unlimited";
|
||||||
variant: "blue",
|
|
||||||
},
|
return {
|
||||||
{
|
id: entry.model,
|
||||||
id: "haiku",
|
label: entry.model,
|
||||||
label: "claude-3-haiku",
|
usage,
|
||||||
usage: "890K / 5M",
|
value: percent,
|
||||||
value: 18,
|
variant: VARIANT_CYCLE[index % VARIANT_CYCLE.length] ?? "blue",
|
||||||
variant: "teal",
|
};
|
||||||
},
|
}
|
||||||
{
|
|
||||||
id: "gpt4o",
|
/* ------------------------------------------------------------------ */
|
||||||
label: "gpt-4o",
|
/* Components */
|
||||||
usage: "320K / 1M",
|
/* ------------------------------------------------------------------ */
|
||||||
value: 32,
|
|
||||||
variant: "purple",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "llama",
|
|
||||||
label: "local/llama-3.3",
|
|
||||||
usage: "unlimited",
|
|
||||||
value: 55,
|
|
||||||
variant: "amber",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface ModelRowProps {
|
interface ModelRowProps {
|
||||||
model: ModelBudget;
|
model: ModelBudgetDisplay;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModelRow({ model }: ModelRowProps): ReactElement {
|
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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<SectionHeader title="Token Budget" subtitle="Usage by model" />
|
<SectionHeader title="Token Budget" subtitle="Usage by model" />
|
||||||
<div>
|
<div>
|
||||||
{models.map((model) => (
|
{displayModels.length > 0 ? (
|
||||||
<ModelRow key={model.id} model={model} />
|
displayModels.map((model) => <ModelRow key={model.id} model={model} />)
|
||||||
))}
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "24px 0",
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
color: "var(--muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No budget data
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
74
apps/web/src/lib/api/dashboard.ts
Normal file
74
apps/web/src/lib/api/dashboard.ts
Normal file
@@ -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<string, unknown> | 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<DashboardSummaryResponse> {
|
||||||
|
return apiGet<DashboardSummaryResponse>("/api/dashboard/summary", workspaceId ?? undefined);
|
||||||
|
}
|
||||||
@@ -13,3 +13,4 @@ export * from "./domains";
|
|||||||
export * from "./teams";
|
export * from "./teams";
|
||||||
export * from "./personalities";
|
export * from "./personalities";
|
||||||
export * from "./telemetry";
|
export * from "./telemetry";
|
||||||
|
export * from "./dashboard";
|
||||||
|
|||||||
@@ -8,10 +8,10 @@
|
|||||||
**ID:** mosaic-stack-go-live-mvp-20260222
|
**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.
|
**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
|
**Phase:** Execution
|
||||||
**Current Milestone:** phase-1 (Dashboard Polish + Theming)
|
**Current Milestone:** phase-2 (Task Ingestion Pipeline)
|
||||||
**Progress:** 0 / 4 milestones
|
**Progress:** 1 / 4 milestones
|
||||||
**Status:** active
|
**Status:** active
|
||||||
**Last Updated:** 2026-02-22 23:51 UTC
|
**Last Updated:** 2026-02-23 00:20 UTC
|
||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
@@ -34,12 +34,12 @@ This mission continues from that foundation.
|
|||||||
|
|
||||||
## Milestones
|
## Milestones
|
||||||
|
|
||||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||||
| --- | ------- | -------------------------- | ----------- | ------------------- | ----- | ---------- | --------- |
|
| --- | ------- | -------------------------- | ----------- | ---------------------- | ----- | ---------- | ---------- |
|
||||||
| 1 | phase-1 | Dashboard Polish + Theming | in-progress | feat/phase-1-polish | #457 | 2026-02-22 | — |
|
| 1 | phase-1 | Dashboard Polish + Theming | completed | feat/phase-1-polish | #457 | 2026-02-22 | 2026-02-23 |
|
||||||
| 2 | phase-2 | Task Ingestion Pipeline | pending | — | — | — | — |
|
| 2 | phase-2 | Task Ingestion Pipeline | in-progress | feat/phase-2-ingestion | #459 | 2026-02-23 | — |
|
||||||
| 3 | phase-3 | Agent Cycle Visibility | pending | — | — | — | — |
|
| 3 | phase-3 | Agent Cycle Visibility | pending | — | — | — | — |
|
||||||
| 4 | phase-4 | Deploy + Smoke Test | pending | — | — | — | — |
|
| 4 | phase-4 | Deploy + Smoke Test | pending | — | — | — | — |
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
> Single-writer: orchestrator only. Workers read but never modify.
|
> Single-writer: orchestrator only. Workers read but never modify.
|
||||||
|
|
||||||
| id | status | milestone | description | pr | notes |
|
| 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-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 | — | issue #457, commit 8fa0b30, 5 files deleted |
|
| 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 | — | issue #457, commit d97a98b, review: approve (0 blockers) |
|
| 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) | — | issue #457, all gates green (forced, no cache) |
|
| 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 |
|
||||||
|
|||||||
@@ -33,9 +33,18 @@ Estimated total: ~50K tokens
|
|||||||
|
|
||||||
## Session Log
|
## Session Log
|
||||||
|
|
||||||
| Session | Date | Milestone | Tasks Done | Outcome |
|
| Session | Date | Milestone | Tasks Done | Outcome |
|
||||||
| ------- | ---------- | --------- | ---------- | ------------------------------------------- |
|
| ------- | ---------- | --------- | ---------- | ------------------------------------------------------ |
|
||||||
| S1 | 2026-02-22 | phase-1 | 0/4 | In progress — bootstrap complete, executing |
|
| 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
|
## Open Questions
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user