feat(web): wire dashboard widgets to real API data (#459)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
All checks were successful
ci/woodpecker/push/web Pipeline was successful
- Create dashboard API client with TypeScript interfaces - Update page.tsx to fetch from GET /api/dashboard/summary on mount - DashboardMetrics: accept metrics prop, map to 6 cells with real data - ActivityFeed: accept items prop, map actions to icons/timestamps - OrchestratorSessions: accept jobs prop, render steps as agent nodes - TokenBudget: accept budgets prop, compute percentages - All widgets have empty/zero fallback states - Update page tests to mock API fetch Task: MS-P2-002 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 => <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 DashboardMetrics widget", (): void => {
|
||||
render(<DashboardPage />);
|
||||
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<void> => {
|
||||
render(<DashboardPage />);
|
||||
expect(screen.getByTestId("orchestrator-sessions")).toBeInTheDocument();
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("dashboard-metrics")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the QuickActions widget", (): void => {
|
||||
it("should render the OrchestratorSessions widget", async (): Promise<void> => {
|
||||
render(<DashboardPage />);
|
||||
expect(screen.getByTestId("quick-actions")).toBeInTheDocument();
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("orchestrator-sessions")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the ActivityFeed widget", (): void => {
|
||||
it("should render the QuickActions widget", async (): Promise<void> => {
|
||||
render(<DashboardPage />);
|
||||
expect(screen.getByTestId("activity-feed")).toBeInTheDocument();
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("quick-actions")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the TokenBudget widget", (): void => {
|
||||
it("should render the ActivityFeed widget", async (): Promise<void> => {
|
||||
render(<DashboardPage />);
|
||||
expect(screen.getByTestId("token-budget")).toBeInTheDocument();
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("activity-feed")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the TokenBudget widget", async (): Promise<void> => {
|
||||
render(<DashboardPage />);
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByTestId("token-budget")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<DashboardSummaryResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadSummary(): Promise<void> {
|
||||
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 (
|
||||
<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 />
|
||||
<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>
|
||||
|
||||
@@ -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 (
|
||||
<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} />;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
<Dot variant="teal" />
|
||||
<Dot variant={statusToDotVariant(session.badge)} />
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--mono)",
|
||||
@@ -223,18 +260,48 @@ function OrchCard({ session }: OrchCardProps): ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
export function OrchestratorSessions(): ReactElement {
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main export */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function OrchestratorSessions({ jobs }: OrchestratorSessionsProps): ReactElement {
|
||||
const sessions = jobs ? jobs.map(mapJobToSession) : [];
|
||||
const activeCount = jobs
|
||||
? jobs.filter(
|
||||
(j) => j.status.toLowerCase() === "running" || j.status.toLowerCase() === "active"
|
||||
).length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
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: 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<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";
|
||||
|
||||
Reference in New Issue
Block a user