mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-02-05 07:56:30 -05:00
Add ROS2 bridge
This commit is contained in:
364
src/components/trials/views/ObserverView.tsx
Normal file
364
src/components/trials/views/ObserverView.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Eye,
|
||||
Clock,
|
||||
Play,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
User,
|
||||
Bot,
|
||||
Activity,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { PanelsContainer } from "~/components/experiments/designer/layout/PanelsContainer";
|
||||
|
||||
interface TrialData {
|
||||
id: string;
|
||||
status: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
|
||||
scheduledAt: Date | null;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
duration: number | null;
|
||||
sessionNumber: number | null;
|
||||
notes: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
experimentId: string;
|
||||
participantId: string | null;
|
||||
wizardId: string | null;
|
||||
experiment: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
studyId: string;
|
||||
};
|
||||
participant: {
|
||||
id: string;
|
||||
participantCode: string;
|
||||
demographics: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface ObserverViewProps {
|
||||
trial: TrialData;
|
||||
}
|
||||
|
||||
export function ObserverView({ trial }: ObserverViewProps) {
|
||||
const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case "scheduled":
|
||||
return { variant: "outline" as const, color: "blue", icon: Clock };
|
||||
case "in_progress":
|
||||
return { variant: "default" as const, color: "green", icon: Play };
|
||||
case "completed":
|
||||
return {
|
||||
variant: "secondary" as const,
|
||||
color: "gray",
|
||||
icon: CheckCircle,
|
||||
};
|
||||
case "aborted":
|
||||
return {
|
||||
variant: "destructive" as const,
|
||||
color: "orange",
|
||||
icon: AlertCircle,
|
||||
};
|
||||
case "failed":
|
||||
return {
|
||||
variant: "destructive" as const,
|
||||
color: "red",
|
||||
icon: AlertCircle,
|
||||
};
|
||||
default:
|
||||
return { variant: "outline" as const, color: "gray", icon: Clock };
|
||||
}
|
||||
};
|
||||
|
||||
const statusConfig = getStatusConfig(trial.status);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
|
||||
const formatElapsedTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const leftPanel = (
|
||||
<div className="flex h-full flex-col space-y-4 p-4">
|
||||
{/* Trial Overview */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Eye className="h-4 w-4" />
|
||||
Trial Overview
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm">Status</span>
|
||||
<Badge
|
||||
variant={statusConfig.variant}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{trial.status.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Session</span>
|
||||
<span>#{trial.sessionNumber}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Participant</span>
|
||||
<span className="font-mono">
|
||||
{trial.participant.participantCode}
|
||||
</span>
|
||||
</div>
|
||||
{trial.startedAt && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Started</span>
|
||||
<span>{new Date(trial.startedAt).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
)}
|
||||
{trial.completedAt && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Completed</span>
|
||||
<span>{new Date(trial.completedAt).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Experiment Info */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">Experiment</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{trial.experiment.name}</div>
|
||||
{trial.experiment.description && (
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
{trial.experiment.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Participant Info */}
|
||||
<Card className="flex-1">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<User className="h-4 w-4" />
|
||||
Participant
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">
|
||||
{trial.participant.participantCode}
|
||||
</div>
|
||||
{trial.participant.demographics && (
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
{Object.keys(trial.participant.demographics).length} demographic
|
||||
fields
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
const centerPanel = (
|
||||
<div className="flex h-full flex-col p-6">
|
||||
{trial.status === "scheduled" ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<Clock className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||
<h3 className="mb-2 text-lg font-semibold">Trial Scheduled</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
This trial is scheduled but has not yet started. You will be
|
||||
able to observe the execution once it begins.
|
||||
</p>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Waiting for wizard to start the trial...
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : trial.status === "in_progress" ? (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
Trial in Progress
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
The trial is currently running. You can observe the progress
|
||||
and events as they happen.
|
||||
</div>
|
||||
|
||||
{trial.startedAt && (
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Started at:</span>
|
||||
<div className="font-mono">
|
||||
{new Date(trial.startedAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Duration:</span>
|
||||
<div className="font-mono">
|
||||
{formatElapsedTime(
|
||||
Math.floor(
|
||||
(Date.now() - new Date(trial.startedAt).getTime()) /
|
||||
1000,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="flex-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bot className="h-5 w-5" />
|
||||
Live Observation
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="bg-muted/50 rounded-lg p-8 text-center">
|
||||
<div className="text-muted-foreground">
|
||||
Live trial observation interface
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-2 text-xs">
|
||||
Real-time trial events and robot status would appear here
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<CheckCircle className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
Trial {trial.status === "completed" ? "Completed" : "Ended"}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
The trial execution has finished. Review the results and data
|
||||
collected during the session.
|
||||
</p>
|
||||
{trial.completedAt && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Ended at {new Date(trial.completedAt).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const rightPanel = (
|
||||
<div className="flex h-full flex-col space-y-4 p-4">
|
||||
{/* System Status */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">System Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Connection</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Observer Mode
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">View Only</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Read Only
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Card className="flex-1">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Activity className="h-4 w-4" />
|
||||
Recent Activity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-3">
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
No recent activity
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
{/* Status Bar */}
|
||||
<div className="bg-background border-b px-4 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<Eye className="h-3 w-3" />
|
||||
Observer Mode
|
||||
</Badge>
|
||||
|
||||
<Badge
|
||||
variant={statusConfig.variant}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{trial.status.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{trial.experiment.name} • {trial.participant.participantCode}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="min-h-0 flex-1">
|
||||
<PanelsContainer
|
||||
left={leftPanel}
|
||||
center={centerPanel}
|
||||
right={rightPanel}
|
||||
showDividers={true}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
338
src/components/trials/views/ParticipantView.tsx
Normal file
338
src/components/trials/views/ParticipantView.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
User,
|
||||
Clock,
|
||||
Play,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Info,
|
||||
Heart,
|
||||
MessageCircle,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
|
||||
interface TrialData {
|
||||
id: string;
|
||||
status: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
|
||||
scheduledAt: Date | null;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
duration: number | null;
|
||||
sessionNumber: number | null;
|
||||
notes: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
experimentId: string;
|
||||
participantId: string | null;
|
||||
wizardId: string | null;
|
||||
experiment: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
studyId: string;
|
||||
};
|
||||
participant: {
|
||||
id: string;
|
||||
participantCode: string;
|
||||
demographics: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface ParticipantViewProps {
|
||||
trial: TrialData;
|
||||
}
|
||||
|
||||
export function ParticipantView({ trial }: ParticipantViewProps) {
|
||||
const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case "scheduled":
|
||||
return {
|
||||
variant: "outline" as const,
|
||||
color: "blue",
|
||||
icon: Clock,
|
||||
message: "Session scheduled",
|
||||
};
|
||||
case "in_progress":
|
||||
return {
|
||||
variant: "default" as const,
|
||||
color: "green",
|
||||
icon: Play,
|
||||
message: "Session in progress",
|
||||
};
|
||||
case "completed":
|
||||
return {
|
||||
variant: "secondary" as const,
|
||||
color: "gray",
|
||||
icon: CheckCircle,
|
||||
message: "Session completed",
|
||||
};
|
||||
case "aborted":
|
||||
return {
|
||||
variant: "destructive" as const,
|
||||
color: "orange",
|
||||
icon: AlertCircle,
|
||||
message: "Session ended early",
|
||||
};
|
||||
case "failed":
|
||||
return {
|
||||
variant: "destructive" as const,
|
||||
color: "red",
|
||||
icon: AlertCircle,
|
||||
message: "Session encountered an issue",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
variant: "outline" as const,
|
||||
color: "gray",
|
||||
icon: Clock,
|
||||
message: "Session status unknown",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const statusConfig = getStatusConfig(trial.status);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
|
||||
const formatElapsedTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const currentTime = new Date();
|
||||
const elapsedSeconds = trial.startedAt
|
||||
? Math.floor(
|
||||
(currentTime.getTime() - new Date(trial.startedAt).getTime()) / 1000,
|
||||
)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="h-full bg-gradient-to-br from-blue-50 to-indigo-50">
|
||||
{/* Header */}
|
||||
<div className="border-b bg-white px-6 py-4 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100">
|
||||
<User className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Research Session</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Participant {trial.participant.participantCode}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
variant={statusConfig.variant}
|
||||
className="flex items-center gap-1 px-3 py-1"
|
||||
>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{statusConfig.message}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full flex-col p-6">
|
||||
{trial.status === "scheduled" ? (
|
||||
// Pre-session view
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Card className="w-full max-w-lg shadow-lg">
|
||||
<CardContent className="pt-8 pb-8 text-center">
|
||||
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-blue-100">
|
||||
<Clock className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
<h2 className="mb-3 text-xl font-semibold">
|
||||
Welcome to Your Session
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6 leading-relaxed">
|
||||
Your research session is scheduled and ready to begin. Please
|
||||
wait for the researcher to start the session.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3 text-left">
|
||||
<div className="bg-muted/50 flex items-center justify-between rounded-lg p-3">
|
||||
<span className="text-sm font-medium">Experiment:</span>
|
||||
<span className="text-sm">{trial.experiment.name}</span>
|
||||
</div>
|
||||
<div className="bg-muted/50 flex items-center justify-between rounded-lg p-3">
|
||||
<span className="text-sm font-medium">Session Number:</span>
|
||||
<span className="text-sm">#{trial.sessionNumber}</span>
|
||||
</div>
|
||||
{trial.scheduledAt && (
|
||||
<div className="bg-muted/50 flex items-center justify-between rounded-lg p-3">
|
||||
<span className="text-sm font-medium">
|
||||
Scheduled Time:
|
||||
</span>
|
||||
<span className="text-sm">
|
||||
{new Date(trial.scheduledAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Alert className="mt-6">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Please remain comfortable and ready. The session will begin
|
||||
shortly.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : trial.status === "in_progress" ? (
|
||||
// Active session view
|
||||
<div className="flex flex-1 flex-col space-y-6">
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-3 w-3 animate-pulse rounded-full bg-green-500" />
|
||||
<span className="font-medium">Session Active</span>
|
||||
</div>
|
||||
{trial.startedAt && (
|
||||
<div className="text-right">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Duration
|
||||
</div>
|
||||
<div className="font-mono text-lg">
|
||||
{formatElapsedTime(elapsedSeconds)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Card className="w-full max-w-2xl shadow-lg">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="flex items-center justify-center gap-2">
|
||||
<Heart className="h-5 w-5 text-pink-500" />
|
||||
Session in Progress
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 pb-8">
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground text-lg leading-relaxed">
|
||||
Thank you for participating! Please follow the
|
||||
researcher's instructions and interact naturally with
|
||||
the robot.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold">Session Information</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-muted/50 rounded-lg p-4 text-center">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Experiment
|
||||
</div>
|
||||
<div className="font-medium">
|
||||
{trial.experiment.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-4 text-center">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Session
|
||||
</div>
|
||||
<div className="font-medium">
|
||||
#{trial.sessionNumber}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert className="border-blue-200 bg-blue-50">
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-blue-800">
|
||||
Feel free to ask questions at any time. Your comfort and
|
||||
safety are our priority.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="text-center">
|
||||
<Button variant="outline" size="lg" className="gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
Need Help?
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Post-session view
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Card className="w-full max-w-lg shadow-lg">
|
||||
<CardContent className="pt-8 pb-8 text-center">
|
||||
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-green-100">
|
||||
<CheckCircle className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
<h2 className="mb-3 text-xl font-semibold">
|
||||
{trial.status === "completed"
|
||||
? "Session Complete!"
|
||||
: "Session Ended"}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6 leading-relaxed">
|
||||
{trial.status === "completed"
|
||||
? "Thank you for your participation! Your session has been completed successfully."
|
||||
: "Your session has ended. Thank you for your time and participation."}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{trial.startedAt && trial.completedAt && (
|
||||
<div className="bg-muted/50 flex items-center justify-between rounded-lg p-3">
|
||||
<span className="text-sm font-medium">
|
||||
Session Duration:
|
||||
</span>
|
||||
<span className="text-sm">
|
||||
{formatElapsedTime(
|
||||
Math.floor(
|
||||
(new Date(trial.completedAt).getTime() -
|
||||
new Date(trial.startedAt).getTime()) /
|
||||
1000,
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{trial.completedAt && (
|
||||
<div className="bg-muted/50 flex items-center justify-between rounded-lg p-3">
|
||||
<span className="text-sm font-medium">Completed At:</span>
|
||||
<span className="text-sm">
|
||||
{new Date(trial.completedAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{trial.status === "completed" && (
|
||||
<Alert className="mt-6 border-green-200 bg-green-50">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-green-800">
|
||||
Your data has been recorded successfully. Thank you for
|
||||
contributing to research!
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<Button className="w-full" size="lg">
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
src/components/trials/views/WizardView.tsx
Normal file
40
src/components/trials/views/WizardView.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { WizardInterface } from "../wizard/WizardInterface";
|
||||
|
||||
interface WizardViewProps {
|
||||
trial: {
|
||||
id: string;
|
||||
status: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
|
||||
scheduledAt: Date | null;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
duration: number | null;
|
||||
sessionNumber: number | null;
|
||||
notes: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
experimentId: string;
|
||||
participantId: string | null;
|
||||
wizardId: string | null;
|
||||
experiment: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
studyId: string;
|
||||
};
|
||||
participant: {
|
||||
id: string;
|
||||
participantCode: string;
|
||||
demographics: Record<string, unknown> | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function WizardView({ trial }: WizardViewProps) {
|
||||
return (
|
||||
<div className="h-full">
|
||||
<WizardInterface trial={trial} userRole="wizard" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
||||
import { Play, CheckCircle, X, Clock, AlertCircle } from "lucide-react";
|
||||
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
|
||||
import { PanelsContainer } from "~/components/experiments/designer/layout/PanelsContainer";
|
||||
import { TrialControlPanel } from "./panels/TrialControlPanel";
|
||||
import { ExecutionPanel } from "./panels/ExecutionPanel";
|
||||
import { MonitoringPanel } from "./panels/MonitoringPanel";
|
||||
|
||||
import { WizardControlPanel } from "./panels/WizardControlPanel";
|
||||
import { WizardExecutionPanel } from "./panels/WizardExecutionPanel";
|
||||
import { WizardMonitoringPanel } from "./panels/WizardMonitoringPanel";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useTrialWebSocket } from "~/hooks/useWebSocket";
|
||||
// import { useTrialWebSocket } from "~/hooks/useWebSocket"; // Removed WebSocket dependency
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface WizardInterfaceProps {
|
||||
trial: {
|
||||
@@ -69,6 +66,17 @@ export function WizardInterface({
|
||||
);
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
|
||||
// Persistent tab states to prevent resets from parent re-renders
|
||||
const [controlPanelTab, setControlPanelTab] = useState<
|
||||
"control" | "step" | "actions"
|
||||
>("control");
|
||||
const [executionPanelTab, setExecutionPanelTab] = useState<
|
||||
"current" | "timeline" | "events"
|
||||
>(trial.status === "in_progress" ? "current" : "timeline");
|
||||
const [monitoringPanelTab, setMonitoringPanelTab] = useState<
|
||||
"status" | "robot" | "events"
|
||||
>("status");
|
||||
|
||||
// Get experiment steps from API
|
||||
const { data: experimentSteps } = api.experiments.getSteps.useQuery(
|
||||
{ experimentId: trial.experimentId },
|
||||
@@ -94,28 +102,25 @@ export function WizardInterface({
|
||||
}
|
||||
};
|
||||
|
||||
// Real-time WebSocket connection
|
||||
const {
|
||||
isConnected: wsConnected,
|
||||
isConnecting: wsConnecting,
|
||||
connectionError: wsError,
|
||||
trialEvents,
|
||||
executeTrialAction,
|
||||
transitionStep,
|
||||
} = useTrialWebSocket(trial.id);
|
||||
|
||||
// Fallback polling for trial updates when WebSocket is not available
|
||||
// Use polling for real-time updates (no WebSocket dependency)
|
||||
const { data: pollingData } = api.trials.get.useQuery(
|
||||
{ id: trial.id },
|
||||
{
|
||||
enabled: !wsConnected && !wsConnecting,
|
||||
refetchInterval: wsConnected ? false : 5000,
|
||||
refetchInterval: 2000, // Poll every 2 seconds
|
||||
},
|
||||
);
|
||||
|
||||
// Mock trial events for now (can be populated from database later)
|
||||
const trialEvents: Array<{
|
||||
type: string;
|
||||
timestamp: Date;
|
||||
data?: unknown;
|
||||
message?: string;
|
||||
}> = [];
|
||||
|
||||
// Update trial data from polling
|
||||
React.useEffect(() => {
|
||||
if (pollingData && !wsConnected) {
|
||||
if (pollingData) {
|
||||
setTrial({
|
||||
...pollingData,
|
||||
metadata: pollingData.metadata as Record<string, unknown> | null,
|
||||
@@ -128,7 +133,7 @@ export function WizardInterface({
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [pollingData, wsConnected]);
|
||||
}, [pollingData]);
|
||||
|
||||
// Transform experiment steps to component format
|
||||
const steps: StepData[] =
|
||||
@@ -225,10 +230,37 @@ export function WizardInterface({
|
||||
|
||||
// Action handlers
|
||||
const handleStartTrial = async () => {
|
||||
console.log(
|
||||
"[WizardInterface] Starting trial:",
|
||||
trial.id,
|
||||
"Current status:",
|
||||
trial.status,
|
||||
);
|
||||
|
||||
// Check if trial can be started
|
||||
if (trial.status !== "scheduled") {
|
||||
toast.error("Trial can only be started from scheduled status");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await startTrialMutation.mutateAsync({ id: trial.id });
|
||||
const result = await startTrialMutation.mutateAsync({ id: trial.id });
|
||||
console.log("[WizardInterface] Trial started successfully", result);
|
||||
|
||||
// Update local state immediately
|
||||
setTrial((prev) => ({
|
||||
...prev,
|
||||
status: "in_progress",
|
||||
startedAt: new Date(),
|
||||
}));
|
||||
setTrialStartTime(new Date());
|
||||
|
||||
toast.success("Trial started successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to start trial:", error);
|
||||
toast.error(
|
||||
`Failed to start trial: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -240,11 +272,7 @@ export function WizardInterface({
|
||||
const handleNextStep = () => {
|
||||
if (currentStepIndex < steps.length - 1) {
|
||||
setCurrentStepIndex(currentStepIndex + 1);
|
||||
transitionStep?.({
|
||||
to_step: currentStepIndex + 1,
|
||||
from_step: currentStepIndex,
|
||||
step_name: steps[currentStepIndex + 1]?.name,
|
||||
});
|
||||
// Note: Step transitions can be enhanced later with database logging
|
||||
}
|
||||
};
|
||||
|
||||
@@ -269,7 +297,8 @@ export function WizardInterface({
|
||||
parameters?: Record<string, unknown>,
|
||||
) => {
|
||||
try {
|
||||
executeTrialAction?.(actionId, parameters ?? {});
|
||||
console.log("Executing action:", actionId, parameters);
|
||||
// Note: Action execution can be enhanced later with tRPC mutations
|
||||
} catch (error) {
|
||||
console.error("Failed to execute action:", error);
|
||||
}
|
||||
@@ -277,7 +306,7 @@ export function WizardInterface({
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Status Bar */}
|
||||
{/* Compact Status Bar */}
|
||||
<div className="bg-background border-b px-4 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -308,28 +337,29 @@ export function WizardInterface({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{trial.experiment.name} • {trial.participant.participantCode}
|
||||
<div className="text-muted-foreground flex items-center gap-4 text-sm">
|
||||
<div>{trial.experiment.name}</div>
|
||||
<div>{trial.participant.participantCode}</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Polling
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* WebSocket Connection Status */}
|
||||
{wsError && (
|
||||
<Alert className="mx-4 mt-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
WebSocket connection failed. Using fallback polling. Some features
|
||||
may be limited.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{/* Connection Status */}
|
||||
<Alert className="mx-4 mt-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Using polling mode for trial updates (refreshes every 2 seconds).
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Main Content - Three Panel Layout */}
|
||||
<div className="min-h-0 flex-1">
|
||||
<PanelsContainer
|
||||
left={
|
||||
<TrialControlPanel
|
||||
<WizardControlPanel
|
||||
trial={trial}
|
||||
currentStep={currentStep}
|
||||
steps={steps}
|
||||
@@ -340,64 +370,33 @@ export function WizardInterface({
|
||||
onCompleteTrial={handleCompleteTrial}
|
||||
onAbortTrial={handleAbortTrial}
|
||||
onExecuteAction={handleExecuteAction}
|
||||
isConnected={wsConnected}
|
||||
_isConnected={true}
|
||||
activeTab={controlPanelTab}
|
||||
onTabChange={setControlPanelTab}
|
||||
isStarting={startTrialMutation.isPending}
|
||||
/>
|
||||
}
|
||||
center={
|
||||
<ExecutionPanel
|
||||
<WizardExecutionPanel
|
||||
trial={trial}
|
||||
currentStep={currentStep}
|
||||
steps={steps}
|
||||
currentStepIndex={currentStepIndex}
|
||||
trialEvents={trialEvents.map((event) => ({
|
||||
type: event.type ?? "unknown",
|
||||
timestamp:
|
||||
"data" in event &&
|
||||
event.data &&
|
||||
typeof event.data === "object" &&
|
||||
"timestamp" in event.data &&
|
||||
typeof event.data.timestamp === "number"
|
||||
? new Date(event.data.timestamp)
|
||||
: new Date(),
|
||||
data: "data" in event ? event.data : undefined,
|
||||
message:
|
||||
"data" in event &&
|
||||
event.data &&
|
||||
typeof event.data === "object" &&
|
||||
"message" in event.data &&
|
||||
typeof event.data.message === "string"
|
||||
? event.data.message
|
||||
: undefined,
|
||||
}))}
|
||||
onStepSelect={(index) => setCurrentStepIndex(index)}
|
||||
trialEvents={trialEvents}
|
||||
onStepSelect={(index: number) => setCurrentStepIndex(index)}
|
||||
onExecuteAction={handleExecuteAction}
|
||||
activeTab={executionPanelTab}
|
||||
onTabChange={setExecutionPanelTab}
|
||||
/>
|
||||
}
|
||||
right={
|
||||
<MonitoringPanel
|
||||
<WizardMonitoringPanel
|
||||
trial={trial}
|
||||
trialEvents={trialEvents.map((event) => ({
|
||||
type: event.type ?? "unknown",
|
||||
timestamp:
|
||||
"data" in event &&
|
||||
event.data &&
|
||||
typeof event.data === "object" &&
|
||||
"timestamp" in event.data &&
|
||||
typeof event.data.timestamp === "number"
|
||||
? new Date(event.data.timestamp)
|
||||
: new Date(),
|
||||
data: "data" in event ? event.data : undefined,
|
||||
message:
|
||||
"data" in event &&
|
||||
event.data &&
|
||||
typeof event.data === "object" &&
|
||||
"message" in event.data &&
|
||||
typeof event.data.message === "string"
|
||||
? event.data.message
|
||||
: undefined,
|
||||
}))}
|
||||
isConnected={wsConnected}
|
||||
wsError={wsError ?? undefined}
|
||||
trialEvents={trialEvents}
|
||||
isConnected={true}
|
||||
wsError={undefined}
|
||||
activeTab={monitoringPanelTab}
|
||||
onTabChange={setMonitoringPanelTab}
|
||||
/>
|
||||
}
|
||||
showDividers={true}
|
||||
|
||||
364
src/components/trials/wizard/panels/ExecutionPanel.tsx
Normal file
364
src/components/trials/wizard/panels/ExecutionPanel.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Play,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Bot,
|
||||
User,
|
||||
Activity,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
|
||||
interface StepData {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type:
|
||||
| "wizard_action"
|
||||
| "robot_action"
|
||||
| "parallel_steps"
|
||||
| "conditional_branch";
|
||||
parameters: Record<string, unknown>;
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface TrialData {
|
||||
id: string;
|
||||
status: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
|
||||
scheduledAt: Date | null;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
duration: number | null;
|
||||
sessionNumber: number | null;
|
||||
notes: string | null;
|
||||
experimentId: string;
|
||||
participantId: string | null;
|
||||
wizardId: string | null;
|
||||
experiment: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
studyId: string;
|
||||
};
|
||||
participant: {
|
||||
id: string;
|
||||
participantCode: string;
|
||||
demographics: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface TrialEvent {
|
||||
type: string;
|
||||
timestamp: Date;
|
||||
data?: unknown;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface ExecutionPanelProps {
|
||||
trial: TrialData;
|
||||
currentStep: StepData | null;
|
||||
steps: StepData[];
|
||||
currentStepIndex: number;
|
||||
trialEvents: TrialEvent[];
|
||||
onStepSelect: (index: number) => void;
|
||||
onExecuteAction: (
|
||||
actionId: string,
|
||||
parameters?: Record<string, unknown>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function ExecutionPanel({
|
||||
trial,
|
||||
currentStep,
|
||||
steps,
|
||||
currentStepIndex,
|
||||
trialEvents,
|
||||
onStepSelect,
|
||||
onExecuteAction,
|
||||
}: ExecutionPanelProps) {
|
||||
const getStepIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "wizard_action":
|
||||
return User;
|
||||
case "robot_action":
|
||||
return Bot;
|
||||
case "parallel_steps":
|
||||
return Activity;
|
||||
case "conditional_branch":
|
||||
return AlertCircle;
|
||||
default:
|
||||
return Play;
|
||||
}
|
||||
};
|
||||
|
||||
const getStepStatus = (stepIndex: number) => {
|
||||
if (stepIndex < currentStepIndex) return "completed";
|
||||
if (stepIndex === currentStepIndex && trial.status === "in_progress")
|
||||
return "active";
|
||||
return "pending";
|
||||
};
|
||||
|
||||
const getStepVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "default";
|
||||
case "active":
|
||||
return "secondary";
|
||||
case "pending":
|
||||
return "outline";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
if (trial.status === "scheduled") {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-8">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<Clock className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||
<h3 className="mb-2 text-lg font-semibold">Trial Ready to Start</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
This trial is scheduled and ready to begin. Use the controls in
|
||||
the left panel to start execution.
|
||||
</p>
|
||||
<div className="text-muted-foreground space-y-1 text-sm">
|
||||
<div>Experiment: {trial.experiment.name}</div>
|
||||
<div>Participant: {trial.participant.participantCode}</div>
|
||||
<div>Session: #{trial.sessionNumber}</div>
|
||||
{steps.length > 0 && <div>{steps.length} steps to execute</div>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
trial.status === "completed" ||
|
||||
trial.status === "aborted" ||
|
||||
trial.status === "failed"
|
||||
) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-8">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<CheckCircle className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
Trial {trial.status === "completed" ? "Completed" : "Ended"}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
The trial execution has finished. You can review the results and
|
||||
captured data.
|
||||
</p>
|
||||
{trial.completedAt && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Ended at {new Date(trial.completedAt).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-6">
|
||||
{/* Current Step Header */}
|
||||
{currentStep && (
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-full">
|
||||
{React.createElement(getStepIcon(currentStep.type), {
|
||||
className: "h-5 w-5 text-primary",
|
||||
})}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold">{currentStep.name}</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Step {currentStepIndex + 1} of {steps.length}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary" className="ml-auto">
|
||||
{currentStep.type.replace("_", " ")}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{currentStep.description && (
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{currentStep.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{currentStep.type === "wizard_action" && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium">Available Actions:</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onExecuteAction("acknowledge")}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Acknowledge
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onExecuteAction("intervene")}
|
||||
>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Intervene
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onExecuteAction("note", { content: "Wizard observation" })
|
||||
}
|
||||
>
|
||||
Note
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep.type === "robot_action" && (
|
||||
<div className="rounded-lg bg-blue-50 p-3 text-sm">
|
||||
<div className="flex items-center gap-2 font-medium text-blue-900">
|
||||
<Bot className="h-4 w-4" />
|
||||
Robot Action in Progress
|
||||
</div>
|
||||
<div className="mt-1 text-blue-700">
|
||||
The robot is executing this step. Monitor progress in the
|
||||
right panel.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Steps Timeline */}
|
||||
<Card className="flex-1">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
Experiment Timeline
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-3">
|
||||
{steps.map((step, index) => {
|
||||
const status = getStepStatus(index);
|
||||
const StepIcon = getStepIcon(step.type);
|
||||
const isActive = index === currentStepIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`hover:bg-muted/50 flex cursor-pointer items-start gap-3 rounded-lg p-3 transition-colors ${
|
||||
isActive ? "bg-primary/5 border-primary/20 border" : ""
|
||||
}`}
|
||||
onClick={() => onStepSelect(index)}
|
||||
>
|
||||
{/* Step Number and Status */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${
|
||||
status === "completed"
|
||||
? "bg-green-100 text-green-700"
|
||||
: status === "active"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{status === "completed" ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={`mt-2 h-6 w-0.5 ${
|
||||
status === "completed"
|
||||
? "bg-green-200"
|
||||
: "bg-border"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<StepIcon className="text-muted-foreground h-4 w-4" />
|
||||
<div className="font-medium">{step.name}</div>
|
||||
<Badge
|
||||
variant={getStepVariant(status)}
|
||||
className="ml-auto text-xs"
|
||||
>
|
||||
{step.type.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
{step.description && (
|
||||
<p className="text-muted-foreground mt-1 line-clamp-2 text-sm">
|
||||
{step.description}
|
||||
</p>
|
||||
)}
|
||||
{isActive && trial.status === "in_progress" && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="bg-primary h-2 w-2 animate-pulse rounded-full" />
|
||||
<span className="text-primary text-xs">
|
||||
Currently executing
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Events */}
|
||||
{trialEvents.length > 0 && (
|
||||
<Card className="mt-4">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">Recent Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-24">
|
||||
<div className="space-y-2">
|
||||
{trialEvents.slice(-5).map((event, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between text-xs"
|
||||
>
|
||||
<span className="font-medium">{event.type}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{new Date(event.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
334
src/components/trials/wizard/panels/MonitoringPanel.tsx
Normal file
334
src/components/trials/wizard/panels/MonitoringPanel.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Bot,
|
||||
User,
|
||||
Activity,
|
||||
Settings,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
|
||||
interface TrialData {
|
||||
id: string;
|
||||
status: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
|
||||
scheduledAt: Date | null;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
duration: number | null;
|
||||
sessionNumber: number | null;
|
||||
notes: string | null;
|
||||
experimentId: string;
|
||||
participantId: string | null;
|
||||
wizardId: string | null;
|
||||
experiment: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
studyId: string;
|
||||
};
|
||||
participant: {
|
||||
id: string;
|
||||
participantCode: string;
|
||||
demographics: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface TrialEvent {
|
||||
type: string;
|
||||
timestamp: Date;
|
||||
data?: unknown;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface MonitoringPanelProps {
|
||||
trial: TrialData;
|
||||
trialEvents: TrialEvent[];
|
||||
isConnected: boolean;
|
||||
wsError?: string;
|
||||
}
|
||||
|
||||
export function MonitoringPanel({
|
||||
trial,
|
||||
trialEvents,
|
||||
isConnected,
|
||||
wsError,
|
||||
}: MonitoringPanelProps) {
|
||||
const formatTimestamp = (timestamp: Date) => {
|
||||
return new Date(timestamp).toLocaleTimeString();
|
||||
};
|
||||
|
||||
const getEventIcon = (eventType: string) => {
|
||||
switch (eventType.toLowerCase()) {
|
||||
case "trial_started":
|
||||
case "trial_resumed":
|
||||
return CheckCircle;
|
||||
case "trial_paused":
|
||||
case "trial_stopped":
|
||||
return AlertCircle;
|
||||
case "step_completed":
|
||||
case "action_completed":
|
||||
return CheckCircle;
|
||||
case "robot_action":
|
||||
case "robot_status":
|
||||
return Bot;
|
||||
case "wizard_action":
|
||||
case "wizard_intervention":
|
||||
return User;
|
||||
case "system_error":
|
||||
case "connection_error":
|
||||
return AlertCircle;
|
||||
default:
|
||||
return Activity;
|
||||
}
|
||||
};
|
||||
|
||||
const getEventColor = (eventType: string) => {
|
||||
switch (eventType.toLowerCase()) {
|
||||
case "trial_started":
|
||||
case "trial_resumed":
|
||||
case "step_completed":
|
||||
case "action_completed":
|
||||
return "text-green-600";
|
||||
case "trial_paused":
|
||||
case "trial_stopped":
|
||||
return "text-yellow-600";
|
||||
case "system_error":
|
||||
case "connection_error":
|
||||
case "trial_failed":
|
||||
return "text-red-600";
|
||||
case "robot_action":
|
||||
case "robot_status":
|
||||
return "text-blue-600";
|
||||
case "wizard_action":
|
||||
case "wizard_intervention":
|
||||
return "text-purple-600";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4 p-4">
|
||||
{/* Connection Status */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Settings className="h-4 w-4" />
|
||||
Connection Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<Wifi className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<WifiOff className="h-4 w-4 text-orange-600" />
|
||||
)}
|
||||
<span className="text-sm">WebSocket</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant={isConnected ? "default" : "secondary"}
|
||||
className="text-xs"
|
||||
>
|
||||
{isConnected ? "Connected" : "Offline"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{wsError && (
|
||||
<Alert variant="destructive" className="mt-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">{wsError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="text-muted-foreground space-y-2 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span>Trial ID</span>
|
||||
<span className="font-mono">{trial.id.slice(-8)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Session</span>
|
||||
<span>#{trial.sessionNumber}</span>
|
||||
</div>
|
||||
{trial.startedAt && (
|
||||
<div className="flex justify-between">
|
||||
<span>Started</span>
|
||||
<span>{formatTimestamp(new Date(trial.startedAt))}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Robot Status */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Bot className="h-4 w-4" />
|
||||
Robot Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Status</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{isConnected ? "Ready" : "Unknown"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Battery</span>
|
||||
<span className="text-muted-foreground text-sm">--</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">Position</span>
|
||||
<span className="text-muted-foreground text-sm">--</span>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="bg-muted/50 text-muted-foreground rounded-lg p-2 text-center text-xs">
|
||||
Robot monitoring requires WebSocket connection
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Participant Info */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<User className="h-4 w-4" />
|
||||
Participant
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Code</span>
|
||||
<span className="font-mono">
|
||||
{trial.participant.participantCode}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Session</span>
|
||||
<span>#{trial.sessionNumber}</span>
|
||||
</div>
|
||||
{trial.participant.demographics && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Demographics</span>
|
||||
<span className="text-xs">
|
||||
{Object.keys(trial.participant.demographics).length} fields
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Live Events */}
|
||||
<Card className="min-h-0 flex-1">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Activity className="h-4 w-4" />
|
||||
Live Events
|
||||
{trialEvents.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-auto text-xs">
|
||||
{trialEvents.length}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-full min-h-0 pb-2">
|
||||
<ScrollArea className="h-full">
|
||||
{trialEvents.length === 0 ? (
|
||||
<div className="text-muted-foreground flex h-32 items-center justify-center text-sm">
|
||||
No events yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{trialEvents
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((event, index) => {
|
||||
const EventIcon = getEventIcon(event.type);
|
||||
const eventColor = getEventColor(event.type);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${event.timestamp.getTime()}-${index}`}
|
||||
className="border-border/50 flex items-start gap-2 rounded-lg border p-2 text-xs"
|
||||
>
|
||||
<div className={`mt-0.5 ${eventColor}`}>
|
||||
<EventIcon className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium capitalize">
|
||||
{event.type.replace(/_/g, " ")}
|
||||
</div>
|
||||
{event.message && (
|
||||
<div className="text-muted-foreground mt-1">
|
||||
{event.message}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-muted-foreground mt-1">
|
||||
{formatTimestamp(event.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* System Info */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Zap className="h-4 w-4" />
|
||||
System
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-muted-foreground space-y-2 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span>Experiment</span>
|
||||
<span
|
||||
className="ml-2 max-w-24 truncate"
|
||||
title={trial.experiment.name}
|
||||
>
|
||||
{trial.experiment.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Study ID</span>
|
||||
<span className="font-mono">
|
||||
{trial.experiment.studyId.slice(-8)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Platform</span>
|
||||
<span>HRIStudio</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
296
src/components/trials/wizard/panels/TrialControlPanel.tsx
Normal file
296
src/components/trials/wizard/panels/TrialControlPanel.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
SkipForward,
|
||||
CheckCircle,
|
||||
X,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
|
||||
interface StepData {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type:
|
||||
| "wizard_action"
|
||||
| "robot_action"
|
||||
| "parallel_steps"
|
||||
| "conditional_branch";
|
||||
parameters: Record<string, unknown>;
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface TrialData {
|
||||
id: string;
|
||||
status: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
|
||||
scheduledAt: Date | null;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
duration: number | null;
|
||||
sessionNumber: number | null;
|
||||
notes: string | null;
|
||||
experimentId: string;
|
||||
participantId: string | null;
|
||||
wizardId: string | null;
|
||||
experiment: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
studyId: string;
|
||||
};
|
||||
participant: {
|
||||
id: string;
|
||||
participantCode: string;
|
||||
demographics: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface TrialControlPanelProps {
|
||||
trial: TrialData;
|
||||
currentStep: StepData | null;
|
||||
steps: StepData[];
|
||||
currentStepIndex: number;
|
||||
onStartTrial: () => void;
|
||||
onPauseTrial: () => void;
|
||||
onNextStep: () => void;
|
||||
onCompleteTrial: () => void;
|
||||
onAbortTrial: () => void;
|
||||
onExecuteAction: (
|
||||
actionId: string,
|
||||
parameters?: Record<string, unknown>,
|
||||
) => void;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
export function TrialControlPanel({
|
||||
trial,
|
||||
currentStep,
|
||||
steps,
|
||||
currentStepIndex,
|
||||
onStartTrial,
|
||||
onPauseTrial,
|
||||
onNextStep,
|
||||
onCompleteTrial,
|
||||
onAbortTrial,
|
||||
onExecuteAction,
|
||||
isConnected,
|
||||
}: TrialControlPanelProps) {
|
||||
const progress =
|
||||
steps.length > 0 ? ((currentStepIndex + 1) / steps.length) * 100 : 0;
|
||||
|
||||
const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case "scheduled":
|
||||
return { variant: "outline" as const, icon: Clock };
|
||||
case "in_progress":
|
||||
return { variant: "default" as const, icon: Play };
|
||||
case "completed":
|
||||
return { variant: "secondary" as const, icon: CheckCircle };
|
||||
case "aborted":
|
||||
case "failed":
|
||||
return { variant: "destructive" as const, icon: X };
|
||||
default:
|
||||
return { variant: "outline" as const, icon: Clock };
|
||||
}
|
||||
};
|
||||
|
||||
const statusConfig = getStatusConfig(trial.status);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4 p-4">
|
||||
{/* Trial Status Card */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center justify-between text-sm">
|
||||
<span>Trial Status</span>
|
||||
<Badge
|
||||
variant={statusConfig.variant}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{trial.status.replace("_", " ")}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Session</span>
|
||||
<span>#{trial.sessionNumber}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Participant</span>
|
||||
<span className="font-mono">
|
||||
{trial.participant.participantCode}
|
||||
</span>
|
||||
</div>
|
||||
{trial.status === "in_progress" && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Progress</span>
|
||||
<span>
|
||||
{currentStepIndex + 1} of {steps.length}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Connection Status */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-muted-foreground text-sm">Connection</span>
|
||||
<Badge
|
||||
variant={isConnected ? "default" : "outline"}
|
||||
className="text-xs"
|
||||
>
|
||||
{isConnected ? "Live" : "Polling"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Trial Controls */}
|
||||
<Card className="flex-1">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">Controls</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{trial.status === "scheduled" && (
|
||||
<Button onClick={onStartTrial} className="w-full" size="sm">
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start Trial
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{trial.status === "in_progress" && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
onClick={onPauseTrial}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!isConnected}
|
||||
>
|
||||
<Pause className="mr-1 h-3 w-3" />
|
||||
Pause
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onNextStep}
|
||||
disabled={currentStepIndex >= steps.length - 1}
|
||||
size="sm"
|
||||
>
|
||||
<SkipForward className="mr-1 h-3 w-3" />
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
onClick={onCompleteTrial}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
size="sm"
|
||||
>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Complete Trial
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onAbortTrial}
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
size="sm"
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Abort Trial
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(trial.status === "completed" || trial.status === "aborted") && (
|
||||
<Alert>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
Trial has ended. All controls are disabled.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Current Step Info */}
|
||||
{currentStep && trial.status === "in_progress" && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">Current Step</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">{currentStep.name}</div>
|
||||
{currentStep.description && (
|
||||
<p className="text-muted-foreground line-clamp-3 text-xs">
|
||||
{currentStep.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{currentStep.type.replace("_", " ")}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Step {currentStepIndex + 1}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
{trial.status === "in_progress" &&
|
||||
currentStep?.type === "wizard_action" && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">Quick Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => onExecuteAction("acknowledge")}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-3 w-3" />
|
||||
Acknowledge
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => onExecuteAction("intervene")}
|
||||
>
|
||||
<AlertCircle className="mr-2 h-3 w-3" />
|
||||
Intervene
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
429
src/components/trials/wizard/panels/WizardControlPanel.tsx
Normal file
429
src/components/trials/wizard/panels/WizardControlPanel.tsx
Normal file
@@ -0,0 +1,429 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
SkipForward,
|
||||
CheckCircle,
|
||||
X,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Settings,
|
||||
Zap,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
|
||||
interface StepData {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type:
|
||||
| "wizard_action"
|
||||
| "robot_action"
|
||||
| "parallel_steps"
|
||||
| "conditional_branch";
|
||||
parameters: Record<string, unknown>;
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface TrialData {
|
||||
id: string;
|
||||
status: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
|
||||
scheduledAt: Date | null;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
duration: number | null;
|
||||
sessionNumber: number | null;
|
||||
notes: string | null;
|
||||
experimentId: string;
|
||||
participantId: string | null;
|
||||
wizardId: string | null;
|
||||
experiment: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
studyId: string;
|
||||
};
|
||||
participant: {
|
||||
id: string;
|
||||
participantCode: string;
|
||||
demographics: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface WizardControlPanelProps {
|
||||
trial: TrialData;
|
||||
currentStep: StepData | null;
|
||||
steps: StepData[];
|
||||
currentStepIndex: number;
|
||||
onStartTrial: () => void;
|
||||
onPauseTrial: () => void;
|
||||
onNextStep: () => void;
|
||||
onCompleteTrial: () => void;
|
||||
onAbortTrial: () => void;
|
||||
onExecuteAction: (
|
||||
actionId: string,
|
||||
parameters?: Record<string, unknown>,
|
||||
) => void;
|
||||
_isConnected: boolean;
|
||||
activeTab: "control" | "step" | "actions";
|
||||
onTabChange: (tab: "control" | "step" | "actions") => void;
|
||||
isStarting?: boolean;
|
||||
}
|
||||
|
||||
export function WizardControlPanel({
|
||||
trial,
|
||||
currentStep,
|
||||
steps,
|
||||
currentStepIndex,
|
||||
onStartTrial,
|
||||
onPauseTrial,
|
||||
onNextStep,
|
||||
onCompleteTrial,
|
||||
onAbortTrial,
|
||||
onExecuteAction,
|
||||
_isConnected,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
isStarting = false,
|
||||
}: WizardControlPanelProps) {
|
||||
const progress =
|
||||
steps.length > 0 ? ((currentStepIndex + 1) / steps.length) * 100 : 0;
|
||||
|
||||
const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case "scheduled":
|
||||
return { variant: "outline" as const, icon: Clock };
|
||||
case "in_progress":
|
||||
return { variant: "default" as const, icon: Play };
|
||||
case "completed":
|
||||
return { variant: "secondary" as const, icon: CheckCircle };
|
||||
case "aborted":
|
||||
case "failed":
|
||||
return { variant: "destructive" as const, icon: X };
|
||||
default:
|
||||
return { variant: "outline" as const, icon: Clock };
|
||||
}
|
||||
};
|
||||
|
||||
const statusConfig = getStatusConfig(trial.status);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Trial Info Header */}
|
||||
<div className="border-b p-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge
|
||||
variant={statusConfig.variant}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{trial.status.replace("_", " ")}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Session #{trial.sessionNumber}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm font-medium">
|
||||
{trial.participant.participantCode}
|
||||
</div>
|
||||
|
||||
{trial.status === "in_progress" && steps.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Progress</span>
|
||||
<span>
|
||||
{currentStepIndex + 1} of {steps.length}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-1.5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabbed Content */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value: string) => {
|
||||
if (value === "control" || value === "step" || value === "actions") {
|
||||
onTabChange(value);
|
||||
}
|
||||
}}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="border-b px-2 py-1">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="control" className="text-xs">
|
||||
<Settings className="mr-1 h-3 w-3" />
|
||||
Control
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="step" className="text-xs">
|
||||
<Play className="mr-1 h-3 w-3" />
|
||||
Step
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="actions" className="text-xs">
|
||||
<Zap className="mr-1 h-3 w-3" />
|
||||
Actions
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
{/* Trial Control Tab */}
|
||||
<TabsContent
|
||||
value="control"
|
||||
className="m-0 h-full data-[state=active]:flex data-[state=active]:flex-col"
|
||||
>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-3 p-3">
|
||||
{trial.status === "scheduled" && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
console.log("[WizardControlPanel] Start Trial clicked");
|
||||
onStartTrial();
|
||||
}}
|
||||
className="w-full"
|
||||
size="sm"
|
||||
disabled={isStarting}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
{isStarting ? "Starting..." : "Start Trial"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{trial.status === "in_progress" && (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
onClick={onPauseTrial}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={false}
|
||||
>
|
||||
<Pause className="mr-1 h-3 w-3" />
|
||||
Pause
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onNextStep}
|
||||
disabled={currentStepIndex >= steps.length - 1}
|
||||
size="sm"
|
||||
>
|
||||
<SkipForward className="mr-1 h-3 w-3" />
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button
|
||||
onClick={onCompleteTrial}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
size="sm"
|
||||
>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Complete Trial
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={onAbortTrial}
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
size="sm"
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Abort Trial
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(trial.status === "completed" ||
|
||||
trial.status === "aborted") && (
|
||||
<Alert>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
Trial has ended. All controls are disabled.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Connection Status */}
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium">Connection</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Status
|
||||
</span>
|
||||
<Badge variant="default" className="text-xs">
|
||||
Polling
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{/* Current Step Tab */}
|
||||
<TabsContent
|
||||
value="step"
|
||||
className="m-0 h-full data-[state=active]:flex data-[state=active]:flex-col"
|
||||
>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-3">
|
||||
{currentStep && trial.status === "in_progress" ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
{currentStep.name}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{currentStep.type.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{currentStep.description && (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{currentStep.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium">Step Progress</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">Current</span>
|
||||
<span>Step {currentStepIndex + 1}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">Remaining</span>
|
||||
<span>{steps.length - currentStepIndex - 1} steps</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentStep.type === "robot_action" && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
Robot is executing this step. Monitor progress in the
|
||||
monitoring panel.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-xs">
|
||||
{trial.status === "scheduled"
|
||||
? "Start trial to see current step"
|
||||
: trial.status === "in_progress"
|
||||
? "No current step"
|
||||
: "Trial has ended"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{/* Quick Actions Tab */}
|
||||
<TabsContent
|
||||
value="actions"
|
||||
className="m-0 h-full data-[state=active]:flex data-[state=active]:flex-col"
|
||||
>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-2 p-3">
|
||||
{trial.status === "in_progress" ? (
|
||||
<>
|
||||
<div className="mb-2 text-xs font-medium">
|
||||
Quick Actions
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
console.log("[WizardControlPanel] Acknowledge clicked");
|
||||
onExecuteAction("acknowledge");
|
||||
}}
|
||||
disabled={false}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-3 w-3" />
|
||||
Acknowledge
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
console.log("[WizardControlPanel] Intervene clicked");
|
||||
onExecuteAction("intervene");
|
||||
}}
|
||||
disabled={false}
|
||||
>
|
||||
<AlertCircle className="mr-2 h-3 w-3" />
|
||||
Intervene
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
console.log("[WizardControlPanel] Add Note clicked");
|
||||
onExecuteAction("note", { content: "Wizard note" });
|
||||
}}
|
||||
disabled={false}
|
||||
>
|
||||
<User className="mr-2 h-3 w-3" />
|
||||
Add Note
|
||||
</Button>
|
||||
|
||||
<Separator />
|
||||
|
||||
{currentStep?.type === "wizard_action" && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium">Step Actions</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => onExecuteAction("step_complete")}
|
||||
disabled={false}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-3 w-3" />
|
||||
Mark Complete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<div className="text-muted-foreground text-center text-xs">
|
||||
{trial.status === "scheduled"
|
||||
? "Start trial to access actions"
|
||||
: "Actions unavailable - trial not active"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
489
src/components/trials/wizard/panels/WizardExecutionPanel.tsx
Normal file
489
src/components/trials/wizard/panels/WizardExecutionPanel.tsx
Normal file
@@ -0,0 +1,489 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Play,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Bot,
|
||||
User,
|
||||
Activity,
|
||||
Zap,
|
||||
Eye,
|
||||
List,
|
||||
} from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
|
||||
interface StepData {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type:
|
||||
| "wizard_action"
|
||||
| "robot_action"
|
||||
| "parallel_steps"
|
||||
| "conditional_branch";
|
||||
parameters: Record<string, unknown>;
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface TrialData {
|
||||
id: string;
|
||||
status: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
|
||||
scheduledAt: Date | null;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
duration: number | null;
|
||||
sessionNumber: number | null;
|
||||
notes: string | null;
|
||||
experimentId: string;
|
||||
participantId: string | null;
|
||||
wizardId: string | null;
|
||||
experiment: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
studyId: string;
|
||||
};
|
||||
participant: {
|
||||
id: string;
|
||||
participantCode: string;
|
||||
demographics: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface TrialEvent {
|
||||
type: string;
|
||||
timestamp: Date;
|
||||
data?: unknown;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface WizardExecutionPanelProps {
|
||||
trial: TrialData;
|
||||
currentStep: StepData | null;
|
||||
steps: StepData[];
|
||||
currentStepIndex: number;
|
||||
trialEvents: TrialEvent[];
|
||||
onStepSelect: (index: number) => void;
|
||||
onExecuteAction: (
|
||||
actionId: string,
|
||||
parameters?: Record<string, unknown>,
|
||||
) => void;
|
||||
activeTab: "current" | "timeline" | "events";
|
||||
onTabChange: (tab: "current" | "timeline" | "events") => void;
|
||||
}
|
||||
|
||||
export function WizardExecutionPanel({
|
||||
trial,
|
||||
currentStep,
|
||||
steps,
|
||||
currentStepIndex,
|
||||
trialEvents,
|
||||
onStepSelect,
|
||||
onExecuteAction,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
}: WizardExecutionPanelProps) {
|
||||
const getStepIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "wizard_action":
|
||||
return User;
|
||||
case "robot_action":
|
||||
return Bot;
|
||||
case "parallel_steps":
|
||||
return Activity;
|
||||
case "conditional_branch":
|
||||
return AlertCircle;
|
||||
default:
|
||||
return Play;
|
||||
}
|
||||
};
|
||||
|
||||
const getStepStatus = (stepIndex: number) => {
|
||||
if (stepIndex < currentStepIndex) return "completed";
|
||||
if (stepIndex === currentStepIndex && trial.status === "in_progress")
|
||||
return "active";
|
||||
return "pending";
|
||||
};
|
||||
|
||||
const getStepVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "default";
|
||||
case "active":
|
||||
return "secondary";
|
||||
case "pending":
|
||||
return "outline";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
// Pre-trial state
|
||||
if (trial.status === "scheduled") {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b p-3">
|
||||
<h3 className="text-sm font-medium">Trial Ready</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{steps.length} steps prepared for execution
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full flex-1 items-center justify-center p-6">
|
||||
<div className="w-full max-w-md space-y-3 text-center">
|
||||
<Clock className="text-muted-foreground mx-auto h-8 w-8" />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">Ready to Begin</h4>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Use the control panel to start this trial
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-muted-foreground space-y-1 text-xs">
|
||||
<div>Experiment: {trial.experiment.name}</div>
|
||||
<div>Participant: {trial.participant.participantCode}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Post-trial state
|
||||
if (
|
||||
trial.status === "completed" ||
|
||||
trial.status === "aborted" ||
|
||||
trial.status === "failed"
|
||||
) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b p-3">
|
||||
<h3 className="text-sm font-medium">
|
||||
Trial {trial.status === "completed" ? "Completed" : "Ended"}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{trial.completedAt &&
|
||||
`Ended at ${new Date(trial.completedAt).toLocaleTimeString()}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full flex-1 items-center justify-center p-6">
|
||||
<div className="w-full max-w-md space-y-3 text-center">
|
||||
<CheckCircle className="text-muted-foreground mx-auto h-8 w-8" />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">Execution Complete</h4>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Review results and captured data
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{trialEvents.length} events recorded
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Active trial state
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="border-b p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">Trial Execution</h3>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{currentStepIndex + 1} / {steps.length}
|
||||
</Badge>
|
||||
</div>
|
||||
{currentStep && (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{currentStep.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabbed Content */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value: string) => {
|
||||
if (
|
||||
value === "current" ||
|
||||
value === "timeline" ||
|
||||
value === "events"
|
||||
) {
|
||||
onTabChange(value);
|
||||
}
|
||||
}}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="border-b px-2 py-1">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="current" className="text-xs">
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
Current
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="timeline" className="text-xs">
|
||||
<List className="mr-1 h-3 w-3" />
|
||||
Timeline
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="events" className="text-xs">
|
||||
<Activity className="mr-1 h-3 w-3" />
|
||||
Events
|
||||
{trialEvents.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 text-xs">
|
||||
{trialEvents.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
{/* Current Step Tab */}
|
||||
<TabsContent value="current" className="m-0 h-full">
|
||||
<div className="h-full">
|
||||
{currentStep ? (
|
||||
<div className="flex h-full flex-col p-4">
|
||||
{/* Current Step Display */}
|
||||
<div className="flex-1 space-y-4 text-left">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-primary/10 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full">
|
||||
{React.createElement(getStepIcon(currentStep.type), {
|
||||
className: "h-5 w-5 text-primary",
|
||||
})}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="text-sm font-medium">
|
||||
{currentStep.name}
|
||||
</h4>
|
||||
<Badge variant="outline" className="mt-1 text-xs">
|
||||
{currentStep.type.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentStep.description && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{currentStep.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step-specific content */}
|
||||
{currentStep.type === "wizard_action" && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium">
|
||||
Available Actions
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => onExecuteAction("acknowledge")}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Acknowledge Step
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => onExecuteAction("intervene")}
|
||||
>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Manual Intervention
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() =>
|
||||
onExecuteAction("note", {
|
||||
content: "Step observation",
|
||||
})
|
||||
}
|
||||
>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Add Observation
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep.type === "robot_action" && (
|
||||
<Alert>
|
||||
<Bot className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
<div className="font-medium">
|
||||
Robot Action in Progress
|
||||
</div>
|
||||
<div className="mt-1 text-xs">
|
||||
The robot is executing this step. Monitor status in
|
||||
the monitoring panel.
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{currentStep.type === "parallel_steps" && (
|
||||
<Alert>
|
||||
<Activity className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
<div className="font-medium">Parallel Execution</div>
|
||||
<div className="mt-1 text-xs">
|
||||
Multiple actions are running simultaneously.
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
No current step available
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Timeline Tab */}
|
||||
<TabsContent value="timeline" className="m-0 h-full">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-2 p-3">
|
||||
{steps.map((step, index) => {
|
||||
const status = getStepStatus(index);
|
||||
const StepIcon = getStepIcon(step.type);
|
||||
const isActive = index === currentStepIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`hover:bg-muted/50 flex cursor-pointer items-start gap-3 rounded-lg p-2 transition-colors ${
|
||||
isActive ? "bg-primary/5 border-primary/20 border" : ""
|
||||
}`}
|
||||
onClick={() => onStepSelect(index)}
|
||||
>
|
||||
{/* Step Number and Status */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-medium ${
|
||||
status === "completed"
|
||||
? "bg-green-100 text-green-700"
|
||||
: status === "active"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{status === "completed" ? (
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={`mt-1 h-4 w-0.5 ${
|
||||
status === "completed"
|
||||
? "bg-green-200"
|
||||
: "bg-border"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<StepIcon className="text-muted-foreground h-3 w-3 flex-shrink-0" />
|
||||
<div className="truncate text-sm font-medium">
|
||||
{step.name}
|
||||
</div>
|
||||
<Badge
|
||||
variant={getStepVariant(status)}
|
||||
className="ml-auto flex-shrink-0 text-xs"
|
||||
>
|
||||
{step.type.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{step.description && (
|
||||
<p className="text-muted-foreground mt-1 line-clamp-2 text-xs">
|
||||
{step.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isActive && trial.status === "in_progress" && (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<div className="bg-primary h-1.5 w-1.5 animate-pulse rounded-full" />
|
||||
<span className="text-primary text-xs">
|
||||
Executing
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{/* Events Tab */}
|
||||
<TabsContent value="events" className="m-0 h-full">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-3">
|
||||
{trialEvents.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<div className="text-muted-foreground text-center text-sm">
|
||||
No events recorded yet
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{trialEvents
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((event, index) => (
|
||||
<div
|
||||
key={`${event.timestamp.getTime()}-${index}`}
|
||||
className="border-border/50 flex items-start gap-2 rounded-lg border p-2"
|
||||
>
|
||||
<div className="bg-muted flex h-6 w-6 flex-shrink-0 items-center justify-center rounded">
|
||||
<Activity className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium capitalize">
|
||||
{event.type.replace(/_/g, " ")}
|
||||
</div>
|
||||
{event.message && (
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
{event.message}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
{event.timestamp.toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
672
src/components/trials/wizard/panels/WizardMonitoringPanel.tsx
Normal file
672
src/components/trials/wizard/panels/WizardMonitoringPanel.tsx
Normal file
@@ -0,0 +1,672 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Bot,
|
||||
User,
|
||||
Activity,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Power,
|
||||
PowerOff,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
import { Button } from "~/components/ui/button";
|
||||
// import { useRosBridge } from "~/hooks/useRosBridge"; // Removed ROS dependency
|
||||
|
||||
interface TrialData {
|
||||
id: string;
|
||||
status: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
|
||||
scheduledAt: Date | null;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
duration: number | null;
|
||||
sessionNumber: number | null;
|
||||
notes: string | null;
|
||||
experimentId: string;
|
||||
participantId: string | null;
|
||||
wizardId: string | null;
|
||||
experiment: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
studyId: string;
|
||||
};
|
||||
participant: {
|
||||
id: string;
|
||||
participantCode: string;
|
||||
demographics: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface TrialEvent {
|
||||
type: string;
|
||||
timestamp: Date;
|
||||
data?: unknown;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface WizardMonitoringPanelProps {
|
||||
trial: TrialData;
|
||||
trialEvents: TrialEvent[];
|
||||
isConnected: boolean;
|
||||
wsError?: string;
|
||||
activeTab: "status" | "robot" | "events";
|
||||
onTabChange: (tab: "status" | "robot" | "events") => void;
|
||||
}
|
||||
|
||||
export function WizardMonitoringPanel({
|
||||
trial,
|
||||
trialEvents,
|
||||
isConnected,
|
||||
wsError,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
}: WizardMonitoringPanelProps) {
|
||||
// Mock robot status for development (ROS bridge removed for now)
|
||||
const mockRobotStatus = {
|
||||
connected: false,
|
||||
battery: 85,
|
||||
position: { x: 0, y: 0, theta: 0 },
|
||||
joints: {},
|
||||
sensors: {},
|
||||
lastUpdate: new Date(),
|
||||
};
|
||||
|
||||
const rosConnected = false;
|
||||
const rosConnecting = false;
|
||||
const rosError = null;
|
||||
const robotStatus = mockRobotStatus;
|
||||
// const connectRos = () => console.log("ROS connection not implemented yet");
|
||||
const disconnectRos = () =>
|
||||
console.log("ROS disconnection not implemented yet");
|
||||
const executeRobotAction = (
|
||||
action: string,
|
||||
parameters?: Record<string, unknown>,
|
||||
) => console.log("Robot action:", action, parameters);
|
||||
|
||||
const formatTimestamp = (timestamp: Date) => {
|
||||
return new Date(timestamp).toLocaleTimeString();
|
||||
};
|
||||
|
||||
const getEventIcon = (eventType: string) => {
|
||||
switch (eventType.toLowerCase()) {
|
||||
case "trial_started":
|
||||
case "trial_resumed":
|
||||
return CheckCircle;
|
||||
case "trial_paused":
|
||||
case "trial_stopped":
|
||||
return AlertCircle;
|
||||
case "step_completed":
|
||||
case "action_completed":
|
||||
return CheckCircle;
|
||||
case "robot_action":
|
||||
case "robot_status":
|
||||
return Bot;
|
||||
case "wizard_action":
|
||||
case "wizard_intervention":
|
||||
return User;
|
||||
case "system_error":
|
||||
case "connection_error":
|
||||
return AlertCircle;
|
||||
default:
|
||||
return Activity;
|
||||
}
|
||||
};
|
||||
|
||||
const getEventColor = (eventType: string) => {
|
||||
switch (eventType.toLowerCase()) {
|
||||
case "trial_started":
|
||||
case "trial_resumed":
|
||||
case "step_completed":
|
||||
case "action_completed":
|
||||
return "text-green-600";
|
||||
case "trial_paused":
|
||||
case "trial_stopped":
|
||||
return "text-yellow-600";
|
||||
case "system_error":
|
||||
case "connection_error":
|
||||
case "trial_failed":
|
||||
return "text-red-600";
|
||||
case "robot_action":
|
||||
case "robot_status":
|
||||
return "text-blue-600";
|
||||
case "wizard_action":
|
||||
case "wizard_intervention":
|
||||
return "text-purple-600";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="border-b p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">Monitoring</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<Wifi className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<WifiOff className="h-4 w-4 text-orange-600" />
|
||||
)}
|
||||
<Badge
|
||||
variant={isConnected ? "default" : "secondary"}
|
||||
className="text-xs"
|
||||
>
|
||||
{isConnected ? "Live" : "Offline"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{wsError && (
|
||||
<Alert variant="destructive" className="mt-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">{wsError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabbed Content */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value: string) => {
|
||||
if (value === "status" || value === "robot" || value === "events") {
|
||||
onTabChange(value);
|
||||
}
|
||||
}}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="border-b px-2 py-1">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="status" className="text-xs">
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
Status
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="robot" className="text-xs">
|
||||
<Bot className="mr-1 h-3 w-3" />
|
||||
Robot
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="events" className="text-xs">
|
||||
<Activity className="mr-1 h-3 w-3" />
|
||||
Events
|
||||
{trialEvents.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 text-xs">
|
||||
{trialEvents.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
{/* Status Tab */}
|
||||
<TabsContent value="status" className="m-0 h-full">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-3">
|
||||
{/* Connection Status */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Connection</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
WebSocket
|
||||
</span>
|
||||
<Badge
|
||||
variant={isConnected ? "default" : "secondary"}
|
||||
className="text-xs"
|
||||
>
|
||||
{isConnected ? "Connected" : "Offline"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Data Mode
|
||||
</span>
|
||||
<span className="text-xs">
|
||||
{isConnected ? "Real-time" : "Polling"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Trial Information */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Trial Info</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">ID</span>
|
||||
<span className="font-mono text-xs">
|
||||
{trial.id.slice(-8)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Session
|
||||
</span>
|
||||
<span className="text-xs">#{trial.sessionNumber}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Status
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{trial.status.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
{trial.startedAt && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Started
|
||||
</span>
|
||||
<span className="text-xs">
|
||||
{formatTimestamp(new Date(trial.startedAt))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Participant Information */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Participant</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Code
|
||||
</span>
|
||||
<span className="font-mono text-xs">
|
||||
{trial.participant.participantCode}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Session
|
||||
</span>
|
||||
<span className="text-xs">#{trial.sessionNumber}</span>
|
||||
</div>
|
||||
{trial.participant.demographics && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Demographics
|
||||
</span>
|
||||
<span className="text-xs">
|
||||
{Object.keys(trial.participant.demographics).length}{" "}
|
||||
fields
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* System Information */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">System</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Experiment
|
||||
</span>
|
||||
<span
|
||||
className="max-w-24 truncate text-xs"
|
||||
title={trial.experiment.name}
|
||||
>
|
||||
{trial.experiment.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Study
|
||||
</span>
|
||||
<span className="font-mono text-xs">
|
||||
{trial.experiment.studyId.slice(-8)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Platform
|
||||
</span>
|
||||
<span className="text-xs">HRIStudio</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{/* Robot Tab */}
|
||||
<TabsContent value="robot" className="m-0 h-full">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-3">
|
||||
{/* Robot Status */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">Robot Status</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{rosConnected ? (
|
||||
<Power className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<PowerOff className="h-3 w-3 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
ROS Bridge
|
||||
</span>
|
||||
<Badge
|
||||
variant={rosConnected ? "default" : "outline"}
|
||||
className="text-xs"
|
||||
>
|
||||
{rosConnecting
|
||||
? "Connecting..."
|
||||
: rosConnected
|
||||
? "Connected"
|
||||
: "Offline"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Battery
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs">
|
||||
{robotStatus
|
||||
? `${Math.round(robotStatus.battery * 100)}%`
|
||||
: "--"}
|
||||
</span>
|
||||
<Progress
|
||||
value={robotStatus ? robotStatus.battery * 100 : 0}
|
||||
className="h-1 w-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Position
|
||||
</span>
|
||||
<span className="text-xs">
|
||||
{robotStatus
|
||||
? `(${robotStatus.position.x.toFixed(1)}, ${robotStatus.position.y.toFixed(1)})`
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Last Update
|
||||
</span>
|
||||
<span className="text-xs">
|
||||
{robotStatus
|
||||
? robotStatus.lastUpdate.toLocaleTimeString()
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ROS Connection Controls */}
|
||||
<div className="pt-2">
|
||||
{!rosConnected ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full text-xs"
|
||||
onClick={() =>
|
||||
console.log("Connect robot (not implemented)")
|
||||
}
|
||||
disabled={true}
|
||||
>
|
||||
<Bot className="mr-1 h-3 w-3" />
|
||||
Connect Robot (Coming Soon)
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full text-xs"
|
||||
onClick={disconnectRos}
|
||||
>
|
||||
<PowerOff className="mr-1 h-3 w-3" />
|
||||
Disconnect Robot
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rosError && (
|
||||
<Alert variant="destructive" className="mt-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
ROS Error: {rosError}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Robot Actions */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Active Actions</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-center text-xs">
|
||||
No active actions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Recent Trial Events */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Recent Events</div>
|
||||
<div className="space-y-1">
|
||||
{trialEvents
|
||||
.filter((e) => e.type.includes("robot"))
|
||||
.slice(-2)
|
||||
.map((event, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border-border/50 flex items-center justify-between rounded border p-2"
|
||||
>
|
||||
<span className="text-xs font-medium">
|
||||
{event.type.replace(/_/g, " ")}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{formatTimestamp(event.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{trialEvents.filter((e) => e.type.includes("robot"))
|
||||
.length === 0 && (
|
||||
<div className="text-muted-foreground py-2 text-center text-xs">
|
||||
No robot events yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Robot Configuration */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Configuration</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Type
|
||||
</span>
|
||||
<span className="text-xs">NAO6</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
ROS Bridge
|
||||
</span>
|
||||
<span className="font-mono text-xs">localhost:9090</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Platform
|
||||
</span>
|
||||
<span className="font-mono text-xs">NAOqi</span>
|
||||
</div>
|
||||
{robotStatus &&
|
||||
Object.keys(robotStatus.joints).length > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Joints
|
||||
</span>
|
||||
<span className="text-xs">
|
||||
{Object.keys(robotStatus.joints).length} active
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Robot Actions */}
|
||||
{rosConnected && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Quick Actions</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
onClick={() =>
|
||||
executeRobotAction("say_text", {
|
||||
text: "Hello from wizard!",
|
||||
})
|
||||
}
|
||||
>
|
||||
Say Hello
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
onClick={() =>
|
||||
executeRobotAction("play_animation", {
|
||||
animation: "Hello",
|
||||
})
|
||||
}
|
||||
>
|
||||
Wave
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
onClick={() =>
|
||||
executeRobotAction("set_led_color", {
|
||||
color: "blue",
|
||||
intensity: 1.0,
|
||||
})
|
||||
}
|
||||
>
|
||||
Blue LEDs
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
onClick={() =>
|
||||
executeRobotAction("turn_head", {
|
||||
yaw: 0,
|
||||
pitch: 0,
|
||||
speed: 0.3,
|
||||
})
|
||||
}
|
||||
>
|
||||
Center Head
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!rosConnected && !rosConnecting && (
|
||||
<Alert className="mt-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
Connect to ROS bridge for live robot monitoring and
|
||||
control
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{/* Events Tab */}
|
||||
<TabsContent value="events" className="m-0 h-full">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-3">
|
||||
{trialEvents.length === 0 ? (
|
||||
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-sm">
|
||||
No events recorded yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Live Events</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{trialEvents.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{trialEvents
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((event, index) => {
|
||||
const EventIcon = getEventIcon(event.type);
|
||||
const eventColor = getEventColor(event.type);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${event.timestamp.getTime()}-${index}`}
|
||||
className="border-border/50 flex items-start gap-2 rounded-lg border p-2"
|
||||
>
|
||||
<div className={`mt-0.5 ${eventColor}`}>
|
||||
<EventIcon className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium capitalize">
|
||||
{event.type.replace(/_/g, " ")}
|
||||
</div>
|
||||
{event.message && (
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
{event.message}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-1 text-xs">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatTimestamp(event.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user