mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-02-05 07:56:30 -05:00
676 lines
20 KiB
TypeScript
Executable File
676 lines
20 KiB
TypeScript
Executable File
"use client";
|
|
|
|
import { format, formatDistanceToNow } from "date-fns";
|
|
import {
|
|
Activity,
|
|
AlertTriangle,
|
|
ArrowRight,
|
|
Bot,
|
|
Camera,
|
|
CheckCircle,
|
|
Eye,
|
|
Hand,
|
|
MessageSquare,
|
|
Pause,
|
|
Play,
|
|
Settings,
|
|
User,
|
|
Volume2,
|
|
XCircle,
|
|
} from "lucide-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?: WebSocketMessage[];
|
|
isWebSocketConnected?: boolean;
|
|
}
|
|
|
|
interface TrialEvent {
|
|
id: string;
|
|
trialId: string;
|
|
eventType: string;
|
|
timestamp: Date;
|
|
data: Record<string, unknown> | null;
|
|
notes: string | null;
|
|
createdAt: Date;
|
|
}
|
|
|
|
const eventTypeConfig = {
|
|
trial_started: {
|
|
label: "Trial Started",
|
|
icon: Play,
|
|
color: "text-green-600",
|
|
bgColor: "bg-green-100",
|
|
importance: "high",
|
|
},
|
|
trial_completed: {
|
|
label: "Trial Completed",
|
|
icon: CheckCircle,
|
|
color: "text-blue-600",
|
|
bgColor: "bg-blue-100",
|
|
importance: "high",
|
|
},
|
|
trial_aborted: {
|
|
label: "Trial Aborted",
|
|
icon: XCircle,
|
|
color: "text-red-600",
|
|
bgColor: "bg-red-100",
|
|
importance: "high",
|
|
},
|
|
step_transition: {
|
|
label: "Step Change",
|
|
icon: ArrowRight,
|
|
color: "text-purple-600",
|
|
bgColor: "bg-purple-100",
|
|
importance: "medium",
|
|
},
|
|
wizard_action: {
|
|
label: "Wizard Action",
|
|
icon: User,
|
|
color: "text-blue-600",
|
|
bgColor: "bg-blue-100",
|
|
importance: "medium",
|
|
},
|
|
robot_action: {
|
|
label: "Robot Action",
|
|
icon: Bot,
|
|
color: "text-green-600",
|
|
bgColor: "bg-green-100",
|
|
importance: "medium",
|
|
},
|
|
wizard_intervention: {
|
|
label: "Intervention",
|
|
icon: Hand,
|
|
color: "text-orange-600",
|
|
bgColor: "bg-orange-100",
|
|
importance: "high",
|
|
},
|
|
manual_intervention: {
|
|
label: "Manual Control",
|
|
icon: Hand,
|
|
color: "text-orange-600",
|
|
bgColor: "bg-orange-100",
|
|
importance: "high",
|
|
},
|
|
emergency_action: {
|
|
label: "Emergency",
|
|
icon: AlertTriangle,
|
|
color: "text-red-600",
|
|
bgColor: "bg-red-100",
|
|
importance: "critical",
|
|
},
|
|
emergency_stop: {
|
|
label: "Emergency Stop",
|
|
icon: AlertTriangle,
|
|
color: "text-red-600",
|
|
bgColor: "bg-red-100",
|
|
importance: "critical",
|
|
},
|
|
recording_control: {
|
|
label: "Recording",
|
|
icon: Camera,
|
|
color: "text-indigo-600",
|
|
bgColor: "bg-indigo-100",
|
|
importance: "low",
|
|
},
|
|
video_control: {
|
|
label: "Video Control",
|
|
icon: Camera,
|
|
color: "text-indigo-600",
|
|
bgColor: "bg-indigo-100",
|
|
importance: "low",
|
|
},
|
|
audio_control: {
|
|
label: "Audio Control",
|
|
icon: Volume2,
|
|
color: "text-indigo-600",
|
|
bgColor: "bg-indigo-100",
|
|
importance: "low",
|
|
},
|
|
pause_interaction: {
|
|
label: "Paused",
|
|
icon: Pause,
|
|
color: "text-yellow-600",
|
|
bgColor: "bg-yellow-100",
|
|
importance: "medium",
|
|
},
|
|
participant_response: {
|
|
label: "Participant",
|
|
icon: MessageSquare,
|
|
color: "text-slate-600",
|
|
bgColor: "bg-slate-100",
|
|
importance: "medium",
|
|
},
|
|
system_event: {
|
|
label: "System",
|
|
icon: Settings,
|
|
color: "text-slate-600",
|
|
bgColor: "bg-slate-100",
|
|
importance: "low",
|
|
},
|
|
annotation: {
|
|
label: "Annotation",
|
|
icon: MessageSquare,
|
|
color: "text-blue-600",
|
|
bgColor: "bg-blue-100",
|
|
importance: "medium",
|
|
},
|
|
default: {
|
|
label: "Event",
|
|
icon: Activity,
|
|
color: "text-slate-600",
|
|
bgColor: "bg-slate-100",
|
|
importance: "low",
|
|
},
|
|
};
|
|
|
|
export function EventsLog({
|
|
trialId,
|
|
refreshKey,
|
|
isLive,
|
|
maxEvents = 100,
|
|
realtimeEvents = [],
|
|
isWebSocketConnected = false,
|
|
}: EventsLogProps) {
|
|
const [events, setEvents] = useState<TrialEvent[]>([]);
|
|
const [isAutoScrollEnabled, setIsAutoScrollEnabled] = useState(true);
|
|
const [filter, setFilter] = useState<string>("all");
|
|
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Fetch trial events (less frequent when WebSocket is connected)
|
|
const { data: eventsData, isLoading } = api.trials.getEvents.useQuery(
|
|
{
|
|
trialId,
|
|
limit: maxEvents,
|
|
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
|
|
refetchOnWindowFocus: false,
|
|
enabled: !isWebSocketConnected || !isLive, // Reduce API calls when WebSocket is connected
|
|
},
|
|
);
|
|
|
|
// 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(() => {
|
|
let newEvents: TrialEvent[] = [];
|
|
|
|
// Add database events
|
|
if (eventsData) {
|
|
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),
|
|
}));
|
|
}
|
|
|
|
// Add real-time WebSocket events
|
|
if (realtimeEvents.length > 0) {
|
|
const wsEvents = realtimeEvents.map(convertWebSocketEvent);
|
|
newEvents = [...newEvents, ...wsEvents];
|
|
}
|
|
|
|
// Sort by timestamp and remove duplicates
|
|
const uniqueEvents = newEvents
|
|
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime())
|
|
.filter(
|
|
(event, index, arr) =>
|
|
index ===
|
|
arr.findIndex(
|
|
(e) =>
|
|
e.eventType === event.eventType &&
|
|
Math.abs(e.timestamp.getTime() - event.timestamp.getTime()) <
|
|
1000,
|
|
),
|
|
)
|
|
.slice(-maxEvents); // Keep only the most recent events
|
|
|
|
setEvents(uniqueEvents);
|
|
}, [
|
|
eventsData,
|
|
refreshKey,
|
|
realtimeEvents,
|
|
trialId,
|
|
maxEvents,
|
|
convertWebSocketEvent,
|
|
]);
|
|
|
|
// Auto-scroll to bottom when new events arrive
|
|
useEffect(() => {
|
|
if (isAutoScrollEnabled && bottomRef.current) {
|
|
bottomRef.current.scrollIntoView({ behavior: "smooth" });
|
|
}
|
|
}, [events, isAutoScrollEnabled]);
|
|
|
|
const getEventConfig = (eventType: string) => {
|
|
return (
|
|
eventTypeConfig[eventType as keyof typeof eventTypeConfig] ||
|
|
eventTypeConfig.default
|
|
);
|
|
};
|
|
|
|
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": {
|
|
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": {
|
|
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": {
|
|
const actionName = str("action_name") ?? "Robot action";
|
|
const hasParams =
|
|
typeof data.parameters !== "undefined" && data.parameters !== null;
|
|
return `${actionName}${hasParams ? " with parameters" : ""}`;
|
|
}
|
|
|
|
case "emergency_action": {
|
|
const emergency = str("emergency_type");
|
|
return `Emergency: ${
|
|
emergency ? emergency.replace(/_/g, " ") : "Unknown"
|
|
}`;
|
|
}
|
|
|
|
case "recording_control": {
|
|
const action = str("action");
|
|
return `Recording ${action === "start_recording" ? "started" : "stopped"}`;
|
|
}
|
|
|
|
case "video_control": {
|
|
const action = str("action");
|
|
return `Video ${action === "video_on" ? "enabled" : "disabled"}`;
|
|
}
|
|
|
|
case "audio_control": {
|
|
const action = str("action");
|
|
return `Audio ${action === "audio_on" ? "enabled" : "disabled"}`;
|
|
}
|
|
|
|
case "wizard_intervention": {
|
|
return (
|
|
str("content") ?? str("intervention_type") ?? "Intervention recorded"
|
|
);
|
|
}
|
|
|
|
default: {
|
|
const message = str("message");
|
|
if (message) return message;
|
|
const description = str("description");
|
|
if (description) return description;
|
|
return null;
|
|
}
|
|
}
|
|
};
|
|
|
|
const getEventImportanceOrder = (importance: string) => {
|
|
const order = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
return order[importance as keyof typeof order] || 4;
|
|
};
|
|
|
|
// Group events by time proximity (within 30 seconds)
|
|
const groupedEvents = events.reduce(
|
|
(groups: TrialEvent[][], event, index) => {
|
|
if (
|
|
index === 0 ||
|
|
Math.abs(
|
|
event.timestamp.getTime() -
|
|
(events[index - 1]?.timestamp.getTime() ?? 0),
|
|
) > 30000
|
|
) {
|
|
groups.push([event]);
|
|
} else {
|
|
groups[groups.length - 1]?.push(event);
|
|
}
|
|
return groups;
|
|
},
|
|
[],
|
|
);
|
|
|
|
// uniqueEventTypes removed (unused)
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
<div className="border-b border-slate-200 p-4">
|
|
<h3 className="flex items-center space-x-2 font-medium text-slate-900">
|
|
<Activity className="h-4 w-4" />
|
|
<span>Events Log</span>
|
|
</h3>
|
|
</div>
|
|
<div className="flex flex-1 items-center justify-center">
|
|
<div className="text-center">
|
|
<Activity className="mx-auto mb-2 h-6 w-6 animate-pulse text-slate-400" />
|
|
<p className="text-sm text-slate-500">Loading events...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
{/* Header */}
|
|
<div className="border-b border-slate-200 p-4">
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<h3 className="flex items-center space-x-2 font-medium text-slate-900">
|
|
<Activity className="h-4 w-4" />
|
|
<span>Events Log</span>
|
|
{isLive && (
|
|
<div className="flex items-center space-x-1">
|
|
<div
|
|
className={`h-2 w-2 animate-pulse rounded-full ${
|
|
isWebSocketConnected ? "bg-green-500" : "bg-red-500"
|
|
}`}
|
|
></div>
|
|
<span
|
|
className={`text-xs ${
|
|
isWebSocketConnected ? "text-green-600" : "text-red-600"
|
|
}`}
|
|
>
|
|
{isWebSocketConnected ? "REAL-TIME" : "LIVE"}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</h3>
|
|
<div className="flex items-center space-x-2">
|
|
<Badge variant="outline" className="text-xs">
|
|
{events.length} events
|
|
</Badge>
|
|
{isWebSocketConnected && (
|
|
<Badge className="bg-green-100 text-xs text-green-800">
|
|
Real-time
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filter Controls */}
|
|
<div className="flex items-center space-x-2">
|
|
<Button
|
|
variant={filter === "all" ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => setFilter("all")}
|
|
className="h-7 text-xs"
|
|
>
|
|
All
|
|
</Button>
|
|
<Button
|
|
variant={filter === "wizard_action" ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => setFilter("wizard_action")}
|
|
className="h-7 text-xs"
|
|
>
|
|
Wizard
|
|
</Button>
|
|
<Button
|
|
variant={filter === "robot_action" ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => setFilter("robot_action")}
|
|
className="h-7 text-xs"
|
|
>
|
|
Robot
|
|
</Button>
|
|
<Button
|
|
variant={filter === "emergency_action" ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => setFilter("emergency_action")}
|
|
className="h-7 text-xs"
|
|
>
|
|
Emergency
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Events List */}
|
|
<ScrollArea className="flex-1" ref={scrollAreaRef}>
|
|
<div className="space-y-4 p-4">
|
|
{events.length === 0 ? (
|
|
<div className="py-8 text-center">
|
|
<Activity className="mx-auto mb-2 h-8 w-8 text-slate-300" />
|
|
<p className="text-sm text-slate-500">No events yet</p>
|
|
<p className="mt-1 text-xs text-slate-400">
|
|
Events will appear here as the trial progresses
|
|
</p>
|
|
</div>
|
|
) : (
|
|
groupedEvents.map((group, groupIndex) => (
|
|
<div key={groupIndex} className="space-y-2">
|
|
{/* Time Header */}
|
|
<div className="flex items-center space-x-2">
|
|
<div className="text-xs font-medium text-slate-500">
|
|
{group[0] ? format(group[0].timestamp, "HH:mm:ss") : ""}
|
|
</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,
|
|
})
|
|
: ""}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Events in Group */}
|
|
{group
|
|
.sort(
|
|
(a, b) =>
|
|
getEventImportanceOrder(
|
|
getEventConfig(a.eventType).importance,
|
|
) -
|
|
getEventImportanceOrder(
|
|
getEventConfig(b.eventType).importance,
|
|
),
|
|
)
|
|
.map((event) => {
|
|
const config = getEventConfig(event.eventType);
|
|
const EventIcon = config.icon;
|
|
const eventData = formatEventData(
|
|
event.eventType,
|
|
event.data,
|
|
);
|
|
|
|
return (
|
|
<div
|
|
key={event.id}
|
|
className={`flex items-start space-x-3 rounded-lg border p-3 transition-colors ${
|
|
config.importance === "critical"
|
|
? "border-red-200 bg-red-50"
|
|
: config.importance === "high"
|
|
? "border-amber-200 bg-amber-50"
|
|
: "border-slate-200 bg-slate-50 hover:bg-slate-100"
|
|
}`}
|
|
>
|
|
<div
|
|
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full ${config.bgColor}`}
|
|
>
|
|
<EventIcon className={`h-3 w-3 ${config.color}`} />
|
|
</div>
|
|
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center space-x-2">
|
|
<span className="text-sm font-medium text-slate-900">
|
|
{config.label}
|
|
</span>
|
|
{config.importance === "critical" && (
|
|
<Badge variant="destructive" className="text-xs">
|
|
CRITICAL
|
|
</Badge>
|
|
)}
|
|
{config.importance === "high" && (
|
|
<Badge
|
|
variant="outline"
|
|
className="border-amber-300 text-xs text-amber-600"
|
|
>
|
|
HIGH
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
{eventData && (
|
|
<p className="mt-1 text-sm break-words text-slate-600">
|
|
{eventData}
|
|
</p>
|
|
)}
|
|
|
|
{event.notes && (
|
|
<p className="mt-1 text-xs text-slate-500 italic">
|
|
{event.notes}
|
|
</p>
|
|
)}
|
|
|
|
{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">
|
|
{format(event.timestamp, "HH:mm")}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
))
|
|
)}
|
|
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
{/* Auto-scroll Control */}
|
|
{events.length > 0 && (
|
|
<div className="border-t border-slate-200 p-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setIsAutoScrollEnabled(!isAutoScrollEnabled)}
|
|
className="w-full text-xs"
|
|
>
|
|
<Eye className="mr-1 h-3 w-3" />
|
|
Auto-scroll: {isAutoScrollEnabled ? "ON" : "OFF"}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|