diff --git a/.env.example b/.env.example index ae2e482..fee9436 100755 --- a/.env.example +++ b/.env.example @@ -16,11 +16,19 @@ AUTH_SECRET="" # Drizzle -DATABASE_URL="postgresql://postgres:password@localhost:5433/hristudio" +DATABASE_URL="postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@localhost:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-hristudio}" + +# PostgreSQL (used by docker-compose) +POSTGRES_USER="postgres" +POSTGRES_PASSWORD="postgres" +POSTGRES_DB="hristudio" +POSTGRES_PORT="5432" # MinIO/S3 Configuration -MINIO_ENDPOINT="http://localhost:9000" +MINIO_ENDPOINT="http://localhost:${MINIO_PORT_API:-9000}" MINIO_REGION="us-east-1" MINIO_ACCESS_KEY="minioadmin" MINIO_SECRET_KEY="minioadmin" MINIO_BUCKET_NAME="hristudio-data" +MINIO_PORT_API="9000" +MINIO_PORT_CONSOLE="9001" diff --git a/docker-compose.yml b/docker-compose.yml index 93f24d8..23f95a8 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,13 +2,13 @@ services: db: image: postgres:15 environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: hristudio + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-hristudio} PGSSLMODE: disable command: -c ssl=off ports: - - "5140:5432" + - "${POSTGRES_PORT:-5432}:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: @@ -20,11 +20,11 @@ services: minio: image: minio/minio ports: - - "9000:9000" # API - - "9001:9001" # Console + - "${MINIO_PORT_API:-9000}:9000" # API + - "${MINIO_PORT_CONSOLE:-9001}:9001" # Console environment: - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin + MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin} volumes: - minio_data:/data command: server --console-address ":9001" /data @@ -35,9 +35,9 @@ services: - minio entrypoint: > /bin/sh -c " - /usr/bin/mc alias set myminio http://minio:9000 minioadmin minioadmin; - /usr/bin/mc mb myminio/hristudio-data; - /usr/bin/mc anonymous set public myminio/hristudio-data; + /usr/bin/mc alias set myminio http://minio:9000 ${MINIO_ACCESS_KEY:-minioadmin} ${MINIO_SECRET_KEY:-minioadmin}; + /usr/bin/mc mb myminio/${MINIO_BUCKET_NAME:-hristudio-data}; + /usr/bin/mc anonymous set public myminio/${MINIO_BUCKET_NAME:-hristudio-data}; exit 0; " diff --git a/package.json b/package.json index 6d262ea..1c8fe44 100755 --- a/package.json +++ b/package.json @@ -5,22 +5,26 @@ "type": "module", "scripts": { "build": "next build", - "check": "next lint && tsc --noEmit", + "check": "eslint . && tsc --noEmit", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio", "db:seed": "bun db:push && bun scripts/seed-dev.ts", - "dev": "bun run ws-server.ts & next dev --turbo", + "db:reset": "docker compose rm -s -f -v db && docker compose up -d db && sleep 2 && bun db:seed", + "db:restart": "docker compose restart db", + "dev": "bun run dev:ws & next dev", "dev:ws": "bun run ws-server.ts", "docker:up": "if [ \"$(uname)\" = \"Darwin\" ]; then colima start; fi && docker compose up -d", "docker:down": "docker compose down && if [ \"$(uname)\" = \"Darwin\" ]; then colima stop; fi", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", "format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache", - "lint": "next lint", - "lint:fix": "next lint --fix", + "lint": "eslint .", + "lint:fix": "eslint . --fix", "preview": "next build && next start", - "start": "next start", + "start": "bun run start:ws & next start", + "start:ws": "bun run ws-server.ts", + "start:web": "next start", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/src/components/experiments/designer/DesignerRoot.tsx b/src/components/experiments/designer/DesignerRoot.tsx index 00d71c6..d6a0b04 100755 --- a/src/components/experiments/designer/DesignerRoot.tsx +++ b/src/components/experiments/designer/DesignerRoot.tsx @@ -1079,14 +1079,18 @@ export function DesignerRoot({ } } + const defExec = actionDef.execution as any; const execution: ExperimentAction["execution"] = - actionDef.execution && - (actionDef.execution.transport === "internal" || - actionDef.execution.transport === "rest" || - actionDef.execution.transport === "ros2") + defExec && + (defExec.transport === "internal" || + defExec.transport === "rest" || + defExec.transport === "ros2") ? { - transport: actionDef.execution.transport, - retryable: actionDef.execution.retryable ?? false, + transport: defExec.transport, + retryable: defExec.retryable ?? false, + timeoutMs: defExec.timeoutMs, + ros2: defExec.ros2, + rest: defExec.rest, } : undefined; diff --git a/src/components/experiments/designer/PropertiesPanel.tsx b/src/components/experiments/designer/PropertiesPanel.tsx index 8c49122..119968f 100755 --- a/src/components/experiments/designer/PropertiesPanel.tsx +++ b/src/components/experiments/designer/PropertiesPanel.tsx @@ -43,7 +43,14 @@ import { Plus, GitBranch, Trash2, + PlayCircle, + Square, + Loader2, + CheckCircle2, + XCircle, } from "lucide-react"; +import { toast } from "sonner"; +import { getWizardRosService, initWizardRosService, resetWizardRosService } from "~/lib/ros/wizard-ros-service"; /** * PropertiesPanel @@ -90,6 +97,10 @@ export function PropertiesPanelBase({ const [localStepDescription, setLocalStepDescription] = useState(""); const [localParams, setLocalParams] = useState>({}); + // Test action state + const [isTesting, setIsTesting] = useState(false); + const [testStatus, setTestStatus] = useState<"idle" | "running" | "success" | "error">("idle"); + // Debounce timers const actionUpdateTimer = useRef(undefined); const stepUpdateTimer = useRef(undefined); @@ -168,6 +179,74 @@ export function PropertiesPanelBase({ selectedAction && design.steps.find((s) => s.actions.some((a) => a.id === selectedAction.id)); + // Test action handler + const handleTestAction = useCallback(async () => { + if (!selectedAction || !containingStep) return; + + setIsTesting(true); + setTestStatus("running"); + + try { + console.log("[Test Action] Starting test for action:", selectedAction.name, selectedAction.type); + console.log("[Test Action] Execution config:", JSON.stringify(selectedAction.execution, null, 2)); + console.log("[Test Action] Parameters:", selectedAction.parameters); + + // Reset service to ensure clean state for testing + resetWizardRosService(); + + // Initialize with actual robot connection (not simulation) + const rosService = await initWizardRosService(false); + console.log("[Test Action] ROS service initialized, connected:", rosService.getConnectionStatus()); + + // Build action config from execution descriptor + const execution = selectedAction.execution; + let actionConfig: { + topic: string; + messageType: string; + payloadMapping: { + type: string; + payload?: Record; + transformFn?: string; + }; + } | undefined; + + if (execution?.transport === "ros2" && execution.ros2) { + const ros2 = execution.ros2 as any; + actionConfig = { + topic: ros2.topic || "/speech", + messageType: ros2.messageType || "std_msgs/msg/String", + payloadMapping: { + type: ros2.payloadMapping?.type || "static", + payload: ros2.payloadMapping?.payload, + transformFn: ros2.payloadMapping?.transformFn, + }, + }; + console.log("[Test Action] Action config built:", JSON.stringify(actionConfig, null, 2)); + } + + // Execute the action on the real robot + const result = await rosService.executeRobotAction( + selectedAction.source?.kind === "plugin" ? (selectedAction.source?.pluginId || "core") : "core", + selectedAction.type, + selectedAction.parameters, + actionConfig, + ); + console.log("[Test Action] Execution result:", result); + + setTestStatus("success"); + toast.success(`Action "${selectedAction.name}" executed on robot`); + } catch (error) { + setTestStatus("error"); + const message = error instanceof Error ? error.message : "Action execution failed"; + toast.error(message); + console.error("Test action error:", error); + } finally { + setIsTesting(false); + // Reset status after a delay + setTimeout(() => setTestStatus("idle"), 2000); + } + }, [selectedAction, containingStep]); + /* -------------------------- Action Properties View -------------------------- */ if (selectedAction && containingStep) { let def = registry.getAction(selectedAction.type); @@ -277,6 +356,41 @@ export function PropertiesPanelBase({ )} + {/* Test Action Button */} + {selectedAction.execution?.transport !== "internal" && ( +
+ +
+ )} + {/* General */}
diff --git a/src/components/trials/TrialForm.tsx b/src/components/trials/TrialForm.tsx index 4ac4442..38e430f 100755 --- a/src/components/trials/TrialForm.tsx +++ b/src/components/trials/TrialForm.tsx @@ -11,6 +11,7 @@ import { EntityForm, FormField, FormSection, + NextSteps, Tips, } from "~/components/ui/entity-form"; import { Input } from "~/components/ui/input"; @@ -26,7 +27,7 @@ import { Textarea } from "~/components/ui/textarea"; import { useStudyContext } from "~/lib/study-context"; import { api } from "~/trpc/react"; -import { Calendar as CalendarIcon, Clock } from "lucide-react"; +import { Calendar as CalendarIcon, Clock, Clock2 } from "lucide-react"; import { format } from "date-fns"; import { cn } from "~/lib/utils"; import { Button } from "~/components/ui/button"; @@ -84,60 +85,61 @@ function DateTimePicker({ return (
-
- - - - - - - - - + + + + + + + + + +
+ +
-
- -
- - -
-
+
); } const trialSchema = z.object({ - experimentId: z.string().uuid("Please select an experiment"), - participantId: z.string().uuid("Please select a participant"), - scheduledAt: z.date(), - wizardId: z.string().uuid().optional().or(z.literal("")), + experimentId: z.string().min(1, "Please select an experiment *"), + participantId: z.string().min(1, "Please select a participant *"), + scheduledAt: z.date({ message: "Scheduled date and time is required *" }), + wizardId: z.string().optional().or(z.literal("")), notes: z.string().max(1000, "Notes cannot exceed 1000 characters").optional(), sessionNumber: z .number() @@ -165,7 +167,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) { defaultValues: { experimentId: "" as any, participantId: "" as any, - scheduledAt: new Date(), + scheduledAt: undefined, wizardId: undefined, notes: "", sessionNumber: 1, @@ -329,6 +331,249 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) { return
Error loading trial: {fetchError.message}
; } + // Sidebar content + const sidebar = ( + <> + + + + ); + + // Form fields + const formFields = ( + <> + +
+ + + + {form.formState.errors.experimentId && ( +

+ {form.formState.errors.experimentId.message} +

+ )} + {mode === "edit" && ( +

+ Experiment cannot be changed after creation +

+ )} +
+ + + + + {form.formState.errors.participantId && ( +

+ {form.formState.errors.participantId.message} +

+ )} + {mode === "edit" && ( +

+ Participant cannot be changed after creation +

+ )} +
+
+
+ + +
+ + + ( + + )} + /> + {form.formState.errors.scheduledAt && ( +

+ {form.formState.errors.scheduledAt.message} +

+ )} +

+ When should this trial be conducted? +

+
+ + + + + {form.formState.errors.sessionNumber && ( +

+ {form.formState.errors.sessionNumber.message} +

+ )} +

+ Auto-incremented based on participant history +

+
+
+
+ + + + + +

+ Who will operate the robot during this trial? +

+
+ + + +