Add scheduling-remote service to docker-compose and enhance camera error handling

- Introduced a new service for scheduling-remote in docker-compose.yml, allowing for better management of scheduling functionalities.
- Enhanced error handling in CameraMonitor and CameraStreamer classes to improve robustness during camera initialization and streaming processes.
- Updated various components in the management dashboard to support dark mode and improve user experience with consistent styling.
- Implemented feature flags for enabling/disabling modules, including the new scheduling module.
This commit is contained in:
salirezav
2025-11-02 19:33:13 -05:00
parent f6a37ca1ba
commit 868aa3f036
33 changed files with 7471 additions and 136 deletions

View File

@@ -196,7 +196,13 @@ class CameraMonitor:
self.logger.info(f"Camera {camera_name} initialized successfully, starting test capture...")
except mvsdk.CameraException as init_e:
self.logger.warning(f"CameraInit failed for {camera_name}: {init_e.message} (error_code: {init_e.error_code})")
return "error", f"Camera initialization failed: {init_e.message}", self._get_device_info_dict(device_info)
# Get device info dict before returning - wrap in try/except in case device_info is corrupted
try:
device_info_dict = self._get_device_info_dict(device_info)
except Exception as dev_info_e:
self.logger.warning(f"Failed to get device info dict after CameraInit failure: {dev_info_e}")
device_info_dict = None
return "error", f"Camera initialization failed: {init_e.message}", device_info_dict
# Quick test - try to get one frame
try:
@@ -232,10 +238,38 @@ class CameraMonitor:
def _get_device_info_dict(self, device_info) -> Dict[str, Any]:
"""Convert device info to dictionary"""
if device_info is None:
return {"error": "device_info is None"}
try:
return {"friendly_name": device_info.GetFriendlyName(), "port_type": device_info.GetPortType(), "serial_number": getattr(device_info, "acSn", "Unknown"), "last_checked": time.time()}
# Safely access device info methods - wrap each in try/except to prevent segfaults
friendly_name = "Unknown"
port_type = "Unknown"
serial_number = "Unknown"
try:
friendly_name = device_info.GetFriendlyName()
except Exception as e:
self.logger.warning(f"Failed to get friendly name: {e}")
try:
port_type = device_info.GetPortType()
except Exception as e:
self.logger.warning(f"Failed to get port type: {e}")
try:
serial_number = getattr(device_info, "acSn", "Unknown")
except Exception as e:
self.logger.warning(f"Failed to get serial number: {e}")
return {
"friendly_name": friendly_name,
"port_type": port_type,
"serial_number": serial_number,
"last_checked": time.time()
}
except Exception as e:
self.logger.error(f"Error getting device info: {e}")
self.logger.error(f"Error getting device info: {e}", exc_info=True)
return {"error": str(e)}
def check_camera_now(self, camera_name: str) -> Dict[str, Any]:

View File

@@ -273,33 +273,99 @@ class CameraStreamer:
return False
# Initialize camera (suppress output to avoid MVCAMAPI error messages)
with suppress_camera_errors():
self.hCamera = mvsdk.CameraInit(self.device_info, -1, -1)
self.logger.info("Camera initialized successfully for streaming")
try:
with suppress_camera_errors():
self.hCamera = mvsdk.CameraInit(self.device_info, -1, -1)
self.logger.info("Camera initialized successfully for streaming")
except mvsdk.CameraException as init_e:
self.logger.error(f"CameraInit failed for streaming {self.camera_config.name}: {init_e.message} (error_code: {init_e.error_code})")
self.hCamera = None
return False
# Ensure hCamera is valid before proceeding
if self.hCamera is None:
self.logger.error("Camera initialization returned None handle")
return False
# Get camera capabilities
self.cap = mvsdk.CameraGetCapability(self.hCamera)
try:
self.cap = mvsdk.CameraGetCapability(self.hCamera)
except mvsdk.CameraException as cap_e:
self.logger.error(f"CameraGetCapability failed for {self.camera_config.name}: {cap_e.message} (error_code: {cap_e.error_code})")
if self.hCamera:
try:
mvsdk.CameraUnInit(self.hCamera)
except:
pass
self.hCamera = None
return False
# Determine if camera is monochrome
self.monoCamera = self.cap.sIspCapacity.bMonoSensor != 0
# Set output format based on camera type and bit depth
if self.monoCamera:
mvsdk.CameraSetIspOutFormat(self.hCamera, mvsdk.CAMERA_MEDIA_TYPE_MONO8)
else:
mvsdk.CameraSetIspOutFormat(self.hCamera, mvsdk.CAMERA_MEDIA_TYPE_BGR8)
try:
if self.monoCamera:
mvsdk.CameraSetIspOutFormat(self.hCamera, mvsdk.CAMERA_MEDIA_TYPE_MONO8)
else:
mvsdk.CameraSetIspOutFormat(self.hCamera, mvsdk.CAMERA_MEDIA_TYPE_BGR8)
except mvsdk.CameraException as fmt_e:
self.logger.error(f"CameraSetIspOutFormat failed for {self.camera_config.name}: {fmt_e.message} (error_code: {fmt_e.error_code})")
if self.hCamera:
try:
mvsdk.CameraUnInit(self.hCamera)
except:
pass
self.hCamera = None
return False
# Configure camera settings for streaming (optimized for preview)
self._configure_streaming_settings()
try:
self._configure_streaming_settings()
except Exception as config_e:
self.logger.error(f"Failed to configure streaming settings for {self.camera_config.name}: {config_e}")
if self.hCamera:
try:
mvsdk.CameraUnInit(self.hCamera)
except:
pass
self.hCamera = None
return False
# Allocate frame buffer
bytes_per_pixel = 1 if self.monoCamera else 3
self.frame_buffer_size = self.cap.sResolutionRange.iWidthMax * self.cap.sResolutionRange.iHeightMax * bytes_per_pixel
self.frame_buffer = mvsdk.CameraAlignMalloc(self.frame_buffer_size, 16)
try:
bytes_per_pixel = 1 if self.monoCamera else 3
self.frame_buffer_size = self.cap.sResolutionRange.iWidthMax * self.cap.sResolutionRange.iHeightMax * bytes_per_pixel
self.frame_buffer = mvsdk.CameraAlignMalloc(self.frame_buffer_size, 16)
except Exception as buf_e:
self.logger.error(f"Failed to allocate frame buffer for {self.camera_config.name}: {buf_e}")
if self.hCamera:
try:
mvsdk.CameraUnInit(self.hCamera)
except:
pass
self.hCamera = None
return False
# Start camera
mvsdk.CameraPlay(self.hCamera)
self.logger.info("Camera started successfully for streaming")
try:
mvsdk.CameraPlay(self.hCamera)
self.logger.info("Camera started successfully for streaming")
except mvsdk.CameraException as play_e:
self.logger.error(f"CameraPlay failed for {self.camera_config.name}: {play_e.message} (error_code: {play_e.error_code})")
if self.frame_buffer:
try:
mvsdk.CameraAlignFree(self.frame_buffer)
except:
pass
self.frame_buffer = None
if self.hCamera:
try:
mvsdk.CameraUnInit(self.hCamera)
except:
pass
self.hCamera = None
return False
return True

