mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-23 19:27:51 -04:00
feat: Introduce dedicated participant, experiment, and trial detail/edit pages, enable MinIO, and refactor dashboard navigation.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user