mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-02-05 07:56:30 -05:00
Pre-conf work 2025
This commit is contained in:
@@ -268,7 +268,7 @@ function TrialCard({ trial, userRole, onTrialAction }: TrialCardProps) {
|
||||
}
|
||||
|
||||
export function TrialsGrid() {
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
|
||||
const { data: userSession } = api.auth.me.useQuery();
|
||||
@@ -282,7 +282,15 @@ export function TrialsGrid() {
|
||||
{
|
||||
page: 1,
|
||||
limit: 50,
|
||||
status: statusFilter === "all" ? undefined : (statusFilter as any),
|
||||
status:
|
||||
statusFilter === "all"
|
||||
? undefined
|
||||
: (statusFilter as
|
||||
| "scheduled"
|
||||
| "in_progress"
|
||||
| "completed"
|
||||
| "aborted"
|
||||
| "failed"),
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
@@ -309,16 +317,13 @@ export function TrialsGrid() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrialCreated = () => {
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
void refetch();
|
||||
};
|
||||
|
||||
|
||||
// Group trials by status for better organization
|
||||
const upcomingTrials = trials.filter((t) => t.status === "scheduled");
|
||||
const activeTrials = trials.filter((t) => t.status === "in_progress");
|
||||
const completedTrials = trials.filter((t) => t.status === "completed");
|
||||
const cancelledTrials = trials.filter((t) => t.status === "aborted");
|
||||
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@@ -2,20 +2,35 @@
|
||||
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
Activity, AlertTriangle, ArrowRight, Bot, Camera, CheckCircle, Eye, Hand, MessageSquare, Pause, Play, Settings, User, Volume2, XCircle
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Bot,
|
||||
Camera,
|
||||
CheckCircle,
|
||||
Eye,
|
||||
Hand,
|
||||
MessageSquare,
|
||||
Pause,
|
||||
Play,
|
||||
Settings,
|
||||
User,
|
||||
Volume2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { api } from "~/trpc/react";
|
||||
import type { WebSocketMessage } from "~/hooks/useWebSocket";
|
||||
|
||||
interface EventsLogProps {
|
||||
trialId: string;
|
||||
refreshKey: number;
|
||||
isLive: boolean;
|
||||
maxEvents?: number;
|
||||
realtimeEvents?: any[];
|
||||
realtimeEvents?: WebSocketMessage[];
|
||||
isWebSocketConnected?: boolean;
|
||||
}
|
||||
|
||||
@@ -24,7 +39,7 @@ interface TrialEvent {
|
||||
trialId: string;
|
||||
eventType: string;
|
||||
timestamp: Date;
|
||||
data: any;
|
||||
data: Record<string, unknown> | null;
|
||||
notes: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -177,7 +192,17 @@ export function EventsLog({
|
||||
{
|
||||
trialId,
|
||||
limit: maxEvents,
|
||||
type: filter === "all" ? undefined : filter as "error" | "custom" | "trial_start" | "trial_end" | "step_start" | "step_end" | "wizard_intervention",
|
||||
type:
|
||||
filter === "all"
|
||||
? undefined
|
||||
: (filter as
|
||||
| "error"
|
||||
| "custom"
|
||||
| "trial_start"
|
||||
| "trial_end"
|
||||
| "step_start"
|
||||
| "step_end"
|
||||
| "wizard_intervention"),
|
||||
},
|
||||
{
|
||||
refetchInterval: isLive && !isWebSocketConnected ? 2000 : 10000, // Less frequent polling when WebSocket is active
|
||||
@@ -186,23 +211,48 @@ export function EventsLog({
|
||||
},
|
||||
);
|
||||
|
||||
// Convert WebSocket events to trial events format
|
||||
const convertWebSocketEvent = (wsEvent: any): TrialEvent => ({
|
||||
id: `ws-${Date.now()}-${Math.random()}`,
|
||||
trialId,
|
||||
eventType:
|
||||
wsEvent.type === "trial_action_executed"
|
||||
? "wizard_action"
|
||||
: wsEvent.type === "intervention_logged"
|
||||
? "wizard_intervention"
|
||||
: wsEvent.type === "step_changed"
|
||||
? "step_transition"
|
||||
: wsEvent.type || "system_event",
|
||||
timestamp: new Date(wsEvent.data?.timestamp || Date.now()),
|
||||
data: wsEvent.data || {},
|
||||
notes: wsEvent.data?.notes || null,
|
||||
createdAt: new Date(wsEvent.data?.timestamp || Date.now()),
|
||||
});
|
||||
// Convert WebSocket events to trial events format (type-safe)
|
||||
const convertWebSocketEvent = useCallback(
|
||||
(wsEvent: WebSocketMessage): TrialEvent => {
|
||||
const eventType =
|
||||
wsEvent.type === "trial_action_executed"
|
||||
? "wizard_action"
|
||||
: wsEvent.type === "intervention_logged"
|
||||
? "wizard_intervention"
|
||||
: wsEvent.type === "step_changed"
|
||||
? "step_transition"
|
||||
: wsEvent.type || "system_event";
|
||||
|
||||
const rawData = wsEvent.data;
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null;
|
||||
|
||||
const data: Record<string, unknown> | null = isRecord(rawData)
|
||||
? rawData
|
||||
: null;
|
||||
|
||||
const ts =
|
||||
isRecord(rawData) && typeof rawData.timestamp === "number"
|
||||
? rawData.timestamp
|
||||
: Date.now();
|
||||
|
||||
const notes =
|
||||
isRecord(rawData) && typeof rawData.notes === "string"
|
||||
? rawData.notes
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: `ws-${Date.now()}-${Math.random()}`,
|
||||
trialId,
|
||||
eventType,
|
||||
timestamp: new Date(ts),
|
||||
data,
|
||||
notes,
|
||||
createdAt: new Date(ts),
|
||||
};
|
||||
},
|
||||
[trialId],
|
||||
);
|
||||
|
||||
// Update events when data changes (prioritize WebSocket events)
|
||||
useEffect(() => {
|
||||
@@ -210,11 +260,26 @@ export function EventsLog({
|
||||
|
||||
// Add database events
|
||||
if (eventsData) {
|
||||
newEvents = eventsData.map((event) => ({
|
||||
...event,
|
||||
type ApiTrialEvent = {
|
||||
id: string;
|
||||
trialId: string;
|
||||
eventType: string;
|
||||
timestamp: string | Date;
|
||||
data: unknown;
|
||||
};
|
||||
|
||||
const apiEvents = (eventsData as unknown as ApiTrialEvent[]) ?? [];
|
||||
newEvents = apiEvents.map((event) => ({
|
||||
id: event.id,
|
||||
trialId: event.trialId,
|
||||
eventType: event.eventType,
|
||||
timestamp: new Date(event.timestamp),
|
||||
data:
|
||||
typeof event.data === "object" && event.data !== null
|
||||
? (event.data as Record<string, unknown>)
|
||||
: null,
|
||||
notes: null,
|
||||
createdAt: new Date(event.timestamp),
|
||||
notes: null, // Add required field
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -240,7 +305,14 @@ export function EventsLog({
|
||||
.slice(-maxEvents); // Keep only the most recent events
|
||||
|
||||
setEvents(uniqueEvents);
|
||||
}, [eventsData, refreshKey, realtimeEvents, trialId, maxEvents]);
|
||||
}, [
|
||||
eventsData,
|
||||
refreshKey,
|
||||
realtimeEvents,
|
||||
trialId,
|
||||
maxEvents,
|
||||
convertWebSocketEvent,
|
||||
]);
|
||||
|
||||
// Auto-scroll to bottom when new events arrive
|
||||
useEffect(() => {
|
||||
@@ -256,41 +328,87 @@ export function EventsLog({
|
||||
);
|
||||
};
|
||||
|
||||
const formatEventData = (eventType: string, data: any) => {
|
||||
const formatEventData = (
|
||||
eventType: string,
|
||||
data: Record<string, unknown> | null,
|
||||
): string | null => {
|
||||
if (!data) return null;
|
||||
|
||||
const str = (k: string): string | undefined => {
|
||||
const v = data[k];
|
||||
return typeof v === "string" ? v : undefined;
|
||||
};
|
||||
const num = (k: string): number | undefined => {
|
||||
const v = data[k];
|
||||
return typeof v === "number" ? v : undefined;
|
||||
};
|
||||
|
||||
switch (eventType) {
|
||||
case "step_transition":
|
||||
return `Step ${data.from_step + 1} → Step ${data.to_step + 1}${data.step_name ? `: ${data.step_name}` : ""}`;
|
||||
case "step_transition": {
|
||||
const fromIdx = num("from_step");
|
||||
const toIdx = num("to_step");
|
||||
const stepName = str("step_name");
|
||||
if (typeof toIdx === "number") {
|
||||
const fromLabel =
|
||||
typeof fromIdx === "number" ? `${fromIdx + 1} → ` : "";
|
||||
const nameLabel = stepName ? `: ${stepName}` : "";
|
||||
return `Step ${fromLabel}${toIdx + 1}${nameLabel}`;
|
||||
}
|
||||
return "Step changed";
|
||||
}
|
||||
|
||||
case "wizard_action":
|
||||
return `${data.action_type ? data.action_type.replace(/_/g, " ") : "Action executed"}${data.step_name ? ` in ${data.step_name}` : ""}`;
|
||||
case "wizard_action": {
|
||||
const actionType = str("action_type");
|
||||
const stepName = str("step_name");
|
||||
const actionLabel = actionType
|
||||
? actionType.replace(/_/g, " ")
|
||||
: "Action executed";
|
||||
const inStep = stepName ? ` in ${stepName}` : "";
|
||||
return `${actionLabel}${inStep}`;
|
||||
}
|
||||
|
||||
case "robot_action":
|
||||
return `${data.action_name || "Robot action"}${data.parameters ? ` with parameters` : ""}`;
|
||||
case "robot_action": {
|
||||
const actionName = str("action_name") ?? "Robot action";
|
||||
const hasParams =
|
||||
typeof data.parameters !== "undefined" && data.parameters !== null;
|
||||
return `${actionName}${hasParams ? " with parameters" : ""}`;
|
||||
}
|
||||
|
||||
case "emergency_action":
|
||||
return `Emergency: ${data.emergency_type ? data.emergency_type.replace(/_/g, " ") : "Unknown"}`;
|
||||
case "emergency_action": {
|
||||
const emergency = str("emergency_type");
|
||||
return `Emergency: ${
|
||||
emergency ? emergency.replace(/_/g, " ") : "Unknown"
|
||||
}`;
|
||||
}
|
||||
|
||||
case "recording_control":
|
||||
return `Recording ${data.action === "start_recording" ? "started" : "stopped"}`;
|
||||
case "recording_control": {
|
||||
const action = str("action");
|
||||
return `Recording ${action === "start_recording" ? "started" : "stopped"}`;
|
||||
}
|
||||
|
||||
case "video_control":
|
||||
return `Video ${data.action === "video_on" ? "enabled" : "disabled"}`;
|
||||
case "video_control": {
|
||||
const action = str("action");
|
||||
return `Video ${action === "video_on" ? "enabled" : "disabled"}`;
|
||||
}
|
||||
|
||||
case "audio_control":
|
||||
return `Audio ${data.action === "audio_on" ? "enabled" : "disabled"}`;
|
||||
case "audio_control": {
|
||||
const action = str("action");
|
||||
return `Audio ${action === "audio_on" ? "enabled" : "disabled"}`;
|
||||
}
|
||||
|
||||
case "wizard_intervention":
|
||||
case "wizard_intervention": {
|
||||
return (
|
||||
data.content || data.intervention_type || "Intervention recorded"
|
||||
str("content") ?? str("intervention_type") ?? "Intervention recorded"
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
if (typeof data === "string") return data;
|
||||
if (data.message) return data.message;
|
||||
if (data.description) return data.description;
|
||||
default: {
|
||||
const message = str("message");
|
||||
if (message) return message;
|
||||
const description = str("description");
|
||||
if (description) return description;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -305,7 +423,8 @@ export function EventsLog({
|
||||
if (
|
||||
index === 0 ||
|
||||
Math.abs(
|
||||
event.timestamp.getTime() - (events[index - 1]?.timestamp.getTime() ?? 0),
|
||||
event.timestamp.getTime() -
|
||||
(events[index - 1]?.timestamp.getTime() ?? 0),
|
||||
) > 30000
|
||||
) {
|
||||
groups.push([event]);
|
||||
@@ -317,7 +436,7 @@ export function EventsLog({
|
||||
[],
|
||||
);
|
||||
|
||||
const uniqueEventTypes = Array.from(new Set(events.map((e) => e.eventType)));
|
||||
// uniqueEventTypes removed (unused)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -433,9 +552,11 @@ export function EventsLog({
|
||||
</div>
|
||||
<div className="h-px flex-1 bg-slate-200"></div>
|
||||
<div className="text-xs text-slate-400">
|
||||
{group[0] ? formatDistanceToNow(group[0].timestamp, {
|
||||
addSuffix: true,
|
||||
}) : ""}
|
||||
{group[0]
|
||||
? formatDistanceToNow(group[0].timestamp, {
|
||||
addSuffix: true,
|
||||
})
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -503,20 +624,22 @@ export function EventsLog({
|
||||
|
||||
{event.notes && (
|
||||
<p className="mt-1 text-xs text-slate-500 italic">
|
||||
"{event.notes}"
|
||||
{event.notes}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{event.data && Object.keys(event.data).length > 0 && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs text-blue-600 hover:text-blue-800">
|
||||
View details
|
||||
</summary>
|
||||
<pre className="mt-1 overflow-x-auto rounded border bg-white p-2 text-xs text-slate-600">
|
||||
{JSON.stringify(event.data, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
{event.data &&
|
||||
typeof event.data === "object" &&
|
||||
Object.keys(event.data).length > 0 && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs text-blue-600 hover:text-blue-800">
|
||||
View details
|
||||
</summary>
|
||||
<pre className="mt-1 overflow-x-auto rounded border bg-white p-2 text-xs text-slate-600">
|
||||
{JSON.stringify(event.data, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 text-xs text-slate-400">
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
@@ -106,13 +107,19 @@ const statusConfig = {
|
||||
};
|
||||
|
||||
function TrialActionsCell({ trial }: { trial: Trial }) {
|
||||
const startTrialMutation = api.trials.start.useMutation();
|
||||
const completeTrialMutation = api.trials.complete.useMutation();
|
||||
const abortTrialMutation = api.trials.abort.useMutation();
|
||||
// const deleteTrialMutation = api.trials.delete.useMutation();
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (
|
||||
window.confirm(`Are you sure you want to delete trial "${trial.name}"?`)
|
||||
) {
|
||||
try {
|
||||
// Delete trial functionality not yet implemented
|
||||
toast.success("Trial deleted successfully");
|
||||
// await deleteTrialMutation.mutateAsync({ id: trial.id });
|
||||
toast.success("Trial deletion not yet implemented");
|
||||
// window.location.reload();
|
||||
} catch {
|
||||
toast.error("Failed to delete trial");
|
||||
}
|
||||
@@ -124,14 +131,22 @@ function TrialActionsCell({ trial }: { trial: Trial }) {
|
||||
toast.success("Trial ID copied to clipboard");
|
||||
};
|
||||
|
||||
const handleStartTrial = () => {
|
||||
window.location.href = `/trials/${trial.id}/wizard`;
|
||||
const handleStartTrial = async () => {
|
||||
try {
|
||||
await startTrialMutation.mutateAsync({ id: trial.id });
|
||||
toast.success("Trial started successfully");
|
||||
window.location.href = `/trials/${trial.id}/wizard`;
|
||||
} catch {
|
||||
toast.error("Failed to start trial");
|
||||
}
|
||||
};
|
||||
|
||||
const handlePauseTrial = async () => {
|
||||
try {
|
||||
// Pause trial functionality not yet implemented
|
||||
toast.success("Trial paused");
|
||||
// For now, pausing means completing the trial
|
||||
await completeTrialMutation.mutateAsync({ id: trial.id });
|
||||
toast.success("Trial paused/completed");
|
||||
window.location.reload();
|
||||
} catch {
|
||||
toast.error("Failed to pause trial");
|
||||
}
|
||||
@@ -140,8 +155,9 @@ function TrialActionsCell({ trial }: { trial: Trial }) {
|
||||
const handleStopTrial = async () => {
|
||||
if (window.confirm("Are you sure you want to stop this trial?")) {
|
||||
try {
|
||||
// Stop trial functionality not yet implemented
|
||||
await abortTrialMutation.mutateAsync({ id: trial.id });
|
||||
toast.success("Trial stopped");
|
||||
window.location.reload();
|
||||
} catch {
|
||||
toast.error("Failed to stop trial");
|
||||
}
|
||||
|
||||
@@ -180,12 +180,18 @@ export function TrialsDataTable() {
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Trials"
|
||||
description="Monitor and manage trial execution for your HRI experiments"
|
||||
description="Schedule and manage trials for your HRI studies"
|
||||
icon={TestTube}
|
||||
actions={
|
||||
<ActionButton href="/trials/new">
|
||||
<ActionButton
|
||||
href={
|
||||
selectedStudyId
|
||||
? `/studies/${selectedStudyId}/trials/new`
|
||||
: "/trials/new"
|
||||
}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Trial
|
||||
Schedule Trial
|
||||
</ActionButton>
|
||||
}
|
||||
/>
|
||||
@@ -210,12 +216,18 @@ export function TrialsDataTable() {
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Trials"
|
||||
description="Monitor and manage trial execution for your HRI experiments"
|
||||
description="Schedule and manage trials for your HRI studies"
|
||||
icon={TestTube}
|
||||
actions={
|
||||
<ActionButton href="/trials/new">
|
||||
<ActionButton
|
||||
href={
|
||||
selectedStudyId
|
||||
? `/studies/${selectedStudyId}/trials/new`
|
||||
: "/trials/new"
|
||||
}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Trial
|
||||
Schedule Trial
|
||||
</ActionButton>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,43 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertTriangle, Camera, Clock, Hand, HelpCircle, Lightbulb, MessageSquare, Pause,
|
||||
Play,
|
||||
RotateCcw, Target, Video,
|
||||
VideoOff, Volume2,
|
||||
VolumeX, Zap
|
||||
AlertTriangle,
|
||||
Camera,
|
||||
Clock,
|
||||
Hand,
|
||||
HelpCircle,
|
||||
Lightbulb,
|
||||
MessageSquare,
|
||||
Pause,
|
||||
RotateCcw,
|
||||
Target,
|
||||
Video,
|
||||
VideoOff,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
|
||||
interface ActionControlsProps {
|
||||
trialId: string;
|
||||
currentStep: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "wizard_action" | "robot_action" | "parallel_steps" | "conditional_branch";
|
||||
parameters?: any;
|
||||
actions?: any[];
|
||||
type:
|
||||
| "wizard_action"
|
||||
| "robot_action"
|
||||
| "parallel_steps"
|
||||
| "conditional_branch";
|
||||
description?: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
duration?: number;
|
||||
} | null;
|
||||
onExecuteAction: (actionType: string, actionData: any) => Promise<void>;
|
||||
trialId: string;
|
||||
onActionComplete: (
|
||||
actionId: string,
|
||||
actionData: Record<string, unknown>,
|
||||
) => void;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
interface QuickAction {
|
||||
@@ -50,7 +69,12 @@ interface QuickAction {
|
||||
requiresConfirmation?: boolean;
|
||||
}
|
||||
|
||||
export function ActionControls({ currentStep, onExecuteAction, trialId }: ActionControlsProps) {
|
||||
export function ActionControls({
|
||||
trialId: _trialId,
|
||||
currentStep,
|
||||
onActionComplete,
|
||||
isConnected: _isConnected,
|
||||
}: ActionControlsProps) {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isVideoOn, setIsVideoOn] = useState(true);
|
||||
const [isAudioOn, setIsAudioOn] = useState(true);
|
||||
@@ -119,82 +143,71 @@ export function ActionControls({ currentStep, onExecuteAction, trialId }: Action
|
||||
{ value: "cut_power", label: "Emergency Power Cut" },
|
||||
];
|
||||
|
||||
const handleQuickAction = async (action: QuickAction) => {
|
||||
const handleQuickAction = (action: QuickAction) => {
|
||||
if (action.requiresConfirmation) {
|
||||
setShowEmergencyDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onExecuteAction(action.action, {
|
||||
action_id: action.id,
|
||||
step_id: currentStep?.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (_error) {
|
||||
console.error(`Failed to execute ${action.action}:`, _error);
|
||||
}
|
||||
onActionComplete(action.id, {
|
||||
action_type: action.action,
|
||||
notes: action.description,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
const handleEmergencyAction = async () => {
|
||||
const handleEmergencyAction = () => {
|
||||
if (!selectedEmergencyAction) return;
|
||||
|
||||
try {
|
||||
await onExecuteAction("emergency_action", {
|
||||
emergency_type: selectedEmergencyAction,
|
||||
step_id: currentStep?.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: "high",
|
||||
});
|
||||
setShowEmergencyDialog(false);
|
||||
setSelectedEmergencyAction("");
|
||||
} catch (_error) {
|
||||
console.error("Failed to execute emergency action:", _error);
|
||||
}
|
||||
onActionComplete("emergency_action", {
|
||||
emergency_type: selectedEmergencyAction,
|
||||
notes: interventionNote || "Emergency action executed",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
setShowEmergencyDialog(false);
|
||||
setSelectedEmergencyAction("");
|
||||
setInterventionNote("");
|
||||
};
|
||||
|
||||
const handleInterventionSubmit = async () => {
|
||||
const handleInterventionSubmit = () => {
|
||||
if (!interventionNote.trim()) return;
|
||||
|
||||
try {
|
||||
await onExecuteAction("wizard_intervention", {
|
||||
intervention_type: "note",
|
||||
content: interventionNote,
|
||||
step_id: currentStep?.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
setInterventionNote("");
|
||||
setIsCommunicationOpen(false);
|
||||
} catch (_error) {
|
||||
console.error("Failed to submit intervention:", _error);
|
||||
}
|
||||
onActionComplete("wizard_intervention", {
|
||||
intervention_type: "note",
|
||||
content: interventionNote,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
setInterventionNote("");
|
||||
setIsCommunicationOpen(false);
|
||||
};
|
||||
|
||||
const toggleRecording = async () => {
|
||||
const toggleRecording = () => {
|
||||
const newState = !isRecording;
|
||||
setIsRecording(newState);
|
||||
|
||||
await onExecuteAction("recording_control", {
|
||||
onActionComplete("recording_control", {
|
||||
action: newState ? "start_recording" : "stop_recording",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
const toggleVideo = async () => {
|
||||
const toggleVideo = () => {
|
||||
const newState = !isVideoOn;
|
||||
setIsVideoOn(newState);
|
||||
|
||||
await onExecuteAction("video_control", {
|
||||
onActionComplete("video_control", {
|
||||
action: newState ? "video_on" : "video_off",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAudio = async () => {
|
||||
const toggleAudio = () => {
|
||||
const newState = !isAudioOn;
|
||||
setIsAudioOn(newState);
|
||||
|
||||
await onExecuteAction("audio_control", {
|
||||
onActionComplete("audio_control", {
|
||||
action: newState ? "audio_on" : "audio_off",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
@@ -217,7 +230,9 @@ export function ActionControls({ currentStep, onExecuteAction, trialId }: Action
|
||||
onClick={toggleRecording}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<div className={`w-2 h-2 rounded-full ${isRecording ? "bg-white animate-pulse" : "bg-red-500"}`}></div>
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${isRecording ? "animate-pulse" : ""}`}
|
||||
></div>
|
||||
<span>{isRecording ? "Stop Recording" : "Start Recording"}</span>
|
||||
</Button>
|
||||
|
||||
@@ -226,7 +241,11 @@ export function ActionControls({ currentStep, onExecuteAction, trialId }: Action
|
||||
onClick={toggleVideo}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
{isVideoOn ? <Video className="h-4 w-4" /> : <VideoOff className="h-4 w-4" />}
|
||||
{isVideoOn ? (
|
||||
<Video className="h-4 w-4" />
|
||||
) : (
|
||||
<VideoOff className="h-4 w-4" />
|
||||
)}
|
||||
<span>Video</span>
|
||||
</Button>
|
||||
|
||||
@@ -235,7 +254,11 @@ export function ActionControls({ currentStep, onExecuteAction, trialId }: Action
|
||||
onClick={toggleAudio}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
{isAudioOn ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
|
||||
{isAudioOn ? (
|
||||
<Volume2 className="h-4 w-4" />
|
||||
) : (
|
||||
<VolumeX className="h-4 w-4" />
|
||||
)}
|
||||
<span>Audio</span>
|
||||
</Button>
|
||||
|
||||
@@ -265,15 +288,18 @@ export function ActionControls({ currentStep, onExecuteAction, trialId }: Action
|
||||
<Button
|
||||
key={action.id}
|
||||
variant={
|
||||
action.type === "emergency" ? "destructive" :
|
||||
action.type === "primary" ? "default" : "outline"
|
||||
action.type === "emergency"
|
||||
? "destructive"
|
||||
: action.type === "primary"
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => handleQuickAction(action)}
|
||||
className="flex items-center justify-start space-x-3 h-12"
|
||||
className="flex h-12 items-center justify-start space-x-3"
|
||||
>
|
||||
<action.icon className="h-4 w-4 flex-shrink-0" />
|
||||
<div className="flex-1 text-left">
|
||||
<div className="font-medium">{action.label}</div>
|
||||
<h4 className="font-medium">{action.label}</h4>
|
||||
<div className="text-xs opacity-75">{action.description}</div>
|
||||
</div>
|
||||
</Button>
|
||||
@@ -293,29 +319,14 @@ export function ActionControls({ currentStep, onExecuteAction, trialId }: Action
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-slate-600">
|
||||
Current step: <span className="font-medium">{currentStep.name}</span>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Current step:{" "}
|
||||
<span className="font-medium">{currentStep.name}</span>
|
||||
</div>
|
||||
|
||||
{currentStep.actions && currentStep.actions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Available Actions:</Label>
|
||||
<div className="grid gap-2">
|
||||
{currentStep.actions.map((action: any, index: number) => (
|
||||
<Button
|
||||
key={action.id || index}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onExecuteAction(`step_action_${action.id}`, action)}
|
||||
className="justify-start text-left"
|
||||
>
|
||||
<Play className="h-3 w-3 mr-2" />
|
||||
{action.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Use the controls below to execute wizard actions for this step.
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -343,8 +354,8 @@ export function ActionControls({ currentStep, onExecuteAction, trialId }: Action
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-4 w-4 text-slate-500" />
|
||||
<span className="text-sm text-slate-500">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{new Date().toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -370,18 +381,22 @@ export function ActionControls({ currentStep, onExecuteAction, trialId }: Action
|
||||
<Dialog open={showEmergencyDialog} onOpenChange={setShowEmergencyDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center space-x-2 text-red-600">
|
||||
<DialogTitle className="flex items-center space-x-2">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
<span>Emergency Action Required</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select the type of emergency action to perform. This will immediately stop or override current robot operations.
|
||||
Select the type of emergency action to perform. This will
|
||||
immediately stop or override current robot operations.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="emergency-select">Emergency Action Type</Label>
|
||||
<Select value={selectedEmergencyAction} onValueChange={setSelectedEmergencyAction}>
|
||||
<Select
|
||||
value={selectedEmergencyAction}
|
||||
onValueChange={setSelectedEmergencyAction}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Select emergency action..." />
|
||||
</SelectTrigger>
|
||||
@@ -394,11 +409,13 @@ export function ActionControls({ currentStep, onExecuteAction, trialId }: Action
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="flex items-start space-x-2">
|
||||
<AlertTriangle className="h-4 w-4 text-red-600 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-red-800">
|
||||
<strong>Warning:</strong> Emergency actions will immediately halt all robot operations and may require manual intervention to resume.
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<strong>Warning:</strong> Emergency actions will immediately
|
||||
halt all robot operations and may require manual intervention
|
||||
to resume.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
151
src/components/trials/wizard/EventsLogSidebar.tsx
Normal file
151
src/components/trials/wizard/EventsLogSidebar.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { Clock, Activity, User, Bot, AlertCircle } from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { WebSocketMessage } from "~/hooks/useWebSocket";
|
||||
|
||||
interface EventsLogSidebarProps {
|
||||
events: WebSocketMessage[];
|
||||
maxEvents?: number;
|
||||
showTimestamps?: boolean;
|
||||
}
|
||||
|
||||
const getEventIcon = (eventType: string) => {
|
||||
switch (eventType) {
|
||||
case "trial_status":
|
||||
case "trial_action_executed":
|
||||
return Activity;
|
||||
case "step_changed":
|
||||
return Clock;
|
||||
case "wizard_intervention":
|
||||
case "intervention_logged":
|
||||
return User;
|
||||
case "robot_action":
|
||||
return Bot;
|
||||
case "error":
|
||||
return AlertCircle;
|
||||
default:
|
||||
return Activity;
|
||||
}
|
||||
};
|
||||
|
||||
const getEventVariant = (eventType: string) => {
|
||||
switch (eventType) {
|
||||
case "error":
|
||||
return "destructive" as const;
|
||||
case "wizard_intervention":
|
||||
case "intervention_logged":
|
||||
return "secondary" as const;
|
||||
case "trial_status":
|
||||
return "default" as const;
|
||||
default:
|
||||
return "outline" as const;
|
||||
}
|
||||
};
|
||||
|
||||
const formatEventData = (event: WebSocketMessage): string => {
|
||||
switch (event.type) {
|
||||
case "trial_status":
|
||||
const trialData = event.data as { trial: { status: string } };
|
||||
return `Trial status: ${trialData.trial.status}`;
|
||||
|
||||
case "step_changed":
|
||||
const stepData = event.data as {
|
||||
to_step: number;
|
||||
step_name?: string;
|
||||
};
|
||||
return `Step ${stepData.to_step + 1}${stepData.step_name ? `: ${stepData.step_name}` : ""}`;
|
||||
|
||||
case "trial_action_executed":
|
||||
const actionData = event.data as { action_type: string };
|
||||
return `Action: ${actionData.action_type}`;
|
||||
|
||||
case "wizard_intervention":
|
||||
case "intervention_logged":
|
||||
const interventionData = event.data as { content?: string };
|
||||
return interventionData.content ?? "Wizard intervention";
|
||||
|
||||
case "error":
|
||||
const errorData = event.data as { message?: string };
|
||||
return errorData.message ?? "System error";
|
||||
|
||||
default:
|
||||
return `Event: ${event.type}`;
|
||||
}
|
||||
};
|
||||
|
||||
const getEventTimestamp = (event: WebSocketMessage): Date => {
|
||||
const data = event.data as { timestamp?: number };
|
||||
return data.timestamp ? new Date(data.timestamp) : new Date();
|
||||
};
|
||||
|
||||
export function EventsLogSidebar({
|
||||
events,
|
||||
maxEvents = 10,
|
||||
showTimestamps = true,
|
||||
}: EventsLogSidebarProps) {
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
const displayEvents = events.slice(-maxEvents).reverse();
|
||||
|
||||
if (displayEvents.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Clock className="text-muted-foreground mb-3 h-8 w-8" />
|
||||
<p className="text-muted-foreground text-sm">No events yet</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Events will appear here during trial execution
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-64">
|
||||
<div className="space-y-3">
|
||||
{displayEvents.map((event, index) => {
|
||||
const Icon = getEventIcon(event.type);
|
||||
const timestamp = getEventTimestamp(event);
|
||||
const eventText = formatEventData(event);
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex-shrink-0">
|
||||
<div className="bg-muted rounded-full p-1.5">
|
||||
<Icon className="h-3 w-3" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<Badge
|
||||
variant={getEventVariant(event.type)}
|
||||
className="text-xs"
|
||||
>
|
||||
{event.type.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
{showTimestamps && isClient && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{formatDistanceToNow(timestamp, { addSuffix: true })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-foreground text-sm break-words">
|
||||
{eventText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
330
src/components/trials/wizard/ExecutionStepDisplay.tsx
Normal file
330
src/components/trials/wizard/ExecutionStepDisplay.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle, Clock, PlayCircle, AlertCircle, Eye } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
|
||||
interface ActionDefinition {
|
||||
id: string;
|
||||
stepId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
type: string;
|
||||
orderIndex: number;
|
||||
parameters: Record<string, unknown>;
|
||||
timeout?: number;
|
||||
required: boolean;
|
||||
condition?: string;
|
||||
}
|
||||
|
||||
interface StepDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
type: string;
|
||||
orderIndex: number;
|
||||
condition?: string;
|
||||
actions: ActionDefinition[];
|
||||
}
|
||||
|
||||
interface ExecutionContext {
|
||||
trialId: string;
|
||||
experimentId: string;
|
||||
participantId: string;
|
||||
wizardId?: string;
|
||||
currentStepIndex: number;
|
||||
startTime: Date;
|
||||
variables: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ExecutionStepDisplayProps {
|
||||
currentStep: StepDefinition | null;
|
||||
executionContext: ExecutionContext | null;
|
||||
totalSteps: number;
|
||||
onExecuteStep: () => void;
|
||||
onAdvanceStep: () => void;
|
||||
onCompleteWizardAction: (
|
||||
actionId: string,
|
||||
data?: Record<string, unknown>,
|
||||
) => void;
|
||||
isExecuting: boolean;
|
||||
}
|
||||
|
||||
export function ExecutionStepDisplay({
|
||||
currentStep,
|
||||
executionContext,
|
||||
totalSteps,
|
||||
onExecuteStep,
|
||||
onAdvanceStep,
|
||||
onCompleteWizardAction,
|
||||
isExecuting,
|
||||
}: ExecutionStepDisplayProps) {
|
||||
if (!currentStep || !executionContext) {
|
||||
return (
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="text-muted-foreground">
|
||||
<Clock className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
<p className="text-sm">No active step</p>
|
||||
<p className="mt-1 text-xs">
|
||||
Trial may not be started or all steps completed
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const progress =
|
||||
totalSteps > 0
|
||||
? ((executionContext.currentStepIndex + 1) / totalSteps) * 100
|
||||
: 0;
|
||||
|
||||
const getActionConfig = (
|
||||
type: string,
|
||||
): { icon: typeof PlayCircle; label: string } => {
|
||||
const configs: Record<string, { icon: typeof PlayCircle; label: string }> =
|
||||
{
|
||||
wizard_say: {
|
||||
icon: PlayCircle,
|
||||
label: "Wizard Speech",
|
||||
},
|
||||
wizard_gesture: {
|
||||
icon: PlayCircle,
|
||||
label: "Wizard Gesture",
|
||||
},
|
||||
wizard_show_object: {
|
||||
icon: Eye,
|
||||
label: "Show Object",
|
||||
},
|
||||
observe_behavior: {
|
||||
icon: Eye,
|
||||
label: "Observe Behavior",
|
||||
},
|
||||
wait: { icon: Clock, label: "Wait" },
|
||||
};
|
||||
|
||||
return (
|
||||
configs[type] ?? {
|
||||
icon: PlayCircle,
|
||||
label: "Action",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const getWizardInstructions = (action: ActionDefinition): string => {
|
||||
switch (action.type) {
|
||||
case "wizard_say":
|
||||
return `Say: "${String(action.parameters.text) ?? "Please speak to the participant"}";`;
|
||||
case "wizard_gesture":
|
||||
return `Perform gesture: ${String(action.parameters.gesture) ?? "as specified in the protocol"}`;
|
||||
case "wizard_show_object":
|
||||
return `Show object: ${String(action.parameters.object) ?? "as specified in the protocol"}`;
|
||||
case "observe_behavior":
|
||||
return `Observe and record: ${String(action.parameters.behavior) ?? "participant behavior"}`;
|
||||
case "wait":
|
||||
return `Wait for ${String(action.parameters.duration) ?? "1000"}ms`;
|
||||
default:
|
||||
return `Execute: ${action.name ?? "Unknown Action"}`;
|
||||
}
|
||||
};
|
||||
|
||||
const requiresWizardInput = (action: ActionDefinition): boolean => {
|
||||
return [
|
||||
"wizard_say",
|
||||
"wizard_gesture",
|
||||
"wizard_show_object",
|
||||
"observe_behavior",
|
||||
].includes(action.type);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Step Progress */}
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg font-semibold">
|
||||
Step {executionContext.currentStepIndex + 1} of {totalSteps}
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{Math.round(progress)}% Complete
|
||||
</Badge>
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium">{currentStep.name}</h3>
|
||||
{currentStep.description && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{currentStep.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{currentStep.type
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{currentStep.actions.length} action
|
||||
{currentStep.actions.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Step Actions */}
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-medium">
|
||||
Step Actions
|
||||
</CardTitle>
|
||||
<Button
|
||||
onClick={onExecuteStep}
|
||||
disabled={isExecuting}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
>
|
||||
<PlayCircle className="mr-1 h-3 w-3" />
|
||||
{isExecuting ? "Executing..." : "Execute Step"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-3">
|
||||
{currentStep.actions?.map((action, _index) => {
|
||||
const config = getActionConfig(action.type);
|
||||
const Icon = config.icon;
|
||||
const needsWizardInput = requiresWizardInput(action);
|
||||
|
||||
return (
|
||||
<div key={action.id} className="rounded-lg border p-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">
|
||||
{action.name}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{config.label}
|
||||
</Badge>
|
||||
{action.required && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Required
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{action.description && (
|
||||
<p className="text-muted-foreground ml-6 text-xs">
|
||||
{action.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{needsWizardInput && (
|
||||
<Alert className="mt-2 ml-6">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
{getWizardInstructions(action)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Action Parameters */}
|
||||
{Object.keys(action.parameters).length > 0 && (
|
||||
<div className="mt-2 ml-6">
|
||||
<details className="text-xs">
|
||||
<summary className="text-muted-foreground cursor-pointer">
|
||||
Parameters (
|
||||
{Object.keys(action.parameters).length})
|
||||
</summary>
|
||||
<div className="mt-1 space-y-1">
|
||||
{Object.entries(action.parameters).map(
|
||||
([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex justify-between text-xs"
|
||||
>
|
||||
<span className="text-muted-foreground font-mono">
|
||||
{key}:
|
||||
</span>
|
||||
<span className="font-mono">
|
||||
{typeof value === "string"
|
||||
? `"${value}"`
|
||||
: String(value)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{needsWizardInput && (
|
||||
<Button
|
||||
onClick={() => onCompleteWizardAction(action.id, {})}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
Complete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Step Controls */}
|
||||
<div className="mt-4 flex justify-end space-x-2">
|
||||
<Button
|
||||
onClick={onAdvanceStep}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isExecuting}
|
||||
>
|
||||
Next Step
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Execution Variables (if any) */}
|
||||
{Object.keys(executionContext.variables).length > 0 && (
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base font-medium">
|
||||
Execution Variables
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-1">
|
||||
{Object.entries(executionContext.variables).map(
|
||||
([key, value]) => (
|
||||
<div key={key} className="flex justify-between text-xs">
|
||||
<span className="font-mono text-slate-600">{key}:</span>
|
||||
<span className="font-mono text-slate-900">
|
||||
{typeof value === "string" ? `"${value}"` : String(value)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Briefcase, Clock, GraduationCap, Info, Mail, Shield, User
|
||||
} from "lucide-react";
|
||||
import { Briefcase, Clock, GraduationCap, Info, Shield } from "lucide-react";
|
||||
import { Avatar, AvatarFallback } from "~/components/ui/avatar";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
|
||||
interface ParticipantInfoProps {
|
||||
participant: {
|
||||
id: string;
|
||||
participantCode: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
demographics: any;
|
||||
demographics: Record<string, unknown> | null;
|
||||
};
|
||||
trialStatus: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
|
||||
}
|
||||
|
||||
export function ParticipantInfo({ participant }: ParticipantInfoProps) {
|
||||
const demographics = participant.demographics || {};
|
||||
export function ParticipantInfo({
|
||||
participant,
|
||||
trialStatus: _trialStatus,
|
||||
}: ParticipantInfoProps) {
|
||||
const demographics = participant.demographics ?? {};
|
||||
|
||||
// Extract common demographic fields
|
||||
const age = demographics.age;
|
||||
const gender = demographics.gender;
|
||||
const occupation = demographics.occupation;
|
||||
const education = demographics.education;
|
||||
const language = demographics.primaryLanguage || demographics.language;
|
||||
const location = demographics.location || demographics.city;
|
||||
const experience = demographics.robotExperience || demographics.experience;
|
||||
const age = demographics.age as string | number | undefined;
|
||||
const gender = demographics.gender as string | undefined;
|
||||
const occupation = demographics.occupation as string | undefined;
|
||||
const education = demographics.education as string | undefined;
|
||||
const language =
|
||||
(demographics.primaryLanguage as string | undefined) ??
|
||||
(demographics.language as string | undefined);
|
||||
const experience =
|
||||
(demographics.robotExperience as string | undefined) ??
|
||||
(demographics.experience as string | undefined);
|
||||
|
||||
// Get participant initials for avatar
|
||||
const getInitials = () => {
|
||||
if (participant.name) {
|
||||
const nameParts = participant.name.split(" ");
|
||||
return nameParts.map((part) => part.charAt(0).toUpperCase()).join("");
|
||||
}
|
||||
return participant.participantCode.substring(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
const formatDemographicValue = (key: string, value: any) => {
|
||||
const formatDemographicValue = (key: string, value: unknown) => {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
|
||||
// Handle different data types
|
||||
@@ -53,81 +51,64 @@ export function ParticipantInfo({ participant }: ParticipantInfoProps) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
return typeof value === "string" ? value : JSON.stringify(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<User className="h-4 w-4 text-slate-600" />
|
||||
<h3 className="font-medium text-slate-900">Participant</h3>
|
||||
</div>
|
||||
|
||||
{/* Basic Info Card */}
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback className="bg-blue-100 font-medium text-blue-600">
|
||||
{getInitials()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-slate-900">
|
||||
{participant.name || "Anonymous"}
|
||||
</div>
|
||||
<div className="text-sm text-slate-600">
|
||||
ID: {participant.participantCode}
|
||||
</div>
|
||||
{participant.email && (
|
||||
<div className="mt-1 flex items-center space-x-1 text-xs text-slate-500">
|
||||
<Mail className="h-3 w-3" />
|
||||
<span className="truncate">{participant.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Basic Info */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback className="font-medium">
|
||||
{getInitials()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-slate-900">
|
||||
Participant {participant.participantCode}
|
||||
</div>
|
||||
<div className="text-sm text-slate-600">
|
||||
ID: {participant.participantCode}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Demographics */}
|
||||
{(age || gender || language) && (
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-1 gap-2 text-sm">
|
||||
{age && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-600">Age:</span>
|
||||
<span className="font-medium">{age}</span>
|
||||
</div>
|
||||
)}
|
||||
{gender && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-600">Gender:</span>
|
||||
<span className="font-medium capitalize">{gender}</span>
|
||||
</div>
|
||||
)}
|
||||
{language && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-600">Language:</span>
|
||||
<span className="font-medium">{language}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{(age ?? gender ?? language) && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="grid grid-cols-1 gap-2 text-sm">
|
||||
{age && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-600">Age:</span>
|
||||
<span className="font-medium">{age}</span>
|
||||
</div>
|
||||
)}
|
||||
{gender && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-600">Gender:</span>
|
||||
<span className="font-medium capitalize">{gender}</span>
|
||||
</div>
|
||||
)}
|
||||
{language && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-600">Language:</span>
|
||||
<span className="font-medium">{language}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background Info */}
|
||||
{(occupation || education || experience) && (
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center space-x-1 text-sm font-medium text-slate-700">
|
||||
<Info className="h-3 w-3" />
|
||||
<span>Background</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 pt-0">
|
||||
{(occupation ?? education ?? experience) && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-3 flex items-center space-x-1 text-sm font-medium text-slate-700">
|
||||
<Info className="h-3 w-3" />
|
||||
<span>Background</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{occupation && (
|
||||
<div className="flex items-start space-x-2 text-sm">
|
||||
<Briefcase className="mt-0.5 h-3 w-3 flex-shrink-0 text-slate-400" />
|
||||
@@ -155,19 +136,17 @@ export function ParticipantInfo({ participant }: ParticipantInfoProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Additional Demographics */}
|
||||
{Object.keys(demographics).length > 0 && (
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-700">
|
||||
Additional Info
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-3 text-sm font-medium text-slate-700">
|
||||
Additional Info
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(demographics)
|
||||
.filter(
|
||||
@@ -211,30 +190,26 @@ export function ParticipantInfo({ participant }: ParticipantInfoProps) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Consent Status */}
|
||||
<Card className="border-green-200 bg-green-50 shadow-sm">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500"></div>
|
||||
<span className="text-sm font-medium text-green-800">
|
||||
Consent Verified
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-green-600">
|
||||
Participant has provided informed consent
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500"></div>
|
||||
<span className="text-sm font-medium">Consent Verified</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
Participant has provided informed consent
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session Info */}
|
||||
<div className="space-y-1 text-xs text-slate-500">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>Session started: {new Date().toLocaleTimeString()}</span>
|
||||
<span>Session active</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity, AlertTriangle, Battery,
|
||||
BatteryLow, Bot, CheckCircle,
|
||||
Clock, RefreshCw, Signal,
|
||||
SignalHigh,
|
||||
SignalLow,
|
||||
SignalMedium, WifiOff
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Battery,
|
||||
BatteryLow,
|
||||
Bot,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
Signal,
|
||||
SignalHigh,
|
||||
SignalLow,
|
||||
SignalMedium,
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
|
||||
interface RobotStatusProps {
|
||||
@@ -37,10 +44,10 @@ interface RobotStatus {
|
||||
z?: number;
|
||||
orientation?: number;
|
||||
};
|
||||
sensors?: Record<string, any>;
|
||||
sensors?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function RobotStatus({ trialId }: RobotStatusProps) {
|
||||
export function RobotStatus({ trialId: _trialId }: RobotStatusProps) {
|
||||
const [robotStatus, setRobotStatus] = useState<RobotStatus | null>(null);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
@@ -62,32 +69,43 @@ export function RobotStatus({ trialId }: RobotStatusProps) {
|
||||
position: {
|
||||
x: 1.2,
|
||||
y: 0.8,
|
||||
orientation: 45
|
||||
orientation: 45,
|
||||
},
|
||||
sensors: {
|
||||
lidar: "operational",
|
||||
camera: "operational",
|
||||
imu: "operational",
|
||||
odometry: "operational"
|
||||
}
|
||||
odometry: "operational",
|
||||
},
|
||||
};
|
||||
|
||||
setRobotStatus(mockStatus);
|
||||
|
||||
// Simulate periodic updates
|
||||
const interval = setInterval(() => {
|
||||
setRobotStatus(prev => {
|
||||
setRobotStatus((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
batteryLevel: Math.max(0, (prev.batteryLevel || 0) - Math.random() * 0.5),
|
||||
signalStrength: Math.max(0, Math.min(100, (prev.signalStrength || 0) + (Math.random() - 0.5) * 10)),
|
||||
batteryLevel: Math.max(
|
||||
0,
|
||||
(prev.batteryLevel ?? 0) - Math.random() * 0.5,
|
||||
),
|
||||
signalStrength: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
100,
|
||||
(prev.signalStrength ?? 0) + (Math.random() - 0.5) * 10,
|
||||
),
|
||||
),
|
||||
lastHeartbeat: new Date(),
|
||||
position: prev.position ? {
|
||||
...prev.position,
|
||||
x: prev.position.x + (Math.random() - 0.5) * 0.1,
|
||||
y: prev.position.y + (Math.random() - 0.5) * 0.1,
|
||||
} : undefined
|
||||
position: prev.position
|
||||
? {
|
||||
...prev.position,
|
||||
x: prev.position.x + (Math.random() - 0.5) * 0.1,
|
||||
y: prev.position.y + (Math.random() - 0.5) * 0.1,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
setLastUpdate(new Date());
|
||||
@@ -103,35 +121,35 @@ export function RobotStatus({ trialId }: RobotStatusProps) {
|
||||
icon: CheckCircle,
|
||||
color: "text-green-600",
|
||||
bgColor: "bg-green-100",
|
||||
label: "Connected"
|
||||
label: "Connected",
|
||||
};
|
||||
case "connecting":
|
||||
return {
|
||||
icon: RefreshCw,
|
||||
color: "text-blue-600",
|
||||
bgColor: "bg-blue-100",
|
||||
label: "Connecting"
|
||||
label: "Connecting",
|
||||
};
|
||||
case "disconnected":
|
||||
return {
|
||||
icon: WifiOff,
|
||||
color: "text-gray-600",
|
||||
bgColor: "bg-gray-100",
|
||||
label: "Disconnected"
|
||||
label: "Disconnected",
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
icon: AlertTriangle,
|
||||
color: "text-red-600",
|
||||
bgColor: "bg-red-100",
|
||||
label: "Error"
|
||||
label: "Error",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: WifiOff,
|
||||
color: "text-gray-600",
|
||||
bgColor: "bg-gray-100",
|
||||
label: "Unknown"
|
||||
label: "Unknown",
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -159,182 +177,173 @@ export function RobotStatus({ trialId }: RobotStatusProps) {
|
||||
if (!robotStatus) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Bot className="h-4 w-4 text-slate-600" />
|
||||
<h3 className="font-medium text-slate-900">Robot Status</h3>
|
||||
<div className="rounded-lg border p-4 text-center">
|
||||
<div className="text-slate-500">
|
||||
<Bot className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
<p className="text-sm">No robot connected</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="text-slate-500">
|
||||
<Bot className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No robot connected</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusConfig = getConnectionStatusConfig(robotStatus.connectionStatus);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
const SignalIcon = getSignalIcon(robotStatus.signalStrength || 0);
|
||||
const BatteryIcon = getBatteryIcon(robotStatus.batteryLevel || 0);
|
||||
const SignalIcon = getSignalIcon(robotStatus.signalStrength ?? 0);
|
||||
const BatteryIcon = getBatteryIcon(robotStatus.batteryLevel ?? 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Bot className="h-4 w-4 text-slate-600" />
|
||||
<h3 className="font-medium text-slate-900">Robot Status</h3>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefreshStatus}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Main Status Card */}
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-3">
|
||||
{/* Robot Info */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium text-slate-900">{robotStatus.name}</div>
|
||||
<Badge className={`${statusConfig.bgColor} ${statusConfig.color}`} variant="secondary">
|
||||
<StatusIcon className="mr-1 h-3 w-3" />
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Connection Details */}
|
||||
<div className="text-sm text-slate-600">
|
||||
Protocol: {robotStatus.communicationProtocol}
|
||||
</div>
|
||||
|
||||
{/* Status Indicators */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Battery */}
|
||||
{robotStatus.batteryLevel !== undefined && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center space-x-1 text-xs text-slate-600">
|
||||
<BatteryIcon className={`h-3 w-3 ${
|
||||
robotStatus.batteryLevel <= 20 ? 'text-red-500' : 'text-green-500'
|
||||
}`} />
|
||||
<span>Battery</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Progress
|
||||
value={robotStatus.batteryLevel}
|
||||
className="flex-1 h-1.5"
|
||||
/>
|
||||
<span className="text-xs font-medium w-8">
|
||||
{Math.round(robotStatus.batteryLevel)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signal Strength */}
|
||||
{robotStatus.signalStrength !== undefined && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center space-x-1 text-xs text-slate-600">
|
||||
<SignalIcon className="h-3 w-3" />
|
||||
<span>Signal</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Progress
|
||||
value={robotStatus.signalStrength}
|
||||
className="flex-1 h-1.5"
|
||||
/>
|
||||
<span className="text-xs font-medium w-8">
|
||||
{Math.round(robotStatus.signalStrength)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Current Mode */}
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-3">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="space-y-3">
|
||||
{/* Robot Info */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Activity className="h-3 w-3 text-slate-600" />
|
||||
<span className="text-sm text-slate-600">Mode:</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{robotStatus.currentMode.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
<div className="font-medium text-slate-900">{robotStatus.name}</div>
|
||||
<Badge
|
||||
className={`${statusConfig.bgColor} ${statusConfig.color}`}
|
||||
variant="secondary"
|
||||
>
|
||||
<StatusIcon className="mr-1 h-3 w-3" />
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
</div>
|
||||
{robotStatus.isMoving && (
|
||||
<div className="flex items-center space-x-1 mt-2 text-xs text-blue-600">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse"></div>
|
||||
<span>Robot is moving</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Connection Details */}
|
||||
<div className="text-sm text-slate-600">
|
||||
Protocol: {robotStatus.communicationProtocol}
|
||||
</div>
|
||||
|
||||
{/* Status Indicators */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Battery */}
|
||||
{robotStatus.batteryLevel !== undefined && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center space-x-1 text-xs text-slate-600">
|
||||
<BatteryIcon className="h-3 w-3" />
|
||||
<span>Battery</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Progress
|
||||
value={robotStatus.batteryLevel}
|
||||
className="h-1.5 flex-1"
|
||||
/>
|
||||
<span className="w-8 text-xs font-medium">
|
||||
{Math.round(robotStatus.batteryLevel)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signal Strength */}
|
||||
{robotStatus.signalStrength !== undefined && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center space-x-1 text-xs text-slate-600">
|
||||
<SignalIcon className="h-3 w-3" />
|
||||
<span>Signal</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Progress
|
||||
value={robotStatus.signalStrength}
|
||||
className="h-1.5 flex-1"
|
||||
/>
|
||||
<span className="w-8 text-xs font-medium">
|
||||
{Math.round(robotStatus.signalStrength)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Mode */}
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Activity className="h-3 w-3 text-slate-600" />
|
||||
<span className="text-sm text-slate-600">Mode:</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{robotStatus.currentMode
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||
</Badge>
|
||||
</div>
|
||||
{robotStatus.isMoving && (
|
||||
<div className="mt-2 flex items-center space-x-1 text-xs">
|
||||
<div className="h-1.5 w-1.5 animate-pulse rounded-full"></div>
|
||||
<span>Robot is moving</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Position Info */}
|
||||
{robotStatus.position && (
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-700">Position</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-3 text-sm font-medium text-slate-700">
|
||||
Position
|
||||
</div>
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">X:</span>
|
||||
<span className="font-mono">{robotStatus.position.x.toFixed(2)}m</span>
|
||||
<span className="font-mono">
|
||||
{robotStatus.position.x.toFixed(2)}m
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Y:</span>
|
||||
<span className="font-mono">{robotStatus.position.y.toFixed(2)}m</span>
|
||||
<span className="font-mono">
|
||||
{robotStatus.position.y.toFixed(2)}m
|
||||
</span>
|
||||
</div>
|
||||
{robotStatus.position.orientation !== undefined && (
|
||||
<div className="flex justify-between col-span-2">
|
||||
<div className="col-span-2 flex justify-between">
|
||||
<span className="text-slate-600">Orientation:</span>
|
||||
<span className="font-mono">{Math.round(robotStatus.position.orientation)}°</span>
|
||||
<span className="font-mono">
|
||||
{Math.round(robotStatus.position.orientation)}°
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sensors Status */}
|
||||
{robotStatus.sensors && (
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-700">Sensors</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-3 text-sm font-medium text-slate-700">Sensors</div>
|
||||
<div>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(robotStatus.sensors).map(([sensor, status]) => (
|
||||
<div key={sensor} className="flex items-center justify-between text-xs">
|
||||
<div
|
||||
key={sensor}
|
||||
className="flex items-center justify-between text-xs"
|
||||
>
|
||||
<span className="text-slate-600 capitalize">{sensor}:</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${
|
||||
status === 'operational'
|
||||
? 'text-green-600 border-green-200'
|
||||
: 'text-red-600 border-red-200'
|
||||
}`}
|
||||
>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{status}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Alert */}
|
||||
@@ -348,7 +357,7 @@ export function RobotStatus({ trialId }: RobotStatusProps) {
|
||||
)}
|
||||
|
||||
{/* Last Update */}
|
||||
<div className="text-xs text-slate-500 flex items-center space-x-1">
|
||||
<div className="flex items-center space-x-1 text-xs text-slate-500">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>Last update: {lastUpdate.toLocaleTimeString()}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity, ArrowRight, Bot, CheckCircle, GitBranch, MessageSquare, Play, Settings, Timer,
|
||||
User, Users
|
||||
Activity,
|
||||
ArrowRight,
|
||||
Bot,
|
||||
CheckCircle,
|
||||
GitBranch,
|
||||
MessageSquare,
|
||||
Play,
|
||||
Settings,
|
||||
Timer,
|
||||
User,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
|
||||
import { useState } from "react";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
@@ -16,7 +30,11 @@ interface StepDisplayProps {
|
||||
step: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "wizard_action" | "robot_action" | "parallel_steps" | "conditional_branch";
|
||||
type:
|
||||
| "wizard_action"
|
||||
| "robot_action"
|
||||
| "parallel_steps"
|
||||
| "conditional_branch";
|
||||
description?: string;
|
||||
parameters?: any;
|
||||
duration?: number;
|
||||
@@ -63,10 +81,12 @@ export function StepDisplay({
|
||||
stepIndex,
|
||||
totalSteps,
|
||||
isActive,
|
||||
onExecuteAction
|
||||
onExecuteAction,
|
||||
}: StepDisplayProps) {
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const [completedActions, setCompletedActions] = useState<Set<string>>(new Set());
|
||||
const [completedActions, setCompletedActions] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const stepConfig = stepTypeConfig[step.type];
|
||||
const StepIcon = stepConfig.icon;
|
||||
@@ -75,7 +95,7 @@ export function StepDisplay({
|
||||
setIsExecuting(true);
|
||||
try {
|
||||
await onExecuteAction(actionId, actionData);
|
||||
setCompletedActions(prev => new Set([...prev, actionId]));
|
||||
setCompletedActions((prev) => new Set([...prev, actionId]));
|
||||
} catch (_error) {
|
||||
console.error("Failed to execute action:", _error);
|
||||
} finally {
|
||||
@@ -97,17 +117,19 @@ export function StepDisplay({
|
||||
|
||||
{step.actions && step.actions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-slate-900">Available Actions:</h4>
|
||||
<h4 className="font-medium text-slate-900">
|
||||
Available Actions:
|
||||
</h4>
|
||||
<div className="grid gap-2">
|
||||
{step.actions.map((action: any, index: number) => {
|
||||
const isCompleted = completedActions.has(action.id);
|
||||
return (
|
||||
<div
|
||||
key={action.id || index}
|
||||
className={`flex items-center justify-between p-3 rounded-lg border ${
|
||||
className={`flex items-center justify-between rounded-lg border p-3 ${
|
||||
isCompleted
|
||||
? "bg-green-50 border-green-200"
|
||||
: "bg-slate-50 border-slate-200"
|
||||
? "border-green-200 bg-green-50"
|
||||
: "border-slate-200 bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
@@ -117,16 +139,20 @@ export function StepDisplay({
|
||||
<Play className="h-4 w-4 text-slate-400" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-sm">{action.name}</p>
|
||||
<p className="text-sm font-medium">{action.name}</p>
|
||||
{action.description && (
|
||||
<p className="text-xs text-slate-600">{action.description}</p>
|
||||
<p className="text-xs text-slate-600">
|
||||
{action.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isActive && !isCompleted && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleActionExecution(action.id, action)}
|
||||
onClick={() =>
|
||||
handleActionExecution(action.id, action)
|
||||
}
|
||||
disabled={isExecuting}
|
||||
>
|
||||
Execute
|
||||
@@ -153,8 +179,10 @@ export function StepDisplay({
|
||||
|
||||
{step.parameters && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-slate-900">Robot Parameters:</h4>
|
||||
<div className="bg-slate-50 rounded-lg p-3 text-sm font-mono">
|
||||
<h4 className="font-medium text-slate-900">
|
||||
Robot Parameters:
|
||||
</h4>
|
||||
<div className="rounded-lg bg-slate-50 p-3 font-mono text-sm">
|
||||
<pre>{JSON.stringify(step.parameters, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,22 +209,26 @@ export function StepDisplay({
|
||||
|
||||
{step.substeps && step.substeps.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-slate-900">Parallel Actions:</h4>
|
||||
<h4 className="font-medium text-slate-900">
|
||||
Parallel Actions:
|
||||
</h4>
|
||||
<div className="grid gap-3">
|
||||
{step.substeps.map((substep: any, index: number) => (
|
||||
<div
|
||||
key={substep.id || index}
|
||||
className="flex items-center space-x-3 p-3 bg-slate-50 rounded-lg border"
|
||||
className="flex items-center space-x-3 rounded-lg border bg-slate-50 p-3"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-6 h-6 rounded-full bg-purple-100 flex items-center justify-center text-xs font-medium text-purple-600">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-purple-100 text-xs font-medium text-purple-600">
|
||||
{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">{substep.name}</p>
|
||||
<p className="text-sm font-medium">{substep.name}</p>
|
||||
{substep.description && (
|
||||
<p className="text-xs text-slate-600">{substep.description}</p>
|
||||
<p className="text-xs text-slate-600">
|
||||
{substep.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
@@ -225,7 +257,7 @@ export function StepDisplay({
|
||||
{step.conditions && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-slate-900">Conditions:</h4>
|
||||
<div className="bg-slate-50 rounded-lg p-3 text-sm">
|
||||
<div className="rounded-lg bg-slate-50 p-3 text-sm">
|
||||
<pre>{JSON.stringify(step.conditions, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,19 +265,23 @@ export function StepDisplay({
|
||||
|
||||
{step.branches && step.branches.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-slate-900">Possible Branches:</h4>
|
||||
<h4 className="font-medium text-slate-900">
|
||||
Possible Branches:
|
||||
</h4>
|
||||
<div className="grid gap-2">
|
||||
{step.branches.map((branch: any, index: number) => (
|
||||
<div
|
||||
key={branch.id || index}
|
||||
className="flex items-center justify-between p-3 bg-slate-50 rounded-lg border"
|
||||
className="flex items-center justify-between rounded-lg border bg-slate-50 p-3"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<ArrowRight className="h-4 w-4 text-orange-500" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">{branch.name}</p>
|
||||
<p className="text-sm font-medium">{branch.name}</p>
|
||||
{branch.condition && (
|
||||
<p className="text-xs text-slate-600">If: {branch.condition}</p>
|
||||
<p className="text-xs text-slate-600">
|
||||
If: {branch.condition}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -253,7 +289,9 @@ export function StepDisplay({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleActionExecution(`branch_${branch.id}`, branch)}
|
||||
onClick={() =>
|
||||
handleActionExecution(`branch_${branch.id}`, branch)
|
||||
}
|
||||
disabled={isExecuting}
|
||||
>
|
||||
Select
|
||||
@@ -269,8 +307,8 @@ export function StepDisplay({
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<Settings className="h-8 w-8 mx-auto mb-2" />
|
||||
<div className="py-8 text-center text-slate-500">
|
||||
<Settings className="mx-auto mb-2 h-8 w-8" />
|
||||
<p>Unknown step type: {step.type}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -278,32 +316,46 @@ export function StepDisplay({
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={`transition-all duration-200 ${
|
||||
isActive ? "ring-2 ring-blue-500 shadow-lg" : "border-slate-200"
|
||||
}`}>
|
||||
<Card
|
||||
className={`transition-all duration-200 ${
|
||||
isActive ? "shadow-lg ring-2 ring-blue-500" : "border-slate-200"
|
||||
}`}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className={`flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
stepConfig.color === "blue" ? "bg-blue-100" :
|
||||
stepConfig.color === "green" ? "bg-green-100" :
|
||||
stepConfig.color === "purple" ? "bg-purple-100" :
|
||||
stepConfig.color === "orange" ? "bg-orange-100" :
|
||||
"bg-slate-100"
|
||||
}`}>
|
||||
<StepIcon className={`h-5 w-5 ${
|
||||
stepConfig.color === "blue" ? "text-blue-600" :
|
||||
stepConfig.color === "green" ? "text-green-600" :
|
||||
stepConfig.color === "purple" ? "text-purple-600" :
|
||||
stepConfig.color === "orange" ? "text-orange-600" :
|
||||
"text-slate-600"
|
||||
}`} />
|
||||
<div
|
||||
className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg ${
|
||||
stepConfig.color === "blue"
|
||||
? "bg-blue-100"
|
||||
: stepConfig.color === "green"
|
||||
? "bg-green-100"
|
||||
: stepConfig.color === "purple"
|
||||
? "bg-purple-100"
|
||||
: stepConfig.color === "orange"
|
||||
? "bg-orange-100"
|
||||
: "bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
<StepIcon
|
||||
className={`h-5 w-5 ${
|
||||
stepConfig.color === "blue"
|
||||
? "text-blue-600"
|
||||
: stepConfig.color === "green"
|
||||
? "text-green-600"
|
||||
: stepConfig.color === "purple"
|
||||
? "text-purple-600"
|
||||
: stepConfig.color === "orange"
|
||||
? "text-orange-600"
|
||||
: "text-slate-600"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-lg font-semibold text-slate-900">
|
||||
{step.name}
|
||||
</CardTitle>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<div className="mt-1 flex items-center space-x-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{stepConfig.label}
|
||||
</Badge>
|
||||
@@ -311,7 +363,7 @@ export function StepDisplay({
|
||||
Step {stepIndex + 1} of {totalSteps}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
{stepConfig.description}
|
||||
</p>
|
||||
</div>
|
||||
@@ -341,9 +393,14 @@ export function StepDisplay({
|
||||
<Separator className="my-4" />
|
||||
<div className="flex items-center justify-between text-xs text-slate-500">
|
||||
<span>Step Progress</span>
|
||||
<span>{stepIndex + 1}/{totalSteps}</span>
|
||||
<span>
|
||||
{stepIndex + 1}/{totalSteps}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={((stepIndex + 1) / totalSteps) * 100} className="h-1 mt-2" />
|
||||
<Progress
|
||||
value={((stepIndex + 1) / totalSteps) * 100}
|
||||
className="mt-2 h-1"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity, Bot, CheckCircle,
|
||||
Circle, Clock, GitBranch, Play, Target, Users
|
||||
Activity,
|
||||
Bot,
|
||||
CheckCircle,
|
||||
Circle,
|
||||
Clock,
|
||||
GitBranch,
|
||||
Play,
|
||||
Target,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
@@ -13,10 +20,14 @@ interface TrialProgressProps {
|
||||
steps: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: "wizard_action" | "robot_action" | "parallel_steps" | "conditional_branch";
|
||||
type:
|
||||
| "wizard_action"
|
||||
| "robot_action"
|
||||
| "parallel_steps"
|
||||
| "conditional_branch";
|
||||
description?: string;
|
||||
duration?: number;
|
||||
parameters?: any;
|
||||
parameters?: Record<string, unknown>;
|
||||
}>;
|
||||
currentStepIndex: number;
|
||||
trialStatus: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
|
||||
@@ -29,7 +40,7 @@ const stepTypeConfig = {
|
||||
color: "blue",
|
||||
bgColor: "bg-blue-100",
|
||||
textColor: "text-blue-600",
|
||||
borderColor: "border-blue-300"
|
||||
borderColor: "border-blue-300",
|
||||
},
|
||||
robot_action: {
|
||||
label: "Robot",
|
||||
@@ -37,7 +48,7 @@ const stepTypeConfig = {
|
||||
color: "green",
|
||||
bgColor: "bg-green-100",
|
||||
textColor: "text-green-600",
|
||||
borderColor: "border-green-300"
|
||||
borderColor: "border-green-300",
|
||||
},
|
||||
parallel_steps: {
|
||||
label: "Parallel",
|
||||
@@ -45,7 +56,7 @@ const stepTypeConfig = {
|
||||
color: "purple",
|
||||
bgColor: "bg-purple-100",
|
||||
textColor: "text-purple-600",
|
||||
borderColor: "border-purple-300"
|
||||
borderColor: "border-purple-300",
|
||||
},
|
||||
conditional_branch: {
|
||||
label: "Branch",
|
||||
@@ -53,17 +64,21 @@ const stepTypeConfig = {
|
||||
color: "orange",
|
||||
bgColor: "bg-orange-100",
|
||||
textColor: "text-orange-600",
|
||||
borderColor: "border-orange-300"
|
||||
}
|
||||
borderColor: "border-orange-300",
|
||||
},
|
||||
};
|
||||
|
||||
export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialProgressProps) {
|
||||
export function TrialProgress({
|
||||
steps,
|
||||
currentStepIndex,
|
||||
trialStatus,
|
||||
}: TrialProgressProps) {
|
||||
if (!steps || steps.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="text-slate-500">
|
||||
<Target className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<Target className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
<p className="text-sm">No experiment steps defined</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -71,19 +86,28 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
|
||||
);
|
||||
}
|
||||
|
||||
const progress = trialStatus === "completed" ? 100 :
|
||||
trialStatus === "aborted" ? 0 :
|
||||
((currentStepIndex + 1) / steps.length) * 100;
|
||||
const progress =
|
||||
trialStatus === "completed"
|
||||
? 100
|
||||
: trialStatus === "aborted"
|
||||
? 0
|
||||
: ((currentStepIndex + 1) / steps.length) * 100;
|
||||
|
||||
const completedSteps = trialStatus === "completed" ? steps.length :
|
||||
trialStatus === "aborted" || trialStatus === "failed" ? 0 :
|
||||
currentStepIndex;
|
||||
const completedSteps =
|
||||
trialStatus === "completed"
|
||||
? steps.length
|
||||
: trialStatus === "aborted" || trialStatus === "failed"
|
||||
? 0
|
||||
: currentStepIndex;
|
||||
|
||||
const getStepStatus = (index: number) => {
|
||||
if (trialStatus === "aborted" || trialStatus === "failed") return "aborted";
|
||||
if (trialStatus === "completed" || index < currentStepIndex) return "completed";
|
||||
if (index === currentStepIndex && trialStatus === "in_progress") return "active";
|
||||
if (index === currentStepIndex && trialStatus === "scheduled") return "pending";
|
||||
if (trialStatus === "completed" || index < currentStepIndex)
|
||||
return "completed";
|
||||
if (index === currentStepIndex && trialStatus === "in_progress")
|
||||
return "active";
|
||||
if (index === currentStepIndex && trialStatus === "scheduled")
|
||||
return "pending";
|
||||
return "upcoming";
|
||||
};
|
||||
|
||||
@@ -95,7 +119,7 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
|
||||
iconColor: "text-green-600",
|
||||
bgColor: "bg-green-100",
|
||||
borderColor: "border-green-300",
|
||||
textColor: "text-green-800"
|
||||
textColor: "text-green-800",
|
||||
};
|
||||
case "active":
|
||||
return {
|
||||
@@ -103,7 +127,7 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
|
||||
iconColor: "text-blue-600",
|
||||
bgColor: "bg-blue-100",
|
||||
borderColor: "border-blue-300",
|
||||
textColor: "text-blue-800"
|
||||
textColor: "text-blue-800",
|
||||
};
|
||||
case "pending":
|
||||
return {
|
||||
@@ -111,7 +135,7 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
|
||||
iconColor: "text-amber-600",
|
||||
bgColor: "bg-amber-100",
|
||||
borderColor: "border-amber-300",
|
||||
textColor: "text-amber-800"
|
||||
textColor: "text-amber-800",
|
||||
};
|
||||
case "aborted":
|
||||
return {
|
||||
@@ -119,7 +143,7 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
|
||||
iconColor: "text-red-600",
|
||||
bgColor: "bg-red-100",
|
||||
borderColor: "border-red-300",
|
||||
textColor: "text-red-800"
|
||||
textColor: "text-red-800",
|
||||
};
|
||||
default: // upcoming
|
||||
return {
|
||||
@@ -127,12 +151,15 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
|
||||
iconColor: "text-slate-400",
|
||||
bgColor: "bg-slate-100",
|
||||
borderColor: "border-slate-300",
|
||||
textColor: "text-slate-600"
|
||||
textColor: "text-slate-600",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const totalDuration = steps.reduce((sum, step) => sum + (step.duration || 0), 0);
|
||||
const totalDuration = steps.reduce(
|
||||
(sum, step) => sum + (step.duration ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -165,19 +192,25 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
|
||||
<Progress
|
||||
value={progress}
|
||||
className={`h-2 ${
|
||||
trialStatus === "completed" ? "bg-green-100" :
|
||||
trialStatus === "aborted" || trialStatus === "failed" ? "bg-red-100" :
|
||||
"bg-blue-100"
|
||||
trialStatus === "completed"
|
||||
? "bg-green-100"
|
||||
: trialStatus === "aborted" || trialStatus === "failed"
|
||||
? "bg-red-100"
|
||||
: "bg-blue-100"
|
||||
}`}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-slate-500">
|
||||
<span>Start</span>
|
||||
<span>
|
||||
{trialStatus === "completed" ? "Completed" :
|
||||
trialStatus === "aborted" ? "Aborted" :
|
||||
trialStatus === "failed" ? "Failed" :
|
||||
trialStatus === "in_progress" ? "In Progress" :
|
||||
"Not Started"}
|
||||
{trialStatus === "completed"
|
||||
? "Completed"
|
||||
: trialStatus === "aborted"
|
||||
? "Aborted"
|
||||
: trialStatus === "failed"
|
||||
? "Failed"
|
||||
: trialStatus === "in_progress"
|
||||
? "In Progress"
|
||||
: "Not Started"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,7 +219,9 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
|
||||
|
||||
{/* Steps Timeline */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-slate-900 text-sm">Experiment Steps</h4>
|
||||
<h4 className="text-sm font-medium text-slate-900">
|
||||
Experiment Steps
|
||||
</h4>
|
||||
|
||||
<div className="space-y-3">
|
||||
{steps.map((step, index) => {
|
||||
@@ -201,9 +236,10 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
|
||||
{/* Connection Line */}
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={`absolute left-6 top-12 w-0.5 h-6 ${
|
||||
className={`absolute top-12 left-6 h-6 w-0.5 ${
|
||||
getStepStatus(index + 1) === "completed" ||
|
||||
(getStepStatus(index + 1) === "active" && status === "completed")
|
||||
(getStepStatus(index + 1) === "active" &&
|
||||
status === "completed")
|
||||
? "bg-green-300"
|
||||
: "bg-slate-300"
|
||||
}`}
|
||||
@@ -211,57 +247,76 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
|
||||
)}
|
||||
|
||||
{/* Step Card */}
|
||||
<div className={`flex items-start space-x-3 p-3 rounded-lg border transition-all ${
|
||||
status === "active"
|
||||
? `${statusConfig.bgColor} ${statusConfig.borderColor} shadow-md ring-2 ring-blue-200`
|
||||
: status === "completed"
|
||||
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
|
||||
: status === "aborted"
|
||||
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
|
||||
: "bg-slate-50 border-slate-200"
|
||||
}`}>
|
||||
<div
|
||||
className={`flex items-start space-x-3 rounded-lg border p-3 transition-all ${
|
||||
status === "active"
|
||||
? `${statusConfig.bgColor} ${statusConfig.borderColor} shadow-md ring-2 ring-blue-200`
|
||||
: status === "completed"
|
||||
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
|
||||
: status === "aborted"
|
||||
? `${statusConfig.bgColor} ${statusConfig.borderColor}`
|
||||
: "border-slate-200 bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
{/* Step Number & Status */}
|
||||
<div className="flex-shrink-0 space-y-1">
|
||||
<div className={`w-12 h-8 rounded-lg flex items-center justify-center ${
|
||||
status === "active" ? statusConfig.bgColor :
|
||||
status === "completed" ? "bg-green-100" :
|
||||
status === "aborted" ? "bg-red-100" :
|
||||
"bg-slate-100"
|
||||
}`}>
|
||||
<span className={`text-sm font-medium ${
|
||||
status === "active" ? statusConfig.textColor :
|
||||
status === "completed" ? "text-green-700" :
|
||||
status === "aborted" ? "text-red-700" :
|
||||
"text-slate-600"
|
||||
}`}>
|
||||
<div
|
||||
className={`flex h-8 w-12 items-center justify-center rounded-lg ${
|
||||
status === "active"
|
||||
? statusConfig.bgColor
|
||||
: status === "completed"
|
||||
? "bg-green-100"
|
||||
: status === "aborted"
|
||||
? "bg-red-100"
|
||||
: "bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
status === "active"
|
||||
? statusConfig.textColor
|
||||
: status === "completed"
|
||||
? "text-green-700"
|
||||
: status === "aborted"
|
||||
? "text-red-700"
|
||||
: "text-slate-600"
|
||||
}`}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<StatusIcon className={`h-4 w-4 ${statusConfig.iconColor}`} />
|
||||
<StatusIcon
|
||||
className={`h-4 w-4 ${statusConfig.iconColor}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h5 className={`font-medium truncate ${
|
||||
status === "active" ? "text-slate-900" :
|
||||
status === "completed" ? "text-green-900" :
|
||||
status === "aborted" ? "text-red-900" :
|
||||
"text-slate-700"
|
||||
}`}>
|
||||
<h5
|
||||
className={`truncate font-medium ${
|
||||
status === "active"
|
||||
? "text-slate-900"
|
||||
: status === "completed"
|
||||
? "text-green-900"
|
||||
: status === "aborted"
|
||||
? "text-red-900"
|
||||
: "text-slate-700"
|
||||
}`}
|
||||
>
|
||||
{step.name}
|
||||
</h5>
|
||||
{step.description && (
|
||||
<p className="text-sm text-slate-600 mt-1 line-clamp-2">
|
||||
<p className="mt-1 line-clamp-2 text-sm text-slate-600">
|
||||
{step.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 ml-3 space-y-1">
|
||||
<div className="ml-3 flex-shrink-0 space-y-1">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${stepConfig.textColor} ${stepConfig.borderColor}`}
|
||||
@@ -280,19 +335,19 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
|
||||
|
||||
{/* Step Status Message */}
|
||||
{status === "active" && trialStatus === "in_progress" && (
|
||||
<div className="flex items-center space-x-1 mt-2 text-sm text-blue-600">
|
||||
<div className="mt-2 flex items-center space-x-1 text-sm text-blue-600">
|
||||
<Activity className="h-3 w-3 animate-pulse" />
|
||||
<span>Currently executing...</span>
|
||||
</div>
|
||||
)}
|
||||
{status === "active" && trialStatus === "scheduled" && (
|
||||
<div className="flex items-center space-x-1 mt-2 text-sm text-amber-600">
|
||||
<div className="mt-2 flex items-center space-x-1 text-sm text-amber-600">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>Ready to start</span>
|
||||
</div>
|
||||
)}
|
||||
{status === "completed" && (
|
||||
<div className="flex items-center space-x-1 mt-2 text-sm text-green-600">
|
||||
<div className="mt-2 flex items-center space-x-1 text-sm text-green-600">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
<span>Completed</span>
|
||||
</div>
|
||||
@@ -309,7 +364,9 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
|
||||
<Separator />
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-600">{completedSteps}</div>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{completedSteps}
|
||||
</div>
|
||||
<div className="text-xs text-slate-600">Completed</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -320,7 +377,9 @@ export function TrialProgress({ steps, currentStepIndex, trialStatus }: TrialPro
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-600">
|
||||
{steps.length - completedSteps - (trialStatus === "in_progress" ? 1 : 0)}
|
||||
{steps.length -
|
||||
completedSteps -
|
||||
(trialStatus === "in_progress" ? 1 : 0)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-600">Remaining</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user