View File

@@ -104,6 +104,26 @@ services:
ports:
- "3002:3002"
scheduling-remote:
image: node:20-alpine
working_dir: /app
env_file:
- ./management-dashboard-web-app/.env
environment:
- CHOKIDAR_USEPOLLING=true
- TZ=America/New_York
volumes:
- ./scheduling-remote:/app
command: >
sh -lc "
npm install;
npm run dev:watch
"
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "3003:3003"
media-api:
build:
context: ./media-api

View File

@@ -5,6 +5,15 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Experiments Dashboard</title>
<script>
// Prevent flash of unstyled content by applying theme immediately
(function() {
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = stored || (prefersDark ? 'dark' : 'light');
document.documentElement.classList.add(theme);
})();
</script>
</head>
<body>
<div id="root"></div>

View File

@@ -115,7 +115,7 @@ function App() {
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading...</p>
@@ -127,7 +127,7 @@ function App() {
// Handle signout route
if (currentRoute === '/signout') {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Signing out...</p>

View File

@@ -7,7 +7,8 @@ import { ExperimentManagement } from './ExperimentManagement'
import { DataEntry } from './DataEntry'
// VisionSystem is now loaded as a microfrontend - see RemoteVisionSystem below
// import { VisionSystem } from './VisionSystem'
import { Scheduling } from './Scheduling'
// Scheduling is now loaded as a microfrontend - see RemoteScheduling below
// import { Scheduling } from './Scheduling'
import React, { Suspense } from 'react'
import { loadRemoteComponent } from '../lib/loadRemote'
import { ErrorBoundary } from './ErrorBoundary'
@@ -172,6 +173,13 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
LocalVisionSystemPlaceholder as any
) as unknown as React.ComponentType
const LocalSchedulingPlaceholder = () => (<div className="p-6">Scheduling module not enabled.</div>)
const RemoteScheduling = loadRemoteComponent(
isFeatureEnabled('enableSchedulingModule'),
() => import('schedulingRemote/App'),
LocalSchedulingPlaceholder as any
) as unknown as React.ComponentType<{ user: User; currentRoute: string }>
const renderCurrentView = () => {
if (!user) return null
@@ -216,7 +224,13 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
</ErrorBoundary>
)
case 'scheduling':
return <Scheduling user={user} currentRoute={currentRoute} />
return (
<ErrorBoundary fallback={<div className="p-6">Failed to load scheduling module. Please try again.</div>}>
<Suspense fallback={<div className="p-6">Loading scheduling module...</div>}>
<RemoteScheduling user={user} currentRoute={currentRoute} />
</Suspense>
</ErrorBoundary>
)
case 'video-library':
return (
<ErrorBoundary fallback={<div className="p-6">Failed to load video module. Please try again.</div>}>
@@ -234,7 +248,7 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-500 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading dashboard...</p>
@@ -245,7 +259,7 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="max-w-md w-full">
<div className="rounded-2xl bg-error-50 border border-error-200 p-4 dark:bg-error-500/15 dark:border-error-500/20">
<div className="text-sm text-error-700 dark:text-error-500">{error}</div>
@@ -263,7 +277,7 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
if (!user) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="text-gray-600 dark:text-gray-400">No user data available</div>
<button
@@ -298,7 +312,7 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
)}
</div>
<div
className={`flex-1 transition-all duration-300 ease-in-out ${isExpanded || isHovered ? "lg:ml-[290px]" : "lg:ml-[90px]"
className={`flex-1 transition-all duration-300 ease-in-out bg-gray-50 dark:bg-gray-900 ${isExpanded || isHovered ? "lg:ml-[290px]" : "lg:ml-[90px]"
} ${isMobileOpen ? "ml-0" : ""}`}
>
<TopNavbar

View File

@@ -98,7 +98,7 @@ export function DataEntry() {
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading experiments...</p>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading experiments...</p>
</div>
</div>
</div>
@@ -108,8 +108,8 @@ export function DataEntry() {
if (error) {
return (
<div className="p-6">
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="text-sm text-red-700">{error}</div>
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-4">
<div className="text-sm text-red-700 dark:text-red-400">{error}</div>
</div>
</div>
)
@@ -129,8 +129,8 @@ export function DataEntry() {
return (
<div className="p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Data Entry</h1>
<p className="mt-1 text-sm text-gray-500">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Data Entry</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Select a repetition to enter measurement data
</p>
</div>
@@ -142,13 +142,13 @@ export function DataEntry() {
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Past/Completed Repetitions */}
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900 flex items-center">
<div className="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg border border-gray-200 dark:border-gray-700">
<div className="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center">
<span className="w-4 h-4 bg-green-500 rounded-full mr-3"></span>
Past/Completed ({pastRepetitions.length})
</h2>
<p className="mt-1 text-sm text-gray-500">
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Completed or past scheduled repetitions
</p>
</div>
@@ -164,7 +164,7 @@ export function DataEntry() {
/>
))}
{pastRepetitions.length === 0 && (
<p className="text-sm text-gray-500 italic text-center py-8">
<p className="text-sm text-gray-500 dark:text-gray-400 italic text-center py-8">
No completed repetitions
</p>
)}
@@ -173,13 +173,13 @@ export function DataEntry() {
</div>
{/* In Progress Repetitions */}
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900 flex items-center">
<div className="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg border border-gray-200 dark:border-gray-700">
<div className="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center">
<span className="w-4 h-4 bg-blue-500 rounded-full mr-3"></span>
In Progress ({inProgressRepetitions.length})
</h2>
<p className="mt-1 text-sm text-gray-500">
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Currently scheduled or active repetitions
</p>
</div>
@@ -195,7 +195,7 @@ export function DataEntry() {
/>
))}
{inProgressRepetitions.length === 0 && (
<p className="text-sm text-gray-500 italic text-center py-8">
<p className="text-sm text-gray-500 dark:text-gray-400 italic text-center py-8">
No repetitions in progress
</p>
)}
@@ -204,13 +204,13 @@ export function DataEntry() {
</div>
{/* Upcoming Repetitions */}
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900 flex items-center">
<div className="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg border border-gray-200 dark:border-gray-700">
<div className="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center">
<span className="w-4 h-4 bg-yellow-500 rounded-full mr-3"></span>
Upcoming ({upcomingRepetitions.length})
</h2>
<p className="mt-1 text-sm text-gray-500">
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Future scheduled repetitions
</p>
</div>
@@ -226,7 +226,7 @@ export function DataEntry() {
/>
))}
{upcomingRepetitions.length === 0 && (
<p className="text-sm text-gray-500 italic text-center py-8">
<p className="text-sm text-gray-500 dark:text-gray-400 italic text-center py-8">
No upcoming repetitions
</p>
)}
@@ -239,7 +239,7 @@ export function DataEntry() {
{experiments.length === 0 && (
<div className="text-center py-12">
<div className="text-gray-500">
<div className="text-gray-500 dark:text-gray-400">
No experiments available for data entry
</div>
</div>
@@ -291,35 +291,35 @@ function RepetitionCard({ experiment, repetition, onSelect, status }: Repetition
<div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-3">
{/* Large, bold experiment number */}
<span className="text-2xl font-bold text-gray-900">
<span className="text-2xl font-bold text-gray-900 dark:text-white">
#{experiment.experiment_number}
</span>
{/* Smaller repetition number */}
<span className="text-lg font-semibold text-gray-700">
<span className="text-lg font-semibold text-gray-700 dark:text-gray-300">
Rep #{repetition.repetition_number}
</span>
<span className="text-lg">{getStatusIcon()}</span>
<span className="text-lg dark:text-white">{getStatusIcon()}</span>
</div>
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${repetition.scheduled_date
? 'bg-blue-100 text-blue-800'
: 'bg-yellow-100 text-yellow-800'
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300'
: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300'
}`}>
{repetition.scheduled_date ? 'scheduled' : 'pending'}
</span>
</div>
{/* Experiment details */}
<div className="text-sm text-gray-600 mb-2">
<div className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{experiment.soaking_duration_hr}h soaking {experiment.air_drying_time_min}min drying
</div>
{repetition.scheduled_date && (
<div className="text-sm text-gray-600 mb-2">
<strong>Scheduled:</strong> {new Date(repetition.scheduled_date).toLocaleString()}
<div className="text-sm text-gray-600 dark:text-gray-400 mb-2">
<strong className="dark:text-white">Scheduled:</strong> {new Date(repetition.scheduled_date).toLocaleString()}
</div>
)}
<div className="text-xs text-gray-500">
<div className="text-xs text-gray-500 dark:text-gray-400">
Click to enter data for this repetition
</div>
</button>

View File

@@ -61,9 +61,9 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
<div className="mb-8">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Experiment Phases</h1>
<p className="mt-2 text-gray-600">Select an experiment phase to view and manage its experiments</p>
<p className="mt-2 text-gray-600">Experiment phases help organize experiments into logical groups for easier navigation and management.</p>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Experiment Phases</h1>
<p className="mt-2 text-gray-600 dark:text-gray-400">Select an experiment phase to view and manage its experiments</p>
<p className="mt-2 text-gray-600 dark:text-gray-400">Experiment phases help organize experiments into logical groups for easier navigation and management.</p>
</div>
{canManagePhases && (
<button
@@ -78,8 +78,8 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
{/* Error Message */}
{error && (
<div className="mb-6 rounded-md bg-red-50 p-4">
<div className="text-sm text-red-700">{error}</div>
<div className="mb-6 rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-4">
<div className="text-sm text-red-700 dark:text-red-400">{error}</div>
</div>
)}
@@ -89,30 +89,30 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
<div
key={phase.id}
onClick={() => onPhaseSelect(phase)}
className="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-gray-200 hover:border-blue-300"
className="bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-600"
>
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center">
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900/20 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
</div>
</div>
<div className="text-right">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300">
Active
</span>
</div>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
{phase.name}
</h3>
{phase.description && (
<p className="text-sm text-gray-600 mb-4 line-clamp-2">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">
{phase.description}
</p>
)}
@@ -121,31 +121,31 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
<div className="mb-4">
<div className="flex flex-wrap gap-1">
{phase.has_soaking && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300">
🌰 Soaking
</span>
)}
{phase.has_airdrying && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300">
💨 Air-Drying
</span>
)}
{phase.has_cracking && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300">
🔨 Cracking
</span>
)}
{phase.has_shelling && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300">
📊 Shelling
</span>
)}
</div>
</div>
<div className="flex items-center justify-between text-sm text-gray-500">
<div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
<span>Created {new Date(phase.created_at).toLocaleDateString()}</span>
<div className="flex items-center text-blue-600 hover:text-blue-800">
<div className="flex items-center text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">
<span className="mr-1">View Experiments</span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
@@ -159,11 +159,11 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
{phases.length === 0 && (
<div className="text-center py-12">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg className="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No experiment phases found</h3>
<p className="mt-1 text-sm text-gray-500">
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-white">No experiment phases found</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Get started by creating your first experiment phase.
</p>
{canManagePhases && (

View File

@@ -36,7 +36,7 @@ export function Login({ onLoginSuccess }: LoginProps) {
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">

View File

@@ -1,5 +1,6 @@
import { useState } from 'react'
import type { User } from '../lib/supabase'
import { useTheme } from '../hooks/useTheme'
interface TopNavbarProps {
user: User
@@ -19,6 +20,7 @@ export function TopNavbar({
onNavigateToProfile
}: TopNavbarProps) {
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false)
const { theme, toggleTheme } = useTheme()
const getPageTitle = (view: string) => {
switch (view) {
@@ -139,6 +141,48 @@ export function TopNavbar({
</div>
<div className="flex items-center justify-between w-full gap-4 px-5 py-4 lg:flex shadow-theme-md lg:justify-end lg:px-0 lg:shadow-none">
{/* Theme Toggle Button */}
<button
onClick={toggleTheme}
className="flex items-center justify-center w-10 h-10 text-gray-500 border border-gray-200 rounded-lg hover:bg-gray-100 dark:border-gray-800 dark:text-gray-400 dark:hover:bg-white/5 transition-colors"
aria-label="Toggle theme"
title={theme === 'light' ? 'Switch to dark mode' : 'Switch to light mode'}
>
{theme === 'light' ? (
// Moon icon for dark mode
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
) : (
// Sun icon for light mode
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
)}
</button>
{/* User Area */}
<div className="relative">
<button

View File

@@ -103,15 +103,15 @@ export function UserManagement() {
const getRoleBadgeColor = (role: string) => {
switch (role) {
case 'admin':
return 'bg-red-100 text-red-800'
return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300'
case 'conductor':
return 'bg-blue-100 text-blue-800'
return 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300'
case 'analyst':
return 'bg-green-100 text-green-800'
return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300'
case 'data recorder':
return 'bg-purple-100 text-purple-800'
return 'bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300'
default:
return 'bg-gray-100 text-gray-800'
return 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-300'
}
}
@@ -120,7 +120,7 @@ export function UserManagement() {
<div className="p-6">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading users...</p>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading users...</p>
</div>
</div>
)
@@ -129,8 +129,8 @@ export function UserManagement() {
if (error) {
return (
<div className="p-6">
<div className="rounded-md bg-red-50 p-4">
<div className="text-sm text-red-700">{error}</div>
<div className="rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-4">
<div className="text-sm text-red-700 dark:text-red-400">{error}</div>
</div>
<button
onClick={loadData}
@@ -148,8 +148,8 @@ export function UserManagement() {
<div className="mb-8">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">User Management</h1>
<p className="mt-2 text-gray-600">Manage user accounts, roles, and permissions</p>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">User Management</h1>
<p className="mt-2 text-gray-600 dark:text-gray-400">Manage user accounts, roles, and permissions</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
@@ -162,7 +162,7 @@ export function UserManagement() {
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg border border-gray-200 dark:border-gray-700">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
@@ -170,15 +170,15 @@ export function UserManagement() {
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Total Users</dt>
<dd className="text-lg font-medium text-gray-900">{users.length}</dd>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">Total Users</dt>
<dd className="text-lg font-medium text-gray-900 dark:text-white">{users.length}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg border border-gray-200 dark:border-gray-700">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
@@ -186,8 +186,8 @@ export function UserManagement() {
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Active Users</dt>
<dd className="text-lg font-medium text-gray-900">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">Active Users</dt>
<dd className="text-lg font-medium text-gray-900 dark:text-white">
{users.filter(u => u.status === 'active').length}
</dd>
</dl>
@@ -196,7 +196,7 @@ export function UserManagement() {
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg border border-gray-200 dark:border-gray-700">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
@@ -204,8 +204,8 @@ export function UserManagement() {
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Disabled Users</dt>
<dd className="text-lg font-medium text-gray-900">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">Disabled Users</dt>
<dd className="text-lg font-medium text-gray-900 dark:text-white">
{users.filter(u => u.status === 'disabled').length}
</dd>
</dl>
@@ -214,7 +214,7 @@ export function UserManagement() {
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg border border-gray-200 dark:border-gray-700">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
@@ -222,8 +222,8 @@ export function UserManagement() {
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Admins</dt>
<dd className="text-lg font-medium text-gray-900">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">Admins</dt>
<dd className="text-lg font-medium text-gray-900 dark:text-white">
{users.filter(u => u.roles.includes('admin')).length}
</dd>
</dl>
@@ -234,35 +234,35 @@ export function UserManagement() {
</div>
{/* Users Table */}
<div className="bg-white shadow overflow-hidden sm:rounded-md">
<div className="px-4 py-5 sm:px-6">
<h3 className="text-lg leading-6 font-medium text-gray-900">Users</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500">
<div className="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-md border border-gray-200 dark:border-gray-700">
<div className="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">Users</h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500 dark:text-gray-400">
Click on any field to edit user details
</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Roles
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{users.map((user) => (
<UserRow
key={user.id}
@@ -352,11 +352,11 @@ function UserRow({
type="email"
value={editEmail}
onChange={(e) => setEditEmail(e.target.value)}
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
className="block w-full border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
) : (
<div
className="text-sm text-gray-900 cursor-pointer hover:text-blue-600"
className="text-sm text-gray-900 dark:text-white cursor-pointer hover:text-blue-600 dark:hover:text-blue-400"
onClick={onEdit}
>
{user.email}
@@ -374,7 +374,7 @@ function UserRow({
onChange={() => handleRoleToggle(role.name)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{role.name}</span>
<span className="ml-2 text-sm text-gray-700 dark:text-gray-300">{role.name}</span>
</label>
))}
</div>
@@ -398,14 +398,14 @@ function UserRow({
<button
onClick={() => onStatusToggle(user.id, user.status)}
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${user.status === 'active'
? 'bg-green-100 text-green-800 hover:bg-green-200'
: 'bg-red-100 text-red-800 hover:bg-red-200'
? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-900/40'
: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-900/40'
}`}
>
{user.status.charAt(0).toUpperCase() + user.status.slice(1)}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{new Date(user.created_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
@@ -413,13 +413,13 @@ function UserRow({
<div className="flex space-x-2">
<button
onClick={handleSave}
className="text-blue-600 hover:text-blue-900"
className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300"
>
Save
</button>
<button
onClick={onCancel}
className="text-gray-600 hover:text-gray-900"
className="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
>
Cancel
</button>
@@ -428,7 +428,7 @@ function UserRow({
<div className="flex space-x-2">
<button
onClick={onEdit}
className="p-1 text-blue-600 hover:text-blue-900 hover:bg-blue-50 rounded"
className="p-1 text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
title="Edit user"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -437,7 +437,7 @@ function UserRow({
</button>
<button
onClick={() => onPasswordReset(user.id, user.email)}
className="p-1 text-orange-600 hover:text-orange-900 hover:bg-orange-50 rounded"
className="p-1 text-orange-600 dark:text-orange-400 hover:text-orange-900 dark:hover:text-orange-300 hover:bg-orange-50 dark:hover:bg-orange-900/20 rounded"
title="Reset password to 'password123'"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -0,0 +1,44 @@
import { useState, useEffect } from 'react'
type Theme = 'light' | 'dark'
// Initialize theme on script load (before React renders)
function initializeTheme(): Theme {
const stored = localStorage.getItem('theme') as Theme | null
if (stored) {
return stored
}
// Check system preference
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark'
}
return 'light'
}
// Apply theme class to document root
function applyTheme(theme: Theme) {
const root = window.document.documentElement
root.classList.remove('light', 'dark')
root.classList.add(theme)
}
// Initialize theme immediately on module load
const initialTheme = initializeTheme()
applyTheme(initialTheme)
export function useTheme() {
const [theme, setTheme] = useState<Theme>(initialTheme)
useEffect(() => {
// Apply theme whenever it changes
applyTheme(theme)
localStorage.setItem('theme', theme)
}, [theme])
const toggleTheme = () => {
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'))
}
return { theme, toggleTheme }
}

View File

@@ -188,7 +188,7 @@
}
body {
@apply relative font-normal font-outfit z-1 bg-gray-50;
@apply relative font-normal font-outfit z-1 bg-gray-50 dark:bg-gray-900;
}
}

View File

@@ -4,6 +4,7 @@ export type FeatureFlags = {
enableExperimentModule: boolean
enableCameraModule: boolean
enableVisionSystemModule: boolean
enableSchedulingModule: boolean
}
const toBool = (v: unknown, fallback = false): boolean => {
@@ -21,6 +22,7 @@ export const featureFlags: FeatureFlags = {
enableExperimentModule: toBool(import.meta.env.VITE_ENABLE_EXPERIMENT_MODULE ?? false),
enableCameraModule: toBool(import.meta.env.VITE_ENABLE_CAMERA_MODULE ?? false),
enableVisionSystemModule: toBool(import.meta.env.VITE_ENABLE_VISION_SYSTEM_MODULE ?? false),
enableSchedulingModule: toBool(import.meta.env.VITE_ENABLE_SCHEDULING_MODULE ?? false),
}
export const isFeatureEnabled = (flag: keyof FeatureFlags): boolean => featureFlags[flag]

View File

@@ -20,7 +20,8 @@ export default defineConfig({
remotes: {
// Allow overriding by env; default to localhost for dev
videoRemote: process.env.VITE_VIDEO_REMOTE_URL || 'http://localhost:3001/assets/remoteEntry.js',
visionSystemRemote: process.env.VITE_VISION_SYSTEM_REMOTE_URL || 'http://localhost:3002/assets/remoteEntry.js'
visionSystemRemote: process.env.VITE_VISION_SYSTEM_REMOTE_URL || 'http://localhost:3002/assets/remoteEntry.js',
schedulingRemote: process.env.VITE_SCHEDULING_REMOTE_URL || 'http://localhost:3003/assets/remoteEntry.js'
},
shared: {
react: { singleton: true, eager: true },

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Scheduling Module</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4494
scheduling-remote/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
{
"name": "scheduling-remote",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:watch": "vite build --watch",
"serve:dist": "serve -s dist -l 3003",
"preview": "vite preview --port 3003",
"dev:watch": "npm run build && (npm run build:watch &) && sleep 1 && npx http-server dist -p 3003 --cors -c-1"
},
"dependencies": {
"@supabase/supabase-js": "^2.52.0",
"moment": "^2.30.1",
"react": "^19.1.0",
"react-big-calendar": "^1.19.4",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@originjs/vite-plugin-federation": "^1.3.3",
"@tailwindcss/vite": "^4.1.11",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^4.6.0",
"http-server": "^14.1.1",
"serve": "^14.2.3",
"tailwindcss": "^4.1.11",
"typescript": "~5.8.3",
"vite": "^7.0.4"
}
}

View File

@@ -0,0 +1,29 @@
import React from 'react'
import { Scheduling } from './components/Scheduling'
import type { User } from './services/supabase'
interface AppProps {
user?: User
currentRoute?: string
}
export default function App(props: AppProps) {
// Get user and route from props or try to get from window (for standalone testing)
const user = props.user || (window as any).__SCHEDULING_USER__
const currentRoute = props.currentRoute || window.location.pathname
if (!user) {
return (
<div className="p-6">
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4">
<div className="text-sm text-yellow-700">
User information is required to use the scheduling module. Please ensure you are logged in.
</div>
</div>
</div>
)
}
return <Scheduling user={user} currentRoute={currentRoute} />
}

View File

@@ -0,0 +1,250 @@
/* Custom styles for React Big Calendar to match dashboard theme */
.rbc-calendar {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
/* Dark mode support */
.dark .rbc-calendar {
background: #1f2937;
color: #f9fafb;
}
/* Header styling */
.rbc-header {
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
color: #374151;
font-weight: 600;
padding: 12px 8px;
}
.dark .rbc-header {
background: #374151;
border-bottom: 1px solid #4b5563;
color: #f9fafb;
}
/* Today styling */
.rbc-today {
background: #eff6ff;
}
.dark .rbc-today {
background: #1e3a8a;
}
/* Date cells */
.rbc-date-cell {
color: #374151;
font-weight: 500;
}
.dark .rbc-date-cell {
color: #f9fafb;
}
/* Event styling */
.rbc-event {
border-radius: 4px;
border: none;
font-size: 12px;
font-weight: 500;
padding: 2px 4px;
}
.rbc-event-content {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Month view specific */
.rbc-month-view {
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.dark .rbc-month-view {
border: 1px solid #4b5563;
}
.rbc-month-row {
border-bottom: 1px solid #e2e8f0;
}
.dark .rbc-month-row {
border-bottom: 1px solid #4b5563;
}
.rbc-date-cell {
padding: 8px;
}
/* Week and day view specific */
.rbc-time-view {
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.dark .rbc-time-view {
border: 1px solid #4b5563;
}
.rbc-time-header {
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.dark .rbc-time-header {
background: #374151;
border-bottom: 1px solid #4b5563;
}
.rbc-time-content {
background: white;
}
.dark .rbc-time-content {
background: #1f2937;
}
/* Time slots */
.rbc-time-slot {
border-top: 1px solid #f1f5f9;
color: #64748b;
}
.dark .rbc-time-slot {
border-top: 1px solid #374151;
color: #9ca3af;
}
/* Toolbar */
.rbc-toolbar {
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
padding: 12px 16px;
margin-bottom: 0;
}
.dark .rbc-toolbar {
background: #374151;
border-bottom: 1px solid #4b5563;
}
.rbc-toolbar button {
background: white;
border: 1px solid #d1d5db;
border-radius: 6px;
color: #374151;
font-weight: 500;
padding: 6px 12px;
transition: all 0.2s;
}
.rbc-toolbar button:hover {
background: #f3f4f6;
border-color: #9ca3af;
}
.rbc-toolbar button:active,
.rbc-toolbar button.rbc-active {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
.dark .rbc-toolbar button {
background: #1f2937;
border: 1px solid #4b5563;
color: #f9fafb;
}
.dark .rbc-toolbar button:hover {
background: #374151;
border-color: #6b7280;
}
.dark .rbc-toolbar button:active,
.dark .rbc-toolbar button.rbc-active {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
/* Labels */
.rbc-toolbar-label {
color: #111827;
font-size: 18px;
font-weight: 600;
}
.dark .rbc-toolbar-label {
color: #f9fafb;
}
/* Drag and drop improvements */
.rbc-event {
cursor: grab !important;
user-select: none;
}
.rbc-event:active {
cursor: grabbing !important;
transform: scale(1.05);
z-index: 1000 !important;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3) !important;
}
/* Improve event spacing and visibility */
.rbc-event-content {
pointer-events: none;
}
/* Better visual feedback for dragging */
.rbc-addons-dnd-dragging {
opacity: 0.8;
transform: rotate(2deg);
z-index: 1000 !important;
}
.rbc-addons-dnd-drag-preview {
background: rgba(255, 255, 255, 0.9) !important;
border: 2px dashed #3b82f6 !important;
border-radius: 8px !important;
padding: 8px 12px !important;
font-weight: bold !important;
color: #1f2937 !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
}
/* Improve event hover states */
.rbc-event:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2) !important;
}
/* Better spacing between events */
.rbc-time-slot {
min-height: 24px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.rbc-toolbar {
flex-direction: column;
gap: 8px;
}
.rbc-toolbar button {
font-size: 14px;
padding: 8px 12px;
}
.rbc-toolbar-label {
font-size: 16px;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
@import "tailwindcss";

View File

@@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,415 @@
import { createClient } from '@supabase/supabase-js'
// Supabase configuration from environment
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Supabase is not configured. Please set VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY in your environment (.env)')
}
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
// Type definitions
export type RoleName = 'admin' | 'conductor' | 'analyst' | 'data recorder'
export type UserStatus = 'active' | 'disabled'
export interface User {
id: string
email: string
first_name?: string
last_name?: string
roles: RoleName[]
status: UserStatus
created_at: string
updated_at: string
}
export interface ExperimentPhase {
id: string
name: string
description?: string | null
has_soaking: boolean
has_airdrying: boolean
has_cracking: boolean
has_shelling: boolean
cracking_machine_type_id?: string | null
created_at: string
updated_at: string
created_by: string
}
export interface Experiment {
id: string
experiment_number: number
reps_required: number
weight_per_repetition_lbs: number
phase_id?: string | null
created_at: string
updated_at: string
created_by: string
}
export interface ExperimentRepetition {
id: string
experiment_id: string
repetition_number: number
scheduled_date?: string | null
completion_status: boolean
created_at: string
updated_at: string
created_by: string
}
export interface Soaking {
id: string
experiment_id: string
repetition_id?: string | null
scheduled_start_time: string
actual_start_time?: string | null
soaking_duration_minutes: number
scheduled_end_time: string
actual_end_time?: string | null
created_at: string
updated_at: string
created_by: string
}
export interface Airdrying {
id: string
experiment_id: string
repetition_id?: string | null
scheduled_start_time: string
actual_start_time?: string | null
duration_minutes: number
scheduled_end_time: string
actual_end_time?: string | null
created_at: string
updated_at: string
created_by: string
}
export interface ConductorAvailability {
id: string
user_id: string
available_from: string
available_to: string
notes?: string | null
status: 'active' | 'cancelled'
created_at: string
updated_at: string
created_by: string
}
export interface CreateAvailabilityRequest {
available_from: string
available_to: string
notes?: string
}
export interface CreateRepetitionRequest {
experiment_id: string
repetition_number: number
scheduled_date?: string | null
}
export interface UpdateRepetitionRequest {
scheduled_date?: string | null
completion_status?: boolean
}
export interface CreateSoakingRequest {
experiment_id: string
repetition_id?: string | null
scheduled_start_time: string
soaking_duration_minutes: number
}
export interface CreateAirdryingRequest {
experiment_id: string
repetition_id?: string | null
scheduled_start_time: string
duration_minutes: number
}
export interface CreateCrackingRequest {
experiment_id: string
repetition_id?: string | null
machine_type_id: string
scheduled_start_time: string
}
// Service APIs
export const userManagement = {
async getAllUsers(): Promise<User[]> {
const { data: profiles, error: profilesError } = await supabase
.from('user_profiles')
.select(`
id,
email,
first_name,
last_name,
status,
created_at,
updated_at
`)
if (profilesError) throw profilesError
const usersWithRoles = await Promise.all(
profiles.map(async (profile) => {
const { data: userRoles, error: rolesError } = await supabase
.from('user_roles')
.select(`
roles!inner (
name
)
`)
.eq('user_id', profile.id)
if (rolesError) throw rolesError
return {
...profile,
roles: userRoles.map(ur => (ur.roles as any).name as RoleName)
}
})
)
return usersWithRoles
},
}
export const experimentPhaseManagement = {
async getAllExperimentPhases(): Promise<ExperimentPhase[]> {
const { data, error } = await supabase
.from('experiment_phases')
.select('*')
.order('created_at', { ascending: false })
if (error) throw error
return data
},
}
export const experimentManagement = {
async getExperimentsByPhaseId(phaseId: string): Promise<Experiment[]> {
const { data, error } = await supabase
.from('experiments')
.select('*')
.eq('phase_id', phaseId)
.order('created_at', { ascending: false })
if (error) throw error
return data
},
}
export const repetitionManagement = {
async getExperimentRepetitions(experimentId: string): Promise<ExperimentRepetition[]> {
const { data, error } = await supabase
.from('experiment_repetitions')
.select('*')
.eq('experiment_id', experimentId)
.order('repetition_number', { ascending: true })
if (error) throw error
return data
},
async createAllRepetitions(experimentId: string): Promise<ExperimentRepetition[]> {
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) throw new Error('User not authenticated')
// Get experiment to find reps_required
const { data: experiment, error: expError } = await supabase
.from('experiments')
.select('reps_required')
.eq('id', experimentId)
.single()
if (expError || !experiment) throw new Error('Experiment not found')
// Get existing repetitions to determine next repetition number
const existingReps = await this.getExperimentRepetitions(experimentId)
const nextRepNumber = existingReps.length > 0
? Math.max(...existingReps.map(r => r.repetition_number)) + 1
: 1
// Create all remaining repetitions
const repsToCreate = experiment.reps_required - existingReps.length
const newReps: ExperimentRepetition[] = []
for (let i = 0; i < repsToCreate; i++) {
const { data, error } = await supabase
.from('experiment_repetitions')
.insert({
experiment_id: experimentId,
repetition_number: nextRepNumber + i,
completion_status: false,
created_by: user.id
})
.select()
.single()
if (error) throw error
if (data) newReps.push(data)
}
return [...existingReps, ...newReps]
},
async updateRepetition(id: string, updates: UpdateRepetitionRequest): Promise<ExperimentRepetition> {
const { data, error } = await supabase
.from('experiment_repetitions')
.update(updates)
.eq('id', id)
.select()
.single()
if (error) throw error
return data
},
}
export const phaseManagement = {
async createSoaking(request: CreateSoakingRequest): Promise<Soaking> {
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) throw new Error('User not authenticated')
const scheduledEndTime = new Date(new Date(request.scheduled_start_time).getTime() + request.soaking_duration_minutes * 60000).toISOString()
const { data, error } = await supabase
.from('soaking')
.upsert({
...request,
scheduled_end_time: scheduledEndTime,
created_by: user.id
}, {
onConflict: 'experiment_id,repetition_id'
})
.select()
.single()
if (error) throw error
return data
},
async createAirdrying(request: CreateAirdryingRequest): Promise<Airdrying> {
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) throw new Error('User not authenticated')
const scheduledEndTime = new Date(new Date(request.scheduled_start_time).getTime() + request.duration_minutes * 60000).toISOString()
const { data, error } = await supabase
.from('airdrying')
.upsert({
...request,
scheduled_end_time: scheduledEndTime,
created_by: user.id
}, {
onConflict: 'experiment_id,repetition_id'
})
.select()
.single()
if (error) throw error
return data
},
async createCracking(request: CreateCrackingRequest): Promise<any> {
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) throw new Error('User not authenticated')
const { data, error } = await supabase
.from('cracking')
.upsert({
...request,
created_by: user.id
}, {
onConflict: 'experiment_id,repetition_id'
})
.select()
.single()
if (error) throw error
return data
},
async getSoakingByExperimentId(experimentId: string): Promise<Soaking | null> {
const { data, error } = await supabase
.from('soaking')
.select('*')
.eq('experiment_id', experimentId)
.is('repetition_id', null)
.single()
if (error) {
if (error.code === 'PGRST116') return null
throw error
}
return data
},
async getAirdryingByExperimentId(experimentId: string): Promise<Airdrying | null> {
const { data, error } = await supabase
.from('airdrying')
.select('*')
.eq('experiment_id', experimentId)
.is('repetition_id', null)
.single()
if (error) {
if (error.code === 'PGRST116') return null
throw error
}
return data
},
}
export const availabilityManagement = {
async getMyAvailability(): Promise<ConductorAvailability[]> {
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) throw new Error('User not authenticated')
const { data, error } = await supabase
.from('conductor_availability')
.select('*')
.eq('user_id', user.id)
.eq('status', 'active')
.order('available_from', { ascending: true })
if (error) throw error
return data
},
async createAvailability(request: CreateAvailabilityRequest): Promise<ConductorAvailability> {
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) throw new Error('User not authenticated')
const { data, error } = await supabase
.from('conductor_availability')
.insert({
user_id: user.id,
available_from: request.available_from,
available_to: request.available_to,
notes: request.notes,
created_by: user.id
})
.select()
.single()
if (error) throw error
return data
},
async deleteAvailability(id: string): Promise<void> {
const { error } = await supabase
.from('conductor_availability')
.update({ status: 'cancelled' })
.eq('id', id)
if (error) throw error
},
}

11
scheduling-remote/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_SUPABASE_URL?: string
readonly VITE_SUPABASE_ANON_KEY?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "src/vite-env.d.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,32 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import federation from '@originjs/vite-plugin-federation'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
react(),
tailwindcss(),
federation({
name: 'schedulingRemote',
filename: 'remoteEntry.js',
exposes: {
'./App': './src/App.tsx',
},
shared: {
react: { singleton: true, eager: true },
'react-dom': { singleton: true, eager: true },
},
}),
],
server: {
port: 3003,
host: '0.0.0.0',
allowedHosts: ['exp-dash', 'localhost'],
cors: true
},
build: {
target: 'esnext',
},
})

View File

@@ -0,0 +1,25 @@
#!/bin/bash
# Helper script to add dark mode classes to components
# This is a reference - actual changes should be made manually for safety
echo "Patterns to apply for dark mode:"
echo ""
echo "1. Backgrounds:"
echo " bg-white -> bg-white dark:bg-gray-800"
echo " bg-gray-50 -> bg-gray-50 dark:bg-gray-900"
echo ""
echo "2. Text:"
echo " text-gray-900 -> text-gray-900 dark:text-white"
echo " text-gray-800 -> text-gray-800 dark:text-white/90"
echo " text-gray-700 -> text-gray-700 dark:text-gray-300"
echo " text-gray-600 -> text-gray-600 dark:text-gray-400"
echo " text-gray-500 -> text-gray-500 dark:text-gray-400"
echo ""
echo "3. Borders:"
echo " border-gray-200 -> border-gray-200 dark:border-gray-700"
echo " border-gray-300 -> border-gray-300 dark:border-gray-600"
echo ""
echo "4. Buttons/Inputs:"
echo " bg-blue-100 -> bg-blue-100 dark:bg-blue-900/20"
echo " text-blue-800 -> text-blue-800 dark:text-blue-300"

View File

@@ -51,3 +51,5 @@ echo ""
echo "All services have been reset and are running in detached mode."
echo "Use 'docker compose logs -f' to view logs or 'docker compose ps' to check status."

View File

@@ -57,8 +57,10 @@ export default function App() {
setRecordings(recordingsData)
setLastUpdate(new Date())
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch data')
// Don't set error state - let widgets show API is unavailable
// Keep existing state so UI can still render
console.error('Failed to fetch initial data:', err)
setLastUpdate(new Date()) // Update timestamp even on error to show we tried
} finally {
setLoading(false)
}
@@ -221,12 +223,32 @@ export default function App() {
const handlePreviewModal = useCallback((cameraName: string) => {
setPreviewCamera(cameraName)
setPreviewModalOpen(true)
// The modal will start streaming and notify us via onStreamStarted callback
}, [])
const handlePreviewNewWindow = useCallback((cameraName: string) => {
// Open camera stream in new window/tab
const streamUrl = visionApi.getStreamUrl(cameraName)
window.open(streamUrl, '_blank')
const handlePreviewNewWindow = useCallback(async (cameraName: string) => {
try {
// Start streaming before opening new window
const result = await visionApi.startStream(cameraName)
if (result.success) {
// Immediately update camera status to show "Stop Streaming" button
setCameras((prev) => ({
...prev,
[cameraName]: {
...prev[cameraName],
status: 'streaming',
},
}))
// Open camera stream in new window/tab
const streamUrl = visionApi.getStreamUrl(cameraName)
window.open(streamUrl, '_blank')
} else {
setNotification({ type: 'error', message: `Failed to start stream: ${result.message}` })
}
} catch (err) {
setNotification({ type: 'error', message: err instanceof Error ? err.message : 'Failed to start stream' })
}
}, [])
const handleConfigure = useCallback((cameraName: string) => {
@@ -259,8 +281,20 @@ export default function App() {
const result = await visionApi.stopStream(cameraName)
if (result.success) {
setNotification({ type: 'success', message: 'Streaming stopped' })
// Refresh camera status
visionApi.getCameras().then(setCameras).catch(console.error)
// Immediately update camera status (UI updates instantly)
setCameras((prev) => ({
...prev,
[cameraName]: {
...prev[cameraName],
status: 'available',
},
}))
// Refresh camera status from API as backup
setTimeout(() => {
visionApi.getCameras().then(setCameras).catch(console.error)
}, 500)
} else {
setNotification({ type: 'error', message: `Failed: ${result.message}` })
}
@@ -438,6 +472,26 @@ export default function App() {
setPreviewModalOpen(false)
setPreviewCamera(null)
}}
onStreamStarted={() => {
// Update camera status when streaming starts
setCameras((prev) => ({
...prev,
[previewCamera]: {
...prev[previewCamera],
status: 'streaming',
},
}))
}}
onStreamStopped={() => {
// Update camera status when streaming stops
setCameras((prev) => ({
...prev,
[previewCamera]: {
...prev[previewCamera],
status: 'available',
},
}))
}}
/>
)}

View File

@@ -195,11 +195,11 @@ export const CameraCard: React.FC<CameraCardProps> = ({
{isStreaming && (
<button
onClick={() => onStopStreaming(cameraName)}
className="px-3 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 transition-colors"
className="flex items-center justify-center px-3 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 transition-colors"
title="Stop streaming"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}

View File

@@ -6,6 +6,8 @@ interface CameraPreviewModalProps {
isOpen: boolean
onClose: () => void
onError?: (error: string) => void
onStreamStarted?: () => void
onStreamStopped?: () => void
}
export const CameraPreviewModal: React.FC<CameraPreviewModalProps> = ({
@@ -13,6 +15,8 @@ export const CameraPreviewModal: React.FC<CameraPreviewModalProps> = ({
isOpen,
onClose,
onError,
onStreamStarted,
onStreamStopped,
}) => {
const [loading, setLoading] = useState(false)
const [streaming, setStreaming] = useState(false)
@@ -46,6 +50,9 @@ export const CameraPreviewModal: React.FC<CameraPreviewModalProps> = ({
if (imgRef.current) {
imgRef.current.src = `${streamUrl}?t=${Date.now()}`
}
// Notify parent that streaming started
onStreamStarted?.()
} else {
throw new Error(result.message)
}
@@ -68,6 +75,9 @@ export const CameraPreviewModal: React.FC<CameraPreviewModalProps> = ({
if (imgRef.current) {
imgRef.current.src = ''
}
// Notify parent that streaming stopped
onStreamStopped?.()
}
} catch (err) {
console.error('Error stopping stream:', err)
@@ -82,12 +92,13 @@ export const CameraPreviewModal: React.FC<CameraPreviewModalProps> = ({
if (!isOpen) return null
return (
<div className="fixed inset-0 z-[999999] flex items-center justify-center overflow-y-auto">
<div className="fixed inset-0 z-[999999] flex items-center justify-center overflow-y-auto p-4">
<div
className="fixed inset-0 h-full w-full bg-gray-900/60 backdrop-blur-sm"
className="fixed inset-0 h-full w-full bg-gray-900/70 backdrop-blur-md"
onClick={handleClose}
style={{ backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)' }}
/>
<div className="relative w-11/12 max-w-5xl rounded-xl bg-white shadow-2xl dark:bg-gray-800 p-6" onClick={(e) => e.stopPropagation()}>
<div className="relative w-full max-w-4xl rounded-xl bg-white shadow-2xl dark:bg-gray-800 p-6" onClick={(e) => e.stopPropagation()}>
{/* Close Button */}
<button
onClick={handleClose}
@@ -148,7 +159,7 @@ export const CameraPreviewModal: React.FC<CameraPreviewModalProps> = ({
<img
ref={imgRef}
alt={`Live stream from ${cameraName}`}
className="w-full h-auto max-h-[70vh] object-contain"
className="w-full h-auto max-h-[60vh] object-contain"
onError={() => setError('Failed to load camera stream')}
/>
</div>