Merge branch 'develop' into feature/52-active-projects-widget
This commit is contained in:
220
apps/web/src/app/(authenticated)/federation/connections/page.tsx
Normal file
220
apps/web/src/app/(authenticated)/federation/connections/page.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Federation Connections Page
|
||||
* Manage connections to remote Mosaic Stack instances
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { ConnectionList } from "@/components/federation/ConnectionList";
|
||||
import { InitiateConnectionDialog } from "@/components/federation/InitiateConnectionDialog";
|
||||
import {
|
||||
mockConnections,
|
||||
FederationConnectionStatus,
|
||||
type ConnectionDetails,
|
||||
} from "@/lib/api/federation";
|
||||
|
||||
// TODO: Replace with real API calls when backend is integrated
|
||||
// import {
|
||||
// fetchConnections,
|
||||
// initiateConnection,
|
||||
// acceptConnection,
|
||||
// rejectConnection,
|
||||
// disconnectConnection,
|
||||
// } from "@/lib/api/federation";
|
||||
|
||||
export default function ConnectionsPage(): React.JSX.Element {
|
||||
const [connections, setConnections] = useState<ConnectionDetails[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [dialogLoading, setDialogLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dialogError, setDialogError] = useState<string | null>(null);
|
||||
|
||||
// Load connections on mount
|
||||
useEffect(() => {
|
||||
void loadConnections();
|
||||
}, []);
|
||||
|
||||
const loadConnections = async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// TODO: Replace with real API call when backend is integrated
|
||||
// const data = await fetchConnections();
|
||||
|
||||
// Using mock data for now
|
||||
await new Promise((resolve) => setTimeout(resolve, 500)); // Simulate network delay
|
||||
setConnections(mockConnections);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Unable to load connections. Please try again."
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInitiate = async (_url: string): Promise<void> => {
|
||||
setDialogLoading(true);
|
||||
setDialogError(null);
|
||||
|
||||
try {
|
||||
// TODO: Replace with real API call
|
||||
// const newConnection = await initiateConnection({ remoteUrl: url });
|
||||
|
||||
// Simulate API call for now
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// Close dialog and reload connections
|
||||
setShowDialog(false);
|
||||
await loadConnections();
|
||||
} catch (err) {
|
||||
setDialogError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Unable to initiate connection. Please check the URL and try again."
|
||||
);
|
||||
} finally {
|
||||
setDialogLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccept = async (connectionId: string): Promise<void> => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// TODO: Replace with real API call
|
||||
// await acceptConnection(connectionId);
|
||||
|
||||
// Simulate API call and optimistic update
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
setConnections((prev) =>
|
||||
prev.map((conn) =>
|
||||
conn.id === connectionId
|
||||
? {
|
||||
...conn,
|
||||
status: FederationConnectionStatus.ACTIVE,
|
||||
connectedAt: new Date().toISOString(),
|
||||
}
|
||||
: conn
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Unable to accept connection. Please try again."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (connectionId: string): Promise<void> => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// TODO: Replace with real API call
|
||||
// await rejectConnection(connectionId, { reason: "User declined" });
|
||||
|
||||
// Simulate API call and optimistic update
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
setConnections((prev) =>
|
||||
prev.map((conn) =>
|
||||
conn.id === connectionId
|
||||
? {
|
||||
...conn,
|
||||
status: FederationConnectionStatus.REJECTED,
|
||||
}
|
||||
: conn
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Unable to reject connection. Please try again."
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async (connectionId: string): Promise<void> => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// TODO: Replace with real API call
|
||||
// await disconnectConnection(connectionId);
|
||||
|
||||
// Simulate API call and optimistic update
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
setConnections((prev) =>
|
||||
prev.map((conn) =>
|
||||
conn.id === connectionId
|
||||
? {
|
||||
...conn,
|
||||
status: FederationConnectionStatus.DISCONNECTED,
|
||||
disconnectedAt: new Date().toISOString(),
|
||||
}
|
||||
: conn
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unable to disconnect. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Federation Connections</h1>
|
||||
<p className="text-gray-600 mt-2">Manage connections to other Mosaic Stack instances</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDialog(true);
|
||||
}}
|
||||
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Connect to Instance
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
}}
|
||||
className="text-sm text-red-700 underline mt-2"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection List */}
|
||||
<ConnectionList
|
||||
connections={connections}
|
||||
isLoading={isLoading}
|
||||
onAccept={handleAccept}
|
||||
onReject={handleReject}
|
||||
onDisconnect={handleDisconnect}
|
||||
/>
|
||||
|
||||
{/* Initiate Connection Dialog */}
|
||||
<InitiateConnectionDialog
|
||||
open={showDialog}
|
||||
onInitiate={handleInitiate}
|
||||
onCancel={() => {
|
||||
setShowDialog(false);
|
||||
setDialogError(null);
|
||||
}}
|
||||
isLoading={dialogLoading}
|
||||
{...(dialogError && { error: dialogError })}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
175
apps/web/src/app/(authenticated)/federation/dashboard/page.tsx
Normal file
175
apps/web/src/app/(authenticated)/federation/dashboard/page.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Aggregated Dashboard Page
|
||||
* Displays data from multiple federated instances in a unified view
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { AggregatedDataGrid } from "@/components/federation/AggregatedDataGrid";
|
||||
import type { FederatedTask, FederatedEvent } from "@/components/federation/types";
|
||||
import {
|
||||
fetchConnections,
|
||||
FederationConnectionStatus,
|
||||
type ConnectionDetails,
|
||||
} from "@/lib/api/federation";
|
||||
import { sendFederatedQuery } from "@/lib/api/federation-queries";
|
||||
import type { Task, Event } from "@mosaic/shared";
|
||||
|
||||
export default function AggregatedDashboardPage(): React.JSX.Element {
|
||||
const [tasks, setTasks] = useState<FederatedTask[]>([]);
|
||||
const [events, setEvents] = useState<FederatedEvent[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connections, setConnections] = useState<ConnectionDetails[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAggregatedData();
|
||||
}, []);
|
||||
|
||||
async function loadAggregatedData(): Promise<void> {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Fetch all active connections
|
||||
const allConnections = await fetchConnections(FederationConnectionStatus.ACTIVE);
|
||||
setConnections(allConnections);
|
||||
|
||||
if (allConnections.length === 0) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Query each connection for tasks and events
|
||||
const allTasks: FederatedTask[] = [];
|
||||
const allEvents: FederatedEvent[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const connection of allConnections) {
|
||||
try {
|
||||
// Query tasks
|
||||
if (connection.remoteCapabilities.supportsQuery) {
|
||||
const taskResponse = await sendFederatedQuery(connection.id, "tasks.list", {
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
// Wait a bit for the query to be processed and response received
|
||||
// In production, this would use WebSocket or polling
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// For MVP, we'll use mock data since query processing is async
|
||||
// In production, we'd poll for the response or use WebSocket
|
||||
if (taskResponse.response) {
|
||||
const responseTasks = (taskResponse.response as { data?: Task[] }).data ?? [];
|
||||
const federatedTasks = responseTasks.map((task) => ({
|
||||
task,
|
||||
provenance: {
|
||||
instanceId: connection.remoteInstanceId,
|
||||
instanceName:
|
||||
(connection.metadata.name as string | undefined) ?? connection.remoteUrl,
|
||||
instanceUrl: connection.remoteUrl,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
}));
|
||||
allTasks.push(...federatedTasks);
|
||||
}
|
||||
|
||||
// Query events
|
||||
const eventResponse = await sendFederatedQuery(connection.id, "events.list", {
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
if (eventResponse.response) {
|
||||
const responseEvents = (eventResponse.response as { data?: Event[] }).data ?? [];
|
||||
const federatedEvents = responseEvents.map((event) => ({
|
||||
event,
|
||||
provenance: {
|
||||
instanceId: connection.remoteInstanceId,
|
||||
instanceName:
|
||||
(connection.metadata.name as string | undefined) ?? connection.remoteUrl,
|
||||
instanceUrl: connection.remoteUrl,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
}));
|
||||
allEvents.push(...federatedEvents);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
||||
const instanceName =
|
||||
(connection.metadata.name as string | undefined) ?? connection.remoteUrl;
|
||||
errors.push(`Unable to reach ${instanceName}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
setTasks(allTasks);
|
||||
setEvents(allEvents);
|
||||
|
||||
if (errors.length > 0 && allTasks.length === 0 && allEvents.length === 0) {
|
||||
setError(errors.join(", "));
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Unable to load connections";
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefresh(): Promise<void> {
|
||||
await loadAggregatedData();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Aggregated Dashboard</h1>
|
||||
<p className="text-gray-600 mt-2">View tasks and events from all connected instances</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isLoading ? "Loading..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Connection status */}
|
||||
{!isLoading && connections.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<p className="text-sm text-gray-600">
|
||||
Connected to <span className="font-medium text-gray-900">{connections.length}</span>{" "}
|
||||
{connections.length === 1 ? "instance" : "instances"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection warning */}
|
||||
{!isLoading && connections.length === 0 && (
|
||||
<div className="mb-6 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p className="text-sm text-yellow-800">
|
||||
No active connections found. Please visit the{" "}
|
||||
<a href="/federation/connections" className="font-medium underline">
|
||||
Connection Manager
|
||||
</a>{" "}
|
||||
to connect to remote instances.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Data grid */}
|
||||
<AggregatedDataGrid
|
||||
tasks={tasks}
|
||||
events={events}
|
||||
isLoading={isLoading}
|
||||
{...(error !== null && { error })}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
228
apps/web/src/app/(authenticated)/federation/settings/page.tsx
Normal file
228
apps/web/src/app/(authenticated)/federation/settings/page.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Federation Settings Page
|
||||
* Configure local instance federation settings (spoke configuration)
|
||||
* Admin-only page
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { SpokeConfigurationForm } from "@/components/federation/SpokeConfigurationForm";
|
||||
import {
|
||||
fetchInstanceIdentity,
|
||||
updateInstanceConfiguration,
|
||||
regenerateInstanceKeys,
|
||||
type PublicInstanceIdentity,
|
||||
type UpdateInstanceRequest,
|
||||
} from "@/lib/api/federation";
|
||||
|
||||
export default function FederationSettingsPage(): React.JSX.Element {
|
||||
const [instance, setInstance] = useState<PublicInstanceIdentity | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [showRegenerateConfirm, setShowRegenerateConfirm] = useState(false);
|
||||
|
||||
// Load instance identity on mount
|
||||
useEffect(() => {
|
||||
void loadInstance();
|
||||
}, []);
|
||||
|
||||
const loadInstance = async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await fetchInstanceIdentity();
|
||||
setInstance(data);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Unable to load instance configuration. Please try again."
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (updates: UpdateInstanceRequest): Promise<void> => {
|
||||
setIsSaving(true);
|
||||
setSaveError(null);
|
||||
setSuccessMessage(null);
|
||||
|
||||
try {
|
||||
const updatedInstance = await updateInstanceConfiguration(updates);
|
||||
setInstance(updatedInstance);
|
||||
setSuccessMessage("Configuration saved successfully");
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
setSuccessMessage(null);
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
setSaveError(
|
||||
err instanceof Error ? err.message : "Unable to save configuration. Please try again."
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerateKeys = async (): Promise<void> => {
|
||||
setIsRegenerating(true);
|
||||
setSaveError(null);
|
||||
setSuccessMessage(null);
|
||||
|
||||
try {
|
||||
const updatedInstance = await regenerateInstanceKeys();
|
||||
setInstance(updatedInstance);
|
||||
setSuccessMessage("Instance keypair regenerated successfully");
|
||||
setShowRegenerateConfirm(false);
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
setSuccessMessage(null);
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
setSaveError(
|
||||
err instanceof Error ? err.message : "Unable to regenerate keys. Please try again."
|
||||
);
|
||||
} finally {
|
||||
setIsRegenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-8">Federation Settings</h1>
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-8 text-center">
|
||||
<div className="text-gray-600">Loading configuration...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-8">Federation Settings</h1>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-8">
|
||||
<h2 className="text-lg font-semibold text-red-900 mb-2">
|
||||
Unable to Load Configuration
|
||||
</h2>
|
||||
<p className="text-red-700">{error}</p>
|
||||
<button
|
||||
onClick={() => void loadInstance()}
|
||||
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No instance (shouldn't happen, but handle gracefully)
|
||||
if (!instance) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-8">Federation Settings</h1>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
|
||||
<div className="text-gray-600">No instance configuration found</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Federation Settings</h1>
|
||||
<p className="text-gray-600">
|
||||
Configure your instance's federation capabilities and identity. These settings determine
|
||||
how your instance interacts with other Mosaic Stack instances.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Success Message */}
|
||||
{successMessage && (
|
||||
<div className="mb-6 bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg">
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Configuration Form */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<SpokeConfigurationForm
|
||||
instance={instance}
|
||||
onSave={handleSave}
|
||||
isLoading={isSaving}
|
||||
{...(saveError && { error: saveError })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Advanced Section: Regenerate Keys */}
|
||||
<div className="mt-8 bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Advanced</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Regenerating your instance's keypair will invalidate all existing federation
|
||||
connections. Connected instances will need to re-establish connections with your new
|
||||
public key.
|
||||
</p>
|
||||
|
||||
{showRegenerateConfirm ? (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<h4 className="font-medium text-yellow-900 mb-2">Confirm Keypair Regeneration</h4>
|
||||
<p className="text-sm text-yellow-800 mb-4">
|
||||
This action will disconnect all federated instances. They will need to reconnect
|
||||
using your new public key. This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={() => void handleRegenerateKeys()}
|
||||
disabled={isRegenerating}
|
||||
className="px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isRegenerating ? "Regenerating..." : "Confirm Regenerate"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowRegenerateConfirm(false);
|
||||
}}
|
||||
disabled={isRegenerating}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 disabled:bg-gray-100 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowRegenerateConfirm(true);
|
||||
}}
|
||||
className="px-4 py-2 border border-yellow-600 text-yellow-700 rounded-lg hover:bg-yellow-50 transition-colors"
|
||||
>
|
||||
Regenerate Keypair
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
apps/web/src/components/federation/AggregatedDataGrid.test.tsx
Normal file
156
apps/web/src/components/federation/AggregatedDataGrid.test.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* AggregatedDataGrid Component Tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { AggregatedDataGrid } from "./AggregatedDataGrid";
|
||||
import { TaskStatus, TaskPriority } from "@mosaic/shared";
|
||||
import type { FederatedTask, FederatedEvent } from "./types";
|
||||
|
||||
const mockTasks: FederatedTask[] = [
|
||||
{
|
||||
task: {
|
||||
id: "task-1",
|
||||
title: "Task from Work",
|
||||
description: "Work task",
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
priority: TaskPriority.HIGH,
|
||||
dueDate: new Date("2026-02-05"),
|
||||
creatorId: "user-1",
|
||||
assigneeId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
metadata: {},
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-02-03"),
|
||||
updatedAt: new Date("2026-02-03"),
|
||||
},
|
||||
provenance: {
|
||||
instanceId: "instance-work-001",
|
||||
instanceName: "Work Instance",
|
||||
instanceUrl: "https://mosaic.work.example.com",
|
||||
timestamp: "2026-02-03T14:00:00Z",
|
||||
},
|
||||
},
|
||||
{
|
||||
task: {
|
||||
id: "task-2",
|
||||
title: "Task from Home",
|
||||
description: "Home task",
|
||||
status: TaskStatus.NOT_STARTED,
|
||||
priority: TaskPriority.MEDIUM,
|
||||
dueDate: new Date("2026-02-06"),
|
||||
creatorId: "user-1",
|
||||
assigneeId: "user-1",
|
||||
workspaceId: "workspace-2",
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
metadata: {},
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-02-03"),
|
||||
updatedAt: new Date("2026-02-03"),
|
||||
},
|
||||
provenance: {
|
||||
instanceId: "instance-home-001",
|
||||
instanceName: "Home Instance",
|
||||
instanceUrl: "https://mosaic.home.example.com",
|
||||
timestamp: "2026-02-03T14:00:00Z",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mockEvents: FederatedEvent[] = [
|
||||
{
|
||||
event: {
|
||||
id: "event-1",
|
||||
title: "Meeting from Work",
|
||||
description: "Team standup",
|
||||
startTime: new Date("2026-02-05T10:00:00"),
|
||||
endTime: new Date("2026-02-05T10:30:00"),
|
||||
allDay: false,
|
||||
location: "Zoom",
|
||||
recurrence: null,
|
||||
creatorId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
metadata: {},
|
||||
createdAt: new Date("2026-02-03"),
|
||||
updatedAt: new Date("2026-02-03"),
|
||||
},
|
||||
provenance: {
|
||||
instanceId: "instance-work-001",
|
||||
instanceName: "Work Instance",
|
||||
instanceUrl: "https://mosaic.work.example.com",
|
||||
timestamp: "2026-02-03T14:00:00Z",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("AggregatedDataGrid", () => {
|
||||
it("should render tasks", () => {
|
||||
render(<AggregatedDataGrid tasks={mockTasks} events={[]} />);
|
||||
|
||||
expect(screen.getByText("Task from Work")).toBeInTheDocument();
|
||||
expect(screen.getByText("Task from Home")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render events", () => {
|
||||
render(<AggregatedDataGrid tasks={[]} events={mockEvents} />);
|
||||
|
||||
expect(screen.getByText("Meeting from Work")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render both tasks and events", () => {
|
||||
render(<AggregatedDataGrid tasks={mockTasks} events={mockEvents} />);
|
||||
|
||||
expect(screen.getByText("Task from Work")).toBeInTheDocument();
|
||||
expect(screen.getByText("Meeting from Work")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show loading state", () => {
|
||||
render(<AggregatedDataGrid tasks={[]} events={[]} isLoading={true} />);
|
||||
|
||||
expect(screen.getByText("Loading data from instances...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show empty state when no data", () => {
|
||||
render(<AggregatedDataGrid tasks={[]} events={[]} />);
|
||||
|
||||
expect(screen.getByText("No data available from connected instances")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show error message", () => {
|
||||
render(<AggregatedDataGrid tasks={[]} events={[]} error="Unable to reach work instance" />);
|
||||
|
||||
expect(screen.getByText("Unable to reach work instance")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with custom className", () => {
|
||||
const { container } = render(
|
||||
<AggregatedDataGrid tasks={mockTasks} events={[]} className="custom-class" />
|
||||
);
|
||||
|
||||
expect(container.querySelector(".custom-class")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show instance provenance indicators", () => {
|
||||
render(<AggregatedDataGrid tasks={mockTasks} events={[]} />);
|
||||
|
||||
expect(screen.getByText("Work Instance")).toBeInTheDocument();
|
||||
expect(screen.getByText("Home Instance")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with compact mode", () => {
|
||||
const { container } = render(
|
||||
<AggregatedDataGrid tasks={mockTasks} events={[]} compact={true} />
|
||||
);
|
||||
|
||||
// Check that cards have compact padding
|
||||
const compactCards = container.querySelectorAll(".p-3");
|
||||
expect(compactCards.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
100
apps/web/src/components/federation/AggregatedDataGrid.tsx
Normal file
100
apps/web/src/components/federation/AggregatedDataGrid.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* AggregatedDataGrid Component
|
||||
* Displays aggregated tasks and events from multiple federated instances
|
||||
*/
|
||||
|
||||
import type { FederatedTask, FederatedEvent } from "./types";
|
||||
import { FederatedTaskCard } from "./FederatedTaskCard";
|
||||
import { FederatedEventCard } from "./FederatedEventCard";
|
||||
|
||||
interface AggregatedDataGridProps {
|
||||
tasks: FederatedTask[];
|
||||
events: FederatedEvent[];
|
||||
isLoading?: boolean;
|
||||
error?: string;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AggregatedDataGrid({
|
||||
tasks,
|
||||
events,
|
||||
isLoading = false,
|
||||
error,
|
||||
compact = false,
|
||||
className = "",
|
||||
}: AggregatedDataGridProps): React.JSX.Element {
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center p-8 ${className}`}>
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mb-4"></div>
|
||||
<p className="text-gray-600">Loading data from instances...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center p-8 ${className}`}>
|
||||
<div className="text-center">
|
||||
<span className="text-4xl mb-4 block">⚠️</span>
|
||||
<p className="text-gray-900 font-medium mb-2">Unable to load data</p>
|
||||
<p className="text-gray-600 text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (tasks.length === 0 && events.length === 0) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center p-8 ${className}`}>
|
||||
<div className="text-center">
|
||||
<span className="text-4xl mb-4 block">📋</span>
|
||||
<p className="text-gray-900 font-medium mb-2">No data available</p>
|
||||
<p className="text-gray-600 text-sm">No data available from connected instances</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{/* Tasks section */}
|
||||
{tasks.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-3">Tasks ({tasks.length})</h3>
|
||||
<div className="space-y-2">
|
||||
{tasks.map((federatedTask) => (
|
||||
<FederatedTaskCard
|
||||
key={`task-${federatedTask.task.id}-${federatedTask.provenance.instanceId}`}
|
||||
federatedTask={federatedTask}
|
||||
compact={compact}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Events section */}
|
||||
{events.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-3">Events ({events.length})</h3>
|
||||
<div className="space-y-2">
|
||||
{events.map((federatedEvent) => (
|
||||
<FederatedEventCard
|
||||
key={`event-${federatedEvent.event.id}-${federatedEvent.provenance.instanceId}`}
|
||||
federatedEvent={federatedEvent}
|
||||
compact={compact}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
apps/web/src/components/federation/ConnectionCard.test.tsx
Normal file
201
apps/web/src/components/federation/ConnectionCard.test.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* ConnectionCard Component Tests
|
||||
* Following TDD - write tests first!
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ConnectionCard } from "./ConnectionCard";
|
||||
import { FederationConnectionStatus, type ConnectionDetails } from "@/lib/api/federation";
|
||||
|
||||
describe("ConnectionCard", (): void => {
|
||||
const mockActiveConnection: ConnectionDetails = {
|
||||
id: "conn-1",
|
||||
workspaceId: "workspace-1",
|
||||
remoteInstanceId: "instance-work-001",
|
||||
remoteUrl: "https://mosaic.work.example.com",
|
||||
remotePublicKey: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----",
|
||||
remoteCapabilities: {
|
||||
supportsQuery: true,
|
||||
supportsCommand: true,
|
||||
supportsEvent: true,
|
||||
supportsAgentSpawn: true,
|
||||
protocolVersion: "1.0",
|
||||
},
|
||||
status: FederationConnectionStatus.ACTIVE,
|
||||
metadata: {
|
||||
name: "Work Instance",
|
||||
description: "Corporate Mosaic instance",
|
||||
},
|
||||
createdAt: new Date("2026-02-01").toISOString(),
|
||||
updatedAt: new Date("2026-02-01").toISOString(),
|
||||
connectedAt: new Date("2026-02-01").toISOString(),
|
||||
disconnectedAt: null,
|
||||
};
|
||||
|
||||
const mockPendingConnection: ConnectionDetails = {
|
||||
...mockActiveConnection,
|
||||
id: "conn-2",
|
||||
status: FederationConnectionStatus.PENDING,
|
||||
metadata: {
|
||||
name: "Partner Instance",
|
||||
description: "Awaiting acceptance",
|
||||
},
|
||||
connectedAt: null,
|
||||
};
|
||||
|
||||
const mockOnAccept = vi.fn();
|
||||
const mockOnReject = vi.fn();
|
||||
const mockOnDisconnect = vi.fn();
|
||||
|
||||
it("should render connection name from metadata", (): void => {
|
||||
render(<ConnectionCard connection={mockActiveConnection} />);
|
||||
expect(screen.getByText("Work Instance")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render connection URL", (): void => {
|
||||
render(<ConnectionCard connection={mockActiveConnection} />);
|
||||
expect(screen.getByText("https://mosaic.work.example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render connection description from metadata", (): void => {
|
||||
render(<ConnectionCard connection={mockActiveConnection} />);
|
||||
expect(screen.getByText("Corporate Mosaic instance")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show Active status with green indicator for active connections", (): void => {
|
||||
render(<ConnectionCard connection={mockActiveConnection} />);
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show Pending status with blue indicator for pending connections", (): void => {
|
||||
render(<ConnectionCard connection={mockPendingConnection} />);
|
||||
expect(screen.getByText("Pending")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show Disconnected status for disconnected connections", (): void => {
|
||||
const disconnectedConnection = {
|
||||
...mockActiveConnection,
|
||||
status: FederationConnectionStatus.DISCONNECTED,
|
||||
disconnectedAt: new Date("2026-02-02").toISOString(),
|
||||
};
|
||||
render(<ConnectionCard connection={disconnectedConnection} />);
|
||||
expect(screen.getByText("Disconnected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show Rejected status for rejected connections", (): void => {
|
||||
const rejectedConnection = {
|
||||
...mockActiveConnection,
|
||||
status: FederationConnectionStatus.REJECTED,
|
||||
};
|
||||
render(<ConnectionCard connection={rejectedConnection} />);
|
||||
expect(screen.getByText("Rejected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show Disconnect button for active connections", (): void => {
|
||||
render(<ConnectionCard connection={mockActiveConnection} onDisconnect={mockOnDisconnect} />);
|
||||
expect(screen.getByRole("button", { name: /disconnect/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show Accept and Reject buttons for pending connections", (): void => {
|
||||
render(
|
||||
<ConnectionCard
|
||||
connection={mockPendingConnection}
|
||||
onAccept={mockOnAccept}
|
||||
onReject={mockOnReject}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /accept/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /reject/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show action buttons for disconnected connections", (): void => {
|
||||
const disconnectedConnection = {
|
||||
...mockActiveConnection,
|
||||
status: FederationConnectionStatus.DISCONNECTED,
|
||||
};
|
||||
render(<ConnectionCard connection={disconnectedConnection} />);
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onAccept when accept button clicked", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConnectionCard
|
||||
connection={mockPendingConnection}
|
||||
onAccept={mockOnAccept}
|
||||
onReject={mockOnReject}
|
||||
/>
|
||||
);
|
||||
|
||||
const acceptButton = screen.getByRole("button", { name: /accept/i });
|
||||
await user.click(acceptButton);
|
||||
|
||||
expect(mockOnAccept).toHaveBeenCalledWith(mockPendingConnection.id);
|
||||
});
|
||||
|
||||
it("should call onReject when reject button clicked", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConnectionCard
|
||||
connection={mockPendingConnection}
|
||||
onAccept={mockOnAccept}
|
||||
onReject={mockOnReject}
|
||||
/>
|
||||
);
|
||||
|
||||
const rejectButton = screen.getByRole("button", { name: /reject/i });
|
||||
await user.click(rejectButton);
|
||||
|
||||
expect(mockOnReject).toHaveBeenCalledWith(mockPendingConnection.id);
|
||||
});
|
||||
|
||||
it("should call onDisconnect when disconnect button clicked", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
render(<ConnectionCard connection={mockActiveConnection} onDisconnect={mockOnDisconnect} />);
|
||||
|
||||
const disconnectButton = screen.getByRole("button", { name: /disconnect/i });
|
||||
await user.click(disconnectButton);
|
||||
|
||||
expect(mockOnDisconnect).toHaveBeenCalledWith(mockActiveConnection.id);
|
||||
});
|
||||
|
||||
it("should display capabilities when showDetails is true", (): void => {
|
||||
render(<ConnectionCard connection={mockActiveConnection} showDetails={true} />);
|
||||
expect(screen.getByText(/Query/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Command/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Events/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Agent Spawn/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display capabilities by default", (): void => {
|
||||
render(<ConnectionCard connection={mockActiveConnection} />);
|
||||
// Capabilities should not be visible without showDetails=true
|
||||
const card = screen.getByText("Work Instance").closest("div");
|
||||
expect(card?.textContent).not.toMatch(/Query.*Command.*Events/);
|
||||
});
|
||||
|
||||
it("should use fallback name if metadata name is missing", (): void => {
|
||||
const connectionWithoutName = {
|
||||
...mockActiveConnection,
|
||||
metadata: {},
|
||||
};
|
||||
render(<ConnectionCard connection={connectionWithoutName} />);
|
||||
expect(screen.getByText("Remote Instance")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with compact layout when compact prop is true", (): void => {
|
||||
const { container } = render(
|
||||
<ConnectionCard connection={mockActiveConnection} compact={true} />
|
||||
);
|
||||
// Verify compact class is applied
|
||||
expect(container.querySelector(".p-3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with full layout by default", (): void => {
|
||||
const { container } = render(<ConnectionCard connection={mockActiveConnection} />);
|
||||
// Verify full padding is applied
|
||||
expect(container.querySelector(".p-4")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
152
apps/web/src/components/federation/ConnectionCard.tsx
Normal file
152
apps/web/src/components/federation/ConnectionCard.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* ConnectionCard Component
|
||||
* Displays a single federation connection with PDA-friendly design
|
||||
*/
|
||||
|
||||
import { FederationConnectionStatus, type ConnectionDetails } from "@/lib/api/federation";
|
||||
|
||||
interface ConnectionCardProps {
|
||||
connection: ConnectionDetails;
|
||||
onAccept?: (connectionId: string) => void;
|
||||
onReject?: (connectionId: string) => void;
|
||||
onDisconnect?: (connectionId: string) => void;
|
||||
showDetails?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PDA-friendly status text and color
|
||||
*/
|
||||
function getStatusDisplay(status: FederationConnectionStatus): {
|
||||
text: string;
|
||||
colorClass: string;
|
||||
icon: string;
|
||||
} {
|
||||
switch (status) {
|
||||
case FederationConnectionStatus.ACTIVE:
|
||||
return {
|
||||
text: "Active",
|
||||
colorClass: "text-green-600 bg-green-50",
|
||||
icon: "🟢",
|
||||
};
|
||||
case FederationConnectionStatus.PENDING:
|
||||
return {
|
||||
text: "Pending",
|
||||
colorClass: "text-blue-600 bg-blue-50",
|
||||
icon: "🔵",
|
||||
};
|
||||
case FederationConnectionStatus.DISCONNECTED:
|
||||
return {
|
||||
text: "Disconnected",
|
||||
colorClass: "text-yellow-600 bg-yellow-50",
|
||||
icon: "⏸️",
|
||||
};
|
||||
case FederationConnectionStatus.REJECTED:
|
||||
return {
|
||||
text: "Rejected",
|
||||
colorClass: "text-gray-600 bg-gray-50",
|
||||
icon: "⚪",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function ConnectionCard({
|
||||
connection,
|
||||
onAccept,
|
||||
onReject,
|
||||
onDisconnect,
|
||||
showDetails = false,
|
||||
compact = false,
|
||||
}: ConnectionCardProps): React.JSX.Element {
|
||||
const status = getStatusDisplay(connection.status);
|
||||
const name =
|
||||
typeof connection.metadata.name === "string" ? connection.metadata.name : "Remote Instance";
|
||||
const description = connection.metadata.description as string | undefined;
|
||||
|
||||
const paddingClass = compact ? "p-3" : "p-4";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border border-gray-200 rounded-lg ${paddingClass} hover:border-gray-300 transition-colors`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-gray-900">{name}</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">{connection.remoteUrl}</p>
|
||||
{description && <p className="text-sm text-gray-500 mt-1">{description}</p>}
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div
|
||||
className={`flex items-center gap-1 px-3 py-1 rounded-full text-sm font-medium ${status.colorClass}`}
|
||||
>
|
||||
<span>{status.icon}</span>
|
||||
<span>{status.text}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Capabilities (when showDetails is true) */}
|
||||
{showDetails && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<p className="text-xs font-medium text-gray-700 mb-2">Capabilities</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{connection.remoteCapabilities.supportsQuery && (
|
||||
<span className="text-xs px-2 py-1 bg-gray-100 text-gray-700 rounded">Query</span>
|
||||
)}
|
||||
{connection.remoteCapabilities.supportsCommand && (
|
||||
<span className="text-xs px-2 py-1 bg-gray-100 text-gray-700 rounded">Command</span>
|
||||
)}
|
||||
{connection.remoteCapabilities.supportsEvent && (
|
||||
<span className="text-xs px-2 py-1 bg-gray-100 text-gray-700 rounded">Events</span>
|
||||
)}
|
||||
{connection.remoteCapabilities.supportsAgentSpawn && (
|
||||
<span className="text-xs px-2 py-1 bg-gray-100 text-gray-700 rounded">
|
||||
Agent Spawn
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{connection.status === FederationConnectionStatus.PENDING && (onAccept ?? onReject) && (
|
||||
<div className="mt-4 flex gap-2">
|
||||
{onAccept && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onAccept(connection.id);
|
||||
}}
|
||||
className="px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-md hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
)}
|
||||
{onReject && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onReject(connection.id);
|
||||
}}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 text-sm font-medium rounded-md hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connection.status === FederationConnectionStatus.ACTIVE && onDisconnect && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
onDisconnect(connection.id);
|
||||
}}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 text-sm font-medium rounded-md hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
apps/web/src/components/federation/ConnectionList.test.tsx
Normal file
120
apps/web/src/components/federation/ConnectionList.test.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* ConnectionList Component Tests
|
||||
* Following TDD - write tests first!
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ConnectionList } from "./ConnectionList";
|
||||
import { FederationConnectionStatus, type ConnectionDetails } from "@/lib/api/federation";
|
||||
|
||||
describe("ConnectionList", (): void => {
|
||||
const mockConnections: ConnectionDetails[] = [
|
||||
{
|
||||
id: "conn-1",
|
||||
workspaceId: "workspace-1",
|
||||
remoteInstanceId: "instance-work-001",
|
||||
remoteUrl: "https://mosaic.work.example.com",
|
||||
remotePublicKey: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----",
|
||||
remoteCapabilities: {
|
||||
supportsQuery: true,
|
||||
supportsCommand: true,
|
||||
supportsEvent: true,
|
||||
supportsAgentSpawn: true,
|
||||
protocolVersion: "1.0",
|
||||
},
|
||||
status: FederationConnectionStatus.ACTIVE,
|
||||
metadata: {
|
||||
name: "Work Instance",
|
||||
description: "Corporate Mosaic instance",
|
||||
},
|
||||
createdAt: new Date("2026-02-01").toISOString(),
|
||||
updatedAt: new Date("2026-02-01").toISOString(),
|
||||
connectedAt: new Date("2026-02-01").toISOString(),
|
||||
disconnectedAt: null,
|
||||
},
|
||||
{
|
||||
id: "conn-2",
|
||||
workspaceId: "workspace-1",
|
||||
remoteInstanceId: "instance-partner-001",
|
||||
remoteUrl: "https://mosaic.partner.example.com",
|
||||
remotePublicKey: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----",
|
||||
remoteCapabilities: {
|
||||
supportsQuery: true,
|
||||
supportsCommand: false,
|
||||
supportsEvent: true,
|
||||
supportsAgentSpawn: false,
|
||||
protocolVersion: "1.0",
|
||||
},
|
||||
status: FederationConnectionStatus.PENDING,
|
||||
metadata: {
|
||||
name: "Partner Instance",
|
||||
description: "Awaiting acceptance",
|
||||
},
|
||||
createdAt: new Date("2026-02-02").toISOString(),
|
||||
updatedAt: new Date("2026-02-02").toISOString(),
|
||||
connectedAt: null,
|
||||
disconnectedAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
const mockOnAccept = vi.fn();
|
||||
const mockOnReject = vi.fn();
|
||||
const mockOnDisconnect = vi.fn();
|
||||
|
||||
it("should render loading state", (): void => {
|
||||
render(<ConnectionList connections={[]} isLoading={true} />);
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render empty state when no connections", (): void => {
|
||||
render(<ConnectionList connections={[]} isLoading={false} />);
|
||||
expect(screen.getByText(/no federation connections/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render PDA-friendly empty state message", (): void => {
|
||||
render(<ConnectionList connections={[]} isLoading={false} />);
|
||||
expect(screen.getByText(/ready to connect/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all connections", (): void => {
|
||||
render(<ConnectionList connections={mockConnections} isLoading={false} />);
|
||||
expect(screen.getByText("Work Instance")).toBeInTheDocument();
|
||||
expect(screen.getByText("Partner Instance")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should group connections by status", (): void => {
|
||||
render(<ConnectionList connections={mockConnections} isLoading={false} />);
|
||||
expect(screen.getByRole("heading", { name: "Active" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Pending" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should pass handlers to connection cards", (): void => {
|
||||
render(
|
||||
<ConnectionList
|
||||
connections={mockConnections}
|
||||
isLoading={false}
|
||||
onAccept={mockOnAccept}
|
||||
onReject={mockOnReject}
|
||||
onDisconnect={mockOnDisconnect}
|
||||
/>
|
||||
);
|
||||
|
||||
const acceptButtons = screen.getAllByRole("button", { name: /accept/i });
|
||||
const disconnectButtons = screen.getAllByRole("button", { name: /disconnect/i });
|
||||
|
||||
expect(acceptButtons.length).toBeGreaterThan(0);
|
||||
expect(disconnectButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should handle null connections array", (): void => {
|
||||
render(<ConnectionList connections={null} isLoading={false} />);
|
||||
expect(screen.getByText(/no federation connections/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with compact layout when compact prop is true", (): void => {
|
||||
render(<ConnectionList connections={mockConnections} isLoading={false} compact={true} />);
|
||||
// Verify connections are rendered
|
||||
expect(screen.getByText("Work Instance")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
116
apps/web/src/components/federation/ConnectionList.tsx
Normal file
116
apps/web/src/components/federation/ConnectionList.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* ConnectionList Component
|
||||
* Displays a list of federation connections grouped by status
|
||||
*/
|
||||
|
||||
import { FederationConnectionStatus, type ConnectionDetails } from "@/lib/api/federation";
|
||||
import { ConnectionCard } from "./ConnectionCard";
|
||||
|
||||
interface ConnectionListProps {
|
||||
connections: ConnectionDetails[] | null;
|
||||
isLoading: boolean;
|
||||
onAccept?: (connectionId: string) => void;
|
||||
onReject?: (connectionId: string) => void;
|
||||
onDisconnect?: (connectionId: string) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function ConnectionList({
|
||||
connections,
|
||||
isLoading,
|
||||
onAccept,
|
||||
onReject,
|
||||
onDisconnect,
|
||||
compact = false,
|
||||
}: ConnectionListProps): React.JSX.Element {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
|
||||
<span className="ml-3 text-gray-600">Loading connections...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle null/undefined connections gracefully
|
||||
if (!connections || connections.length === 0) {
|
||||
return (
|
||||
<div className="text-center p-8 text-gray-500">
|
||||
<p className="text-lg">No federation connections</p>
|
||||
<p className="text-sm mt-2">Ready to connect to remote instances</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Group connections by status
|
||||
const groupedConnections: Record<FederationConnectionStatus, ConnectionDetails[]> = {
|
||||
[FederationConnectionStatus.PENDING]: [],
|
||||
[FederationConnectionStatus.ACTIVE]: [],
|
||||
[FederationConnectionStatus.DISCONNECTED]: [],
|
||||
[FederationConnectionStatus.REJECTED]: [],
|
||||
};
|
||||
|
||||
connections.forEach((connection) => {
|
||||
groupedConnections[connection.status].push(connection);
|
||||
});
|
||||
|
||||
// Define group order with PDA-friendly labels
|
||||
const groups: {
|
||||
status: FederationConnectionStatus;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
status: FederationConnectionStatus.ACTIVE,
|
||||
label: "Active",
|
||||
description: "Currently connected instances",
|
||||
},
|
||||
{
|
||||
status: FederationConnectionStatus.PENDING,
|
||||
label: "Pending",
|
||||
description: "Connections awaiting acceptance",
|
||||
},
|
||||
{
|
||||
status: FederationConnectionStatus.DISCONNECTED,
|
||||
label: "Disconnected",
|
||||
description: "Previously connected instances",
|
||||
},
|
||||
{
|
||||
status: FederationConnectionStatus.REJECTED,
|
||||
label: "Rejected",
|
||||
description: "Connection requests that were declined",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{groups.map((group) => {
|
||||
const groupConnections = groupedConnections[group.status];
|
||||
if (groupConnections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section key={group.status}>
|
||||
<div className="mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-700">{group.label}</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">{group.description}</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{groupConnections.map((connection) => (
|
||||
<ConnectionCard
|
||||
key={connection.id}
|
||||
connection={connection}
|
||||
{...(onAccept && { onAccept })}
|
||||
{...(onReject && { onReject })}
|
||||
{...(onDisconnect && { onDisconnect })}
|
||||
compact={compact}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
apps/web/src/components/federation/FederatedEventCard.test.tsx
Normal file
136
apps/web/src/components/federation/FederatedEventCard.test.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* FederatedEventCard Component Tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { FederatedEventCard } from "./FederatedEventCard";
|
||||
import type { FederatedEvent } from "./types";
|
||||
|
||||
const mockEvent: FederatedEvent = {
|
||||
event: {
|
||||
id: "event-1",
|
||||
title: "Team standup",
|
||||
description: "Daily sync meeting",
|
||||
startTime: new Date("2026-02-05T10:00:00"),
|
||||
endTime: new Date("2026-02-05T10:30:00"),
|
||||
allDay: false,
|
||||
location: "Zoom",
|
||||
recurrence: null,
|
||||
creatorId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
metadata: {},
|
||||
createdAt: new Date("2026-02-03"),
|
||||
updatedAt: new Date("2026-02-03"),
|
||||
},
|
||||
provenance: {
|
||||
instanceId: "instance-work-001",
|
||||
instanceName: "Work Instance",
|
||||
instanceUrl: "https://mosaic.work.example.com",
|
||||
timestamp: "2026-02-03T14:00:00Z",
|
||||
},
|
||||
};
|
||||
|
||||
describe("FederatedEventCard", () => {
|
||||
it("should render event title", () => {
|
||||
render(<FederatedEventCard federatedEvent={mockEvent} />);
|
||||
|
||||
expect(screen.getByText("Team standup")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render event description", () => {
|
||||
render(<FederatedEventCard federatedEvent={mockEvent} />);
|
||||
|
||||
expect(screen.getByText("Daily sync meeting")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render provenance indicator", () => {
|
||||
render(<FederatedEventCard federatedEvent={mockEvent} />);
|
||||
|
||||
expect(screen.getByText("Work Instance")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render event time", () => {
|
||||
render(<FederatedEventCard federatedEvent={mockEvent} />);
|
||||
|
||||
// Check for time components
|
||||
expect(screen.getByText(/10:00/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render event location", () => {
|
||||
render(<FederatedEventCard federatedEvent={mockEvent} />);
|
||||
|
||||
expect(screen.getByText("Zoom")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with compact mode", () => {
|
||||
const { container } = render(<FederatedEventCard federatedEvent={mockEvent} compact={true} />);
|
||||
|
||||
const card = container.querySelector(".p-3");
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle event without description", () => {
|
||||
const eventNoDesc: FederatedEvent = {
|
||||
...mockEvent,
|
||||
event: {
|
||||
...mockEvent.event,
|
||||
description: null,
|
||||
},
|
||||
};
|
||||
|
||||
render(<FederatedEventCard federatedEvent={eventNoDesc} />);
|
||||
|
||||
expect(screen.getByText("Team standup")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Daily sync meeting")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle event without location", () => {
|
||||
const eventNoLocation: FederatedEvent = {
|
||||
...mockEvent,
|
||||
event: {
|
||||
...mockEvent.event,
|
||||
location: null,
|
||||
},
|
||||
};
|
||||
|
||||
render(<FederatedEventCard federatedEvent={eventNoLocation} />);
|
||||
|
||||
expect(screen.getByText("Team standup")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Zoom")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all-day event", () => {
|
||||
const allDayEvent: FederatedEvent = {
|
||||
...mockEvent,
|
||||
event: {
|
||||
...mockEvent.event,
|
||||
allDay: true,
|
||||
},
|
||||
};
|
||||
|
||||
render(<FederatedEventCard federatedEvent={allDayEvent} />);
|
||||
|
||||
expect(screen.getByText("All day")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle onClick callback", () => {
|
||||
let clicked = false;
|
||||
const handleClick = (): void => {
|
||||
clicked = true;
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<FederatedEventCard federatedEvent={mockEvent} onClick={handleClick} />
|
||||
);
|
||||
|
||||
const card = container.querySelector(".cursor-pointer");
|
||||
expect(card).toBeInTheDocument();
|
||||
|
||||
if (card instanceof HTMLElement) {
|
||||
card.click();
|
||||
}
|
||||
expect(clicked).toBe(true);
|
||||
});
|
||||
});
|
||||
94
apps/web/src/components/federation/FederatedEventCard.tsx
Normal file
94
apps/web/src/components/federation/FederatedEventCard.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* FederatedEventCard Component
|
||||
* Displays an event from a federated instance with provenance indicator
|
||||
*/
|
||||
|
||||
import type { FederatedEvent } from "./types";
|
||||
import { ProvenanceIndicator } from "./ProvenanceIndicator";
|
||||
|
||||
interface FederatedEventCardProps {
|
||||
federatedEvent: FederatedEvent;
|
||||
compact?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time for display
|
||||
*/
|
||||
function formatTime(date: Date): string {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
}).format(new Date(date));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
*/
|
||||
function formatDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(date));
|
||||
}
|
||||
|
||||
export function FederatedEventCard({
|
||||
federatedEvent,
|
||||
compact = false,
|
||||
onClick,
|
||||
}: FederatedEventCardProps): React.JSX.Element {
|
||||
const { event, provenance } = federatedEvent;
|
||||
|
||||
const paddingClass = compact ? "p-3" : "p-4";
|
||||
const clickableClass = onClick ? "cursor-pointer hover:border-gray-300" : "";
|
||||
|
||||
const startTime = formatTime(event.startTime);
|
||||
const endTime = event.endTime !== null ? formatTime(event.endTime) : "";
|
||||
const startDate = formatDate(event.startTime);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border border-gray-200 rounded-lg ${paddingClass} ${clickableClass} transition-colors`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Header with title and provenance */}
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base font-medium text-gray-900">{event.title}</h3>
|
||||
{event.description && <p className="text-sm text-gray-600 mt-1">{event.description}</p>}
|
||||
</div>
|
||||
<ProvenanceIndicator
|
||||
instanceId={provenance.instanceId}
|
||||
instanceName={provenance.instanceName}
|
||||
instanceUrl={provenance.instanceUrl}
|
||||
compact={compact}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Metadata row */}
|
||||
<div className="flex items-center gap-3 text-sm flex-wrap">
|
||||
{/* Date */}
|
||||
<span className="text-xs text-gray-700 font-medium">{startDate}</span>
|
||||
|
||||
{/* Time */}
|
||||
{event.allDay ? (
|
||||
<span className="text-xs text-gray-600">All day</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-600">
|
||||
{startTime} - {endTime}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Location */}
|
||||
{event.location && (
|
||||
<span className="text-xs text-gray-600">
|
||||
<span className="mr-1">📍</span>
|
||||
{event.location}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
apps/web/src/components/federation/FederatedTaskCard.test.tsx
Normal file
144
apps/web/src/components/federation/FederatedTaskCard.test.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* FederatedTaskCard Component Tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { FederatedTaskCard } from "./FederatedTaskCard";
|
||||
import { TaskStatus, TaskPriority } from "@mosaic/shared";
|
||||
import type { FederatedTask } from "./types";
|
||||
|
||||
const mockTask: FederatedTask = {
|
||||
task: {
|
||||
id: "task-1",
|
||||
title: "Review pull request",
|
||||
description: "Review and provide feedback on frontend PR",
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
priority: TaskPriority.HIGH,
|
||||
dueDate: new Date("2026-02-05"),
|
||||
creatorId: "user-1",
|
||||
assigneeId: "user-1",
|
||||
workspaceId: "workspace-1",
|
||||
projectId: null,
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
metadata: {},
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-02-03"),
|
||||
updatedAt: new Date("2026-02-03"),
|
||||
},
|
||||
provenance: {
|
||||
instanceId: "instance-work-001",
|
||||
instanceName: "Work Instance",
|
||||
instanceUrl: "https://mosaic.work.example.com",
|
||||
timestamp: "2026-02-03T14:00:00Z",
|
||||
},
|
||||
};
|
||||
|
||||
describe("FederatedTaskCard", () => {
|
||||
it("should render task title", () => {
|
||||
render(<FederatedTaskCard federatedTask={mockTask} />);
|
||||
|
||||
expect(screen.getByText("Review pull request")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render task description", () => {
|
||||
render(<FederatedTaskCard federatedTask={mockTask} />);
|
||||
|
||||
expect(screen.getByText("Review and provide feedback on frontend PR")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render provenance indicator", () => {
|
||||
render(<FederatedTaskCard federatedTask={mockTask} />);
|
||||
|
||||
expect(screen.getByText("Work Instance")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render status badge", () => {
|
||||
render(<FederatedTaskCard federatedTask={mockTask} />);
|
||||
|
||||
expect(screen.getByText("In Progress")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render priority indicator for high priority", () => {
|
||||
render(<FederatedTaskCard federatedTask={mockTask} />);
|
||||
|
||||
expect(screen.getByText("High")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render target date", () => {
|
||||
render(<FederatedTaskCard federatedTask={mockTask} />);
|
||||
|
||||
// Check for "Target:" text followed by a date
|
||||
expect(screen.getByText(/Target:/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2026/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with compact mode", () => {
|
||||
const { container } = render(<FederatedTaskCard federatedTask={mockTask} compact={true} />);
|
||||
|
||||
const card = container.querySelector(".p-3");
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render completed task with completed status", () => {
|
||||
const completedTask: FederatedTask = {
|
||||
...mockTask,
|
||||
task: {
|
||||
...mockTask.task,
|
||||
status: TaskStatus.COMPLETED,
|
||||
completedAt: new Date("2026-02-04"),
|
||||
},
|
||||
};
|
||||
|
||||
render(<FederatedTaskCard federatedTask={completedTask} />);
|
||||
|
||||
expect(screen.getByText("Completed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle task without description", () => {
|
||||
const taskNoDesc: FederatedTask = {
|
||||
...mockTask,
|
||||
task: {
|
||||
...mockTask.task,
|
||||
description: null,
|
||||
},
|
||||
};
|
||||
|
||||
render(<FederatedTaskCard federatedTask={taskNoDesc} />);
|
||||
|
||||
expect(screen.getByText("Review pull request")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Review and provide feedback on frontend PR")
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle task without target date", () => {
|
||||
const taskNoTarget: FederatedTask = {
|
||||
...mockTask,
|
||||
task: {
|
||||
...mockTask.task,
|
||||
dueDate: null,
|
||||
},
|
||||
};
|
||||
|
||||
render(<FederatedTaskCard federatedTask={taskNoTarget} />);
|
||||
|
||||
expect(screen.getByText("Review pull request")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Target:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should use PDA-friendly language for status", () => {
|
||||
const pausedTask: FederatedTask = {
|
||||
...mockTask,
|
||||
task: {
|
||||
...mockTask.task,
|
||||
status: TaskStatus.PAUSED,
|
||||
},
|
||||
};
|
||||
|
||||
render(<FederatedTaskCard federatedTask={pausedTask} />);
|
||||
|
||||
expect(screen.getByText("Paused")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
113
apps/web/src/components/federation/FederatedTaskCard.tsx
Normal file
113
apps/web/src/components/federation/FederatedTaskCard.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* FederatedTaskCard Component
|
||||
* Displays a task from a federated instance with provenance indicator
|
||||
*/
|
||||
|
||||
import { TaskStatus, TaskPriority } from "@mosaic/shared";
|
||||
import type { FederatedTask } from "./types";
|
||||
import { ProvenanceIndicator } from "./ProvenanceIndicator";
|
||||
|
||||
interface FederatedTaskCardProps {
|
||||
federatedTask: FederatedTask;
|
||||
compact?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PDA-friendly status text and color
|
||||
*/
|
||||
function getStatusDisplay(status: TaskStatus): { text: string; colorClass: string } {
|
||||
switch (status) {
|
||||
case TaskStatus.NOT_STARTED:
|
||||
return { text: "Not Started", colorClass: "bg-gray-100 text-gray-700" };
|
||||
case TaskStatus.IN_PROGRESS:
|
||||
return { text: "In Progress", colorClass: "bg-blue-100 text-blue-700" };
|
||||
case TaskStatus.COMPLETED:
|
||||
return { text: "Completed", colorClass: "bg-green-100 text-green-700" };
|
||||
case TaskStatus.PAUSED:
|
||||
return { text: "Paused", colorClass: "bg-yellow-100 text-yellow-700" };
|
||||
case TaskStatus.ARCHIVED:
|
||||
return { text: "Archived", colorClass: "bg-gray-100 text-gray-600" };
|
||||
default:
|
||||
return { text: "Unknown", colorClass: "bg-gray-100 text-gray-700" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get priority text and color
|
||||
*/
|
||||
function getPriorityDisplay(priority: TaskPriority): { text: string; colorClass: string } {
|
||||
switch (priority) {
|
||||
case TaskPriority.LOW:
|
||||
return { text: "Low", colorClass: "text-gray-600" };
|
||||
case TaskPriority.MEDIUM:
|
||||
return { text: "Medium", colorClass: "text-blue-600" };
|
||||
case TaskPriority.HIGH:
|
||||
return { text: "High", colorClass: "text-orange-600" };
|
||||
default:
|
||||
return { text: "Unknown", colorClass: "text-gray-600" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
*/
|
||||
function formatDate(date: Date | null): string | null {
|
||||
if (!date) {
|
||||
return null;
|
||||
}
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(date));
|
||||
}
|
||||
|
||||
export function FederatedTaskCard({
|
||||
federatedTask,
|
||||
compact = false,
|
||||
onClick,
|
||||
}: FederatedTaskCardProps): React.JSX.Element {
|
||||
const { task, provenance } = federatedTask;
|
||||
const status = getStatusDisplay(task.status);
|
||||
const priority = getPriorityDisplay(task.priority);
|
||||
const dueDate = formatDate(task.dueDate);
|
||||
|
||||
const paddingClass = compact ? "p-3" : "p-4";
|
||||
const clickableClass = onClick ? "cursor-pointer hover:border-gray-300" : "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border border-gray-200 rounded-lg ${paddingClass} ${clickableClass} transition-colors`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Header with title and provenance */}
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base font-medium text-gray-900">{task.title}</h3>
|
||||
{task.description && <p className="text-sm text-gray-600 mt-1">{task.description}</p>}
|
||||
</div>
|
||||
<ProvenanceIndicator
|
||||
instanceId={provenance.instanceId}
|
||||
instanceName={provenance.instanceName}
|
||||
instanceUrl={provenance.instanceUrl}
|
||||
compact={compact}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Metadata row */}
|
||||
<div className="flex items-center gap-3 text-sm flex-wrap">
|
||||
{/* Status badge */}
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${status.colorClass}`}>
|
||||
{status.text}
|
||||
</span>
|
||||
|
||||
{/* Priority */}
|
||||
<span className={`text-xs font-medium ${priority.colorClass}`}>{priority.text}</span>
|
||||
|
||||
{/* Target date */}
|
||||
{dueDate && <span className="text-xs text-gray-600">Target: {dueDate}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* InitiateConnectionDialog Component Tests
|
||||
* Following TDD - write tests first!
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { InitiateConnectionDialog } from "./InitiateConnectionDialog";
|
||||
|
||||
describe("InitiateConnectionDialog", (): void => {
|
||||
const mockOnInitiate = vi.fn();
|
||||
const mockOnCancel = vi.fn();
|
||||
|
||||
it("should render when open is true", (): void => {
|
||||
render(
|
||||
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
|
||||
);
|
||||
expect(screen.getByText(/connect to remote instance/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render when open is false", (): void => {
|
||||
const { container } = render(
|
||||
<InitiateConnectionDialog open={false} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("should render PDA-friendly title", (): void => {
|
||||
render(
|
||||
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
|
||||
);
|
||||
expect(screen.getByText(/connect to remote instance/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render PDA-friendly description", (): void => {
|
||||
render(
|
||||
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
|
||||
);
|
||||
expect(screen.getByText(/enter the url/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render URL input field", (): void => {
|
||||
render(
|
||||
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
|
||||
);
|
||||
expect(screen.getByLabelText(/instance url/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render Connect button", (): void => {
|
||||
render(
|
||||
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /connect/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render Cancel button", (): void => {
|
||||
render(
|
||||
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onCancel when cancel button clicked", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
const cancelButton = screen.getByRole("button", { name: /cancel/i });
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call onInitiate with URL when connect button clicked", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
const urlInput = screen.getByLabelText(/instance url/i);
|
||||
await user.type(urlInput, "https://mosaic.example.com");
|
||||
|
||||
const connectButton = screen.getByRole("button", { name: /connect/i });
|
||||
await user.click(connectButton);
|
||||
|
||||
expect(mockOnInitiate).toHaveBeenCalledWith("https://mosaic.example.com");
|
||||
});
|
||||
|
||||
it("should disable connect button when URL is empty", (): void => {
|
||||
render(
|
||||
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
|
||||
);
|
||||
const connectButton = screen.getByRole("button", { name: /connect/i });
|
||||
expect(connectButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable connect button when URL is entered", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
const urlInput = screen.getByLabelText(/instance url/i);
|
||||
await user.type(urlInput, "https://mosaic.example.com");
|
||||
|
||||
const connectButton = screen.getByRole("button", { name: /connect/i });
|
||||
expect(connectButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should show validation error for invalid URL", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
const urlInput = screen.getByLabelText(/instance url/i);
|
||||
await user.type(urlInput, "not-a-valid-url");
|
||||
|
||||
const connectButton = screen.getByRole("button", { name: /connect/i });
|
||||
await user.click(connectButton);
|
||||
|
||||
expect(screen.getByText(/please enter a valid url/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should clear input when dialog is closed", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(
|
||||
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
const urlInput = screen.getByLabelText(/instance url/i);
|
||||
await user.type(urlInput, "https://mosaic.example.com");
|
||||
|
||||
// Close dialog
|
||||
rerender(
|
||||
<InitiateConnectionDialog open={false} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
// Reopen dialog
|
||||
rerender(
|
||||
<InitiateConnectionDialog open={true} onInitiate={mockOnInitiate} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
const newUrlInput = screen.getByLabelText(/instance url/i);
|
||||
expect(newUrlInput).toHaveValue("");
|
||||
});
|
||||
|
||||
it("should show loading state when isLoading is true", (): void => {
|
||||
render(
|
||||
<InitiateConnectionDialog
|
||||
open={true}
|
||||
onInitiate={mockOnInitiate}
|
||||
onCancel={mockOnCancel}
|
||||
isLoading={true}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/connecting/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable buttons when isLoading is true", (): void => {
|
||||
render(
|
||||
<InitiateConnectionDialog
|
||||
open={true}
|
||||
onInitiate={mockOnInitiate}
|
||||
onCancel={mockOnCancel}
|
||||
isLoading={true}
|
||||
/>
|
||||
);
|
||||
const connectButton = screen.getByRole("button", { name: /connecting/i });
|
||||
const cancelButton = screen.getByRole("button", { name: /cancel/i });
|
||||
|
||||
expect(connectButton).toBeDisabled();
|
||||
expect(cancelButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should display error message when error prop is provided", (): void => {
|
||||
render(
|
||||
<InitiateConnectionDialog
|
||||
open={true}
|
||||
onInitiate={mockOnInitiate}
|
||||
onCancel={mockOnCancel}
|
||||
error="Unable to connect to remote instance"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Unable to connect to remote instance")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
129
apps/web/src/components/federation/InitiateConnectionDialog.tsx
Normal file
129
apps/web/src/components/federation/InitiateConnectionDialog.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* InitiateConnectionDialog Component
|
||||
* Dialog for initiating a new federation connection
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface InitiateConnectionDialogProps {
|
||||
open: boolean;
|
||||
onInitiate: (url: string) => void;
|
||||
onCancel: () => void;
|
||||
isLoading?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a string is a valid URL
|
||||
*/
|
||||
function isValidUrl(url: string): boolean {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
return parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function InitiateConnectionDialog({
|
||||
open,
|
||||
onInitiate,
|
||||
onCancel,
|
||||
isLoading = false,
|
||||
error,
|
||||
}: InitiateConnectionDialogProps): React.JSX.Element | null {
|
||||
const [url, setUrl] = useState("");
|
||||
const [validationError, setValidationError] = useState("");
|
||||
|
||||
// Clear input when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setUrl("");
|
||||
setValidationError("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleConnect = (): void => {
|
||||
// Validate URL
|
||||
if (!url.trim()) {
|
||||
setValidationError("Please enter a URL");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidUrl(url)) {
|
||||
setValidationError("Please enter a valid URL (must start with http:// or https://)");
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationError("");
|
||||
onInitiate(url);
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === "Enter" && url.trim() && !isLoading) {
|
||||
handleConnect();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg max-w-md w-full p-6">
|
||||
{/* Header */}
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">Connect to Remote Instance</h2>
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
Enter the URL of the Mosaic Stack instance you'd like to connect to
|
||||
</p>
|
||||
|
||||
{/* URL Input */}
|
||||
<div className="mb-4">
|
||||
<label htmlFor="instance-url" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Instance URL
|
||||
</label>
|
||||
<input
|
||||
id="instance-url"
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
setValidationError("");
|
||||
}}
|
||||
onKeyDown={handleKeyPress}
|
||||
disabled={isLoading}
|
||||
placeholder="https://mosaic.example.com"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
/>
|
||||
{validationError && <p className="text-sm text-red-600 mt-1">{validationError}</p>}
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={!url.trim() || isLoading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? "Connecting..." : "Connect"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
apps/web/src/components/federation/ProvenanceIndicator.test.tsx
Normal file
105
apps/web/src/components/federation/ProvenanceIndicator.test.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* ProvenanceIndicator Component Tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ProvenanceIndicator } from "./ProvenanceIndicator";
|
||||
|
||||
describe("ProvenanceIndicator", () => {
|
||||
it("should render instance name", () => {
|
||||
render(
|
||||
<ProvenanceIndicator
|
||||
instanceId="instance-work-001"
|
||||
instanceName="Work Instance"
|
||||
instanceUrl="https://mosaic.work.example.com"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Work Instance")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with compact mode", () => {
|
||||
const { container } = render(
|
||||
<ProvenanceIndicator
|
||||
instanceId="instance-work-001"
|
||||
instanceName="Work Instance"
|
||||
instanceUrl="https://mosaic.work.example.com"
|
||||
compact={true}
|
||||
/>
|
||||
);
|
||||
|
||||
// Compact mode should have smaller padding
|
||||
const badge = container.querySelector(".px-2");
|
||||
expect(badge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with custom color", () => {
|
||||
const { container } = render(
|
||||
<ProvenanceIndicator
|
||||
instanceId="instance-work-001"
|
||||
instanceName="Work Instance"
|
||||
instanceUrl="https://mosaic.work.example.com"
|
||||
color="bg-blue-100"
|
||||
/>
|
||||
);
|
||||
|
||||
const badge = container.querySelector(".bg-blue-100");
|
||||
expect(badge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with default color when not provided", () => {
|
||||
const { container } = render(
|
||||
<ProvenanceIndicator
|
||||
instanceId="instance-work-001"
|
||||
instanceName="Work Instance"
|
||||
instanceUrl="https://mosaic.work.example.com"
|
||||
/>
|
||||
);
|
||||
|
||||
const badge = container.querySelector(".bg-gray-100");
|
||||
expect(badge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show tooltip with instance details on hover", () => {
|
||||
render(
|
||||
<ProvenanceIndicator
|
||||
instanceId="instance-work-001"
|
||||
instanceName="Work Instance"
|
||||
instanceUrl="https://mosaic.work.example.com"
|
||||
/>
|
||||
);
|
||||
|
||||
// Check for title attribute (tooltip)
|
||||
const badge = screen.getByText("Work Instance");
|
||||
expect(badge.closest("div")).toHaveAttribute(
|
||||
"title",
|
||||
"From: Work Instance (https://mosaic.work.example.com)"
|
||||
);
|
||||
});
|
||||
|
||||
it("should render with icon when showIcon is true", () => {
|
||||
render(
|
||||
<ProvenanceIndicator
|
||||
instanceId="instance-work-001"
|
||||
instanceName="Work Instance"
|
||||
instanceUrl="https://mosaic.work.example.com"
|
||||
showIcon={true}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("🔗")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render icon by default", () => {
|
||||
render(
|
||||
<ProvenanceIndicator
|
||||
instanceId="instance-work-001"
|
||||
instanceName="Work Instance"
|
||||
instanceUrl="https://mosaic.work.example.com"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText("🔗")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
36
apps/web/src/components/federation/ProvenanceIndicator.tsx
Normal file
36
apps/web/src/components/federation/ProvenanceIndicator.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* ProvenanceIndicator Component
|
||||
* Shows which instance data came from with PDA-friendly design
|
||||
*/
|
||||
|
||||
interface ProvenanceIndicatorProps {
|
||||
instanceId: string;
|
||||
instanceName: string;
|
||||
instanceUrl: string;
|
||||
compact?: boolean;
|
||||
color?: string;
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
export function ProvenanceIndicator({
|
||||
instanceId,
|
||||
instanceName,
|
||||
instanceUrl,
|
||||
compact = false,
|
||||
color = "bg-gray-100",
|
||||
showIcon = false,
|
||||
}: ProvenanceIndicatorProps): React.JSX.Element {
|
||||
const paddingClass = compact ? "px-2 py-0.5 text-xs" : "px-3 py-1 text-sm";
|
||||
const tooltipText = `From: ${instanceName} (${instanceUrl})`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex items-center gap-1 ${paddingClass} ${color} text-gray-700 rounded-full font-medium`}
|
||||
title={tooltipText}
|
||||
data-instance-id={instanceId}
|
||||
>
|
||||
{showIcon && <span>🔗</span>}
|
||||
<span>{instanceName}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Tests for SpokeConfigurationForm Component
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { SpokeConfigurationForm } from "./SpokeConfigurationForm";
|
||||
import type { PublicInstanceIdentity } from "@/lib/api/federation";
|
||||
|
||||
describe("SpokeConfigurationForm", () => {
|
||||
const mockInstance: PublicInstanceIdentity = {
|
||||
id: "instance-123",
|
||||
instanceId: "test-instance-001",
|
||||
name: "Test Instance",
|
||||
url: "https://test.example.com",
|
||||
publicKey: "-----BEGIN PUBLIC KEY-----\nMOCKPUBLICKEY\n-----END PUBLIC KEY-----",
|
||||
capabilities: {
|
||||
supportsQuery: true,
|
||||
supportsCommand: true,
|
||||
supportsEvent: true,
|
||||
supportsAgentSpawn: false,
|
||||
protocolVersion: "1.0",
|
||||
},
|
||||
metadata: {
|
||||
description: "Test instance description",
|
||||
},
|
||||
createdAt: "2026-02-01T00:00:00Z",
|
||||
updatedAt: "2026-02-01T00:00:00Z",
|
||||
};
|
||||
|
||||
it("should render instance identity information", () => {
|
||||
const onSave = vi.fn();
|
||||
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} />);
|
||||
|
||||
expect(screen.getByDisplayValue("Test Instance")).toBeInTheDocument();
|
||||
expect(screen.getByText("test-instance-001")).toBeInTheDocument();
|
||||
expect(screen.getByText("https://test.example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render capability toggles with correct initial state", () => {
|
||||
const onSave = vi.fn();
|
||||
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} />);
|
||||
|
||||
const queryToggle = screen.getByLabelText(/Query Support/i);
|
||||
const commandToggle = screen.getByLabelText(/Command Support/i);
|
||||
const eventToggle = screen.getByLabelText(/Event Support/i);
|
||||
const agentToggle = screen.getByLabelText(/Agent Spawn Support/i);
|
||||
|
||||
expect(queryToggle).toBeChecked();
|
||||
expect(commandToggle).toBeChecked();
|
||||
expect(eventToggle).toBeChecked();
|
||||
expect(agentToggle).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("should allow editing instance name", async () => {
|
||||
const onSave = vi.fn();
|
||||
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} />);
|
||||
|
||||
const nameInput = screen.getByDisplayValue("Test Instance");
|
||||
fireEvent.change(nameInput, { target: { value: "Updated Instance" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("Updated Instance")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should toggle capabilities", async () => {
|
||||
const onSave = vi.fn();
|
||||
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} />);
|
||||
|
||||
const agentToggle = screen.getByLabelText(/Agent Spawn Support/i);
|
||||
expect(agentToggle).not.toBeChecked();
|
||||
|
||||
fireEvent.click(agentToggle);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(agentToggle).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
it("should call onSave with updated configuration", async () => {
|
||||
const onSave = vi.fn();
|
||||
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} />);
|
||||
|
||||
// Change name
|
||||
const nameInput = screen.getByDisplayValue("Test Instance");
|
||||
fireEvent.change(nameInput, { target: { value: "Updated Instance" } });
|
||||
|
||||
// Toggle agent spawn
|
||||
const agentToggle = screen.getByLabelText(/Agent Spawn Support/i);
|
||||
fireEvent.click(agentToggle);
|
||||
|
||||
// Click save
|
||||
const saveButton = screen.getByText("Save Configuration");
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledWith({
|
||||
name: "Updated Instance",
|
||||
capabilities: {
|
||||
supportsQuery: true,
|
||||
supportsCommand: true,
|
||||
supportsEvent: true,
|
||||
supportsAgentSpawn: true,
|
||||
protocolVersion: "1.0",
|
||||
},
|
||||
metadata: {
|
||||
description: "Test instance description",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should display loading state when saving", () => {
|
||||
const onSave = vi.fn();
|
||||
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} isLoading={true} />);
|
||||
|
||||
const saveButton = screen.getByText("Saving...");
|
||||
expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should display error message when provided", () => {
|
||||
const onSave = vi.fn();
|
||||
render(
|
||||
<SpokeConfigurationForm
|
||||
instance={mockInstance}
|
||||
onSave={onSave}
|
||||
error="Unable to save configuration"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Unable to save configuration")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should use PDA-friendly language in help text", () => {
|
||||
const onSave = vi.fn();
|
||||
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} />);
|
||||
|
||||
// Should NOT use demanding language
|
||||
expect(screen.queryByText(/must/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/required/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/critical/i)).not.toBeInTheDocument();
|
||||
|
||||
// Should use friendly language (multiple instances expected)
|
||||
const friendlyText = screen.getAllByText(/Allows connected instances/i);
|
||||
expect(friendlyText.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should truncate public key and show copy button", () => {
|
||||
const onSave = vi.fn();
|
||||
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} />);
|
||||
|
||||
// Public key should be truncated
|
||||
expect(screen.getByText(/-----BEGIN PUBLIC KEY-----/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Copy/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle cancel action", async () => {
|
||||
const onSave = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
render(<SpokeConfigurationForm instance={mockInstance} onSave={onSave} onCancel={onCancel} />);
|
||||
|
||||
const cancelButton = screen.getByText("Cancel");
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
276
apps/web/src/components/federation/SpokeConfigurationForm.tsx
Normal file
276
apps/web/src/components/federation/SpokeConfigurationForm.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* SpokeConfigurationForm Component
|
||||
* Allows administrators to configure local instance federation settings
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { PublicInstanceIdentity, UpdateInstanceRequest } from "@/lib/api/federation";
|
||||
|
||||
interface SpokeConfigurationFormProps {
|
||||
instance: PublicInstanceIdentity;
|
||||
onSave: (updates: UpdateInstanceRequest) => void;
|
||||
onCancel?: () => void;
|
||||
isLoading?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function SpokeConfigurationForm({
|
||||
instance,
|
||||
onSave,
|
||||
onCancel,
|
||||
isLoading = false,
|
||||
error,
|
||||
}: SpokeConfigurationFormProps): React.JSX.Element {
|
||||
const [name, setName] = useState(instance.name);
|
||||
const [description, setDescription] = useState((instance.metadata.description as string) || "");
|
||||
const [capabilities, setCapabilities] = useState(instance.capabilities);
|
||||
|
||||
const handleSubmit = (e: React.SyntheticEvent<HTMLFormElement>): void => {
|
||||
e.preventDefault();
|
||||
|
||||
const updates: UpdateInstanceRequest = {
|
||||
name,
|
||||
capabilities,
|
||||
metadata: {
|
||||
...instance.metadata,
|
||||
description,
|
||||
},
|
||||
};
|
||||
|
||||
onSave(updates);
|
||||
};
|
||||
|
||||
const handleCapabilityToggle = (capability: keyof typeof capabilities): void => {
|
||||
if (capability === "protocolVersion") return; // Can't toggle protocol version
|
||||
|
||||
setCapabilities((prev) => ({
|
||||
...prev,
|
||||
[capability]: !prev[capability],
|
||||
}));
|
||||
};
|
||||
|
||||
const copyPublicKey = async (): Promise<void> => {
|
||||
await navigator.clipboard.writeText(instance.publicKey);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instance Identity Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Instance Identity</h3>
|
||||
|
||||
{/* Instance ID (Read-only) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Instance ID</label>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg px-3 py-2 text-gray-600">
|
||||
{instance.instanceId}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instance URL (Read-only) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Instance URL</label>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg px-3 py-2 text-gray-600">
|
||||
{instance.url}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instance Name (Editable) */}
|
||||
<div>
|
||||
<label htmlFor="instance-name" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Instance Name
|
||||
</label>
|
||||
<input
|
||||
id="instance-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:text-gray-500"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
This name helps identify your instance in federation connections
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description (Editable) */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="instance-description"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="instance-description"
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
setDescription(e.target.value);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
rows={3}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:text-gray-500"
|
||||
placeholder="Optional description for this instance"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Public Key (Read-only with copy button) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Public Key</label>
|
||||
<div className="relative">
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg px-3 py-2 text-gray-600 font-mono text-xs overflow-hidden">
|
||||
{instance.publicKey.substring(0, 100)}...
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyPublicKey}
|
||||
className="absolute top-2 right-2 px-2 py-1 text-xs bg-white border border-gray-300 rounded hover:bg-gray-50"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Federation Capabilities Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Federation Capabilities</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Configure which federation features this instance supports. These settings determine what
|
||||
connected instances can do with your instance.
|
||||
</p>
|
||||
|
||||
{/* Query Support */}
|
||||
<div className="flex items-start space-x-3 p-3 border border-gray-200 rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="supports-query"
|
||||
checked={capabilities.supportsQuery}
|
||||
onChange={() => {
|
||||
handleCapabilityToggle("supportsQuery");
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="supports-query" className="font-medium text-gray-900 cursor-pointer">
|
||||
Query Support
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Allows connected instances to query data from this instance (tasks, events, projects)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Command Support */}
|
||||
<div className="flex items-start space-x-3 p-3 border border-gray-200 rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="supports-command"
|
||||
checked={capabilities.supportsCommand}
|
||||
onChange={() => {
|
||||
handleCapabilityToggle("supportsCommand");
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="supports-command" className="font-medium text-gray-900 cursor-pointer">
|
||||
Command Support
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Allows connected instances to send commands to this instance
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event Support */}
|
||||
<div className="flex items-start space-x-3 p-3 border border-gray-200 rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="supports-event"
|
||||
checked={capabilities.supportsEvent}
|
||||
onChange={() => {
|
||||
handleCapabilityToggle("supportsEvent");
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="supports-event" className="font-medium text-gray-900 cursor-pointer">
|
||||
Event Support
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Allows connected instances to subscribe to events from this instance
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Spawn Support */}
|
||||
<div className="flex items-start space-x-3 p-3 border border-gray-200 rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="supports-agent-spawn"
|
||||
checked={capabilities.supportsAgentSpawn}
|
||||
onChange={() => {
|
||||
handleCapabilityToggle("supportsAgentSpawn");
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="supports-agent-spawn"
|
||||
className="font-medium text-gray-900 cursor-pointer"
|
||||
>
|
||||
Agent Spawn Support
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Allows connected instances to spawn and manage agents on this instance
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Protocol Version (Read-only) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Protocol Version</label>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg px-3 py-2 text-gray-600">
|
||||
{capabilities.protocolVersion}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center space-x-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isLoading ? "Saving..." : "Save Configuration"}
|
||||
</button>
|
||||
{onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 disabled:bg-gray-100 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
36
apps/web/src/components/federation/types.ts
Normal file
36
apps/web/src/components/federation/types.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Federation Component Types
|
||||
*/
|
||||
|
||||
import type { Task, Event } from "@mosaic/shared";
|
||||
|
||||
/**
|
||||
* Provenance information - where the data came from
|
||||
*/
|
||||
export interface ProvenanceInfo {
|
||||
instanceId: string;
|
||||
instanceName: string;
|
||||
instanceUrl: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Federated task with provenance
|
||||
*/
|
||||
export interface FederatedTask {
|
||||
task: Task;
|
||||
provenance: ProvenanceInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Federated event with provenance
|
||||
*/
|
||||
export interface FederatedEvent {
|
||||
event: Event;
|
||||
provenance: ProvenanceInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Federated data item (union type)
|
||||
*/
|
||||
export type FederatedItem = FederatedTask | FederatedEvent;
|
||||
154
apps/web/src/lib/api/federation-queries.test.ts
Normal file
154
apps/web/src/lib/api/federation-queries.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
90
apps/web/src/lib/api/federation-queries.ts
Normal file
90
apps/web/src/lib/api/federation-queries.ts
Normal 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}`);
|
||||
}
|
||||
272
apps/web/src/lib/api/federation.ts
Normal file
272
apps/web/src/lib/api/federation.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Federation API Client
|
||||
* Handles federation connection management API requests
|
||||
*/
|
||||
|
||||
import { apiGet, apiPost, apiPatch } from "./client";
|
||||
|
||||
/**
|
||||
* Federation connection status
|
||||
*/
|
||||
export enum FederationConnectionStatus {
|
||||
PENDING = "PENDING",
|
||||
ACTIVE = "ACTIVE",
|
||||
DISCONNECTED = "DISCONNECTED",
|
||||
REJECTED = "REJECTED",
|
||||
}
|
||||
|
||||
/**
|
||||
* Federation capabilities
|
||||
*/
|
||||
export interface FederationCapabilities {
|
||||
supportsQuery: boolean;
|
||||
supportsCommand: boolean;
|
||||
supportsEvent: boolean;
|
||||
supportsAgentSpawn: boolean;
|
||||
protocolVersion: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection details
|
||||
*/
|
||||
export interface ConnectionDetails {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
remoteInstanceId: string;
|
||||
remoteUrl: string;
|
||||
remotePublicKey: string;
|
||||
remoteCapabilities: FederationCapabilities;
|
||||
status: FederationConnectionStatus;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
connectedAt: string | null;
|
||||
disconnectedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public instance identity
|
||||
*/
|
||||
export interface PublicInstanceIdentity {
|
||||
id: string;
|
||||
instanceId: string;
|
||||
name: string;
|
||||
url: string;
|
||||
publicKey: string;
|
||||
capabilities: FederationCapabilities;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate connection request
|
||||
*/
|
||||
export interface InitiateConnectionRequest {
|
||||
remoteUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept connection request
|
||||
*/
|
||||
export interface AcceptConnectionRequest {
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject connection request
|
||||
*/
|
||||
export interface RejectConnectionRequest {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect connection request
|
||||
*/
|
||||
export interface DisconnectConnectionRequest {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all connections
|
||||
*/
|
||||
export async function fetchConnections(
|
||||
status?: FederationConnectionStatus
|
||||
): Promise<ConnectionDetails[]> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (status) {
|
||||
params.append("status", status);
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
const endpoint = queryString
|
||||
? `/api/v1/federation/connections?${queryString}`
|
||||
: "/api/v1/federation/connections";
|
||||
|
||||
return apiGet<ConnectionDetails[]>(endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single connection
|
||||
*/
|
||||
export async function fetchConnection(connectionId: string): Promise<ConnectionDetails> {
|
||||
return apiGet<ConnectionDetails>(`/api/v1/federation/connections/${connectionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate a new connection
|
||||
*/
|
||||
export async function initiateConnection(
|
||||
request: InitiateConnectionRequest
|
||||
): Promise<ConnectionDetails> {
|
||||
return apiPost<ConnectionDetails>("/api/v1/federation/connections/initiate", request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a pending connection
|
||||
*/
|
||||
export async function acceptConnection(
|
||||
connectionId: string,
|
||||
request?: AcceptConnectionRequest
|
||||
): Promise<ConnectionDetails> {
|
||||
return apiPost<ConnectionDetails>(
|
||||
`/api/v1/federation/connections/${connectionId}/accept`,
|
||||
request ?? {}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a pending connection
|
||||
*/
|
||||
export async function rejectConnection(
|
||||
connectionId: string,
|
||||
request: RejectConnectionRequest
|
||||
): Promise<ConnectionDetails> {
|
||||
return apiPost<ConnectionDetails>(
|
||||
`/api/v1/federation/connections/${connectionId}/reject`,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect an active connection
|
||||
*/
|
||||
export async function disconnectConnection(
|
||||
connectionId: string,
|
||||
request?: DisconnectConnectionRequest
|
||||
): Promise<ConnectionDetails> {
|
||||
return apiPost<ConnectionDetails>(
|
||||
`/api/v1/federation/connections/${connectionId}/disconnect`,
|
||||
request ?? {}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get this instance's public identity
|
||||
*/
|
||||
export async function fetchInstanceIdentity(): Promise<PublicInstanceIdentity> {
|
||||
return apiGet<PublicInstanceIdentity>("/api/v1/federation/instance");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update instance configuration request
|
||||
*/
|
||||
export interface UpdateInstanceRequest {
|
||||
name?: string;
|
||||
capabilities?: FederationCapabilities;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update this instance's configuration
|
||||
* Admin-only operation
|
||||
*/
|
||||
export async function updateInstanceConfiguration(
|
||||
updates: UpdateInstanceRequest
|
||||
): Promise<PublicInstanceIdentity> {
|
||||
return apiPatch<PublicInstanceIdentity>("/api/v1/federation/instance", updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate instance keypair
|
||||
* Admin-only operation
|
||||
*/
|
||||
export async function regenerateInstanceKeys(): Promise<PublicInstanceIdentity> {
|
||||
return apiPost<PublicInstanceIdentity>("/api/v1/federation/instance/regenerate-keys", {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock connections for development
|
||||
*/
|
||||
export const mockConnections: ConnectionDetails[] = [
|
||||
{
|
||||
id: "conn-1",
|
||||
workspaceId: "workspace-1",
|
||||
remoteInstanceId: "instance-work-001",
|
||||
remoteUrl: "https://mosaic.work.example.com",
|
||||
remotePublicKey: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----",
|
||||
remoteCapabilities: {
|
||||
supportsQuery: true,
|
||||
supportsCommand: true,
|
||||
supportsEvent: true,
|
||||
supportsAgentSpawn: true,
|
||||
protocolVersion: "1.0",
|
||||
},
|
||||
status: FederationConnectionStatus.ACTIVE,
|
||||
metadata: {
|
||||
name: "Work Instance",
|
||||
description: "Corporate Mosaic instance",
|
||||
},
|
||||
createdAt: new Date("2026-02-01").toISOString(),
|
||||
updatedAt: new Date("2026-02-01").toISOString(),
|
||||
connectedAt: new Date("2026-02-01").toISOString(),
|
||||
disconnectedAt: null,
|
||||
},
|
||||
{
|
||||
id: "conn-2",
|
||||
workspaceId: "workspace-1",
|
||||
remoteInstanceId: "instance-partner-001",
|
||||
remoteUrl: "https://mosaic.partner.example.com",
|
||||
remotePublicKey: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----",
|
||||
remoteCapabilities: {
|
||||
supportsQuery: true,
|
||||
supportsCommand: false,
|
||||
supportsEvent: true,
|
||||
supportsAgentSpawn: false,
|
||||
protocolVersion: "1.0",
|
||||
},
|
||||
status: FederationConnectionStatus.PENDING,
|
||||
metadata: {
|
||||
name: "Partner Instance",
|
||||
description: "Shared project collaboration",
|
||||
},
|
||||
createdAt: new Date("2026-02-02").toISOString(),
|
||||
updatedAt: new Date("2026-02-02").toISOString(),
|
||||
connectedAt: null,
|
||||
disconnectedAt: null,
|
||||
},
|
||||
{
|
||||
id: "conn-3",
|
||||
workspaceId: "workspace-1",
|
||||
remoteInstanceId: "instance-old-001",
|
||||
remoteUrl: "https://mosaic.old.example.com",
|
||||
remotePublicKey: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----",
|
||||
remoteCapabilities: {
|
||||
supportsQuery: true,
|
||||
supportsCommand: true,
|
||||
supportsEvent: false,
|
||||
supportsAgentSpawn: false,
|
||||
protocolVersion: "1.0",
|
||||
},
|
||||
status: FederationConnectionStatus.DISCONNECTED,
|
||||
metadata: {
|
||||
name: "Previous Instance",
|
||||
description: "No longer in use",
|
||||
},
|
||||
createdAt: new Date("2026-01-15").toISOString(),
|
||||
updatedAt: new Date("2026-01-30").toISOString(),
|
||||
connectedAt: new Date("2026-01-15").toISOString(),
|
||||
disconnectedAt: new Date("2026-01-30").toISOString(),
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user