feat: Introduce dedicated participant, experiment, and trial detail/edit pages, enable MinIO, and refactor dashboard navigation.

This commit is contained in:
2025-12-11 20:04:52 -05:00
parent 5be4ff0372
commit d83c02759a
45 changed files with 4123 additions and 1455 deletions

View File

@@ -2,6 +2,7 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Play, CheckCircle, X, Clock, AlertCircle } from "lucide-react";
import { useRouter } from "next/navigation";
import { Badge } from "~/components/ui/badge";
import { Progress } from "~/components/ui/progress";
import { Alert, AlertDescription } from "~/components/ui/alert";
@@ -9,6 +10,12 @@ import { PanelsContainer } from "~/components/experiments/designer/layout/Panels
import { WizardControlPanel } from "./panels/WizardControlPanel";
import { WizardExecutionPanel } from "./panels/WizardExecutionPanel";
import { WizardMonitoringPanel } from "./panels/WizardMonitoringPanel";
import { WizardObservationPane } from "./panels/WizardObservationPane";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/ui/resizable";
import { api } from "~/trpc/react";
import { useWizardRos } from "~/hooks/useWizardRos";
import { toast } from "sonner";
@@ -42,6 +49,16 @@ interface WizardInterfaceProps {
userRole: string;
}
interface ActionData {
id: string;
name: string;
description: string | null;
type: string;
parameters: Record<string, unknown>;
order: number;
pluginId: string | null;
}
interface StepData {
id: string;
name: string;
@@ -53,6 +70,7 @@ interface StepData {
| "conditional_branch";
parameters: Record<string, unknown>;
order: number;
actions: ActionData[];
}
export const WizardInterface = React.memo(function WizardInterface({
@@ -65,6 +83,7 @@ export const WizardInterface = React.memo(function WizardInterface({
initialTrial.startedAt ? new Date(initialTrial.startedAt) : null,
);
const [elapsedTime, setElapsedTime] = useState(0);
const router = useRouter();
// Persistent tab states to prevent resets from parent re-renders
const [controlPanelTab, setControlPanelTab] = useState<
@@ -73,9 +92,16 @@ export const WizardInterface = React.memo(function WizardInterface({
const [executionPanelTab, setExecutionPanelTab] = useState<
"current" | "timeline" | "events"
>(trial.status === "in_progress" ? "current" : "timeline");
const [isExecutingAction, setIsExecutingAction] = useState(false);
const [monitoringPanelTab, setMonitoringPanelTab] = useState<
"status" | "robot" | "events"
>("status");
const [completedActionsCount, setCompletedActionsCount] = useState(0);
// Reset completed actions when step changes
useEffect(() => {
setCompletedActionsCount(0);
}, [currentStepIndex]);
// Get experiment steps from API
const { data: experimentSteps } = api.experiments.getSteps.useQuery(
@@ -100,6 +126,13 @@ export const WizardInterface = React.memo(function WizardInterface({
},
});
// Log robot action mutation (for client-side execution)
const logRobotActionMutation = api.trials.logRobotAction.useMutation({
onError: (error) => {
console.error("Failed to log robot action:", error);
},
});
// Map database step types to component step types
const mapStepType = (dbType: string) => {
switch (dbType) {
@@ -136,6 +169,7 @@ export const WizardInterface = React.memo(function WizardInterface({
connect: connectRos,
disconnect: disconnectRos,
executeRobotAction: executeRosAction,
setAutonomousLife,
} = useWizardRos({
autoConnect: true,
onActionCompleted,
@@ -152,6 +186,15 @@ export const WizardInterface = React.memo(function WizardInterface({
},
);
// Poll for trial events
const { data: fetchedEvents } = api.trials.getEvents.useQuery(
{ trialId: trial.id, limit: 100 },
{
refetchInterval: 3000,
staleTime: 1000,
}
);
// Update local trial state from polling
useEffect(() => {
if (pollingData) {
@@ -168,7 +211,15 @@ export const WizardInterface = React.memo(function WizardInterface({
}
}, [pollingData]);
// Auto-start trial on mount if scheduled
useEffect(() => {
if (trial.status === "scheduled") {
handleStartTrial();
}
}, []); // Run once on mount
// Trial events from robot actions
const trialEvents = useMemo<
Array<{
type: string;
@@ -176,7 +227,38 @@ export const WizardInterface = React.memo(function WizardInterface({
data?: unknown;
message?: string;
}>
>(() => [], []);
>(() => {
return (fetchedEvents ?? []).map(event => {
let message: string | undefined;
const eventData = event.data as any;
// Extract or generate message based on event type
if (event.eventType.startsWith('annotation_')) {
message = eventData?.description || eventData?.label || 'Annotation added';
} else if (event.eventType.startsWith('robot_action_')) {
const actionName = event.eventType.replace('robot_action_', '').replace(/_/g, ' ');
message = `Robot action: ${actionName}`;
} else if (event.eventType === 'trial_started') {
message = 'Trial started';
} else if (event.eventType === 'trial_completed') {
message = 'Trial completed';
} else if (event.eventType === 'step_changed') {
message = `Step changed to: ${eventData?.stepName || 'next step'}`;
} else if (event.eventType.startsWith('wizard_')) {
message = eventData?.notes || eventData?.message || event.eventType.replace('wizard_', '').replace(/_/g, ' ');
} else {
// Generic fallback
message = eventData?.notes || eventData?.message || eventData?.description || event.eventType.replace(/_/g, ' ');
}
return {
type: event.eventType,
timestamp: new Date(event.timestamp),
data: event.data,
message,
};
}).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); // Newest first
}, [fetchedEvents]);
// Transform experiment steps to component format
const steps: StepData[] =
@@ -187,6 +269,15 @@ export const WizardInterface = React.memo(function WizardInterface({
type: mapStepType(step.type),
parameters: step.parameters ?? {},
order: step.order ?? index,
actions: step.actions?.map((action) => ({
id: action.id,
name: action.name,
description: action.description,
type: action.type,
parameters: action.parameters ?? {},
order: action.order,
pluginId: action.pluginId,
})) ?? [],
})) ?? [];
const currentStep = steps[currentStepIndex] ?? null;
@@ -261,6 +352,8 @@ export const WizardInterface = React.memo(function WizardInterface({
status: data.status,
completedAt: data.completedAt,
});
toast.success("Trial completed! Redirecting to analysis...");
router.push(`/studies/${trial.experiment.studyId}/trials/${trial.id}/analysis`);
}
},
});
@@ -314,6 +407,7 @@ export const WizardInterface = React.memo(function WizardInterface({
const handleNextStep = () => {
if (currentStepIndex < steps.length - 1) {
setCompletedActionsCount(0); // Reset immediately to prevent flickering/double-click issues
setCurrentStepIndex(currentStepIndex + 1);
// Note: Step transitions can be enhanced later with database logging
}
@@ -335,15 +429,72 @@ export const WizardInterface = React.memo(function WizardInterface({
}
};
// Mutations for annotations
const addAnnotationMutation = api.trials.addAnnotation.useMutation({
onSuccess: () => {
toast.success("Note added");
},
onError: (error) => {
toast.error("Failed to add note", { description: error.message });
},
});
const handleAddAnnotation = async (
description: string,
category?: string,
tags?: string[],
) => {
await addAnnotationMutation.mutateAsync({
trialId: trial.id,
description,
category,
tags,
});
};
// Mutation for events (Acknowledge)
const logEventMutation = api.trials.logEvent.useMutation({
onSuccess: () => toast.success("Event logged"),
});
// Mutation for interventions
const addInterventionMutation = api.trials.addIntervention.useMutation({
onSuccess: () => toast.success("Intervention logged"),
});
const handleExecuteAction = async (
actionId: string,
parameters?: Record<string, unknown>,
) => {
try {
console.log("Executing action:", actionId, parameters);
if (actionId === "acknowledge") {
await logEventMutation.mutateAsync({
trialId: trial.id,
type: "wizard_acknowledge",
data: parameters,
});
handleNextStep();
} else if (actionId === "intervene") {
await addInterventionMutation.mutateAsync({
trialId: trial.id,
type: "manual_intervention",
description: "Wizard manual intervention triggered",
data: parameters,
});
} else if (actionId === "note") {
await addAnnotationMutation.mutateAsync({
trialId: trial.id,
description: String(parameters?.content || "Quick note"),
category: String(parameters?.category || "quick_note")
});
}
// Note: Action execution can be enhanced later with tRPC mutations
} catch (error) {
console.error("Failed to execute action:", error);
toast.error("Failed to execute action");
}
};
@@ -352,22 +503,34 @@ export const WizardInterface = React.memo(function WizardInterface({
pluginName: string,
actionId: string,
parameters: Record<string, unknown>,
options?: { autoAdvance?: boolean },
) => {
try {
setIsExecutingAction(true);
// Try direct WebSocket execution first for better performance
if (rosConnected) {
try {
await executeRosAction(pluginName, actionId, parameters);
const result = await executeRosAction(pluginName, actionId, parameters);
const duration =
result.endTime && result.startTime
? result.endTime.getTime() - result.startTime.getTime()
: 0;
// Log to trial events for data capture
await executeRobotActionMutation.mutateAsync({
await logRobotActionMutation.mutateAsync({
trialId: trial.id,
pluginName,
actionId,
parameters,
duration,
result: { status: result.status },
});
toast.success(`Robot action executed: ${actionId}`);
if (options?.autoAdvance) {
handleNextStep();
}
} catch (rosError) {
console.warn(
"WebSocket execution failed, falling back to tRPC:",
@@ -383,6 +546,9 @@ export const WizardInterface = React.memo(function WizardInterface({
});
toast.success(`Robot action executed via fallback: ${actionId}`);
if (options?.autoAdvance) {
handleNextStep();
}
}
} else {
// Use tRPC execution if WebSocket not connected
@@ -394,17 +560,51 @@ export const WizardInterface = React.memo(function WizardInterface({
});
toast.success(`Robot action executed: ${actionId}`);
if (options?.autoAdvance) {
handleNextStep();
}
}
} catch (error) {
console.error("Failed to execute robot action:", error);
toast.error(`Failed to execute robot action: ${actionId}`, {
description: error instanceof Error ? error.message : "Unknown error",
});
} finally {
setIsExecutingAction(false);
}
},
[rosConnected, executeRosAction, executeRobotActionMutation, trial.id],
);
const handleSkipAction = useCallback(
async (
pluginName: string,
actionId: string,
parameters: Record<string, unknown>,
options?: { autoAdvance?: boolean },
) => {
try {
await logRobotActionMutation.mutateAsync({
trialId: trial.id,
pluginName,
actionId,
parameters,
duration: 0,
result: { skipped: true },
});
toast.info(`Action skipped: ${actionId}`);
if (options?.autoAdvance) {
handleNextStep();
}
} catch (error) {
console.error("Failed to skip action:", error);
toast.error("Failed to skip action");
}
},
[logRobotActionMutation, trial.id],
);
return (
<div className="flex h-full flex-col">
{/* Compact Status Bar */}
@@ -451,58 +651,78 @@ export const WizardInterface = React.memo(function WizardInterface({
</div>
</div>
{/* No connection status alert - ROS connection shown in monitoring panel */}
{/* Main Content - Three Panel Layout */}
{/* Main Content with Vertical Resizable Split */}
<div className="min-h-0 flex-1">
<PanelsContainer
left={
<WizardControlPanel
trial={trial}
currentStep={currentStep}
steps={steps}
currentStepIndex={currentStepIndex}
onStartTrial={handleStartTrial}
onPauseTrial={handlePauseTrial}
onNextStep={handleNextStep}
onCompleteTrial={handleCompleteTrial}
onAbortTrial={handleAbortTrial}
onExecuteAction={handleExecuteAction}
onExecuteRobotAction={handleExecuteRobotAction}
studyId={trial.experiment.studyId}
_isConnected={rosConnected}
activeTab={controlPanelTab}
onTabChange={setControlPanelTab}
isStarting={startTrialMutation.isPending}
<ResizablePanelGroup direction="vertical">
<ResizablePanel defaultSize={75} minSize={30}>
<PanelsContainer
left={
<WizardControlPanel
trial={trial}
currentStep={currentStep}
steps={steps}
currentStepIndex={currentStepIndex}
onStartTrial={handleStartTrial}
onPauseTrial={handlePauseTrial}
onNextStep={handleNextStep}
onCompleteTrial={handleCompleteTrial}
onAbortTrial={handleAbortTrial}
onExecuteAction={handleExecuteAction}
onExecuteRobotAction={handleExecuteRobotAction}
studyId={trial.experiment.studyId}
_isConnected={rosConnected}
activeTab={controlPanelTab}
onTabChange={setControlPanelTab}
isStarting={startTrialMutation.isPending}
onSetAutonomousLife={setAutonomousLife}
/>
}
center={
<WizardExecutionPanel
trial={trial}
currentStep={currentStep}
steps={steps}
currentStepIndex={currentStepIndex}
trialEvents={trialEvents}
onStepSelect={(index: number) => setCurrentStepIndex(index)}
onExecuteAction={handleExecuteAction}
onExecuteRobotAction={handleExecuteRobotAction}
activeTab={executionPanelTab}
onTabChange={setExecutionPanelTab}
onSkipAction={handleSkipAction}
isExecuting={isExecutingAction}
onNextStep={handleNextStep}
completedActionsCount={completedActionsCount}
onActionCompleted={() => setCompletedActionsCount(c => c + 1)}
onCompleteTrial={handleCompleteTrial}
/>
}
right={
<WizardMonitoringPanel
rosConnected={rosConnected}
rosConnecting={rosConnecting}
rosError={rosError ?? undefined}
robotStatus={robotStatus}
connectRos={connectRos}
disconnectRos={disconnectRos}
executeRosAction={executeRosAction}
/>
}
showDividers={true}
className="h-full"
/>
}
center={
<WizardExecutionPanel
trial={trial}
currentStep={currentStep}
steps={steps}
currentStepIndex={currentStepIndex}
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={25} minSize={10}>
<WizardObservationPane
onAddAnnotation={handleAddAnnotation}
isSubmitting={addAnnotationMutation.isPending}
trialEvents={trialEvents}
onStepSelect={(index: number) => setCurrentStepIndex(index)}
onExecuteAction={handleExecuteAction}
activeTab={executionPanelTab}
onTabChange={setExecutionPanelTab}
/>
}
right={
<WizardMonitoringPanel
rosConnected={rosConnected}
rosConnecting={rosConnecting}
rosError={rosError ?? undefined}
robotStatus={robotStatus}
connectRos={connectRos}
disconnectRos={disconnectRos}
executeRosAction={executeRosAction}
/>
}
showDividers={true}
className="h-full"
/>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
);