feat(#92): implement Aggregated Dashboard View

Implement unified dashboard to display tasks and events from multiple
federated Mosaic Stack instances with clear provenance indicators.

Backend Integration:
- Extended federation API client with query support (sendFederatedQuery)
- Added query message fetching functions
- Integrated with existing QUERY message type from Phase 3

Components Created:
- ProvenanceIndicator: Shows which instance data came from
- FederatedTaskCard: Task display with provenance
- FederatedEventCard: Event display with provenance
- AggregatedDataGrid: Unified grid for multiple data types
- Dashboard page at /federation/dashboard

Key Features:
- Query all ACTIVE federated connections on load
- Display aggregated tasks and events in unified view
- Clear provenance indicators (instance name badges)
- PDA-friendly language throughout (no demanding terms)
- Loading states and error handling
- Empty state when no connections available

Technical Implementation:
- Uses POST /api/v1/federation/query to send queries
- Queries each connection for tasks.list and events.list
- Aggregates responses with provenance metadata
- Handles connection failures gracefully
- 86 tests passing with >85% coverage
- TypeScript strict mode compliant
- ESLint compliant

PDA-Friendly Design:
- "Unable to reach" instead of "Connection failed"
- "No data available" instead of "No results"
- "Loading data from instances..." instead of "Fetching..."
- Calm color palette (soft blues, greens, grays)
- Status indicators: 🟢 Active, 📋 No data, ⚠️ Error

Files Added:
- apps/web/src/lib/api/federation-queries.ts
- apps/web/src/lib/api/federation-queries.test.ts
- apps/web/src/components/federation/types.ts
- apps/web/src/components/federation/ProvenanceIndicator.tsx
- apps/web/src/components/federation/ProvenanceIndicator.test.tsx
- apps/web/src/components/federation/FederatedTaskCard.tsx
- apps/web/src/components/federation/FederatedTaskCard.test.tsx
- apps/web/src/components/federation/FederatedEventCard.tsx
- apps/web/src/components/federation/FederatedEventCard.test.tsx
- apps/web/src/components/federation/AggregatedDataGrid.tsx
- apps/web/src/components/federation/AggregatedDataGrid.test.tsx
- apps/web/src/app/(authenticated)/federation/dashboard/page.tsx
- docs/scratchpads/92-aggregated-dashboard.md

Testing:
- 86 total tests passing
- Unit tests for all components
- Integration tests for API client
- PDA-friendly language verified
- TypeScript type checking passing
- ESLint passing

Ready for code review and QA testing.

Related Issues:
- Depends on #85 (FED-005: QUERY Message Type) - COMPLETED
- Depends on #91 (FED-008: Connection Manager UI) - COMPLETED
- Uses #90 (FED-007: EVENT Subscriptions) infrastructure

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-03 14:18:18 -06:00
parent 5cf02e824b
commit 8178617e53
13 changed files with 1535 additions and 0 deletions

View File

@@ -0,0 +1,154 @@
/**
* Federation Queries API Client Tests
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
sendFederatedQuery,
fetchQueryMessages,
fetchQueryMessage,
FederationMessageStatus,
} from "./federation-queries";
import * as client from "./client";
// Mock the API client
vi.mock("./client", () => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
}));
describe("Federation Queries API", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("sendFederatedQuery", () => {
it("should send a query to a remote instance", async () => {
const mockResponse = {
id: "msg-123",
messageId: "uuid-123",
connectionId: "conn-1",
query: "tasks.list",
status: FederationMessageStatus.PENDING,
createdAt: new Date().toISOString(),
};
vi.mocked(client.apiPost).mockResolvedValue(mockResponse);
const result = await sendFederatedQuery("conn-1", "tasks.list", { limit: 10 });
expect(client.apiPost).toHaveBeenCalledWith("/api/v1/federation/query", {
connectionId: "conn-1",
query: "tasks.list",
context: { limit: 10 },
});
expect(result).toEqual(mockResponse);
});
it("should send a query without context", async () => {
const mockResponse = {
id: "msg-124",
messageId: "uuid-124",
connectionId: "conn-2",
query: "events.today",
status: FederationMessageStatus.PENDING,
createdAt: new Date().toISOString(),
};
vi.mocked(client.apiPost).mockResolvedValue(mockResponse);
const result = await sendFederatedQuery("conn-2", "events.today");
expect(client.apiPost).toHaveBeenCalledWith("/api/v1/federation/query", {
connectionId: "conn-2",
query: "events.today",
});
expect(result).toEqual(mockResponse);
});
it("should handle API errors", async () => {
vi.mocked(client.apiPost).mockRejectedValue(new Error("Network error"));
await expect(sendFederatedQuery("conn-1", "tasks.list")).rejects.toThrow("Network error");
});
});
describe("fetchQueryMessages", () => {
it("should fetch all query messages", async () => {
const mockMessages = [
{
id: "msg-1",
messageId: "uuid-1",
connectionId: "conn-1",
query: "tasks.list",
status: FederationMessageStatus.DELIVERED,
response: { data: [] },
createdAt: new Date().toISOString(),
},
{
id: "msg-2",
messageId: "uuid-2",
connectionId: "conn-2",
query: "events.list",
status: FederationMessageStatus.PENDING,
createdAt: new Date().toISOString(),
},
];
vi.mocked(client.apiGet).mockResolvedValue(mockMessages);
const result = await fetchQueryMessages();
expect(client.apiGet).toHaveBeenCalledWith("/api/v1/federation/queries");
expect(result).toEqual(mockMessages);
});
it("should fetch query messages filtered by status", async () => {
const mockMessages = [
{
id: "msg-1",
messageId: "uuid-1",
connectionId: "conn-1",
query: "tasks.list",
status: FederationMessageStatus.DELIVERED,
response: { data: [] },
createdAt: new Date().toISOString(),
},
];
vi.mocked(client.apiGet).mockResolvedValue(mockMessages);
const result = await fetchQueryMessages(FederationMessageStatus.DELIVERED);
expect(client.apiGet).toHaveBeenCalledWith("/api/v1/federation/queries?status=DELIVERED");
expect(result).toEqual(mockMessages);
});
});
describe("fetchQueryMessage", () => {
it("should fetch a single query message", async () => {
const mockMessage = {
id: "msg-123",
messageId: "uuid-123",
connectionId: "conn-1",
query: "tasks.list",
status: FederationMessageStatus.DELIVERED,
response: { data: [{ id: "task-1", title: "Test Task" }] },
createdAt: new Date().toISOString(),
};
vi.mocked(client.apiGet).mockResolvedValue(mockMessage);
const result = await fetchQueryMessage("msg-123");
expect(client.apiGet).toHaveBeenCalledWith("/api/v1/federation/queries/msg-123");
expect(result).toEqual(mockMessage);
});
it("should handle not found errors", async () => {
vi.mocked(client.apiGet).mockRejectedValue(new Error("Not found"));
await expect(fetchQueryMessage("msg-999")).rejects.toThrow("Not found");
});
});
});

View File

@@ -0,0 +1,90 @@
/**
* Federation Queries API Client
* Handles federated query requests to remote instances
*/
import { apiGet, apiPost } from "./client";
/**
* Federation message status (matches backend enum)
*/
export enum FederationMessageStatus {
PENDING = "PENDING",
DELIVERED = "DELIVERED",
FAILED = "FAILED",
}
/**
* Query message details
*/
export interface QueryMessageDetails {
id: string;
workspaceId: string;
connectionId: string;
messageType: string;
messageId: string;
correlationId?: string;
query?: string;
response?: unknown;
status: FederationMessageStatus;
error?: string;
createdAt: string;
updatedAt: string;
deliveredAt?: string;
}
/**
* Send query request
*/
export interface SendQueryRequest {
connectionId: string;
query: string;
context?: Record<string, unknown>;
}
/**
* Send a federated query to a remote instance
*/
export async function sendFederatedQuery(
connectionId: string,
query: string,
context?: Record<string, unknown>
): Promise<QueryMessageDetails> {
const request: SendQueryRequest = {
connectionId,
query,
};
if (context) {
request.context = context;
}
return apiPost<QueryMessageDetails>("/api/v1/federation/query", request);
}
/**
* Fetch all query messages for the workspace
*/
export async function fetchQueryMessages(
status?: FederationMessageStatus
): Promise<QueryMessageDetails[]> {
const params = new URLSearchParams();
if (status) {
params.append("status", status);
}
const queryString = params.toString();
const endpoint = queryString
? `/api/v1/federation/queries?${queryString}`
: "/api/v1/federation/queries";
return apiGet<QueryMessageDetails[]>(endpoint);
}
/**
* Fetch a single query message
*/
export async function fetchQueryMessage(messageId: string): Promise<QueryMessageDetails> {
return apiGet<QueryMessageDetails>(`/api/v1/federation/queries/${messageId}`);
}