Compare commits
7 Commits
v0.0.15
...
d5a0696e67
| Author | SHA1 | Date | |
|---|---|---|---|
| d5a0696e67 | |||
| 5d3045ab4f | |||
| 63178df643 | |||
| 7581d26567 | |||
| 07f5225a76 | |||
| 7c55464d54 | |||
| ea1620fa7a |
51
.mosaic/orchestrator/mission.json
Normal file
51
.mosaic/orchestrator/mission.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"mission_id": "mosaic-stack-go-live-mvp-20260222",
|
||||
"name": "Mosaic Stack Go-Live MVP",
|
||||
"description": "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.",
|
||||
"project_path": "/home/jwoltje/src/mosaic-stack",
|
||||
"created_at": "2026-02-22T23:35:51Z",
|
||||
"status": "active",
|
||||
"task_prefix": "MS",
|
||||
"quality_gates": "pnpm lint && pnpm typecheck && pnpm test",
|
||||
"milestone_version": "0.0.16",
|
||||
"milestones": [
|
||||
{
|
||||
"id": "phase-1",
|
||||
"name": "Dashboard Polish + Theming",
|
||||
"status": "pending",
|
||||
"branch": "dashboard-polish-theming",
|
||||
"issue_ref": "",
|
||||
"started_at": "",
|
||||
"completed_at": ""
|
||||
},
|
||||
{
|
||||
"id": "phase-2",
|
||||
"name": "Task Ingestion Pipeline",
|
||||
"status": "pending",
|
||||
"branch": "task-ingestion-pipeline",
|
||||
"issue_ref": "",
|
||||
"started_at": "",
|
||||
"completed_at": ""
|
||||
},
|
||||
{
|
||||
"id": "phase-3",
|
||||
"name": "Agent Cycle Visibility",
|
||||
"status": "pending",
|
||||
"branch": "agent-cycle-visibility",
|
||||
"issue_ref": "",
|
||||
"started_at": "",
|
||||
"completed_at": ""
|
||||
},
|
||||
{
|
||||
"id": "phase-4",
|
||||
"name": "Deploy + Smoke Test",
|
||||
"status": "pending",
|
||||
"branch": "deploy-smoke-test",
|
||||
"issue_ref": "",
|
||||
"started_at": "",
|
||||
"completed_at": ""
|
||||
}
|
||||
],
|
||||
"sessions": []
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
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";
|
||||
@@ -4,6 +4,7 @@ import { RunnerJobsService } from "./runner-jobs.service";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { BullMqModule } from "../bullmq/bullmq.module";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { WebSocketModule } from "../websocket/websocket.module";
|
||||
|
||||
/**
|
||||
* Runner Jobs Module
|
||||
@@ -12,7 +13,7 @@ import { AuthModule } from "../auth/auth.module";
|
||||
* for asynchronous job processing.
|
||||
*/
|
||||
@Module({
|
||||
imports: [PrismaModule, BullMqModule, AuthModule],
|
||||
imports: [PrismaModule, BullMqModule, AuthModule, WebSocketModule],
|
||||
controllers: [RunnerJobsController],
|
||||
providers: [RunnerJobsService],
|
||||
exports: [RunnerJobsService],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { RunnerJobsService } from "./runner-jobs.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { BullMqService } from "../bullmq/bullmq.service";
|
||||
import { WebSocketGateway } from "../websocket/websocket.gateway";
|
||||
import { RunnerJobStatus } from "@prisma/client";
|
||||
import { ConflictException, BadRequestException } from "@nestjs/common";
|
||||
|
||||
@@ -19,6 +20,12 @@ describe("RunnerJobsService - Concurrency", () => {
|
||||
getQueue: vi.fn(),
|
||||
};
|
||||
|
||||
const mockWebSocketGateway = {
|
||||
emitJobCreated: vi.fn(),
|
||||
emitJobStatusChanged: vi.fn(),
|
||||
emitJobProgress: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
@@ -37,6 +44,10 @@ describe("RunnerJobsService - Concurrency", () => {
|
||||
provide: BullMqService,
|
||||
useValue: mockBullMqService,
|
||||
},
|
||||
{
|
||||
provide: WebSocketGateway,
|
||||
useValue: mockWebSocketGateway,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { RunnerJobsService } from "./runner-jobs.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { BullMqService } from "../bullmq/bullmq.service";
|
||||
import { WebSocketGateway } from "../websocket/websocket.gateway";
|
||||
import { RunnerJobStatus } from "@prisma/client";
|
||||
import { NotFoundException, BadRequestException } from "@nestjs/common";
|
||||
import { CreateJobDto, QueryJobsDto } from "./dto";
|
||||
@@ -32,6 +33,12 @@ describe("RunnerJobsService", () => {
|
||||
getQueue: vi.fn(),
|
||||
};
|
||||
|
||||
const mockWebSocketGateway = {
|
||||
emitJobCreated: vi.fn(),
|
||||
emitJobStatusChanged: vi.fn(),
|
||||
emitJobProgress: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
@@ -44,6 +51,10 @@ describe("RunnerJobsService", () => {
|
||||
provide: BullMqService,
|
||||
useValue: mockBullMqService,
|
||||
},
|
||||
{
|
||||
provide: WebSocketGateway,
|
||||
useValue: mockWebSocketGateway,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Prisma, RunnerJobStatus } from "@prisma/client";
|
||||
import { Response } from "express";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { BullMqService } from "../bullmq/bullmq.service";
|
||||
import { WebSocketGateway } from "../websocket/websocket.gateway";
|
||||
import { QUEUE_NAMES } from "../bullmq/queues";
|
||||
import { ConcurrentUpdateException } from "../common/exceptions/concurrent-update.exception";
|
||||
import type { CreateJobDto, QueryJobsDto } from "./dto";
|
||||
@@ -14,7 +15,8 @@ import type { CreateJobDto, QueryJobsDto } from "./dto";
|
||||
export class RunnerJobsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly bullMq: BullMqService
|
||||
private readonly bullMq: BullMqService,
|
||||
private readonly wsGateway: WebSocketGateway
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -56,6 +58,8 @@ export class RunnerJobsService {
|
||||
{ priority }
|
||||
);
|
||||
|
||||
this.wsGateway.emitJobCreated(workspaceId, job);
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
@@ -194,6 +198,13 @@ export class RunnerJobsService {
|
||||
throw new NotFoundException(`RunnerJob with ID ${id} not found after cancel`);
|
||||
}
|
||||
|
||||
this.wsGateway.emitJobStatusChanged(workspaceId, id, {
|
||||
id,
|
||||
workspaceId,
|
||||
status: job.status,
|
||||
previousStatus: existingJob.status,
|
||||
});
|
||||
|
||||
return job;
|
||||
});
|
||||
}
|
||||
@@ -248,6 +259,8 @@ export class RunnerJobsService {
|
||||
{ priority: existingJob.priority }
|
||||
);
|
||||
|
||||
this.wsGateway.emitJobCreated(workspaceId, newJob);
|
||||
|
||||
return newJob;
|
||||
}
|
||||
|
||||
@@ -530,6 +543,13 @@ export class RunnerJobsService {
|
||||
throw new NotFoundException(`RunnerJob with ID ${id} not found after update`);
|
||||
}
|
||||
|
||||
this.wsGateway.emitJobStatusChanged(workspaceId, id, {
|
||||
id,
|
||||
workspaceId,
|
||||
status: updatedJob.status,
|
||||
previousStatus: existingJob.status,
|
||||
});
|
||||
|
||||
return updatedJob;
|
||||
});
|
||||
}
|
||||
@@ -606,6 +626,12 @@ export class RunnerJobsService {
|
||||
throw new NotFoundException(`RunnerJob with ID ${id} not found after update`);
|
||||
}
|
||||
|
||||
this.wsGateway.emitJobProgress(workspaceId, id, {
|
||||
id,
|
||||
workspaceId,
|
||||
progressPercent: updatedJob.progressPercent,
|
||||
});
|
||||
|
||||
return updatedJob;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,85 +1,112 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import DashboardPage from "./page";
|
||||
import { fetchDashboardSummary } from "@/lib/api/dashboard";
|
||||
|
||||
// Mock dashboard widgets
|
||||
vi.mock("@/components/dashboard/RecentTasksWidget", () => ({
|
||||
RecentTasksWidget: ({
|
||||
tasks,
|
||||
isLoading,
|
||||
}: {
|
||||
tasks: unknown[];
|
||||
isLoading: boolean;
|
||||
}): React.JSX.Element => (
|
||||
<div data-testid="recent-tasks">
|
||||
{isLoading ? "Loading tasks" : `${String(tasks.length)} tasks`}
|
||||
</div>
|
||||
// Mock Phase 3 dashboard widgets
|
||||
vi.mock("@/components/dashboard/DashboardMetrics", () => ({
|
||||
DashboardMetrics: (): React.JSX.Element => (
|
||||
<div data-testid="dashboard-metrics">Dashboard Metrics</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/dashboard/UpcomingEventsWidget", () => ({
|
||||
UpcomingEventsWidget: ({
|
||||
events,
|
||||
isLoading,
|
||||
}: {
|
||||
events: unknown[];
|
||||
isLoading: boolean;
|
||||
}): React.JSX.Element => (
|
||||
<div data-testid="upcoming-events">
|
||||
{isLoading ? "Loading events" : `${String(events.length)} events`}
|
||||
</div>
|
||||
vi.mock("@/components/dashboard/OrchestratorSessions", () => ({
|
||||
OrchestratorSessions: (): React.JSX.Element => (
|
||||
<div data-testid="orchestrator-sessions">Orchestrator Sessions</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/dashboard/QuickCaptureWidget", () => ({
|
||||
QuickCaptureWidget: (): React.JSX.Element => <div data-testid="quick-capture">Quick Capture</div>,
|
||||
vi.mock("@/components/dashboard/QuickActions", () => ({
|
||||
QuickActions: (): React.JSX.Element => <div data-testid="quick-actions">Quick Actions</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/dashboard/DomainOverviewWidget", () => ({
|
||||
DomainOverviewWidget: ({
|
||||
tasks,
|
||||
isLoading,
|
||||
}: {
|
||||
tasks: unknown[];
|
||||
isLoading: boolean;
|
||||
}): React.JSX.Element => (
|
||||
<div data-testid="domain-overview">
|
||||
{isLoading ? "Loading overview" : `${String(tasks.length)} tasks overview`}
|
||||
</div>
|
||||
),
|
||||
vi.mock("@/components/dashboard/ActivityFeed", () => ({
|
||||
ActivityFeed: (): React.JSX.Element => <div data-testid="activity-feed">Activity Feed</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/dashboard/TokenBudget", () => ({
|
||||
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 => {
|
||||
it("should render the page title", (): void => {
|
||||
render(<DashboardPage />);
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Dashboard");
|
||||
});
|
||||
|
||||
it("should show loading state initially", (): void => {
|
||||
render(<DashboardPage />);
|
||||
expect(screen.getByTestId("recent-tasks")).toHaveTextContent("Loading tasks");
|
||||
expect(screen.getByTestId("upcoming-events")).toHaveTextContent("Loading events");
|
||||
expect(screen.getByTestId("domain-overview")).toHaveTextContent("Loading overview");
|
||||
});
|
||||
|
||||
it("should render all widgets with data after loading", async (): Promise<void> => {
|
||||
render(<DashboardPage />);
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("recent-tasks")).toHaveTextContent("4 tasks");
|
||||
expect(screen.getByTestId("upcoming-events")).toHaveTextContent("3 events");
|
||||
expect(screen.getByTestId("domain-overview")).toHaveTextContent("4 tasks overview");
|
||||
expect(screen.getByTestId("quick-capture")).toBeInTheDocument();
|
||||
beforeEach((): void => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fetchDashboardSummary).mockResolvedValue({
|
||||
metrics: {
|
||||
activeAgents: 5,
|
||||
tasksCompleted: 42,
|
||||
totalTasks: 100,
|
||||
tasksInProgress: 10,
|
||||
activeProjects: 3,
|
||||
errorRate: 0.5,
|
||||
},
|
||||
recentActivity: [],
|
||||
activeJobs: [],
|
||||
tokenBudget: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("should have proper layout structure", (): void => {
|
||||
const { container } = render(<DashboardPage />);
|
||||
const main = container.querySelector("main");
|
||||
expect(main).toBeInTheDocument();
|
||||
it("should render the DashboardMetrics widget", async (): Promise<void> => {
|
||||
render(<DashboardPage />);
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("dashboard-metrics")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the welcome subtitle", (): void => {
|
||||
it("should render the OrchestratorSessions widget", async (): Promise<void> => {
|
||||
render(<DashboardPage />);
|
||||
expect(screen.getByText(/Welcome back/)).toBeInTheDocument();
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("orchestrator-sessions")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the QuickActions widget", async (): Promise<void> => {
|
||||
render(<DashboardPage />);
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("quick-actions")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the ActivityFeed widget", async (): Promise<void> => {
|
||||
render(<DashboardPage />);
|
||||
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,30 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import { DashboardMetrics } from "@/components/dashboard/DashboardMetrics";
|
||||
import { OrchestratorSessions } from "@/components/dashboard/OrchestratorSessions";
|
||||
import { QuickActions } from "@/components/dashboard/QuickActions";
|
||||
import { ActivityFeed } from "@/components/dashboard/ActivityFeed";
|
||||
import { TokenBudget } from "@/components/dashboard/TokenBudget";
|
||||
import { fetchDashboardSummary } from "@/lib/api/dashboard";
|
||||
import type { DashboardSummaryResponse } from "@/lib/api/dashboard";
|
||||
import { useWorkspaceId } from "@/lib/hooks";
|
||||
|
||||
export default function DashboardPage(): ReactElement {
|
||||
const workspaceId = useWorkspaceId();
|
||||
const [data, setData] = useState<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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId) return;
|
||||
|
||||
let cancelled = false;
|
||||
const wsId = workspaceId;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
fetchDashboardSummary(wsId)
|
||||
.then((summary) => {
|
||||
if (!cancelled) setData(summary);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error("[Dashboard] Refresh failed:", err);
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
return (): void => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<DashboardMetrics />
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 320px",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{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 style={{ display: "flex", flexDirection: "column", gap: 16, minWidth: 0 }}>
|
||||
<OrchestratorSessions />
|
||||
<OrchestratorSessions jobs={data?.activeJobs} />
|
||||
<QuickActions />
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<ActivityFeed />
|
||||
<TokenBudget />
|
||||
<ActivityFeed items={data?.recentActivity} />
|
||||
<TokenBudget budgets={data?.tokenBudget} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -765,6 +765,62 @@ body::before {
|
||||
animation: scaleIn 0.1s ease-out;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Dashboard Layout — Responsive Grids
|
||||
----------------------------------------------------------------------------- */
|
||||
.metrics-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--ms-cols, 6), 1fr);
|
||||
gap: 0;
|
||||
border-radius: var(--r-lg);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.metric-cell {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.metric-cell:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.metrics-strip {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.metric-cell:nth-child(3n + 1) {
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.metrics-strip {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.metric-cell:nth-child(3n + 1) {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.metric-cell:nth-child(2n + 1) {
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
|
||||
.dash-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dash-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Responsive Typography Adjustments
|
||||
----------------------------------------------------------------------------- */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { Card, SectionHeader, Badge } from "@mosaic/ui";
|
||||
import type { RecentActivity } from "@/lib/api/dashboard";
|
||||
|
||||
type BadgeVariantType =
|
||||
| "badge-amber"
|
||||
@@ -10,7 +11,7 @@ type BadgeVariantType =
|
||||
| "badge-purple"
|
||||
| "badge-pulse";
|
||||
|
||||
interface ActivityItem {
|
||||
interface ActivityDisplayItem {
|
||||
id: string;
|
||||
icon: string;
|
||||
iconBg: string;
|
||||
@@ -18,82 +19,91 @@ interface ActivityItem {
|
||||
highlight: string;
|
||||
rest: string;
|
||||
timestamp: string;
|
||||
badge?: {
|
||||
text: string;
|
||||
variant: BadgeVariantType;
|
||||
badge?:
|
||||
| {
|
||||
text: string;
|
||||
variant: BadgeVariantType;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export interface ActivityFeedProps {
|
||||
items?: RecentActivity[] | undefined;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mapping helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function getIconForAction(action: string): { icon: string; iconBg: string } {
|
||||
const lower = action.toLowerCase();
|
||||
if (lower.includes("complet") || lower.includes("finish") || lower.includes("success")) {
|
||||
return { icon: "\u2713", iconBg: "rgba(20,184,166,0.15)" };
|
||||
}
|
||||
if (lower.includes("fail") || lower.includes("error")) {
|
||||
return { icon: "\u2717", iconBg: "rgba(229,72,77,0.15)" };
|
||||
}
|
||||
if (lower.includes("warn") || lower.includes("limit")) {
|
||||
return { icon: "\u26A0", iconBg: "rgba(245,158,11,0.15)" };
|
||||
}
|
||||
if (lower.includes("start") || lower.includes("creat")) {
|
||||
return { icon: "\u2191", iconBg: "rgba(47,128,255,0.15)" };
|
||||
}
|
||||
if (lower.includes("update") || lower.includes("modif")) {
|
||||
return { icon: "\u21BB", iconBg: "rgba(139,92,246,0.15)" };
|
||||
}
|
||||
return { icon: "\u2022", iconBg: "rgba(100,116,139,0.15)" };
|
||||
}
|
||||
|
||||
function getBadgeForAction(action: string): ActivityDisplayItem["badge"] {
|
||||
const lower = action.toLowerCase();
|
||||
if (lower.includes("fail") || lower.includes("error")) {
|
||||
return { text: "error", variant: "badge-red" };
|
||||
}
|
||||
if (lower.includes("warn") || lower.includes("limit")) {
|
||||
return { text: "warn", variant: "badge-amber" };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatRelativeTime(isoDate: string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(isoDate).getTime();
|
||||
const diffMs = now - then;
|
||||
|
||||
if (Number.isNaN(diffMs) || diffMs < 0) return "just now";
|
||||
|
||||
const minutes = Math.floor(diffMs / 60_000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${String(minutes)}m ago`;
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${String(hours)}h ago`;
|
||||
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${String(days)}d ago`;
|
||||
}
|
||||
|
||||
function mapActivityToDisplay(activity: RecentActivity): ActivityDisplayItem {
|
||||
const { icon, iconBg } = getIconForAction(activity.action);
|
||||
return {
|
||||
id: activity.id,
|
||||
icon,
|
||||
iconBg,
|
||||
title: "",
|
||||
highlight: activity.entityType,
|
||||
rest: ` ${activity.action} (${activity.entityId})`,
|
||||
timestamp: formatRelativeTime(activity.createdAt),
|
||||
badge: getBadgeForAction(activity.action),
|
||||
};
|
||||
}
|
||||
|
||||
const activityItems: ActivityItem[] = [
|
||||
{
|
||||
id: "act-1",
|
||||
icon: "✓",
|
||||
iconBg: "rgba(20,184,166,0.15)",
|
||||
title: "",
|
||||
highlight: "planner-agent",
|
||||
rest: " completed task analysis for infra-refactor",
|
||||
timestamp: "2m ago",
|
||||
},
|
||||
{
|
||||
id: "act-2",
|
||||
icon: "⚠",
|
||||
iconBg: "rgba(245,158,11,0.15)",
|
||||
title: "",
|
||||
highlight: "executor-agent",
|
||||
rest: " hit rate limit on Terraform API",
|
||||
timestamp: "5m ago",
|
||||
badge: { text: "warn", variant: "badge-amber" },
|
||||
},
|
||||
{
|
||||
id: "act-3",
|
||||
icon: "↑",
|
||||
iconBg: "rgba(47,128,255,0.15)",
|
||||
title: "",
|
||||
highlight: "ORCH-002",
|
||||
rest: " session started for api-v3-migration",
|
||||
timestamp: "12m ago",
|
||||
},
|
||||
{
|
||||
id: "act-4",
|
||||
icon: "✗",
|
||||
iconBg: "rgba(229,72,77,0.15)",
|
||||
title: "",
|
||||
highlight: "migrator-agent",
|
||||
rest: " failed to connect to staging database",
|
||||
timestamp: "18m ago",
|
||||
badge: { text: "error", variant: "badge-red" },
|
||||
},
|
||||
{
|
||||
id: "act-5",
|
||||
icon: "✓",
|
||||
iconBg: "rgba(20,184,166,0.15)",
|
||||
title: "",
|
||||
highlight: "reviewer-agent",
|
||||
rest: " approved PR #214 in infra-refactor",
|
||||
timestamp: "34m ago",
|
||||
},
|
||||
{
|
||||
id: "act-6",
|
||||
icon: "⟳",
|
||||
iconBg: "rgba(139,92,246,0.15)",
|
||||
title: "Token budget reset for ",
|
||||
highlight: "gpt-4o",
|
||||
rest: " model",
|
||||
timestamp: "1h ago",
|
||||
},
|
||||
{
|
||||
id: "act-7",
|
||||
icon: "★",
|
||||
iconBg: "rgba(20,184,166,0.15)",
|
||||
title: "Project ",
|
||||
highlight: "data-pipeline",
|
||||
rest: " marked as completed",
|
||||
timestamp: "2h ago",
|
||||
},
|
||||
];
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Components */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface ActivityItemRowProps {
|
||||
item: ActivityItem;
|
||||
item: ActivityDisplayItem;
|
||||
}
|
||||
|
||||
function ActivityItemRow({ item }: ActivityItemRowProps): ReactElement {
|
||||
@@ -102,8 +112,8 @@ function ActivityItemRow({ item }: ActivityItemRowProps): ReactElement {
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 10,
|
||||
padding: "8px 0",
|
||||
gap: 12,
|
||||
padding: "10px 0",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
@@ -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 (
|
||||
<Card>
|
||||
<SectionHeader title="Activity Feed" subtitle="Recent agent events" />
|
||||
<div>
|
||||
{activityItems.map((item) => (
|
||||
<ActivityItemRow key={item.id} item={item} />
|
||||
))}
|
||||
{displayItems.length > 0 ? (
|
||||
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>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,45 +1,69 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { MetricsStrip, type MetricCell } from "@mosaic/ui";
|
||||
import type { DashboardMetrics as DashboardMetricsData } from "@/lib/api/dashboard";
|
||||
|
||||
const cells: MetricCell[] = [
|
||||
{
|
||||
label: "Active Agents",
|
||||
value: "47",
|
||||
color: "var(--ms-blue-400)",
|
||||
trend: { direction: "up", text: "↑ +3 from yesterday" },
|
||||
},
|
||||
{
|
||||
label: "Tasks Completed",
|
||||
value: "1,284",
|
||||
color: "var(--ms-teal-400)",
|
||||
trend: { direction: "up", text: "↑ +128 today" },
|
||||
},
|
||||
{
|
||||
label: "Avg Response Time",
|
||||
value: "2.4s",
|
||||
color: "var(--ms-purple-400)",
|
||||
trend: { direction: "down", text: "↓ -0.3s improved" },
|
||||
},
|
||||
{
|
||||
label: "Token Usage",
|
||||
value: "3.2M",
|
||||
color: "var(--ms-amber-400)",
|
||||
trend: { direction: "neutral", text: "78% of budget" },
|
||||
},
|
||||
{
|
||||
label: "Error Rate",
|
||||
value: "0.4%",
|
||||
color: "var(--ms-red-400)",
|
||||
trend: { direction: "down", text: "↓ -0.1% improved" },
|
||||
},
|
||||
{
|
||||
label: "Active Projects",
|
||||
value: "8",
|
||||
color: "var(--ms-cyan-500)",
|
||||
trend: { direction: "neutral", text: "2 deploying" },
|
||||
},
|
||||
export interface DashboardMetricsProps {
|
||||
metrics?: DashboardMetricsData | undefined;
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
function buildCells(metrics: DashboardMetricsData): MetricCell[] {
|
||||
return [
|
||||
{
|
||||
label: "Active Agents",
|
||||
value: formatNumber(metrics.activeAgents),
|
||||
color: "var(--ms-blue-400)",
|
||||
trend: { direction: "neutral", text: "currently active" },
|
||||
},
|
||||
{
|
||||
label: "Tasks Completed",
|
||||
value: formatNumber(metrics.tasksCompleted),
|
||||
color: "var(--ms-teal-400)",
|
||||
trend: { direction: "neutral", text: `of ${formatNumber(metrics.totalTasks)} total` },
|
||||
},
|
||||
{
|
||||
label: "Total Tasks",
|
||||
value: formatNumber(metrics.totalTasks),
|
||||
color: "var(--ms-purple-400)",
|
||||
trend: { direction: "neutral", text: "across workspace" },
|
||||
},
|
||||
{
|
||||
label: "In Progress",
|
||||
value: formatNumber(metrics.tasksInProgress),
|
||||
color: "var(--ms-amber-400)",
|
||||
trend: { direction: "neutral", text: "tasks running" },
|
||||
},
|
||||
{
|
||||
label: "Error Rate",
|
||||
value: `${String(metrics.errorRate)}%`,
|
||||
color: "var(--ms-red-400)",
|
||||
trend: {
|
||||
direction: metrics.errorRate > 1 ? "up" : "down",
|
||||
text: metrics.errorRate > 1 ? "above threshold" : "within threshold",
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Active Projects",
|
||||
value: formatNumber(metrics.activeProjects),
|
||||
color: "var(--ms-cyan-500)",
|
||||
trend: { direction: "neutral", text: "in workspace" },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const EMPTY_CELLS: MetricCell[] = [
|
||||
{ label: "Active Agents", value: "0", color: "var(--ms-blue-400)" },
|
||||
{ label: "Tasks Completed", value: "0", color: "var(--ms-teal-400)" },
|
||||
{ label: "Total Tasks", value: "0", color: "var(--ms-purple-400)" },
|
||||
{ label: "In Progress", value: "0", color: "var(--ms-amber-400)" },
|
||||
{ label: "Error Rate", value: "0%", color: "var(--ms-red-400)" },
|
||||
{ label: "Active Projects", value: "0", color: "var(--ms-cyan-500)" },
|
||||
];
|
||||
|
||||
export function DashboardMetrics(): ReactElement {
|
||||
export function DashboardMetrics({ metrics }: DashboardMetricsProps): ReactElement {
|
||||
const cells = metrics ? buildCells(metrics) : EMPTY_CELLS;
|
||||
return <MetricsStrip cells={cells} />;
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import type { Task } from "@mosaic/shared";
|
||||
import { TaskStatus, TaskPriority } from "@mosaic/shared";
|
||||
|
||||
interface DomainOverviewWidgetProps {
|
||||
tasks: Task[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function DomainOverviewWidget({
|
||||
tasks,
|
||||
isLoading,
|
||||
}: DomainOverviewWidgetProps): React.JSX.Element {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex justify-center items-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900"></div>
|
||||
<span className="ml-3 text-gray-600">Loading overview...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stats = {
|
||||
total: tasks.length,
|
||||
inProgress: tasks.filter((t) => t.status === TaskStatus.IN_PROGRESS).length,
|
||||
completed: tasks.filter((t) => t.status === TaskStatus.COMPLETED).length,
|
||||
highPriority: tasks.filter((t) => t.priority === TaskPriority.HIGH).length,
|
||||
};
|
||||
|
||||
const StatCard = ({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}): React.JSX.Element => (
|
||||
<div className={`p-4 rounded-lg bg-gradient-to-br ${color}`}>
|
||||
<div className="text-3xl font-bold text-white mb-1">{value}</div>
|
||||
<div className="text-sm text-white/90">{label}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Domain Overview</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<StatCard label="Total Tasks" value={stats.total} color="from-blue-500 to-blue-600" />
|
||||
<StatCard
|
||||
label="In Progress"
|
||||
value={stats.inProgress}
|
||||
color="from-green-500 to-green-600"
|
||||
/>
|
||||
<StatCard label="Completed" value={stats.completed} color="from-purple-500 to-purple-600" />
|
||||
<StatCard
|
||||
label="High Priority"
|
||||
value={stats.highPriority}
|
||||
color="from-red-500 to-red-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,22 @@
|
||||
import { useState } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import { Card, SectionHeader, Badge, Dot } from "@mosaic/ui";
|
||||
import type { ActiveJob } from "@/lib/api/dashboard";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Internal display types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type DotVariant = "teal" | "blue" | "amber" | "red" | "muted";
|
||||
|
||||
type BadgeVariant =
|
||||
| "badge-teal"
|
||||
| "badge-amber"
|
||||
| "badge-red"
|
||||
| "badge-blue"
|
||||
| "badge-muted"
|
||||
| "badge-purple"
|
||||
| "badge-pulse";
|
||||
|
||||
interface AgentNode {
|
||||
id: string;
|
||||
@@ -10,7 +26,8 @@ interface AgentNode {
|
||||
avatarColor: string;
|
||||
name: string;
|
||||
task: string;
|
||||
status: "teal" | "blue" | "amber" | "red" | "muted";
|
||||
status: DotVariant;
|
||||
statusLabel: string;
|
||||
}
|
||||
|
||||
interface OrchestratorSession {
|
||||
@@ -18,73 +35,97 @@ interface OrchestratorSession {
|
||||
orchId: string;
|
||||
name: string;
|
||||
badge: string;
|
||||
badgeVariant:
|
||||
| "badge-teal"
|
||||
| "badge-amber"
|
||||
| "badge-red"
|
||||
| "badge-blue"
|
||||
| "badge-muted"
|
||||
| "badge-purple"
|
||||
| "badge-pulse";
|
||||
badgeVariant: BadgeVariant;
|
||||
duration: string;
|
||||
progress: number;
|
||||
agents: AgentNode[];
|
||||
}
|
||||
|
||||
const sessions: OrchestratorSession[] = [
|
||||
{
|
||||
id: "s1",
|
||||
orchId: "ORCH-001",
|
||||
name: "infra-refactor",
|
||||
badge: "running",
|
||||
badgeVariant: "badge-teal",
|
||||
duration: "2h 14m",
|
||||
agents: [
|
||||
{
|
||||
id: "a1",
|
||||
initials: "PL",
|
||||
avatarColor: "rgba(47,128,255,0.15)",
|
||||
name: "planner-agent",
|
||||
task: "Analyzing network topology",
|
||||
status: "blue",
|
||||
},
|
||||
{
|
||||
id: "a2",
|
||||
initials: "EX",
|
||||
avatarColor: "rgba(20,184,166,0.15)",
|
||||
name: "executor-agent",
|
||||
task: "Applying Terraform modules",
|
||||
status: "teal",
|
||||
},
|
||||
{
|
||||
id: "a3",
|
||||
initials: "QA",
|
||||
avatarColor: "rgba(245,158,11,0.15)",
|
||||
name: "reviewer-agent",
|
||||
task: "Waiting for executor output",
|
||||
status: "amber",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
orchId: "ORCH-002",
|
||||
name: "api-v3-migration",
|
||||
badge: "running",
|
||||
badgeVariant: "badge-teal",
|
||||
duration: "45m",
|
||||
agents: [
|
||||
{
|
||||
id: "a4",
|
||||
initials: "MG",
|
||||
avatarColor: "rgba(139,92,246,0.15)",
|
||||
name: "migrator-agent",
|
||||
task: "Rewriting endpoint handlers",
|
||||
status: "blue",
|
||||
},
|
||||
],
|
||||
},
|
||||
export interface OrchestratorSessionsProps {
|
||||
jobs?: ActiveJob[] | undefined;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mapping helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const STEP_COLORS: string[] = [
|
||||
"rgba(47,128,255,0.15)",
|
||||
"rgba(20,184,166,0.15)",
|
||||
"rgba(245,158,11,0.15)",
|
||||
"rgba(139,92,246,0.15)",
|
||||
"rgba(229,72,77,0.15)",
|
||||
];
|
||||
|
||||
function statusToDotVariant(status: string): DotVariant {
|
||||
const lower = status.toLowerCase();
|
||||
if (lower === "running" || lower === "active" || lower === "completed") return "teal";
|
||||
if (lower === "pending" || lower === "queued") return "blue";
|
||||
if (lower === "waiting" || lower === "paused") return "amber";
|
||||
if (lower === "failed" || lower === "error") return "red";
|
||||
return "muted";
|
||||
}
|
||||
|
||||
function statusToBadgeVariant(status: string): BadgeVariant {
|
||||
const lower = status.toLowerCase();
|
||||
if (lower === "running" || lower === "active") return "badge-teal";
|
||||
if (lower === "pending" || lower === "queued") return "badge-blue";
|
||||
if (lower === "waiting" || lower === "paused") return "badge-amber";
|
||||
if (lower === "failed" || lower === "error") return "badge-red";
|
||||
if (lower === "completed") return "badge-purple";
|
||||
return "badge-muted";
|
||||
}
|
||||
|
||||
function formatDuration(isoDate: string): string {
|
||||
const now = Date.now();
|
||||
const start = new Date(isoDate).getTime();
|
||||
const diffMs = now - start;
|
||||
|
||||
if (Number.isNaN(diffMs) || diffMs < 0) return "0m";
|
||||
|
||||
const totalMinutes = Math.floor(diffMs / 60_000);
|
||||
if (totalMinutes < 60) return `${String(totalMinutes)}m`;
|
||||
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
return `${String(hours)}h ${String(minutes)}m`;
|
||||
}
|
||||
|
||||
function initials(name: string): string {
|
||||
return name
|
||||
.split(/[\s\-_]+/)
|
||||
.slice(0, 2)
|
||||
.map((w) => w.charAt(0).toUpperCase())
|
||||
.join("");
|
||||
}
|
||||
|
||||
function mapJobToSession(job: ActiveJob): OrchestratorSession {
|
||||
const agents: AgentNode[] = job.steps.map((step, idx) => ({
|
||||
id: step.id,
|
||||
initials: initials(step.name),
|
||||
avatarColor: STEP_COLORS[idx % STEP_COLORS.length] ?? "rgba(100,116,139,0.15)",
|
||||
name: step.name,
|
||||
task: `Phase: ${step.phase}`,
|
||||
status: statusToDotVariant(step.status),
|
||||
statusLabel: step.status.toLowerCase(),
|
||||
}));
|
||||
|
||||
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),
|
||||
progress: job.progressPercent,
|
||||
agents,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sub-components */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface AgentNodeItemProps {
|
||||
agent: AgentNode;
|
||||
}
|
||||
@@ -155,6 +196,16 @@ function AgentNodeItem({ agent }: AgentNodeItemProps): ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
<Dot variant={agent.status} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.65rem",
|
||||
fontFamily: "var(--mono)",
|
||||
color: "var(--muted)",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
{agent.statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -169,7 +220,7 @@ function OrchCard({ session }: OrchCardProps): ReactElement {
|
||||
style={{
|
||||
background: "var(--bg-mid)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r-md)",
|
||||
borderRadius: "var(--r)",
|
||||
padding: "12px 14px",
|
||||
marginBottom: 10,
|
||||
}}
|
||||
@@ -182,7 +233,7 @@ function OrchCard({ session }: OrchCardProps): ReactElement {
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<Dot variant="teal" />
|
||||
<Dot variant={statusToDotVariant(session.badge)} />
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--mono)",
|
||||
@@ -214,6 +265,27 @@ function OrchCard({ session }: OrchCardProps): ReactElement {
|
||||
{session.duration}
|
||||
</span>
|
||||
</div>
|
||||
{session.progress > 0 && (
|
||||
<div
|
||||
style={{
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
background: "var(--border)",
|
||||
marginBottom: 10,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${String(session.progress)}%`,
|
||||
background: "var(--primary)",
|
||||
borderRadius: 2,
|
||||
transition: "width 0.3s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{session.agents.map((agent) => (
|
||||
<AgentNodeItem key={agent.id} agent={agent} />
|
||||
@@ -223,18 +295,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 (
|
||||
<Card>
|
||||
<SectionHeader
|
||||
title="Active Orchestrator Sessions"
|
||||
subtitle="3 of 8 projects running"
|
||||
actions={<Badge variant="badge-teal">3 active</Badge>}
|
||||
subtitle={
|
||||
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>
|
||||
{sessions.map((session) => (
|
||||
<OrchCard key={session.id} session={session} />
|
||||
))}
|
||||
{sessions.length > 0 ? (
|
||||
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>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -12,10 +12,10 @@ interface QuickAction {
|
||||
}
|
||||
|
||||
const actions: QuickAction[] = [
|
||||
{ id: "new-project", label: "New Project", icon: "🚀", iconBg: "rgba(47,128,255,0.15)" },
|
||||
{ id: "spawn-agent", label: "Spawn Agent", icon: "🤖", iconBg: "rgba(139,92,246,0.15)" },
|
||||
{ id: "view-telemetry", label: "View Telemetry", icon: "📊", iconBg: "rgba(20,184,166,0.15)" },
|
||||
{ id: "review-tasks", label: "Review Tasks", icon: "📋", iconBg: "rgba(245,158,11,0.15)" },
|
||||
{ id: "new-project", label: "New Project", icon: "🚀", iconBg: "rgba(47,128,255,0.12)" },
|
||||
{ id: "spawn-agent", label: "Spawn Agent", icon: "🤖", iconBg: "rgba(139,92,246,0.12)" },
|
||||
{ id: "view-telemetry", label: "View Telemetry", icon: "📊", iconBg: "rgba(20,184,166,0.12)" },
|
||||
{ id: "review-tasks", label: "Review Tasks", icon: "📋", iconBg: "rgba(245,158,11,0.12)" },
|
||||
];
|
||||
|
||||
interface ActionButtonProps {
|
||||
@@ -36,24 +36,25 @@ function ActionButton({ action }: ActionButtonProps): ReactElement {
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
padding: "16px 12px",
|
||||
borderRadius: "var(--r-md)",
|
||||
padding: "10px 12px",
|
||||
borderRadius: "var(--r)",
|
||||
border: `1px solid ${hovered ? "var(--ms-border-700)" : "var(--border)"}`,
|
||||
background: hovered ? "var(--surface)" : "var(--bg-mid)",
|
||||
cursor: "pointer",
|
||||
transition: "border-color 0.15s, background 0.15s",
|
||||
transition: "border-color 0.15s, background 0.15s, color 0.15s",
|
||||
width: "100%",
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 600,
|
||||
color: hovered ? "var(--text)" : "var(--text-2)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 6,
|
||||
borderRadius: 5,
|
||||
background: action.iconBg,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -63,15 +64,7 @@ function ActionButton({ action }: ActionButtonProps): ReactElement {
|
||||
>
|
||||
{action.icon}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--text)",
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</span>
|
||||
<span>{action.label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -84,7 +77,7 @@ export function QuickActions(): ReactElement {
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 10,
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{actions.map((action) => (
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@mosaic/ui";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ComingSoon } from "@/components/ui/ComingSoon";
|
||||
|
||||
/**
|
||||
* Check if we're in development mode (runtime check for testability)
|
||||
*/
|
||||
function isDevelopment(): boolean {
|
||||
return process.env.NODE_ENV === "development";
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal Quick Capture Widget implementation
|
||||
*/
|
||||
function QuickCaptureWidgetInternal(): React.JSX.Element {
|
||||
const [idea, setIdea] = useState("");
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = (e: React.SyntheticEvent<HTMLFormElement>): void => {
|
||||
e.preventDefault();
|
||||
if (!idea.trim()) return;
|
||||
|
||||
// TODO: Implement quick capture API call
|
||||
// For now, just show a success indicator
|
||||
console.log("Quick capture:", idea);
|
||||
setIdea("");
|
||||
};
|
||||
|
||||
const goToTasks = (): void => {
|
||||
router.push("/tasks");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Quick Capture</h2>
|
||||
<p className="text-sm text-gray-600 mb-4">Quickly jot down ideas or brain dumps</p>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<textarea
|
||||
value={idea}
|
||||
onChange={(e) => {
|
||||
setIdea(e.target.value);
|
||||
}}
|
||||
placeholder="What's on your mind?"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" variant="primary" size="sm">
|
||||
Save Note
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={goToTasks}>
|
||||
Create Task
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick Capture Widget (Dashboard version)
|
||||
*
|
||||
* In production: Shows Coming Soon placeholder
|
||||
* In development: Full widget functionality
|
||||
*/
|
||||
export function QuickCaptureWidget(): React.JSX.Element {
|
||||
// In production, show Coming Soon placeholder
|
||||
if (!isDevelopment()) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<ComingSoon
|
||||
feature="Quick Capture"
|
||||
description="Quickly jot down ideas for later organization. This feature is currently under development."
|
||||
className="!p-0 !min-h-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// In development, show full widget functionality
|
||||
return <QuickCaptureWidgetInternal />;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import type { Task } from "@mosaic/shared";
|
||||
import { TaskPriority } from "@mosaic/shared";
|
||||
import { formatDate } from "@/lib/utils/date-format";
|
||||
import { TaskStatus } from "@mosaic/shared";
|
||||
import Link from "next/link";
|
||||
|
||||
interface RecentTasksWidgetProps {
|
||||
tasks: Task[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const statusIcons: Record<TaskStatus, string> = {
|
||||
[TaskStatus.NOT_STARTED]: "⚪",
|
||||
[TaskStatus.IN_PROGRESS]: "🟢",
|
||||
[TaskStatus.PAUSED]: "⏸️",
|
||||
[TaskStatus.COMPLETED]: "✅",
|
||||
[TaskStatus.ARCHIVED]: "💤",
|
||||
};
|
||||
|
||||
export function RecentTasksWidget({ tasks, isLoading }: RecentTasksWidgetProps): React.JSX.Element {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex justify-center items-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900"></div>
|
||||
<span className="ml-3 text-gray-600">Loading tasks...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const recentTasks = tasks.slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Recent Tasks</h2>
|
||||
<Link href="/tasks" className="text-sm text-blue-600 hover:text-blue-700">
|
||||
View all →
|
||||
</Link>
|
||||
</div>
|
||||
{recentTasks.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 text-center py-4">No tasks yet</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{recentTasks.map((task) => (
|
||||
<li
|
||||
key={task.id}
|
||||
className="flex items-start gap-3 p-3 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<span className="text-lg flex-shrink-0" aria-label={`Status: ${task.status}`}>
|
||||
{statusIcons[task.status]}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-gray-900 text-sm truncate">{task.title}</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{task.priority !== TaskPriority.LOW && (
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
task.priority === TaskPriority.HIGH
|
||||
? "bg-red-100 text-red-700"
|
||||
: "bg-blue-100 text-blue-700"
|
||||
}`}
|
||||
>
|
||||
{task.priority}
|
||||
</span>
|
||||
)}
|
||||
{task.dueDate && (
|
||||
<span className="text-xs text-gray-500">{formatDate(task.dueDate)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,24 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { Card, SectionHeader, ProgressBar, type ProgressBarVariant } from "@mosaic/ui";
|
||||
import type { TokenBudgetEntry } from "@/lib/api/dashboard";
|
||||
|
||||
interface ModelBudget {
|
||||
export interface TokenBudgetProps {
|
||||
budgets?: TokenBudgetEntry[] | undefined;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const VARIANT_CYCLE: ProgressBarVariant[] = ["blue", "teal", "purple", "amber"];
|
||||
|
||||
function formatTokenCount(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
interface ModelBudgetDisplay {
|
||||
id: string;
|
||||
label: string;
|
||||
usage: string;
|
||||
@@ -9,39 +26,28 @@ interface ModelBudget {
|
||||
variant: ProgressBarVariant;
|
||||
}
|
||||
|
||||
const models: ModelBudget[] = [
|
||||
{
|
||||
id: "sonnet",
|
||||
label: "claude-3-5-sonnet",
|
||||
usage: "2.1M / 3M",
|
||||
value: 70,
|
||||
variant: "blue",
|
||||
},
|
||||
{
|
||||
id: "haiku",
|
||||
label: "claude-3-haiku",
|
||||
usage: "890K / 5M",
|
||||
value: 18,
|
||||
variant: "teal",
|
||||
},
|
||||
{
|
||||
id: "gpt4o",
|
||||
label: "gpt-4o",
|
||||
usage: "320K / 1M",
|
||||
value: 32,
|
||||
variant: "purple",
|
||||
},
|
||||
{
|
||||
id: "llama",
|
||||
label: "local/llama-3.3",
|
||||
usage: "unlimited",
|
||||
value: 55,
|
||||
variant: "amber",
|
||||
},
|
||||
];
|
||||
function mapBudgetToDisplay(entry: TokenBudgetEntry, index: number): ModelBudgetDisplay {
|
||||
const percent = entry.limit > 0 ? Math.round((entry.used / entry.limit) * 100) : 0;
|
||||
const usage =
|
||||
entry.limit > 0
|
||||
? `${formatTokenCount(entry.used)} / ${formatTokenCount(entry.limit)}`
|
||||
: "unlimited";
|
||||
|
||||
return {
|
||||
id: entry.model,
|
||||
label: entry.model,
|
||||
usage,
|
||||
value: percent,
|
||||
variant: VARIANT_CYCLE[index % VARIANT_CYCLE.length] ?? "blue",
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Components */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface ModelRowProps {
|
||||
model: ModelBudget;
|
||||
model: ModelBudgetDisplay;
|
||||
}
|
||||
|
||||
function ModelRow({ model }: ModelRowProps): ReactElement {
|
||||
@@ -84,14 +90,27 @@ function ModelRow({ model }: ModelRowProps): ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
export function TokenBudget(): ReactElement {
|
||||
export function TokenBudget({ budgets }: TokenBudgetProps): ReactElement {
|
||||
const displayModels = budgets ? budgets.map(mapBudgetToDisplay) : [];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<SectionHeader title="Token Budget" subtitle="Usage by model" />
|
||||
<div>
|
||||
{models.map((model) => (
|
||||
<ModelRow key={model.id} model={model} />
|
||||
))}
|
||||
{displayModels.length > 0 ? (
|
||||
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>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import type { Event } from "@mosaic/shared";
|
||||
import { formatTime, formatDate } from "@/lib/utils/date-format";
|
||||
import Link from "next/link";
|
||||
|
||||
interface UpcomingEventsWidgetProps {
|
||||
events: Event[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function UpcomingEventsWidget({
|
||||
events,
|
||||
isLoading,
|
||||
}: UpcomingEventsWidgetProps): React.JSX.Element {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex justify-center items-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900"></div>
|
||||
<span className="ml-3 text-gray-600">Loading events...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const upcomingEvents = events.slice(0, 4);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Upcoming Events</h2>
|
||||
<Link href="/calendar" className="text-sm text-blue-600 hover:text-blue-700">
|
||||
View calendar →
|
||||
</Link>
|
||||
</div>
|
||||
{upcomingEvents.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 text-center py-4">No upcoming events</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{upcomingEvents.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="flex items-start gap-3 p-3 rounded-lg border-l-4 border-blue-500 bg-gray-50"
|
||||
>
|
||||
<div className="flex-shrink-0 text-center min-w-[3.5rem]">
|
||||
<div className="text-xs text-gray-500 uppercase font-semibold">
|
||||
{formatDate(event.startTime).split(",")[0]}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{formatTime(event.startTime)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-gray-900 text-sm truncate">{event.title}</h3>
|
||||
{event.location && (
|
||||
<p className="text-xs text-gray-500 mt-0.5">📍 {event.location}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* QuickCaptureWidget (Dashboard) Component Tests
|
||||
* Tests environment-based behavior
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { QuickCaptureWidget } from "../QuickCaptureWidget";
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: (): { push: () => void } => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("QuickCaptureWidget (Dashboard)", (): void => {
|
||||
beforeEach((): void => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach((): void => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("Development mode", (): void => {
|
||||
beforeEach((): void => {
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
});
|
||||
|
||||
it("should render the widget form in development", (): void => {
|
||||
render(<QuickCaptureWidget />);
|
||||
|
||||
// Should show the header
|
||||
expect(screen.getByText("Quick Capture")).toBeInTheDocument();
|
||||
// Should show the textarea
|
||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||
// Should show the Save Note button
|
||||
expect(screen.getByRole("button", { name: /save note/i })).toBeInTheDocument();
|
||||
// Should show the Create Task button
|
||||
expect(screen.getByRole("button", { name: /create task/i })).toBeInTheDocument();
|
||||
// Should NOT show Coming Soon badge
|
||||
expect(screen.queryByText("Coming Soon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have a placeholder for the textarea", (): void => {
|
||||
render(<QuickCaptureWidget />);
|
||||
|
||||
const textarea = screen.getByRole("textbox");
|
||||
expect(textarea).toHaveAttribute("placeholder", "What's on your mind?");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Production mode", (): void => {
|
||||
beforeEach((): void => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
});
|
||||
|
||||
it("should show Coming Soon placeholder in production", (): void => {
|
||||
render(<QuickCaptureWidget />);
|
||||
|
||||
// Should show Coming Soon badge
|
||||
expect(screen.getByText("Coming Soon")).toBeInTheDocument();
|
||||
// Should show feature name
|
||||
expect(screen.getByText("Quick Capture")).toBeInTheDocument();
|
||||
// Should NOT show the textarea
|
||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
||||
// Should NOT show the buttons
|
||||
expect(screen.queryByRole("button", { name: /save note/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /create task/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show description in Coming Soon placeholder", (): void => {
|
||||
render(<QuickCaptureWidget />);
|
||||
|
||||
expect(screen.getByText(/jot down ideas for later organization/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test mode (non-development)", (): void => {
|
||||
beforeEach((): void => {
|
||||
vi.stubEnv("NODE_ENV", "test");
|
||||
});
|
||||
|
||||
it("should show Coming Soon placeholder in test mode", (): void => {
|
||||
render(<QuickCaptureWidget />);
|
||||
|
||||
// Test mode is not development, so should show Coming Soon
|
||||
expect(screen.getByText("Coming Soon")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -356,7 +356,7 @@ function NavItem({ item, isActive, collapsed }: NavItemProps): React.JSX.Element
|
||||
alignItems: "center",
|
||||
gap: "11px",
|
||||
padding: "9px 10px",
|
||||
borderRadius: "6px",
|
||||
borderRadius: "var(--r-sm)",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 500,
|
||||
color: isActive ? "var(--text)" : "var(--muted)",
|
||||
|
||||
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 "./personalities";
|
||||
export * from "./telemetry";
|
||||
export * from "./dashboard";
|
||||
|
||||
69
docs/MISSION-MANIFEST.md
Normal file
69
docs/MISSION-MANIFEST.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Mission Manifest — Mosaic Stack Go-Live MVP
|
||||
|
||||
> Persistent document tracking full mission scope, status, and session history.
|
||||
> Updated by the orchestrator at each phase transition and milestone completion.
|
||||
|
||||
## Mission
|
||||
|
||||
**ID:** mosaic-stack-go-live-mvp-20260222
|
||||
**Statement:** Ship Mosaic Stack MVP: operational dashboard with theming, task ingestion, one visible agent cycle, deployed and smoke-tested. Unblocks SagePHR, DYOR, Calibr, and downstream projects.
|
||||
**Phase:** Execution
|
||||
**Current Milestone:** phase-3 (Agent Cycle Visibility)
|
||||
**Progress:** 2 / 4 milestones
|
||||
**Status:** active
|
||||
**Last Updated:** 2026-02-23 18:55 UTC
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. Dashboard loads reliably at mosaic.woltje.com with theming system operational
|
||||
2. Task/status ingestion: create/update flow persists records visible in UI
|
||||
3. One agent job runs from queued to completion/failure with status transitions visible
|
||||
4. Stack deployed via Coolify, auth + dashboard reachable, smoke checks passing
|
||||
5. Design reference: `mosaic-stack-website/docs/designs/round-5/claude/01/dashboard.html`
|
||||
|
||||
## Prior Work (MS15-DashboardShell — Complete)
|
||||
|
||||
The Feb 22 agent completed the dashboard shell foundation:
|
||||
|
||||
- PR #451: Design System & App Shell (tokens, layout, sidebar, topbar, responsive, spinner)
|
||||
- PR #452: Shared Components & Terminal Panel (ui token alignment, card/badge/button, metrics, terminal)
|
||||
- PR #453: Dashboard Page (widget grid, activity feed, command palette, notifications)
|
||||
- PR #454: Design system reference docs
|
||||
|
||||
This mission continues from that foundation.
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||
| --- | ------- | -------------------------- | ----------- | ----------------------------- | ----- | ---------- | ---------- |
|
||||
| 1 | phase-1 | Dashboard Polish + Theming | completed | feat/phase-1-polish | #457 | 2026-02-22 | 2026-02-23 |
|
||||
| 2 | phase-2 | Task Ingestion Pipeline | completed | feat/phase-2-ingestion | #459 | 2026-02-23 | 2026-02-23 |
|
||||
| 3 | phase-3 | Agent Cycle Visibility | in-progress | feat/phase-3-agent-visibility | #461 | 2026-02-23 | — |
|
||||
| 4 | phase-4 | Deploy + Smoke Test | pending | — | — | — | — |
|
||||
|
||||
## Deployment
|
||||
|
||||
| Target | URL | Method |
|
||||
| ---------- | ---------------------- | ------------------- |
|
||||
| Production | mosaic.woltje.com | Coolify (10.1.1.44) |
|
||||
| Auth | auth.diversecanvas.com | Authentik SSO |
|
||||
| Registry | git.mosaicstack.dev | Gitea Packages |
|
||||
|
||||
## Token Budget
|
||||
|
||||
| Metric | Value |
|
||||
| ------ | ------ |
|
||||
| Budget | — |
|
||||
| Used | 0 |
|
||||
| Mode | normal |
|
||||
|
||||
## Session History
|
||||
|
||||
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
||||
| ------- | ------- | ---------------- | -------- | ------------ | --------- |
|
||||
| S1 | Claude | 2026-02-22 17:50 | — | context | MS-P2-002 |
|
||||
| S2 | Claude | 2026-02-23 18:45 | — | — | — |
|
||||
|
||||
## Scratchpad
|
||||
|
||||
Path: `docs/scratchpads/mosaic-stack-go-live-mvp-20260222.md`
|
||||
16
docs/TASKS.md
Normal file
16
docs/TASKS.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Tasks — Mosaic Stack Go-Live MVP
|
||||
|
||||
> Single-writer: orchestrator only. Workers read but never modify.
|
||||
|
||||
| id | status | milestone | description | pr | notes |
|
||||
| --------- | ------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------ | ---- | ---------------------------------------------- |
|
||||
| MS-P1-001 | done | phase-1 | Fix broken test suites: Button.test.tsx (4 fails, old Tailwind classes) + page.test.tsx (5 fails, old widget refs) | #458 | issue #457, commit 8fa0b30 |
|
||||
| MS-P1-002 | done | phase-1 | Remove legacy unused dashboard widgets: DomainOverviewWidget, RecentTasksWidget, UpcomingEventsWidget, QuickCaptureWidget | #458 | issue #457, commit 8fa0b30, 5 files deleted |
|
||||
| MS-P1-003 | done | phase-1 | Visual + theme polish: audit current vs design reference, fix gaps, verify dark/light across all components, responsive verification | #458 | issue #457, commit d97a98b, review: approve |
|
||||
| MS-P1-004 | done | phase-1 | Phase verification: all quality gates pass (lint 8/8, typecheck 7/7, test 8/8) | #458 | issue #457, merged 07f5225, issue closed |
|
||||
| MS-P2-001 | done | phase-2 | Create dashboard summary API endpoint: aggregate task counts, project counts, recent activity, active jobs in single call | — | issue #459, commit e38aaa9, 7 files +430 lines |
|
||||
| MS-P2-002 | done | phase-2 | Wire dashboard widgets to real API data: ActivityFeed, DashboardMetrics, OrchestratorSessions replace mock with API calls | — | issue #459, commit 7c762e6 + remediation |
|
||||
| MS-P2-003 | done | phase-2 | Phase verification: create task via API, confirm visible in dashboard, all quality gates pass | — | issue #459, lint 8/8 typecheck 7/7 test 8/8 |
|
||||
| MS-P3-001 | done | phase-3 | Wire WebSocket emits into RunnerJobsService: broadcast job status/progress/step events to workspace rooms | — | issue #461, commit 5d3045a |
|
||||
| MS-P3-002 | done | phase-3 | Dashboard auto-refresh + enhanced OrchestratorSessions: polling interval, progress bars, step status indicators, timestamps | — | issue #461, commit 5d3045a |
|
||||
| MS-P3-003 | done | phase-3 | Phase verification: all quality gates pass, demonstrate agent job cycle visibility end-to-end | — | issue #461, lint 8/8 typecheck 7/7 test 8/8 |
|
||||
65
docs/scratchpads/mosaic-stack-go-live-mvp-20260222.md
Normal file
65
docs/scratchpads/mosaic-stack-go-live-mvp-20260222.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Mission Scratchpad — Mosaic Stack Go-Live MVP
|
||||
|
||||
> Append-only log. NEVER delete entries. NEVER overwrite sections.
|
||||
> This is the orchestrator's working memory across sessions.
|
||||
|
||||
## Original Mission Prompt
|
||||
|
||||
```
|
||||
Continue Mosaic Stack Go-Live MVP from existing state.
|
||||
- Mission: Ship Mosaic Stack MVP: operational dashboard with theming, task ingestion,
|
||||
one visible agent cycle, deployed and smoke-tested.
|
||||
- 4 milestones: Dashboard Polish+Theming, Task Ingestion Pipeline,
|
||||
Agent Cycle Visibility, Deploy+Smoke Test
|
||||
- Prior work: MS15-DashboardShell complete (PRs #451-454)
|
||||
- Design ref: mosaic-stack-website/docs/designs/round-5/claude/01/dashboard.html
|
||||
```
|
||||
|
||||
## Planning Decisions
|
||||
|
||||
### 2026-02-22: Phase-1 Task Breakdown
|
||||
|
||||
Baseline assessment:
|
||||
|
||||
- Lint: PASS, Typecheck: PASS, Tests: FAIL (9 failures)
|
||||
- UI Button.test.tsx: 4 fails (old Tailwind class assertions vs new CSS token Button)
|
||||
- Web page.test.tsx: 5 fails (old widget layout vs Phase 3 rebuild)
|
||||
- Design system + theming already substantially complete from MS15
|
||||
- 5 live widgets all use mock data (real data integration is phase-2)
|
||||
- 4 legacy widgets unused (hardcoded light theme, pre-Phase 3)
|
||||
|
||||
Tasks created: MS-P1-001 through MS-P1-004, issue #457, milestone Go-Live-MVP-Phase1 (0.0.16)
|
||||
Estimated total: ~50K tokens
|
||||
|
||||
## Session Log
|
||||
|
||||
| Session | Date | Milestone | Tasks Done | Outcome |
|
||||
| ------- | ---------- | --------- | ---------- | ------------------------------------------------------ |
|
||||
| S1 | 2026-02-22 | phase-1 | 4/4 | COMPLETE — PR #458 merged (07f5225), issue #457 closed |
|
||||
|
||||
### 2026-02-23: Phase-1 Completion Summary
|
||||
|
||||
- PR #458 merged to main (squash), commit 07f5225
|
||||
- Issue #457 closed
|
||||
- 4/4 tasks done, all quality gates green
|
||||
- Pre-existing bug noted: Toast.tsx var(--info) undefined (not in scope)
|
||||
- Net: -373 lines (legacy cleanup + responsive CSS additions)
|
||||
- Review: approve (0 blockers, 0 critical security)
|
||||
|
||||
### 2026-02-23: Phase-2 Completion Summary
|
||||
|
||||
- PR #460 merged to main (squash), commit 7581d26
|
||||
- Issue #459 closed
|
||||
- 3/3 tasks done (MS-P2-001 through MS-P2-003)
|
||||
- New files: dashboard module (controller, service, DTOs, tests), API client, typed widget props
|
||||
- Review blockers fixed: race condition (null workspaceId guard), TypeScript strict typing, error state UI
|
||||
- Net: +1042 lines, -253 lines (18 files changed)
|
||||
- All quality gates green: lint 8/8, typecheck 7/7, test 8/8 (no cache)
|
||||
|
||||
| Session | Date | Milestone | Tasks Done | Outcome |
|
||||
| ------- | ---------- | --------- | ---------- | ------------------------------------------------------ |
|
||||
| S2 | 2026-02-23 | phase-2 | 3/3 | COMPLETE — PR #460 merged (7581d26), issue #459 closed |
|
||||
|
||||
## Open Questions
|
||||
|
||||
## Corrections
|
||||
@@ -16,19 +16,21 @@ describe("Button", () => {
|
||||
it("should apply primary variant styles by default", () => {
|
||||
render(<Button>Primary</Button>);
|
||||
const button = screen.getByRole("button");
|
||||
expect(button.className).toContain("bg-blue-600");
|
||||
expect(button.style.background).toBe("var(--ms-blue-500)");
|
||||
});
|
||||
|
||||
it("should apply secondary variant styles", () => {
|
||||
render(<Button variant="secondary">Secondary</Button>);
|
||||
const button = screen.getByRole("button");
|
||||
expect(button.className).toContain("bg-gray-200");
|
||||
expect(button.style.background).toBe("transparent");
|
||||
expect(button.style.border).toBe("1px solid var(--border)");
|
||||
});
|
||||
|
||||
it("should apply danger variant styles", () => {
|
||||
render(<Button variant="danger">Delete</Button>);
|
||||
const button = screen.getByRole("button");
|
||||
expect(button.className).toContain("bg-red-600");
|
||||
expect(button.style.background).toBe("rgba(229, 72, 77, 0.12)");
|
||||
expect(button.style.color).toBe("var(--danger)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,6 +83,6 @@ describe("Button", () => {
|
||||
render(<Button className="custom-class">Custom</Button>);
|
||||
const button = screen.getByRole("button");
|
||||
expect(button.className).toContain("custom-class");
|
||||
expect(button.className).toContain("bg-blue-600");
|
||||
expect(button.style.background).toBe("var(--ms-blue-500)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface MetricsStripProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function MetricCellItem({ cell, isFirst }: { cell: MetricCell; isFirst: boolean }): ReactElement {
|
||||
function MetricCellItem({ cell }: { cell: MetricCell }): ReactElement {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const trendColor =
|
||||
@@ -28,6 +28,7 @@ function MetricCellItem({ cell, isFirst }: { cell: MetricCell; isFirst: boolean
|
||||
|
||||
return (
|
||||
<div
|
||||
className="metric-cell"
|
||||
onMouseEnter={(): void => {
|
||||
setHovered(true);
|
||||
}}
|
||||
@@ -37,7 +38,6 @@ function MetricCellItem({ cell, isFirst }: { cell: MetricCell; isFirst: boolean
|
||||
style={{
|
||||
padding: "14px 16px",
|
||||
background: hovered ? "var(--surface-2)" : "var(--surface)",
|
||||
borderLeft: isFirst ? "none" : "1px solid var(--border)",
|
||||
borderTop: `2px solid ${cell.color}`,
|
||||
transition: "background 0.15s ease",
|
||||
}}
|
||||
@@ -82,17 +82,15 @@ function MetricCellItem({ cell, isFirst }: { cell: MetricCell; isFirst: boolean
|
||||
export function MetricsStrip({ cells, className = "" }: MetricsStripProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${String(cells.length)}, 1fr)`,
|
||||
borderRadius: "var(--r-lg)",
|
||||
overflow: "hidden",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
className={`metrics-strip ${className}`.trim()}
|
||||
style={
|
||||
{
|
||||
"--ms-cols": String(cells.length),
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{cells.map((cell, index) => (
|
||||
<MetricCellItem key={cell.label} cell={cell} isFirst={index === 0} />
|
||||
{cells.map((cell) => (
|
||||
<MetricCellItem key={cell.label} cell={cell} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,33 @@ source "$SCRIPT_DIR/common.sh"
|
||||
ensure_repo_root
|
||||
load_repo_hooks
|
||||
|
||||
# ─── Mission session cleanup (ORCHESTRATOR-PROTOCOL) ────────────────────────
|
||||
ORCH_DIR=".mosaic/orchestrator"
|
||||
MISSION_JSON="$ORCH_DIR/mission.json"
|
||||
SESSION_LOCK="$ORCH_DIR/session.lock"
|
||||
COORD_LIB="$HOME/.config/mosaic/tools/orchestrator/_lib.sh"
|
||||
|
||||
if [[ -f "$SESSION_LOCK" ]] && [[ -f "$COORD_LIB" ]] && command -v jq &>/dev/null; then
|
||||
# shellcheck source=/dev/null
|
||||
source "$COORD_LIB"
|
||||
|
||||
sess_id="$(jq -r '.session_id // ""' "$SESSION_LOCK")"
|
||||
if [[ -n "$sess_id" && -f "$MISSION_JSON" ]]; then
|
||||
updated="$(jq \
|
||||
--arg sid "$sess_id" \
|
||||
--arg ts "$(iso_now)" \
|
||||
--arg reason "completed" \
|
||||
'(.sessions[] | select(.session_id == $sid)) |= . + {
|
||||
ended_at: $ts,
|
||||
ended_reason: $reason
|
||||
}' "$MISSION_JSON")"
|
||||
echo "$updated" > "$MISSION_JSON.tmp" && mv "$MISSION_JSON.tmp" "$MISSION_JSON"
|
||||
echo "[agent-framework] Session $sess_id recorded in mission state"
|
||||
fi
|
||||
|
||||
session_lock_clear "."
|
||||
fi
|
||||
|
||||
if declare -F mosaic_hook_session_end >/dev/null 2>&1; then
|
||||
run_step "Run repo end hook" mosaic_hook_session_end
|
||||
else
|
||||
|
||||
@@ -43,6 +43,70 @@ if git rev-parse --is-inside-work-tree >/dev/null 2>&1 && has_remote; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─── Mission state detection (ORCHESTRATOR-PROTOCOL) ────────────────────────
|
||||
ORCH_DIR=".mosaic/orchestrator"
|
||||
MISSION_JSON="$ORCH_DIR/mission.json"
|
||||
COORD_LIB="$HOME/.config/mosaic/tools/orchestrator/_lib.sh"
|
||||
|
||||
if [[ -f "$MISSION_JSON" ]] && command -v jq &>/dev/null; then
|
||||
mission_status="$(jq -r '.status // "inactive"' "$MISSION_JSON")"
|
||||
|
||||
if [[ "$mission_status" == "active" || "$mission_status" == "paused" ]]; then
|
||||
mission_name="$(jq -r '.name // "unnamed"' "$MISSION_JSON")"
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo "ACTIVE MISSION DETECTED"
|
||||
echo "========================================="
|
||||
echo " Mission: $mission_name"
|
||||
|
||||
manifest="docs/MISSION-MANIFEST.md"
|
||||
if [[ -f "$manifest" ]]; then
|
||||
phase="$(grep -m1 '^\*\*Phase:\*\*' "$manifest" 2>/dev/null | sed 's/.*\*\*Phase:\*\* //' || true)"
|
||||
milestone="$(grep -m1 '^\*\*Current Milestone:\*\*' "$manifest" 2>/dev/null | sed 's/.*\*\*Current Milestone:\*\* //' || true)"
|
||||
progress="$(grep -m1 '^\*\*Progress:\*\*' "$manifest" 2>/dev/null | sed 's/.*\*\*Progress:\*\* //' || true)"
|
||||
[[ -n "$phase" ]] && echo " Phase: $phase"
|
||||
[[ -n "$milestone" ]] && echo " Milestone: $milestone"
|
||||
[[ -n "$progress" ]] && echo " Progress: $progress"
|
||||
fi
|
||||
|
||||
if [[ -f "docs/TASKS.md" ]]; then
|
||||
total="$(grep -c '^|' "docs/TASKS.md" 2>/dev/null || true)"
|
||||
total="${total:-0}"
|
||||
done_count="$(grep -ci '| done \|| completed ' "docs/TASKS.md" 2>/dev/null || true)"
|
||||
done_count="${done_count:-0}"
|
||||
approx_total=$(( total > 2 ? total - 2 : 0 ))
|
||||
echo " Tasks: ~${done_count} done of ~${approx_total} total"
|
||||
fi
|
||||
|
||||
if [[ -d "docs/scratchpads" ]]; then
|
||||
latest_sp="$(ls -t docs/scratchpads/*.md 2>/dev/null | head -1 || true)"
|
||||
[[ -n "$latest_sp" ]] && echo " Scratchpad: $latest_sp"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " Resume: Read manifest + scratchpad before taking action."
|
||||
echo " Protocol: ~/.config/mosaic/guides/ORCHESTRATOR-PROTOCOL.md"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
|
||||
if [[ -f "$COORD_LIB" ]]; then
|
||||
# shellcheck source=/dev/null
|
||||
source "$COORD_LIB"
|
||||
sess_id="$(next_session_id ".")"
|
||||
runtime="${MOSAIC_RUNTIME:-unknown}"
|
||||
session_lock_write "." "$sess_id" "$runtime" "$$"
|
||||
|
||||
updated="$(jq \
|
||||
--arg sid "$sess_id" \
|
||||
--arg rt "$runtime" \
|
||||
--arg ts "$(iso_now)" \
|
||||
'.sessions += [{"session_id":$sid,"runtime":$rt,"started_at":$ts,"ended_at":"","ended_reason":"","milestone_at_end":"","tasks_completed":[],"last_task_id":""}]' \
|
||||
"$MISSION_JSON")"
|
||||
echo "$updated" > "$MISSION_JSON.tmp" && mv "$MISSION_JSON.tmp" "$MISSION_JSON"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if declare -F mosaic_hook_session_start >/dev/null 2>&1; then
|
||||
run_step "Run repo start hook" mosaic_hook_session_start
|
||||
else
|
||||
|
||||
Reference in New Issue
Block a user