diff --git a/apps/web/src/app/(authenticated)/page.test.tsx b/apps/web/src/app/(authenticated)/page.test.tsx
index 98da381..27383bc 100644
--- a/apps/web/src/app/(authenticated)/page.test.tsx
+++ b/apps/web/src/app/(authenticated)/page.test.tsx
@@ -1,5 +1,5 @@
-import { describe, it, expect, vi } from "vitest";
-import { render, screen } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
import DashboardPage from "./page";
// Mock Phase 3 dashboard widgets
@@ -27,29 +27,64 @@ vi.mock("@/components/dashboard/TokenBudget", () => ({
TokenBudget: (): React.JSX.Element =>
Token Budget
,
}));
+// 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 DashboardMetrics widget", (): void => {
- render();
- expect(screen.getByTestId("dashboard-metrics")).toBeInTheDocument();
+ beforeEach((): void => {
+ vi.clearAllMocks();
});
- it("should render the OrchestratorSessions widget", (): void => {
+ it("should render the DashboardMetrics widget", async (): Promise => {
render();
- expect(screen.getByTestId("orchestrator-sessions")).toBeInTheDocument();
+ await waitFor((): void => {
+ expect(screen.getByTestId("dashboard-metrics")).toBeInTheDocument();
+ });
});
- it("should render the QuickActions widget", (): void => {
+ it("should render the OrchestratorSessions widget", async (): Promise => {
render();
- expect(screen.getByTestId("quick-actions")).toBeInTheDocument();
+ await waitFor((): void => {
+ expect(screen.getByTestId("orchestrator-sessions")).toBeInTheDocument();
+ });
});
- it("should render the ActivityFeed widget", (): void => {
+ it("should render the QuickActions widget", async (): Promise => {
render();
- expect(screen.getByTestId("activity-feed")).toBeInTheDocument();
+ await waitFor((): void => {
+ expect(screen.getByTestId("quick-actions")).toBeInTheDocument();
+ });
});
- it("should render the TokenBudget widget", (): void => {
+ it("should render the ActivityFeed widget", async (): Promise => {
render();
- expect(screen.getByTestId("token-budget")).toBeInTheDocument();
+ await waitFor((): void => {
+ expect(screen.getByTestId("activity-feed")).toBeInTheDocument();
+ });
+ });
+
+ it("should render the TokenBudget widget", async (): Promise => {
+ render();
+ await waitFor((): void => {
+ expect(screen.getByTestId("token-budget")).toBeInTheDocument();
+ });
});
});
diff --git a/apps/web/src/app/(authenticated)/page.tsx b/apps/web/src/app/(authenticated)/page.tsx
index bf09ec1..3a55688 100644
--- a/apps/web/src/app/(authenticated)/page.tsx
+++ b/apps/web/src/app/(authenticated)/page.tsx
@@ -1,24 +1,76 @@
"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(null);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function loadSummary(): Promise {
+ try {
+ const summary = await fetchDashboardSummary(workspaceId ?? undefined);
+ if (!cancelled) {
+ setData(summary);
+ }
+ } catch (err: unknown) {
+ // Log but do not crash; widgets will render with empty states
+ console.error("[Dashboard] Failed to fetch summary:", err);
+ } finally {
+ if (!cancelled) {
+ setIsLoading(false);
+ }
+ }
+ }
+
+ void loadSummary();
+
+ return (): void => {
+ cancelled = true;
+ };
+ }, [workspaceId]);
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
return (
diff --git a/apps/web/src/components/dashboard/ActivityFeed.tsx b/apps/web/src/components/dashboard/ActivityFeed.tsx
index 3a623bf..a151a10 100644
--- a/apps/web/src/components/dashboard/ActivityFeed.tsx
+++ b/apps/web/src/components/dashboard/ActivityFeed.tsx
@@ -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 {
@@ -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 (
- {activityItems.map((item) => (
-
- ))}
+ {displayItems.length > 0 ? (
+ displayItems.map((item) =>
)
+ ) : (
+
+ No recent activity
+
+ )}
);
diff --git a/apps/web/src/components/dashboard/DashboardMetrics.tsx b/apps/web/src/components/dashboard/DashboardMetrics.tsx
index 16a5945..94e28ef 100644
--- a/apps/web/src/components/dashboard/DashboardMetrics.tsx
+++ b/apps/web/src/components/dashboard/DashboardMetrics.tsx
@@ -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 ;
}
diff --git a/apps/web/src/components/dashboard/OrchestratorSessions.tsx b/apps/web/src/components/dashboard/OrchestratorSessions.tsx
index c9e9732..e654163 100644
--- a/apps/web/src/components/dashboard/OrchestratorSessions.tsx
+++ b/apps/web/src/components/dashboard/OrchestratorSessions.tsx
@@ -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,7 @@ interface AgentNode {
avatarColor: string;
name: string;
task: string;
- status: "teal" | "blue" | "amber" | "red" | "muted";
+ status: DotVariant;
}
interface OrchestratorSession {
@@ -18,73 +34,94 @@ 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;
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),
+ }));
+
+ return {
+ id: job.id,
+ orchId: job.id.length > 10 ? job.id.slice(0, 10).toUpperCase() : job.id.toUpperCase(),
+ name: job.type,
+ badge: job.status,
+ badgeVariant: statusToBadgeVariant(job.status),
+ duration: formatDuration(job.createdAt),
+ agents,
+ };
+}
+
+/* ------------------------------------------------------------------ */
+/* Sub-components */
+/* ------------------------------------------------------------------ */
+
interface AgentNodeItemProps {
agent: AgentNode;
}
@@ -182,7 +219,7 @@ function OrchCard({ session }: OrchCardProps): ReactElement {
marginBottom: 10,
}}
>
-
+
j.status.toLowerCase() === "running" || j.status.toLowerCase() === "active"
+ ).length
+ : 0;
+
return (
3 active}
+ subtitle={
+ sessions.length > 0
+ ? `${String(activeCount)} of ${String(sessions.length)} jobs running`
+ : "No active sessions"
+ }
+ actions={
+ sessions.length > 0 ? (
+ {String(activeCount)} active
+ ) : undefined
+ }
/>
- {sessions.map((session) => (
-
- ))}
+ {sessions.length > 0 ? (
+ sessions.map((session) =>
)
+ ) : (
+
+ No active sessions
+
+ )}
);
diff --git a/apps/web/src/components/dashboard/TokenBudget.tsx b/apps/web/src/components/dashboard/TokenBudget.tsx
index 7aad59d..2e434cb 100644
--- a/apps/web/src/components/dashboard/TokenBudget.tsx
+++ b/apps/web/src/components/dashboard/TokenBudget.tsx
@@ -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 (
- {models.map((model) => (
-
- ))}
+ {displayModels.length > 0 ? (
+ displayModels.map((model) =>
)
+ ) : (
+
+ No budget data
+
+ )}
);
diff --git a/apps/web/src/lib/api/dashboard.ts b/apps/web/src/lib/api/dashboard.ts
new file mode 100644
index 0000000..ea2851e
--- /dev/null
+++ b/apps/web/src/lib/api/dashboard.ts
@@ -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: unknown;
+ 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 {
+ return apiGet("/api/dashboard/summary", workspaceId ?? undefined);
+}
diff --git a/apps/web/src/lib/api/index.ts b/apps/web/src/lib/api/index.ts
index 5877de4..3d97eae 100644
--- a/apps/web/src/lib/api/index.ts
+++ b/apps/web/src/lib/api/index.ts
@@ -13,3 +13,4 @@ export * from "./domains";
export * from "./teams";
export * from "./personalities";
export * from "./telemetry";
+export * from "./dashboard";