Compare commits
15 Commits
5caa74fa27
...
86b4add6ab
Author | SHA1 | Date |
---|---|---|
|
86b4add6ab | |
|
8ac87c8f98 | |
|
3417056b06 | |
|
a4f74b70ed | |
|
0f2b9b8fb4 | |
|
e7cee49b1e | |
|
02b622cb20 | |
|
3a830fe100 | |
|
60db337284 | |
|
4481eb33a7 | |
|
609ae865de | |
|
e4908396be | |
|
696b79ba0d | |
|
e97cd5260b | |
|
405edd682e |
107
OFFLINE_USAGE.md
107
OFFLINE_USAGE.md
|
@ -1,107 +0,0 @@
|
|||
# Offline Usage Configuration
|
||||
|
||||
## Overview
|
||||
The application has been configured to work completely offline without any CDN dependencies.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Chart.js Libraries Migration
|
||||
**Before (CDN Dependencies):**
|
||||
- Chart.js loaded from `https://cdn.jsdelivr.net/npm/chart.js@3.9.1`
|
||||
- Luxon from `https://cdn.jsdelivr.net/npm/luxon@2`
|
||||
- Chart.js adapter from `https://cdn.jsdelivr.net/npm/chartjs-adapter-luxon@1.3.1`
|
||||
- Zoom plugin from `https://unpkg.com/chartjs-plugin-zoom@1.2.1`
|
||||
- Streaming plugin from `https://cdn.jsdelivr.net/npm/chartjs-plugin-streaming@2.0.0`
|
||||
|
||||
**After (NPM Dependencies):**
|
||||
All Chart.js libraries are now installed as npm packages and bundled with the application:
|
||||
```json
|
||||
"chart.js": "^3.9.1",
|
||||
"chartjs-adapter-luxon": "^1.3.1",
|
||||
"chartjs-plugin-streaming": "^2.0.0",
|
||||
"chartjs-plugin-zoom": "^1.2.1",
|
||||
"luxon": "^2.5.2"
|
||||
```
|
||||
|
||||
### 2. Chart.js Setup Module
|
||||
Created `frontend/src/utils/chartSetup.js` that:
|
||||
- Imports all Chart.js components as ES modules
|
||||
- Registers all required plugins (zoom, streaming, time scales)
|
||||
- Makes Chart.js available globally for existing components
|
||||
- Provides console confirmation of successful setup
|
||||
|
||||
### 3. Application Entry Point
|
||||
Modified `frontend/src/main.jsx` to import the Chart.js setup before rendering the application.
|
||||
|
||||
### 4. Updated HTML Template
|
||||
Removed all CDN script tags from `frontend/index.html`.
|
||||
|
||||
## Verification
|
||||
|
||||
### Build Verification
|
||||
The application builds successfully without any external dependencies:
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Development Server
|
||||
The development server runs without internet connection:
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Runtime Verification
|
||||
- No network requests to external CDNs
|
||||
- All Chart.js functionality preserved (zooming, streaming, real-time plots)
|
||||
- Completely self-contained in bundled JavaScript
|
||||
|
||||
## Backend Offline Compliance
|
||||
|
||||
The Python backend already uses only local dependencies:
|
||||
- Flask for web server
|
||||
- python-snap7 for PLC communication
|
||||
- Local file-based configuration
|
||||
- No external API calls or services
|
||||
|
||||
## Deployment for Offline Use
|
||||
|
||||
### Frontend Production Build
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
The `dist/` folder contains all necessary files with no external dependencies.
|
||||
|
||||
### Complete Offline Package
|
||||
The entire application (backend + frontend) can be deployed on systems without internet access:
|
||||
|
||||
1. **Python Requirements**: Install from `requirements.txt`
|
||||
2. **Frontend**: Use built files from `dist/` folder
|
||||
3. **PLC Communication**: Requires `snap7.dll` in system PATH
|
||||
4. **Configuration**: All JSON-based, stored locally
|
||||
|
||||
## Chart.js Feature Compatibility
|
||||
|
||||
All existing Chart.js features remain functional:
|
||||
- ✅ Real-time streaming plots
|
||||
- ✅ Zoom and pan functionality
|
||||
- ✅ Time-based X-axis with Luxon adapter
|
||||
- ✅ Multiple dataset support
|
||||
- ✅ Dynamic color assignment
|
||||
- ✅ Plot session management
|
||||
- ✅ CSV data export integration
|
||||
|
||||
## Technical Notes
|
||||
|
||||
### Global vs ES Module Access
|
||||
The setup maintains backward compatibility by making Chart.js available both ways:
|
||||
- **Global**: `window.Chart` (for existing components)
|
||||
- **ES Module**: `import ChartJS from './utils/chartSetup.js'` (for new components)
|
||||
|
||||
### Bundle Size Impact
|
||||
The Chart.js libraries add approximately ~400KB to the bundle (gzipped), which is acceptable for offline industrial applications.
|
||||
|
||||
### Browser Compatibility
|
||||
All dependencies support modern browsers without requiring polyfills for the target deployment environment.
|
File diff suppressed because it is too large
Load Diff
|
@ -34,11 +34,13 @@
|
|||
"type": "real"
|
||||
},
|
||||
{
|
||||
"configType": "symbol",
|
||||
"area": "db",
|
||||
"streaming": false,
|
||||
"symbol": "AUX Blink_1.6S",
|
||||
"type": "real"
|
||||
"configType": "manual",
|
||||
"area": "m",
|
||||
"streaming": true,
|
||||
"type": "bool",
|
||||
"name": "AUX Blink_1.6S",
|
||||
"offset": 0,
|
||||
"bit": 6
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -5,10 +5,10 @@
|
|||
"line_tension": 0,
|
||||
"name": "UR29",
|
||||
"point_hover_radius": 4,
|
||||
"point_radius": 0,
|
||||
"stacked": false,
|
||||
"point_radius": 2.5,
|
||||
"stacked": true,
|
||||
"stepped": true,
|
||||
"time_window": 10,
|
||||
"time_window": 36,
|
||||
"trigger_enabled": false,
|
||||
"trigger_on_true": true,
|
||||
"trigger_variable": null,
|
||||
|
|
|
@ -31,7 +31,7 @@
|
|||
},
|
||||
"enabled": {
|
||||
"ui:help": "When enabled, this dataset will be actively sampled and recorded",
|
||||
"ui:widget": "checkbox"
|
||||
"ui:widget": "switch"
|
||||
},
|
||||
"sampling_interval": {
|
||||
"ui:help": "Custom sampling interval in seconds (0.01–10). Leave empty to use the global PLC sampling interval.",
|
||||
|
|
|
@ -187,7 +187,7 @@
|
|||
}
|
||||
},
|
||||
"streaming": {
|
||||
"ui:widget": "checkbox",
|
||||
"ui:widget": "switch",
|
||||
"ui:help": "📡 Enable real-time streaming to PlotJuggler for visualization"
|
||||
}
|
||||
},
|
||||
|
@ -229,7 +229,7 @@
|
|||
"ui:help": "🔍 Search and select a symbol from the loaded ASC file"
|
||||
},
|
||||
"streaming": {
|
||||
"ui:widget": "checkbox",
|
||||
"ui:widget": "switch",
|
||||
"ui:help": "📡 Enable real-time streaming to PlotJuggler for visualization"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
},
|
||||
"rotation_enabled": {
|
||||
"ui:column": 3,
|
||||
"ui:widget": "checkbox"
|
||||
"ui:widget": "switch"
|
||||
},
|
||||
"ui:order": [
|
||||
"max_days",
|
||||
|
|
|
@ -118,11 +118,11 @@
|
|||
"ui:help": "🎯 Variable name to use as trigger (optional)"
|
||||
},
|
||||
"trigger_enabled": {
|
||||
"ui:widget": "checkbox",
|
||||
"ui:widget": "switch",
|
||||
"ui:help": "✅ Enable trigger-based recording"
|
||||
},
|
||||
"trigger_on_true": {
|
||||
"ui:widget": "checkbox",
|
||||
"ui:widget": "switch",
|
||||
"ui:help": "🔄 Trigger when variable becomes true (vs false)"
|
||||
},
|
||||
"line_tension": {
|
||||
|
@ -130,11 +130,11 @@
|
|||
"ui:help": "📈 Line smoothness: 0=straight lines, 0.4=smooth curves"
|
||||
},
|
||||
"stepped": {
|
||||
"ui:widget": "checkbox",
|
||||
"ui:widget": "switch",
|
||||
"ui:help": "📊 Enable stepped line style instead of curves"
|
||||
},
|
||||
"stacked": {
|
||||
"ui:widget": "checkbox",
|
||||
"ui:widget": "switch",
|
||||
"ui:help": "📚 Enable stacked Y-axes for multi-axis visualization"
|
||||
},
|
||||
"point_radius": {
|
||||
|
|
|
@ -101,7 +101,7 @@
|
|||
"ui:help": "📊 Which Y-axis to use for this variable (left or right)"
|
||||
},
|
||||
"enabled": {
|
||||
"ui:widget": "checkbox",
|
||||
"ui:widget": "switch",
|
||||
"ui:help": "📊 Enable this variable to be displayed in the real-time plot"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,6 +26,8 @@ class PlotSession:
|
|||
|
||||
def __init__(self, session_id: str, config: Dict[str, Any]):
|
||||
self.session_id = session_id
|
||||
# Store original plot ID for reference and grouping
|
||||
self.plot_id = config.get("id", "unknown")
|
||||
self.name = config.get("name", f"Plot {session_id}")
|
||||
|
||||
# Handle new variable structure with colors and enabled state
|
||||
|
@ -225,6 +227,7 @@ class PlotSession:
|
|||
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"plot_id": self.plot_id, # Include original plot ID for reference
|
||||
"name": self.name,
|
||||
"datasets": datasets,
|
||||
"y_min": y_min,
|
||||
|
@ -243,6 +246,7 @@ class PlotSession:
|
|||
"""Obtener estado de la sesión"""
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"plot_id": self.plot_id, # Include original plot ID for reference
|
||||
"name": self.name,
|
||||
"is_active": self.is_active,
|
||||
"is_paused": self.is_paused,
|
||||
|
@ -275,19 +279,51 @@ class PlotManager:
|
|||
os.path.join(data_dir, "plot_variables.json")
|
||||
)
|
||||
|
||||
# Initialize cleanup timer for inactive sessions
|
||||
self._last_cleanup = time.time()
|
||||
self._cleanup_interval = 300 # 5 minutes
|
||||
|
||||
# Legacy plot file for migration
|
||||
self.plots_file = resource_path(os.path.join(data_dir, "plot_sessions.json"))
|
||||
|
||||
# Load existing plots from disk
|
||||
self.load_plots()
|
||||
|
||||
def create_session(self, config: Dict[str, Any]) -> str:
|
||||
def create_session(
|
||||
self, config: Dict[str, Any], allow_multiple: bool = True
|
||||
) -> str:
|
||||
"""Crear una nueva sesión de plotting"""
|
||||
with self.lock:
|
||||
# Use the ID from config if available, otherwise generate one
|
||||
session_id = config.get("id") or f"plot_{self.session_counter}"
|
||||
if not config.get("id"):
|
||||
self.session_counter += 1
|
||||
base_id = config.get("id") or f"plot_{self.session_counter}"
|
||||
browser_tab_id = config.get("browser_tab_id", "")
|
||||
|
||||
if not allow_multiple:
|
||||
# Check if there's already an active session for this plot
|
||||
existing_sessions = [
|
||||
sid
|
||||
for sid, session in self.sessions.items()
|
||||
if session.plot_id == base_id and session.is_active
|
||||
]
|
||||
|
||||
if existing_sessions:
|
||||
# Return the existing active session ID
|
||||
if self.logger:
|
||||
self.logger.info(
|
||||
f"Returning existing active session for plot {base_id}: {existing_sessions[0]}"
|
||||
)
|
||||
return existing_sessions[0]
|
||||
|
||||
# Generate unique session ID to allow multiple browser instances
|
||||
# Format: {plot_id}_{browser_tab_id}_{timestamp}_{counter} to ensure uniqueness
|
||||
timestamp = int(time.time() * 1000) # millisecond precision
|
||||
session_id_parts = [base_id]
|
||||
|
||||
if browser_tab_id:
|
||||
session_id_parts.append(browser_tab_id)
|
||||
|
||||
session_id_parts.extend([str(timestamp), str(self.session_counter)])
|
||||
session_id = "_".join(session_id_parts)
|
||||
self.session_counter += 1
|
||||
|
||||
session = PlotSession(session_id, config)
|
||||
# 🔑 CAMBIO: Crear sesiones en modo activo por defecto para mejor UX
|
||||
|
@ -319,6 +355,44 @@ class PlotManager:
|
|||
|
||||
return session_id
|
||||
|
||||
def get_sessions_by_plot_id(self, plot_id: str) -> List[str]:
|
||||
"""Get all session IDs for a specific plot ID"""
|
||||
with self.lock:
|
||||
return [
|
||||
sid
|
||||
for sid, session in self.sessions.items()
|
||||
if session.plot_id == plot_id
|
||||
]
|
||||
|
||||
def cleanup_inactive_sessions(self, max_age_seconds: int = 3600) -> int:
|
||||
"""Remove inactive sessions older than max_age_seconds"""
|
||||
current_time = time.time()
|
||||
removed_count = 0
|
||||
|
||||
with self.lock:
|
||||
sessions_to_remove = []
|
||||
for session_id, session in self.sessions.items():
|
||||
# Calculate session age from timestamp in session_id
|
||||
try:
|
||||
parts = session_id.split("_")
|
||||
if len(parts) >= 3:
|
||||
timestamp_ms = int(parts[-2])
|
||||
session_age = current_time - (timestamp_ms / 1000.0)
|
||||
if not session.is_active and session_age > max_age_seconds:
|
||||
sessions_to_remove.append(session_id)
|
||||
except (ValueError, IndexError):
|
||||
# Skip sessions with invalid timestamp format
|
||||
continue
|
||||
|
||||
for session_id in sessions_to_remove:
|
||||
del self.sessions[session_id]
|
||||
removed_count += 1
|
||||
|
||||
if removed_count > 0 and self.logger:
|
||||
self.logger.info(f"Cleaned up {removed_count} inactive plot sessions")
|
||||
|
||||
return removed_count
|
||||
|
||||
def remove_session(self, session_id: str) -> bool:
|
||||
"""Eliminar una sesión de plotting"""
|
||||
with self.lock:
|
||||
|
@ -382,6 +456,12 @@ class PlotManager:
|
|||
|
||||
def update_data(self, dataset_id: str, variables_data: Dict[str, Any]) -> None:
|
||||
"""Actualizar datos de todas las sesiones activas usando cache del recording"""
|
||||
# Periodic cleanup of inactive sessions
|
||||
current_time = time.time()
|
||||
if current_time - self._last_cleanup > self._cleanup_interval:
|
||||
self.cleanup_inactive_sessions()
|
||||
self._last_cleanup = current_time
|
||||
|
||||
with self.lock:
|
||||
for session in self.sessions.values():
|
||||
if not session.is_active:
|
||||
|
@ -636,23 +716,45 @@ class PlotManager:
|
|||
def get_session_config(self, session_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Obtener configuración completa de una sesión"""
|
||||
with self.lock:
|
||||
if session_id not in self.sessions:
|
||||
return None
|
||||
# First try direct session_id lookup
|
||||
if session_id in self.sessions:
|
||||
session = self.sessions[session_id]
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"plot_id": session.plot_id, # Include original plot_id for reference
|
||||
"name": session.name,
|
||||
"variables": session.variables,
|
||||
"time_window": session.time_window,
|
||||
"y_min": session.y_min,
|
||||
"y_max": session.y_max,
|
||||
"trigger_variable": session.trigger_variable,
|
||||
"trigger_enabled": session.trigger_enabled,
|
||||
"trigger_on_true": session.trigger_on_true,
|
||||
"is_active": session.is_active,
|
||||
"is_paused": session.is_paused,
|
||||
}
|
||||
|
||||
session = self.sessions[session_id]
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"name": session.name,
|
||||
"variables": session.variables,
|
||||
"time_window": session.time_window,
|
||||
"y_min": session.y_min,
|
||||
"y_max": session.y_max,
|
||||
"trigger_variable": session.trigger_variable,
|
||||
"trigger_enabled": session.trigger_enabled,
|
||||
"trigger_on_true": session.trigger_on_true,
|
||||
"is_active": session.is_active,
|
||||
"is_paused": session.is_paused,
|
||||
}
|
||||
# If not found, try to find a session by plot_id (backward compatibility)
|
||||
# This handles cases where frontend queries with plot_id instead of unique session_id
|
||||
for sid, session in self.sessions.items():
|
||||
if session.plot_id == session_id:
|
||||
# Return the first session found for this plot_id
|
||||
return {
|
||||
"session_id": sid, # Return the actual unique session_id
|
||||
"plot_id": session.plot_id,
|
||||
"name": session.name,
|
||||
"variables": session.variables,
|
||||
"time_window": session.time_window,
|
||||
"y_min": session.y_min,
|
||||
"y_max": session.y_max,
|
||||
"trigger_variable": session.trigger_variable,
|
||||
"trigger_enabled": session.trigger_enabled,
|
||||
"trigger_on_true": session.trigger_on_true,
|
||||
"is_active": session.is_active,
|
||||
"is_paused": session.is_paused,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def get_active_sessions_count(self) -> int:
|
||||
"""Obtener número de sesiones activas"""
|
||||
|
|
|
@ -342,7 +342,11 @@ class DataStreamer:
|
|||
)
|
||||
row = [timestamp]
|
||||
for var_name in dataset_variables.keys():
|
||||
row.append(data.get(var_name, None))
|
||||
value = data.get(var_name, None)
|
||||
# Convert boolean values to 0 or 1 for CSV consistency
|
||||
if isinstance(value, bool):
|
||||
value = 1 if value else 0
|
||||
row.append(value)
|
||||
|
||||
self.dataset_csv_writers[dataset_id].writerow(row)
|
||||
self.dataset_csv_files[dataset_id].flush()
|
||||
|
|
|
@ -0,0 +1,430 @@
|
|||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { Box, Text, Switch, FormLabel, HStack, useColorModeValue } from '@chakra-ui/react';
|
||||
|
||||
// Historical Chart.js Plot Component with Zoom and Pan
|
||||
const ChartjsHistoricalPlot = ({ session, height = '400px' }) => {
|
||||
const canvasRef = useRef(null);
|
||||
const chartRef = useRef(null);
|
||||
const sessionDataRef = useRef({
|
||||
sessionId: null,
|
||||
currentTimeRange: { start: null, end: null },
|
||||
pendingDataRequest: false
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [dataPointsCount, setDataPointsCount] = useState(0);
|
||||
const [isZoomEnabled, setIsZoomEnabled] = useState(true);
|
||||
const [currentRange, setCurrentRange] = useState('');
|
||||
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const textColor = useColorModeValue('gray.600', 'gray.300');
|
||||
|
||||
// Check if Chart.js is available
|
||||
const checkChartDependencies = useCallback(() => {
|
||||
try {
|
||||
if (typeof window === 'undefined' || !window.Chart) {
|
||||
console.error('❌ Chart.js not loaded');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ Chart.js dependency check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Destroy chart helper
|
||||
const destroyChart = useCallback(() => {
|
||||
if (chartRef.current) {
|
||||
try {
|
||||
chartRef.current.destroy();
|
||||
} catch (error) {
|
||||
console.warn('Chart destroy error (non-critical):', error);
|
||||
}
|
||||
chartRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Create historical chart
|
||||
const createChart = useCallback(() => {
|
||||
if (!canvasRef.current || !checkChartDependencies()) {
|
||||
setError('Chart.js not available');
|
||||
return;
|
||||
}
|
||||
|
||||
destroyChart();
|
||||
|
||||
const ctx = canvasRef.current.getContext('2d');
|
||||
if (!ctx) {
|
||||
setError('Cannot get canvas context');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Initialize empty datasets
|
||||
const datasets = (session.config.variables || []).map((variable, index) => ({
|
||||
label: variable.name || variable,
|
||||
data: [],
|
||||
borderColor: `hsl(${(index * 137.5) % 360}, 70%, 50%)`,
|
||||
backgroundColor: `hsla(${(index * 137.5) % 360}, 70%, 50%, 0.1)`,
|
||||
borderWidth: 2,
|
||||
fill: session.config.stacked || false,
|
||||
tension: session.config.line_tension || 0.4,
|
||||
pointRadius: session.config.point_radius || 1,
|
||||
pointHoverRadius: session.config.point_hover_radius || 4,
|
||||
stepped: session.config.stepped || false
|
||||
}));
|
||||
|
||||
const config = {
|
||||
type: 'line',
|
||||
data: { datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false, // Disable animations for better performance
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 10
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
title: function(context) {
|
||||
if (context[0] && context[0].parsed && context[0].parsed.x) {
|
||||
return new Date(context[0].parsed.x).toLocaleString();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
},
|
||||
zoom: isZoomEnabled && window.Chart?.registry?.plugins?.get?.('zoom') ? {
|
||||
pan: {
|
||||
enabled: true,
|
||||
mode: 'x',
|
||||
onPanComplete: handlePanComplete
|
||||
},
|
||||
zoom: {
|
||||
wheel: {
|
||||
enabled: true,
|
||||
},
|
||||
pinch: {
|
||||
enabled: true,
|
||||
},
|
||||
mode: 'x',
|
||||
onZoomComplete: handleZoomComplete
|
||||
}
|
||||
} : {}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
time: {
|
||||
displayFormats: {
|
||||
millisecond: 'HH:mm:ss.SSS',
|
||||
second: 'HH:mm:ss',
|
||||
minute: 'HH:mm',
|
||||
hour: 'MM/DD HH:mm',
|
||||
day: 'MM/DD',
|
||||
week: 'MM/DD',
|
||||
month: 'MM/YY',
|
||||
quarter: 'MM/YY',
|
||||
year: 'YYYY'
|
||||
}
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Time'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Value'
|
||||
},
|
||||
min: session.config.y_min,
|
||||
max: session.config.y_max
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
chartRef.current = new window.Chart(ctx, config);
|
||||
|
||||
// Register chart controls
|
||||
if (session.onChartReady) {
|
||||
session.onChartReady({
|
||||
loadHistoricalRange: loadHistoricalRange,
|
||||
refreshData: refreshCurrentRange,
|
||||
getTimeRange: getCurrentTimeRange,
|
||||
resetZoom: resetZoom
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`📈 Historical chart created for session ${session.session_id}`);
|
||||
setError(null);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating historical chart:', error);
|
||||
setError(`Chart creation failed: ${error.message}`);
|
||||
}
|
||||
}, [session, isZoomEnabled]);
|
||||
|
||||
// Handle zoom completion
|
||||
const handleZoomComplete = useCallback(({ chart }) => {
|
||||
const xScale = chart.scales.x;
|
||||
const startTime = xScale.min;
|
||||
const endTime = xScale.max;
|
||||
|
||||
updateCurrentRange(startTime, endTime);
|
||||
requestAdditionalData(startTime, endTime);
|
||||
}, []);
|
||||
|
||||
// Handle pan completion
|
||||
const handlePanComplete = useCallback(({ chart }) => {
|
||||
const xScale = chart.scales.x;
|
||||
const startTime = xScale.min;
|
||||
const endTime = xScale.max;
|
||||
|
||||
updateCurrentRange(startTime, endTime);
|
||||
requestAdditionalData(startTime, endTime);
|
||||
}, []);
|
||||
|
||||
// Update current range display
|
||||
const updateCurrentRange = (startTime, endTime) => {
|
||||
const start = new Date(startTime);
|
||||
const end = new Date(endTime);
|
||||
const range = `${start.toLocaleString()} - ${end.toLocaleString()}`;
|
||||
setCurrentRange(range);
|
||||
|
||||
// Update session time range if callback available
|
||||
if (session.onTimeRangeChange) {
|
||||
session.onTimeRangeChange(startTime, endTime);
|
||||
}
|
||||
};
|
||||
|
||||
// Request additional data for the visible range
|
||||
const requestAdditionalData = useCallback(async (startTime, endTime) => {
|
||||
if (!session.onDataRequest || sessionDataRef.current.pendingDataRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
sessionDataRef.current.pendingDataRequest = true;
|
||||
setIsLoading(true);
|
||||
|
||||
const data = await session.onDataRequest(startTime, endTime);
|
||||
|
||||
if (data && chartRef.current) {
|
||||
updateChartData(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error requesting historical data:', error);
|
||||
} finally {
|
||||
sessionDataRef.current.pendingDataRequest = false;
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
// Load specific time range
|
||||
const loadHistoricalRange = useCallback(async (startTime, endTime) => {
|
||||
if (!session.onDataRequest || !chartRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const data = await session.onDataRequest(startTime, endTime);
|
||||
|
||||
if (data) {
|
||||
// Clear existing data
|
||||
chartRef.current.data.datasets.forEach(dataset => {
|
||||
dataset.data = [];
|
||||
});
|
||||
|
||||
updateChartData(data);
|
||||
|
||||
// Set chart view to the requested range
|
||||
if (chartRef.current.scales?.x) {
|
||||
chartRef.current.scales.x.options.min = startTime;
|
||||
chartRef.current.scales.x.options.max = endTime;
|
||||
}
|
||||
chartRef.current.update('none');
|
||||
|
||||
updateCurrentRange(startTime, endTime);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading historical range:', error);
|
||||
setError(`Failed to load data: ${error.message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
// Update chart with new data from the optimized API response
|
||||
const updateChartData = useCallback((response) => {
|
||||
if (!chartRef.current || !response) return;
|
||||
|
||||
let totalPoints = 0;
|
||||
|
||||
try {
|
||||
// Use the optimized chart_data format from the API
|
||||
const chartData = response.chart_data || {};
|
||||
|
||||
chartRef.current.data.datasets.forEach((dataset, index) => {
|
||||
const variableName = session.config.variables[index]?.name || session.config.variables[index];
|
||||
const variableData = chartData[variableName];
|
||||
|
||||
if (variableData && Array.isArray(variableData)) {
|
||||
// Data is already in {x, y} format for Chart.js
|
||||
const newPoints = variableData.map(point => ({
|
||||
x: new Date(point.x).getTime(),
|
||||
y: point.y
|
||||
}));
|
||||
|
||||
// Merge with existing data and remove duplicates
|
||||
const allPoints = [...dataset.data, ...newPoints];
|
||||
const uniquePoints = allPoints.filter((point, index, arr) =>
|
||||
arr.findIndex(p => p.x === point.x) === index
|
||||
);
|
||||
|
||||
// Sort by timestamp
|
||||
dataset.data = uniquePoints.sort((a, b) => a.x - b.x);
|
||||
totalPoints += dataset.data.length;
|
||||
}
|
||||
});
|
||||
|
||||
chartRef.current.update('none');
|
||||
setDataPointsCount(totalPoints);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating chart data:', error);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
// Refresh current visible range
|
||||
const refreshCurrentRange = useCallback(() => {
|
||||
if (!chartRef.current?.scales?.x) return;
|
||||
|
||||
const xScale = chartRef.current.scales.x;
|
||||
const startTime = xScale.min;
|
||||
const endTime = xScale.max;
|
||||
|
||||
if (startTime && endTime) {
|
||||
loadHistoricalRange(startTime, endTime);
|
||||
}
|
||||
}, [loadHistoricalRange]);
|
||||
|
||||
// Get current time range
|
||||
const getCurrentTimeRange = useCallback(() => {
|
||||
if (!chartRef.current?.scales?.x) return null;
|
||||
|
||||
const xScale = chartRef.current.scales.x;
|
||||
return {
|
||||
start: xScale.min,
|
||||
end: xScale.max
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Reset zoom to show all data
|
||||
const resetZoom = useCallback(() => {
|
||||
if (chartRef.current && chartRef.current.resetZoom) {
|
||||
chartRef.current.resetZoom();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initialize chart
|
||||
useEffect(() => {
|
||||
createChart();
|
||||
return destroyChart;
|
||||
}, [createChart, destroyChart]);
|
||||
|
||||
// Handle session changes
|
||||
useEffect(() => {
|
||||
sessionDataRef.current.sessionId = session.session_id;
|
||||
}, [session.session_id]);
|
||||
|
||||
// Load initial data when component mounts
|
||||
useEffect(() => {
|
||||
if (session.timeRange && session.timeRange.startDate && session.timeRange.endDate) {
|
||||
const startDateTime = new Date(`${session.timeRange.startDate}T${session.timeRange.startTime || '00:00'}:00`);
|
||||
const endDateTime = new Date(`${session.timeRange.endDate}T${session.timeRange.endTime || '23:59'}:00`);
|
||||
|
||||
loadHistoricalRange(startDateTime.getTime(), endDateTime.getTime());
|
||||
}
|
||||
}, [session.timeRange, loadHistoricalRange]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box
|
||||
height={height}
|
||||
bg={bgColor}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
borderRadius="md"
|
||||
border="1px solid"
|
||||
borderColor="red.200"
|
||||
>
|
||||
<Text color="red.500" textAlign="center">
|
||||
❌ {error}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box height={height} bg={bgColor} borderRadius="md" position="relative">
|
||||
{/* Controls */}
|
||||
<HStack spacing={4} mb={2} fontSize="sm">
|
||||
<FormLabel mb={0} fontSize="xs">
|
||||
<Switch
|
||||
size="sm"
|
||||
isChecked={isZoomEnabled}
|
||||
onChange={(e) => setIsZoomEnabled(e.target.checked)}
|
||||
mr={2}
|
||||
/>
|
||||
Zoom/Pan
|
||||
</FormLabel>
|
||||
|
||||
{dataPointsCount > 0 && (
|
||||
<Text color={textColor} fontSize="xs">
|
||||
{dataPointsCount.toLocaleString()} data points
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<Text color="blue.500" fontSize="xs">
|
||||
Loading...
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* Current range display */}
|
||||
{currentRange && (
|
||||
<Text fontSize="xs" color={textColor} mb={2}>
|
||||
Viewing: {currentRange}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Chart canvas */}
|
||||
<Box height="calc(100% - 60px)">
|
||||
<canvas ref={canvasRef} style={{ width: '100%', height: '100%' }} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChartjsHistoricalPlot;
|
|
@ -66,17 +66,36 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
const [dataPointsCount, setDataPointsCount] = useState(0);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isLoadingHistorical, setIsLoadingHistorical] = useState(false);
|
||||
const [isZoomEnabled, setIsZoomEnabled] = useState(true);
|
||||
const [isZoomEnabled, setIsZoomEnabled] = useState(false);
|
||||
const resolvedConfigRef = useRef(null);
|
||||
|
||||
|
||||
// Chart health monitoring
|
||||
const chartHealthRef = useRef({
|
||||
lastDataTimestamp: 0,
|
||||
consecutiveErrors: 0,
|
||||
isHealthy: true,
|
||||
lastHealthCheck: 0
|
||||
});
|
||||
|
||||
// Safe Chart.js operation helper
|
||||
const safeChartUpdate = useCallback((chart, updateMode = 'none', operation = 'update') => {
|
||||
if (!chart || !canvasRef.current || !document.contains(canvasRef.current)) {
|
||||
console.warn(`🚫 Safe chart ${operation} aborted - invalid DOM state for session ${sessionDataRef.current.sessionId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!chart.ctx || !chart.canvas || chart.canvas !== canvasRef.current) {
|
||||
console.warn(`🚫 Safe chart ${operation} aborted - chart context mismatch for session ${sessionDataRef.current.sessionId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
chart.update(updateMode);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`📈 Error in chart ${operation} for session ${sessionDataRef.current.sessionId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const textColor = useColorModeValue('gray.600', 'gray.300');
|
||||
|
||||
|
@ -117,7 +136,8 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
name: varConfig.variable_name,
|
||||
label: varConfig.label || varConfig.variable_name, // Use display label if available
|
||||
color: varConfig.color || getColor(varConfig.variable_name, index),
|
||||
enabled: varConfig.enabled !== false
|
||||
enabled: varConfig.enabled !== false,
|
||||
y_axis: varConfig.y_axis || 'left' // Include Y-axis positioning
|
||||
}));
|
||||
}
|
||||
// Handle simple array of strings
|
||||
|
@ -125,7 +145,8 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
name: variable,
|
||||
label: variable, // For simple strings, name and label are the same
|
||||
color: getColor(variable, index),
|
||||
enabled: true
|
||||
enabled: true,
|
||||
y_axis: 'left' // Default for simple strings
|
||||
}));
|
||||
} else if (typeof variables === 'object') {
|
||||
const firstVarConfig = Object.values(variables)[0];
|
||||
|
@ -136,7 +157,8 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
name: config.variable_name,
|
||||
label: config.label || config.variable_name, // Use display label if available
|
||||
color: config.color || getColor(config.variable_name),
|
||||
enabled: config.enabled !== false
|
||||
enabled: config.enabled !== false,
|
||||
y_axis: config.y_axis || 'left' // Include Y-axis positioning
|
||||
}));
|
||||
} else {
|
||||
return Object.entries(variables)
|
||||
|
@ -144,7 +166,8 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
.map(([name, config]) => ({
|
||||
name: name,
|
||||
color: config.color || getColor(name),
|
||||
enabled: config.enabled !== false
|
||||
enabled: config.enabled !== false,
|
||||
y_axis: config.y_axis || 'left' // Include Y-axis positioning
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
@ -154,23 +177,23 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
// Chart health monitoring and auto-recovery
|
||||
const checkChartHealth = useCallback(() => {
|
||||
if (!chartRef.current) return false;
|
||||
|
||||
|
||||
const health = chartHealthRef.current;
|
||||
const now = Date.now();
|
||||
|
||||
|
||||
// Check if chart is receiving data
|
||||
const timeSinceLastData = now - health.lastDataTimestamp;
|
||||
const hasRecentData = timeSinceLastData < 30000; // 30 seconds
|
||||
|
||||
|
||||
// Check if streaming is properly configured
|
||||
const realtimeOptions = chartRef.current.options?.scales?.x?.realtime;
|
||||
const isStreamingConfigured = !!realtimeOptions;
|
||||
|
||||
|
||||
// Check if datasets exist and are properly configured
|
||||
const hasDatasets = chartRef.current.data?.datasets?.length > 0;
|
||||
|
||||
|
||||
const isHealthy = hasRecentData && isStreamingConfigured && hasDatasets;
|
||||
|
||||
|
||||
if (!isHealthy && health.isHealthy) {
|
||||
console.warn('⚠️ Chart health check failed:', {
|
||||
hasRecentData,
|
||||
|
@ -180,16 +203,16 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
consecutiveErrors: health.consecutiveErrors
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
health.isHealthy = isHealthy;
|
||||
health.lastHealthCheck = now;
|
||||
|
||||
|
||||
return isHealthy;
|
||||
}, []);
|
||||
|
||||
|
||||
const attemptAutoRecovery = useCallback(async () => {
|
||||
console.log('🔄 Attempting chart auto-recovery...');
|
||||
|
||||
|
||||
try {
|
||||
// Clear all data directly
|
||||
if (chartRef.current) {
|
||||
|
@ -201,10 +224,10 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
chartRef.current.update('quiet');
|
||||
setDataPointsCount(0);
|
||||
}
|
||||
|
||||
|
||||
// Wait a moment
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
|
||||
// Recreate chart if session is active
|
||||
if (session?.is_active && !session?.is_paused) {
|
||||
// Trigger a recreation by clearing refs and letting useEffect handle it
|
||||
|
@ -213,48 +236,19 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
// The useEffect will recreate the chart
|
||||
console.log('✅ Chart auto-recovery completed');
|
||||
}, 100);
|
||||
|
||||
|
||||
// Reset health counters
|
||||
chartHealthRef.current.consecutiveErrors = 0;
|
||||
chartHealthRef.current.isHealthy = true;
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Chart auto-recovery failed:', error);
|
||||
chartHealthRef.current.consecutiveErrors++;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [session]);
|
||||
|
||||
// Diagnostic function to help identify issues
|
||||
const runDiagnostics = useCallback(() => {
|
||||
console.log('🔍 Chart Diagnostics:', {
|
||||
chartExists: !!chartRef.current,
|
||||
canvasExists: !!canvasRef.current,
|
||||
sessionId: session?.session_id,
|
||||
sessionActive: session?.is_active,
|
||||
sessionPaused: session?.is_paused,
|
||||
health: chartHealthRef.current,
|
||||
datasets: chartRef.current?.data?.datasets?.length || 0,
|
||||
realtimeConfig: !!chartRef.current?.options?.scales?.x?.realtime
|
||||
});
|
||||
|
||||
// Check for common issues
|
||||
const issues = [];
|
||||
if (!chartRef.current) issues.push('Chart not initialized');
|
||||
if (!session?.is_active) issues.push('Session not active');
|
||||
if (session?.is_paused) issues.push('Session is paused');
|
||||
if (chartHealthRef.current.consecutiveErrors > 5) issues.push(`${chartHealthRef.current.consecutiveErrors} consecutive errors`);
|
||||
|
||||
if (issues.length > 0) {
|
||||
console.warn('⚠️ Issues detected:', issues);
|
||||
} else {
|
||||
console.log('✅ No issues detected');
|
||||
}
|
||||
|
||||
return issues;
|
||||
return false;
|
||||
}, [session]);
|
||||
|
||||
// Load historical data from CSV files
|
||||
|
@ -319,7 +313,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
if (timestamp instanceof Date) {
|
||||
return timestamp.getTime();
|
||||
}
|
||||
|
||||
|
||||
let raw = timestamp;
|
||||
if (typeof raw === 'string') {
|
||||
const asNum = Number(raw);
|
||||
|
@ -335,12 +329,12 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
|
||||
console.warn('⚠️ Invalid timestamp value:', timestamp);
|
||||
return Date.now(); // fallback to current time
|
||||
}
|
||||
|
||||
|
||||
// Normalize seconds to milliseconds
|
||||
const normalized = raw < 1e12 ? raw * 1000 : raw;
|
||||
return normalized;
|
||||
|
@ -350,10 +344,10 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
// Ensure we have the most up-to-date config
|
||||
const sessionConfig = session?.config;
|
||||
const resolvedConfig = resolvedConfigRef.current;
|
||||
|
||||
|
||||
// Prefer session config if it's newer or if resolved config is missing
|
||||
const cfg = sessionConfig || resolvedConfig;
|
||||
|
||||
|
||||
if (!canvasRef.current || !cfg) return;
|
||||
|
||||
// Update resolvedConfigRef to ensure consistency
|
||||
|
@ -525,7 +519,11 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
const rt = existingChart.options?.scales?.x?.realtime;
|
||||
if (rt) {
|
||||
rt.pause = true;
|
||||
existingChart.update('none');
|
||||
try {
|
||||
existingChart.update('none');
|
||||
} catch (updateError) {
|
||||
console.warn('⚠️ Error updating chart during destruction:', updateError);
|
||||
}
|
||||
}
|
||||
|
||||
// Stop any running animations and timers
|
||||
|
@ -614,8 +612,10 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
pointHoverRadius: pointHoverRadius,
|
||||
tension: lineTension,
|
||||
stepped: stepped,
|
||||
// When stacked is enabled, assign each dataset to its own Y axis for multi-axis visualization
|
||||
...(useStackedAxes ? { yAxisID: `y-axis-${index}` } : {})
|
||||
// Assign Y axis based on configuration and mode
|
||||
yAxisID: useStackedAxes
|
||||
? `y-axis-${index}` // Stacked mode: each variable gets its own axis
|
||||
: (variableInfo.y_axis === 'right' ? 'y-right' : 'y-left') // Non-stacked: use left/right configuration
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -659,7 +659,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
// Sort points by timestamp to ensure proper order
|
||||
historicalPoints.sort((a, b) => a.x - b.x);
|
||||
datasets[index].data = historicalPoints;
|
||||
|
||||
|
||||
// Update lastPushedX tracking for streaming continuity
|
||||
const lastPoint = historicalPoints[historicalPoints.length - 1];
|
||||
if (lastPoint && typeof lastPoint.x === 'number') {
|
||||
|
@ -685,7 +685,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
|
||||
const yMinInitial = (typeof config.y_min === 'number' && isFinite(config.y_min)) ? config.y_min : undefined;
|
||||
const yMaxInitial = (typeof config.y_max === 'number' && isFinite(config.y_max)) ? config.y_max : undefined;
|
||||
|
||||
|
||||
const chartConfig = {
|
||||
type: 'line',
|
||||
data: { datasets },
|
||||
|
@ -729,87 +729,105 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
},
|
||||
...(useStackedAxes && enabledVariables.length > 0
|
||||
? // When stacked is enabled, create a separate Y axis for each dataset
|
||||
enabledVariables.reduce((scales, variable, index) => {
|
||||
// Safe access to variable properties
|
||||
const variableName = variable.name || variable.label || `Variable ${index}`;
|
||||
const color = variable.color || getColor(variableName, index);
|
||||
|
||||
// Improved Y-axis positioning logic for better visual distribution
|
||||
// Distribute axes more evenly: left, right, left, right, etc.
|
||||
// But group consecutive axes to avoid overcrowding
|
||||
const totalAxes = enabledVariables.length;
|
||||
let position;
|
||||
|
||||
if (totalAxes <= 2) {
|
||||
// With 1-2 axes, alternate left/right
|
||||
position = index === 0 ? 'left' : 'right';
|
||||
} else if (totalAxes <= 4) {
|
||||
// With 3-4 axes, use pattern: left, right, left, right
|
||||
position = index % 2 === 0 ? 'left' : 'right';
|
||||
} else {
|
||||
// With 5+ axes, group them: first half on left, second half on right
|
||||
const halfPoint = Math.ceil(totalAxes / 2);
|
||||
position = index < halfPoint ? 'left' : 'right';
|
||||
}
|
||||
|
||||
scales[`y-axis-${index}`] = {
|
||||
type: 'linear',
|
||||
position: position,
|
||||
stack: 'stack-group',
|
||||
stackWeight: 1,
|
||||
border: {
|
||||
color: color,
|
||||
width: 2
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: index === 0, // Only show grid for the first axis
|
||||
color: color + '20' // Semi-transparent grid lines
|
||||
},
|
||||
ticks: {
|
||||
color: color,
|
||||
font: {
|
||||
weight: 'bold'
|
||||
}
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: variable.label || variable.name || `Variable ${index}`,
|
||||
color: color,
|
||||
font: {
|
||||
weight: 'bold',
|
||||
size: 12
|
||||
}
|
||||
},
|
||||
min: yMinInitial,
|
||||
max: yMaxInitial
|
||||
};
|
||||
return scales;
|
||||
}, {})
|
||||
: useStackedAxes && enabledVariables.length === 0
|
||||
? // Stacked is enabled but no variables, add a placeholder axis
|
||||
{
|
||||
'y-axis-default': {
|
||||
type: 'linear',
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'No Variables'
|
||||
},
|
||||
min: yMinInitial,
|
||||
max: yMaxInitial
|
||||
}
|
||||
enabledVariables.reduce((scales, variable, index) => {
|
||||
// Safe access to variable properties
|
||||
const variableName = variable.name || variable.label || `Variable ${index}`;
|
||||
const color = variable.color || getColor(variableName, index);
|
||||
|
||||
// Respect the y_axis configuration from variable settings
|
||||
// If y_axis is specified, use it; otherwise use intelligent distribution
|
||||
let position;
|
||||
if (variable.y_axis === 'left' || variable.y_axis === 'right') {
|
||||
position = variable.y_axis;
|
||||
} else {
|
||||
// Fallback to intelligent distribution for variables without y_axis config
|
||||
const totalAxes = enabledVariables.length;
|
||||
if (totalAxes <= 2) {
|
||||
position = index === 0 ? 'left' : 'right';
|
||||
} else if (totalAxes <= 4) {
|
||||
position = index % 2 === 0 ? 'left' : 'right';
|
||||
} else {
|
||||
const halfPoint = Math.ceil(totalAxes / 2);
|
||||
position = index < halfPoint ? 'left' : 'right';
|
||||
}
|
||||
}
|
||||
: // Default single Y axis
|
||||
|
||||
scales[`y-axis-${index}`] = {
|
||||
type: 'linear',
|
||||
position: position,
|
||||
stack: 'stack-group',
|
||||
stackWeight: 1,
|
||||
border: {
|
||||
color: color,
|
||||
width: 2
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: index === 0, // Only show grid for the first axis
|
||||
color: color + '20' // Semi-transparent grid lines
|
||||
},
|
||||
ticks: {
|
||||
color: color,
|
||||
font: {
|
||||
weight: 'bold'
|
||||
}
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: variable.label || variable.name || `Variable ${index}`,
|
||||
color: color,
|
||||
font: {
|
||||
weight: 'bold',
|
||||
size: 12
|
||||
}
|
||||
},
|
||||
min: yMinInitial,
|
||||
max: yMaxInitial
|
||||
};
|
||||
return scales;
|
||||
}, {})
|
||||
: useStackedAxes && enabledVariables.length === 0
|
||||
? // Stacked is enabled but no variables, add a placeholder axis
|
||||
{
|
||||
y: {
|
||||
'y-axis-default': {
|
||||
type: 'linear',
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Valor'
|
||||
text: 'No Variables'
|
||||
},
|
||||
min: yMinInitial,
|
||||
max: yMaxInitial
|
||||
}
|
||||
}
|
||||
: // Non-stacked mode with left/right Y axes
|
||||
{
|
||||
'y-left': {
|
||||
type: 'linear',
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Valor (Left)'
|
||||
},
|
||||
min: yMinInitial,
|
||||
max: yMaxInitial,
|
||||
grid: {
|
||||
drawOnChartArea: true
|
||||
}
|
||||
},
|
||||
'y-right': {
|
||||
type: 'linear',
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Valor (Right)'
|
||||
},
|
||||
min: yMinInitial,
|
||||
max: yMaxInitial,
|
||||
grid: {
|
||||
drawOnChartArea: false // Don't draw grid for right axis to avoid overlap
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
plugins: {
|
||||
|
@ -828,7 +846,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
enabled: isZoomEnabled,
|
||||
mode: 'x',
|
||||
modifierKey: 'shift',
|
||||
onPanComplete: function(context) {
|
||||
onPanComplete: function (context) {
|
||||
// Preserve streaming state after pan
|
||||
const chart = context.chart;
|
||||
if (chart.options?.scales?.x?.realtime) {
|
||||
|
@ -841,21 +859,21 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
}
|
||||
},
|
||||
zoom: {
|
||||
drag: {
|
||||
drag: {
|
||||
enabled: isZoomEnabled,
|
||||
backgroundColor: 'rgba(128,128,128,0.3)'
|
||||
},
|
||||
wheel: {
|
||||
wheel: {
|
||||
enabled: isZoomEnabled,
|
||||
speed: 0.1
|
||||
},
|
||||
pinch: { enabled: isZoomEnabled },
|
||||
mode: 'x',
|
||||
onZoomComplete: function(context) {
|
||||
onZoomComplete: function (context) {
|
||||
// Preserve streaming state and data after zoom
|
||||
const chart = context.chart;
|
||||
console.log('🔍 Zoom completed, preserving streaming state...');
|
||||
|
||||
|
||||
if (chart.options?.scales?.x?.realtime) {
|
||||
const realtime = chart.options.scales.x.realtime;
|
||||
// Don't auto-pause streaming after zoom
|
||||
|
@ -979,6 +997,18 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
const sessionId = sessionDataRef.current.sessionId;
|
||||
if (!sessionId) return;
|
||||
|
||||
// Enhanced DOM and chart validation
|
||||
if (!chart || !canvasRef.current || !document.contains(canvasRef.current)) {
|
||||
console.warn(`🚫 onStreamingRefresh aborted - invalid DOM state for session ${sessionId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate that chart is still properly initialized and not destroyed
|
||||
if (!chart.ctx || !chart.canvas || chart.canvas !== canvasRef.current) {
|
||||
console.warn(`🚫 onStreamingRefresh aborted - chart context mismatch for session ${sessionId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const now = Date.now();
|
||||
const refreshRate = sessionDataRef.current.refreshRate;
|
||||
|
@ -1007,7 +1037,10 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
if (sessionDataRef.current.isPaused && !sessionDataRef.current.userPaused) {
|
||||
const rt = chart.options?.scales?.x?.realtime;
|
||||
if (rt) rt.pause = false;
|
||||
chart.update('none');
|
||||
// Double-check chart validity before update
|
||||
if (chart.ctx && canvasRef.current && document.contains(canvasRef.current)) {
|
||||
chart.update('none');
|
||||
}
|
||||
sessionDataRef.current.isPaused = false;
|
||||
sessionDataRef.current.ingestPaused = false;
|
||||
}
|
||||
|
@ -1016,7 +1049,10 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
if (sessionDataRef.current.noDataCycles >= 3) {
|
||||
const rt = chart.options?.scales?.x?.realtime;
|
||||
if (rt) rt.pause = true;
|
||||
chart.update('none');
|
||||
// Double-check chart validity before update
|
||||
if (chart.ctx && canvasRef.current && document.contains(canvasRef.current)) {
|
||||
chart.update('none');
|
||||
}
|
||||
sessionDataRef.current.isPaused = true;
|
||||
sessionDataRef.current.ingestPaused = true;
|
||||
}
|
||||
|
@ -1106,19 +1142,19 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
|
||||
// Update chart
|
||||
if (pointsAdded > 0) {
|
||||
chart.update('quiet');
|
||||
|
||||
safeChartUpdate(chart, 'quiet', 'data ingestion');
|
||||
|
||||
// Update health monitoring
|
||||
chartHealthRef.current.lastDataTimestamp = Date.now();
|
||||
chartHealthRef.current.consecutiveErrors = 0;
|
||||
} else {
|
||||
// No new data received - increment error counter
|
||||
chartHealthRef.current.consecutiveErrors++;
|
||||
|
||||
|
||||
// Check if we need auto-recovery after multiple failed attempts
|
||||
if (chartHealthRef.current.consecutiveErrors > 10) {
|
||||
console.warn(`⚠️ No data received for ${chartHealthRef.current.consecutiveErrors} consecutive attempts`);
|
||||
|
||||
|
||||
// Attempt auto-recovery if too many consecutive errors
|
||||
if (chartHealthRef.current.consecutiveErrors > 20) {
|
||||
console.warn('🚑 Triggering auto-recovery due to consecutive data failures');
|
||||
|
@ -1166,7 +1202,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
if (rt) {
|
||||
rt.pause = true;
|
||||
}
|
||||
chart.update('none');
|
||||
safeChartUpdate(chart, 'none', 'pause streaming');
|
||||
} else {
|
||||
if (sessionData.manualInterval) {
|
||||
clearInterval(sessionData.manualInterval);
|
||||
|
@ -1222,7 +1258,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
// Sort points by timestamp to ensure proper order
|
||||
historicalPoints.sort((a, b) => a.x - b.x);
|
||||
chart.data.datasets[index].data = historicalPoints;
|
||||
|
||||
|
||||
// Update lastPushedX tracking for streaming continuity
|
||||
const lastPoint = historicalPoints[historicalPoints.length - 1];
|
||||
if (lastPoint && typeof lastPoint.x === 'number') {
|
||||
|
@ -1232,7 +1268,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
});
|
||||
|
||||
// Update chart with historical data
|
||||
chart.update('quiet');
|
||||
safeChartUpdate(chart, 'quiet', 'historical data load');
|
||||
|
||||
// Update data points counter
|
||||
const totalHistoricalPoints = Object.values(dataByVariable).reduce((sum, points) => sum + points.length, 0);
|
||||
|
@ -1253,7 +1289,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
if (rt) {
|
||||
rt.pause = false;
|
||||
}
|
||||
chart.update('none');
|
||||
safeChartUpdate(chart, 'none', 'resume streaming');
|
||||
} else {
|
||||
if (!sessionData.manualInterval) {
|
||||
startManualRefresh();
|
||||
|
@ -1283,7 +1319,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
|
||||
try {
|
||||
console.log('🔄 Resetting zoom with full data reload...');
|
||||
|
||||
|
||||
// Get current configuration for variable information
|
||||
const cfg = resolvedConfigRef.current || session?.config;
|
||||
if (!cfg || !cfg.variables) {
|
||||
|
@ -1317,14 +1353,14 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
const enabledVariables = getEnabledVariables(cfg.variables);
|
||||
if (enabledVariables.length > 0) {
|
||||
console.log(`📊 Reloading data for ${enabledVariables.length} variables...`);
|
||||
|
||||
|
||||
// Get current time window (use same as initial load)
|
||||
const timeWindow = cfg.time_window || 3600; // Default 1 hour
|
||||
const variableNames = enabledVariables.map(v => v.name);
|
||||
|
||||
|
||||
// Load fresh historical data
|
||||
const historicalData = await loadHistoricalData(variableNames, timeWindow);
|
||||
|
||||
|
||||
if (historicalData.length > 0) {
|
||||
console.log(`📊 Loaded ${historicalData.length} historical data points for reset`);
|
||||
|
||||
|
@ -1348,7 +1384,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
// Sort points by timestamp to ensure proper order
|
||||
historicalPoints.sort((a, b) => a.x - b.x);
|
||||
chartRef.current.data.datasets[index].data = historicalPoints;
|
||||
|
||||
|
||||
// Update lastPushedX tracking for streaming continuity
|
||||
const lastPoint = historicalPoints[historicalPoints.length - 1];
|
||||
if (lastPoint && typeof lastPoint.x === 'number') {
|
||||
|
@ -1364,7 +1400,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
const totalHistoricalPoints = Object.values(dataByVariable).reduce((sum, points) => sum + points.length, 0);
|
||||
setDataPointsCount(totalHistoricalPoints);
|
||||
}
|
||||
|
||||
|
||||
console.log('✅ Historical data reloaded');
|
||||
}
|
||||
|
||||
|
@ -1381,31 +1417,25 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
}
|
||||
}, [session, getEnabledVariables, loadHistoricalData, normalizeTimestamp]);
|
||||
|
||||
// Update configuration directly (for real-time style changes)
|
||||
// Update configuration (always recreates chart for simplicity and reliability)
|
||||
const updateConfig = useCallback(async (newConfig) => {
|
||||
try {
|
||||
console.log(`🔄 Updating configuration for plot session ${session?.session_id}...`);
|
||||
console.log(`📋 New config received:`, newConfig);
|
||||
|
||||
const oldConfig = resolvedConfigRef.current;
|
||||
resolvedConfigRef.current = { ...oldConfig, ...newConfig };
|
||||
console.log(`📋 Old config:`, oldConfig);
|
||||
|
||||
// Check if chart recreation is needed
|
||||
const needsRecreation = !oldConfig ||
|
||||
oldConfig.line_tension !== newConfig.line_tension ||
|
||||
oldConfig.stepped !== newConfig.stepped ||
|
||||
oldConfig.stacked !== newConfig.stacked ||
|
||||
oldConfig.point_radius !== newConfig.point_radius ||
|
||||
oldConfig.point_hover_radius !== newConfig.point_hover_radius ||
|
||||
oldConfig.time_window !== newConfig.time_window ||
|
||||
oldConfig.y_min !== newConfig.y_min ||
|
||||
oldConfig.y_max !== newConfig.y_max;
|
||||
// Merge new config with current configuration
|
||||
const mergedConfig = { ...oldConfig, ...newConfig };
|
||||
console.log(`📋 Merged config:`, mergedConfig);
|
||||
|
||||
if (needsRecreation) {
|
||||
console.log(`🔄 Chart needs recreation due to configuration changes`);
|
||||
await createStreamingChart();
|
||||
} else {
|
||||
console.log(`✅ No chart recreation needed, configuration updated`);
|
||||
}
|
||||
resolvedConfigRef.current = mergedConfig;
|
||||
|
||||
// Always recreate chart to ensure all changes are applied correctly
|
||||
console.log(`<EFBFBD> Recreating chart to apply configuration changes...`);
|
||||
await createStreamingChart();
|
||||
console.log(`✅ Chart recreated successfully with new configuration`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error updating configuration for plot session ${session?.session_id}:`, error);
|
||||
setError(error.message);
|
||||
|
@ -1515,20 +1545,20 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
// Update zoom configuration when zoom enabled state changes
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) return;
|
||||
|
||||
|
||||
const chart = chartRef.current;
|
||||
const zoomPlugin = chart.options?.plugins?.zoom;
|
||||
|
||||
|
||||
if (zoomPlugin) {
|
||||
// Update zoom configuration dynamically
|
||||
zoomPlugin.pan.enabled = isZoomEnabled;
|
||||
zoomPlugin.zoom.drag.enabled = isZoomEnabled;
|
||||
zoomPlugin.zoom.wheel.enabled = isZoomEnabled;
|
||||
zoomPlugin.zoom.pinch.enabled = isZoomEnabled;
|
||||
|
||||
|
||||
// Update the chart to apply the new configuration
|
||||
chart.update('none');
|
||||
|
||||
safeChartUpdate(chart, 'none', 'zoom configuration');
|
||||
|
||||
console.log(`🔍 Zoom/Pan ${isZoomEnabled ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
}, [isZoomEnabled]);
|
||||
|
@ -1536,10 +1566,10 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
// Periodic health monitoring
|
||||
useEffect(() => {
|
||||
if (!session?.session_id || !chartRef.current) return;
|
||||
|
||||
|
||||
const healthCheckInterval = setInterval(() => {
|
||||
const isHealthy = checkChartHealth();
|
||||
|
||||
|
||||
// Log health status periodically for debugging
|
||||
if (!isHealthy) {
|
||||
console.warn('📊 Chart health check:', {
|
||||
|
@ -1551,7 +1581,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
});
|
||||
}
|
||||
}, 15000); // Check every 15 seconds
|
||||
|
||||
|
||||
return () => clearInterval(healthCheckInterval);
|
||||
}, [session?.session_id, checkChartHealth]);
|
||||
|
||||
|
@ -1812,54 +1842,13 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
>
|
||||
🔄 Reset Zoom
|
||||
</button>
|
||||
|
||||
{/* Auto-recovery button when chart health is poor */}
|
||||
{!chartHealthRef.current.isHealthy && (
|
||||
<>
|
||||
<button
|
||||
onClick={attemptAutoRecovery}
|
||||
style={{
|
||||
background: 'rgba(255, 140, 0, 0.9)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}
|
||||
title="Restart chart (use when data stops loading)"
|
||||
>
|
||||
🚑 Fix Chart
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={runDiagnostics}
|
||||
style={{
|
||||
background: 'rgba(128, 128, 128, 0.9)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}
|
||||
title="Show chart diagnostics in console"
|
||||
>
|
||||
🔍 Debug
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
id={`chart-canvas-${session?.session_id || 'loading'}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
|
|
|
@ -0,0 +1,852 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Text,
|
||||
Input,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Flex,
|
||||
Spacer,
|
||||
Badge,
|
||||
Spinner,
|
||||
useToast,
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionButton,
|
||||
AccordionPanel,
|
||||
AccordionIcon,
|
||||
Checkbox,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Collapse,
|
||||
Tag,
|
||||
TagLabel,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
useColorModeValue
|
||||
} from '@chakra-ui/react'
|
||||
import {
|
||||
FaPlay,
|
||||
FaFileExcel,
|
||||
FaFolder,
|
||||
FaFile,
|
||||
FaSearch,
|
||||
FaFilter,
|
||||
FaCalendar,
|
||||
FaDatabase,
|
||||
FaSync,
|
||||
FaCog,
|
||||
FaCopy
|
||||
} from 'react-icons/fa'
|
||||
import * as api from '../services/api'
|
||||
|
||||
// Filter functions
|
||||
const filterFiles = (files, query, selectedDatasets, selectedDates) => {
|
||||
if (!files) return []
|
||||
|
||||
return files.filter(file => {
|
||||
// Text search in filename
|
||||
const matchesQuery = !query ||
|
||||
file.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
file.dataset.toLowerCase().includes(query.toLowerCase())
|
||||
|
||||
// Dataset filter
|
||||
const matchesDataset = selectedDatasets.length === 0 ||
|
||||
selectedDatasets.includes(file.dataset)
|
||||
|
||||
// Date filter
|
||||
const matchesDate = selectedDates.length === 0 ||
|
||||
selectedDates.includes(file.date)
|
||||
|
||||
return matchesQuery && matchesDataset && matchesDate
|
||||
})
|
||||
}
|
||||
|
||||
// Rebuild tree structure from filtered files
|
||||
const rebuildTreeFromFilteredFiles = (filteredFiles) => {
|
||||
if (!filteredFiles || filteredFiles.length === 0) return []
|
||||
|
||||
const treeMap = new Map()
|
||||
|
||||
filteredFiles.forEach(file => {
|
||||
const dateKey = file.date
|
||||
const datasetKey = `${file.date}_${file.dataset}`
|
||||
|
||||
// Create date node if it doesn't exist
|
||||
if (!treeMap.has(dateKey)) {
|
||||
treeMap.set(dateKey, {
|
||||
id: `date_${dateKey}`,
|
||||
name: `📅 ${dateKey}`,
|
||||
value: dateKey,
|
||||
type: "date",
|
||||
children: new Map()
|
||||
})
|
||||
}
|
||||
|
||||
const dateNode = treeMap.get(dateKey)
|
||||
|
||||
// Create dataset node if it doesn't exist under this date
|
||||
if (!dateNode.children.has(datasetKey)) {
|
||||
dateNode.children.set(datasetKey, {
|
||||
id: `dataset_${dateKey}_${file.dataset}`,
|
||||
name: `📊 ${file.dataset}`,
|
||||
value: file.dataset,
|
||||
type: "dataset",
|
||||
children: []
|
||||
})
|
||||
}
|
||||
|
||||
const datasetNode = dateNode.children.get(datasetKey)
|
||||
datasetNode.children.push({
|
||||
id: `file_${file.date}_${file.value}`,
|
||||
name: `📊 ${file.value}`,
|
||||
value: file.value,
|
||||
type: "file",
|
||||
path: file.path,
|
||||
date: file.date,
|
||||
dataset: file.dataset,
|
||||
size: file.size,
|
||||
size_human: file.size_human,
|
||||
modified: file.modified,
|
||||
modified_human: file.modified_human,
|
||||
columns: file.columns,
|
||||
column_count: file.column_count,
|
||||
row_count: file.row_count,
|
||||
preview: file.preview
|
||||
})
|
||||
})
|
||||
|
||||
// Convert Maps to Arrays and sort
|
||||
const tree = Array.from(treeMap.values()).map(dateNode => ({
|
||||
...dateNode,
|
||||
children: Array.from(dateNode.children.values()).sort((a, b) => a.value.localeCompare(b.value))
|
||||
})).sort((a, b) => b.value.localeCompare(a.value)) // Sort dates in descending order
|
||||
|
||||
return tree
|
||||
}
|
||||
|
||||
// PlotJuggler Configuration Modal
|
||||
function PlotJugglerConfigModal({ isOpen, onClose, onSave, currentPath }) {
|
||||
const [path, setPath] = useState(currentPath || '')
|
||||
const [testing, setTesting] = useState(false)
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
setPath(currentPath || '')
|
||||
}, [currentPath])
|
||||
|
||||
const testPath = async () => {
|
||||
if (!path) {
|
||||
toast({
|
||||
title: '❌ No path provided',
|
||||
description: 'Please enter a path to test',
|
||||
status: 'error',
|
||||
duration: 2000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setTesting(true)
|
||||
try {
|
||||
// Simple test by trying to set the path
|
||||
await api.setPlotJugglerPath(path)
|
||||
toast({
|
||||
title: '✅ Path is valid',
|
||||
description: 'PlotJuggler executable found',
|
||||
status: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Invalid path',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(path)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="lg">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>🔧 Configure PlotJuggler Path</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel>PlotJuggler Executable Path</FormLabel>
|
||||
<Input
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
placeholder="C:\Program Files\PlotJuggler\plotjuggler.exe"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
<Text fontSize="sm">
|
||||
The system will automatically search common locations for PlotJuggler.
|
||||
You only need to set this if PlotJuggler is installed in a custom location.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
Common locations:
|
||||
<br />• C:\Program Files\PlotJuggler\plotjuggler.exe
|
||||
<br />• C:\Program Files (x86)\PlotJuggler\plotjuggler.exe
|
||||
</Text>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack spacing={2}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={testPath}
|
||||
isLoading={testing}
|
||||
loadingText="Testing..."
|
||||
>
|
||||
🧪 Test Path
|
||||
</Button>
|
||||
<Button colorScheme="blue" onClick={handleSave}>
|
||||
💾 Save
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// File Tree Component (Accordion-based since TreeView might not be available)
|
||||
function FileTree({ tree, selectedFiles, onFileToggle, expandedItems, onToggleExpand }) {
|
||||
const cardBg = useColorModeValue('white', 'gray.700')
|
||||
|
||||
if (!tree || tree.length === 0) {
|
||||
return (
|
||||
<Box textAlign="center" py={8}>
|
||||
<Text color="gray.500">No CSV files found</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
allowMultiple
|
||||
index={expandedItems}
|
||||
onChange={onToggleExpand}
|
||||
>
|
||||
{tree.map((dateNode, dateIndex) => (
|
||||
<AccordionItem key={dateNode.id} border="1px solid" borderColor="gray.200" mb={2}>
|
||||
<AccordionButton>
|
||||
<Box flex="1" textAlign="left">
|
||||
<HStack>
|
||||
<FaCalendar />
|
||||
<Text fontWeight="bold">{dateNode.name}</Text>
|
||||
<Badge colorScheme="blue">
|
||||
{dateNode.children?.reduce((sum, dataset) => sum + (dataset.children?.length || 0), 0)} files
|
||||
</Badge>
|
||||
</HStack>
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel pb={4}>
|
||||
<Accordion allowMultiple>
|
||||
{dateNode.children?.map((datasetNode, datasetIndex) => (
|
||||
<AccordionItem key={datasetNode.id}>
|
||||
<AccordionButton>
|
||||
<Box flex="1" textAlign="left">
|
||||
<HStack>
|
||||
<FaDatabase />
|
||||
<Text>{datasetNode.name}</Text>
|
||||
<Badge colorScheme="green">
|
||||
{datasetNode.children?.length || 0} files
|
||||
</Badge>
|
||||
</HStack>
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel pb={4}>
|
||||
<VStack spacing={2} align="stretch">
|
||||
{datasetNode.children?.map((fileNode) => (
|
||||
<Card key={fileNode.id} size="sm" bg={cardBg}>
|
||||
<CardBody py={2}>
|
||||
<HStack spacing={3}>
|
||||
<Checkbox
|
||||
isChecked={selectedFiles.has(fileNode.path)}
|
||||
onChange={() => onFileToggle(fileNode)}
|
||||
/>
|
||||
<FaFile />
|
||||
<VStack spacing={1} align="start" flex={1}>
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{fileNode.value}
|
||||
</Text>
|
||||
<HStack spacing={2}>
|
||||
<Badge size="sm" colorScheme="gray">
|
||||
{fileNode.size_human}
|
||||
</Badge>
|
||||
<Badge size="sm" colorScheme="blue">
|
||||
{fileNode.row_count} rows
|
||||
</Badge>
|
||||
<Badge size="sm" colorScheme="green">
|
||||
{fileNode.column_count} cols
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
📊 {fileNode.preview}
|
||||
</Text>
|
||||
</VStack>
|
||||
<VStack spacing={1}>
|
||||
<Tooltip label="Open in PlotJuggler">
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon={<FaPlay />}
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
onClick={() => onFileToggle(fileNode, 'plotjuggler')}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label="Open in Excel">
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon={<FaFileExcel />}
|
||||
colorScheme="green"
|
||||
variant="outline"
|
||||
onClick={() => onFileToggle(fileNode, 'excel')}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label="Copy file path">
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon={<FaCopy />}
|
||||
colorScheme="purple"
|
||||
variant="outline"
|
||||
onClick={() => onFileToggle(fileNode, 'copy')}
|
||||
/>
|
||||
</Tooltip>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</VStack>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
|
||||
// Main CSV File Browser Component
|
||||
export default function CsvFileBrowser() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [files, setFiles] = useState([])
|
||||
const [originalTree, setOriginalTree] = useState([])
|
||||
const [filteredTree, setFilteredTree] = useState([])
|
||||
const [filteredFiles, setFilteredFiles] = useState([])
|
||||
const [selectedFiles, setSelectedFiles] = useState(new Set())
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedDatasets, setSelectedDatasets] = useState([])
|
||||
const [selectedDates, setSelectedDates] = useState([])
|
||||
const [expandedItems, setExpandedItems] = useState([])
|
||||
const [plotjugglerPath, setPlotjugglerPath] = useState(null)
|
||||
|
||||
const toast = useToast()
|
||||
const { isOpen: isConfigOpen, onOpen: onConfigOpen, onClose: onConfigClose } = useDisclosure()
|
||||
|
||||
// Load CSV files
|
||||
const loadFiles = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await api.getCsvFiles()
|
||||
setFiles(response.files || [])
|
||||
setOriginalTree(response.tree || [])
|
||||
setFilteredTree(response.tree || [])
|
||||
|
||||
toast({
|
||||
title: '✅ Files loaded',
|
||||
description: `Found ${response.total_files || 0} CSV files`,
|
||||
status: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to load files',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Load PlotJuggler path
|
||||
const loadPlotJugglerPath = async () => {
|
||||
try {
|
||||
const response = await api.getPlotJugglerPath()
|
||||
setPlotjugglerPath(response.path)
|
||||
} catch (error) {
|
||||
console.warn('PlotJuggler path not configured')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle file selection
|
||||
const handleFileToggle = async (fileNode, action = 'select') => {
|
||||
if (action === 'plotjuggler') {
|
||||
// Open single file in PlotJuggler
|
||||
try {
|
||||
const response = await api.launchPlotJuggler([fileNode.path])
|
||||
toast({
|
||||
title: '🚀 PlotJuggler launched',
|
||||
description: `Opened ${fileNode.value}`,
|
||||
status: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
|
||||
// Show command in console for debugging
|
||||
if (response.command) {
|
||||
console.log('PlotJuggler command:', response.command)
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to launch PlotJuggler',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
} else if (action === 'excel') {
|
||||
// Open in Excel
|
||||
try {
|
||||
await api.openCsvInExcel(fileNode.path)
|
||||
toast({
|
||||
title: '📊 File opened',
|
||||
description: `Opened ${fileNode.value} in Excel`,
|
||||
status: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to open file',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
} else if (action === 'copy') {
|
||||
// Copy path to clipboard
|
||||
try {
|
||||
await navigator.clipboard.writeText(fileNode.path)
|
||||
toast({
|
||||
title: '📋 Path copied',
|
||||
description: `File path copied to clipboard`,
|
||||
status: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
// Fallback for older browsers
|
||||
try {
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = fileNode.path
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textArea)
|
||||
toast({
|
||||
title: '📋 Path copied',
|
||||
description: `File path copied to clipboard`,
|
||||
status: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
} catch (fallbackError) {
|
||||
toast({
|
||||
title: '❌ Failed to copy path',
|
||||
description: 'Clipboard access not available',
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Toggle selection
|
||||
const newSelected = new Set(selectedFiles)
|
||||
if (newSelected.has(fileNode.path)) {
|
||||
newSelected.delete(fileNode.path)
|
||||
} else {
|
||||
newSelected.add(fileNode.path)
|
||||
}
|
||||
setSelectedFiles(newSelected)
|
||||
}
|
||||
}
|
||||
|
||||
// Copy selected file paths
|
||||
const copySelectedPaths = async () => {
|
||||
if (selectedFiles.size === 0) {
|
||||
toast({
|
||||
title: '⚠️ No files selected',
|
||||
description: 'Please select files to copy their paths',
|
||||
status: 'warning',
|
||||
duration: 2000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const filePaths = Array.from(selectedFiles)
|
||||
const pathsText = filePaths.join('\n')
|
||||
await navigator.clipboard.writeText(pathsText)
|
||||
toast({
|
||||
title: '📋 Paths copied',
|
||||
description: `${filePaths.length} file path(s) copied to clipboard`,
|
||||
status: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
// Fallback for older browsers
|
||||
try {
|
||||
const filePaths = Array.from(selectedFiles)
|
||||
const pathsText = filePaths.join('\n')
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = pathsText
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textArea)
|
||||
toast({
|
||||
title: '📋 Paths copied',
|
||||
description: `${filePaths.length} file path(s) copied to clipboard`,
|
||||
status: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
} catch (fallbackError) {
|
||||
toast({
|
||||
title: '❌ Failed to copy paths',
|
||||
description: 'Clipboard access not available',
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Launch PlotJuggler with selected files
|
||||
const launchSelectedFiles = async () => {
|
||||
if (selectedFiles.size === 0) {
|
||||
toast({
|
||||
title: '⚠️ No files selected',
|
||||
description: 'Please select files to open in PlotJuggler',
|
||||
status: 'warning',
|
||||
duration: 2000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const filePaths = Array.from(selectedFiles)
|
||||
const response = await api.launchPlotJuggler(filePaths)
|
||||
|
||||
toast({
|
||||
title: '🚀 PlotJuggler launched',
|
||||
description: filePaths.length === 1
|
||||
? `Opened ${filePaths.length} file`
|
||||
: `Opened ${filePaths.length} files in single instance`,
|
||||
status: 'success',
|
||||
duration: 3000
|
||||
})
|
||||
|
||||
// Show command in console for debugging
|
||||
if (response.command) {
|
||||
console.log('PlotJuggler command:', response.command)
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to launch PlotJuggler',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Save PlotJuggler path
|
||||
const savePlotJugglerPath = async (path) => {
|
||||
try {
|
||||
await api.setPlotJugglerPath(path)
|
||||
setPlotjugglerPath(path)
|
||||
toast({
|
||||
title: '✅ PlotJuggler path saved',
|
||||
status: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to save path',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get unique datasets and dates for filtering
|
||||
const availableDatasets = [...new Set(files.map(f => f.dataset))].sort()
|
||||
const availableDates = [...new Set(files.map(f => f.date))].sort().reverse()
|
||||
|
||||
// Filter files and rebuild tree based on search and filters
|
||||
useEffect(() => {
|
||||
const filtered = filterFiles(files, searchQuery, selectedDatasets, selectedDates)
|
||||
setFilteredFiles(filtered)
|
||||
|
||||
// Rebuild tree with filtered files
|
||||
const newFilteredTree = rebuildTreeFromFilteredFiles(filtered)
|
||||
setFilteredTree(newFilteredTree)
|
||||
}, [files, searchQuery, selectedDatasets, selectedDates])
|
||||
|
||||
// Load data on mount
|
||||
useEffect(() => {
|
||||
loadFiles()
|
||||
loadPlotJugglerPath()
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Flex align="center" justify="center" py={8}>
|
||||
<Spinner size="xl" mr={4} />
|
||||
<Text fontSize="lg">Loading CSV files...</Text>
|
||||
</Flex>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* Header */}
|
||||
<Flex align="center">
|
||||
<Heading size="lg">📁 CSV File Browser</Heading>
|
||||
<Spacer />
|
||||
<HStack spacing={2}>
|
||||
<Tooltip label="Configure PlotJuggler">
|
||||
<IconButton
|
||||
icon={<FaCog />}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onConfigOpen}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
leftIcon={<FaSync />}
|
||||
onClick={loadFiles}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* PlotJuggler Status */}
|
||||
{plotjugglerPath && (
|
||||
<Alert status="success">
|
||||
<AlertIcon />
|
||||
<Text fontSize="sm">
|
||||
PlotJuggler found: <code>{plotjugglerPath}</code>
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Search and Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Heading size="md">🔍 Search & Filter</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* Search */}
|
||||
<HStack>
|
||||
<FaSearch />
|
||||
<Input
|
||||
placeholder="Search files by name or dataset..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* Filters */}
|
||||
<HStack spacing={4} align="start">
|
||||
<Box flex={1}>
|
||||
<Text fontSize="sm" fontWeight="bold" mb={2}>📊 Datasets</Text>
|
||||
<Wrap>
|
||||
{availableDatasets.map(dataset => (
|
||||
<WrapItem key={dataset}>
|
||||
<Tag
|
||||
size="sm"
|
||||
variant={selectedDatasets.includes(dataset) ? 'solid' : 'outline'}
|
||||
colorScheme="blue"
|
||||
cursor="pointer"
|
||||
onClick={() => {
|
||||
if (selectedDatasets.includes(dataset)) {
|
||||
setSelectedDatasets(selectedDatasets.filter(d => d !== dataset))
|
||||
} else {
|
||||
setSelectedDatasets([...selectedDatasets, dataset])
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TagLabel>{dataset}</TagLabel>
|
||||
</Tag>
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
</Box>
|
||||
|
||||
<Box flex={1}>
|
||||
<Text fontSize="sm" fontWeight="bold" mb={2}>📅 Dates</Text>
|
||||
<Wrap>
|
||||
{availableDates.map(date => (
|
||||
<WrapItem key={date}>
|
||||
<Tag
|
||||
size="sm"
|
||||
variant={selectedDates.includes(date) ? 'solid' : 'outline'}
|
||||
colorScheme="green"
|
||||
cursor="pointer"
|
||||
onClick={() => {
|
||||
if (selectedDates.includes(date)) {
|
||||
setSelectedDates(selectedDates.filter(d => d !== date))
|
||||
} else {
|
||||
setSelectedDates([...selectedDates, date])
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TagLabel>{date}</TagLabel>
|
||||
</Tag>
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
</Box>
|
||||
</HStack>
|
||||
|
||||
{/* Clear filters */}
|
||||
{(searchQuery || selectedDatasets.length > 0 || selectedDates.length > 0) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSearchQuery('')
|
||||
setSelectedDatasets([])
|
||||
setSelectedDates([])
|
||||
}}
|
||||
>
|
||||
🗑️ Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Selected Files Actions */}
|
||||
{selectedFiles.size > 0 && (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<HStack spacing={4}>
|
||||
<Text fontWeight="bold">
|
||||
{selectedFiles.size} file(s) selected
|
||||
</Text>
|
||||
<Button
|
||||
leftIcon={<FaPlay />}
|
||||
colorScheme="blue"
|
||||
onClick={launchSelectedFiles}
|
||||
>
|
||||
Open in PlotJuggler
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<FaCopy />}
|
||||
colorScheme="purple"
|
||||
variant="outline"
|
||||
onClick={copySelectedPaths}
|
||||
>
|
||||
Copy Paths
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSelectedFiles(new Set())}
|
||||
>
|
||||
Clear Selection
|
||||
</Button>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* File Tree */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Flex align="center">
|
||||
<Heading size="md">📂 File Tree</Heading>
|
||||
<Spacer />
|
||||
<Badge colorScheme="blue">
|
||||
{filteredFiles.length} of {files.length} files
|
||||
</Badge>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<FileTree
|
||||
tree={filteredTree}
|
||||
selectedFiles={selectedFiles}
|
||||
onFileToggle={handleFileToggle}
|
||||
expandedItems={expandedItems}
|
||||
onToggleExpand={setExpandedItems}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* PlotJuggler Config Modal */}
|
||||
<PlotJugglerConfigModal
|
||||
isOpen={isConfigOpen}
|
||||
onClose={onConfigClose}
|
||||
onSave={savePlotJugglerPath}
|
||||
currentPath={plotjugglerPath}
|
||||
/>
|
||||
</VStack>
|
||||
)
|
||||
}
|
|
@ -1,310 +0,0 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Select,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Heading,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
Button
|
||||
} from '@chakra-ui/react'
|
||||
// No necesitamos Form completo, solo FormTable
|
||||
import FormTable from './FormTable.jsx'
|
||||
import { getSchema, readConfig, writeConfig, activateDataset, deactivateDataset } from '../services/api.js'
|
||||
|
||||
/**
|
||||
* DatasetCompleteManager - Gestiona datasets y variables de forma simplificada
|
||||
* Incluye: tabla de datasets individuales + variables (sin campos estáticos de configuración)
|
||||
*/
|
||||
export default function DatasetCompleteManager({ status }) {
|
||||
const [fullData, setFullData] = useState({})
|
||||
const [datasetVariables, setDatasetVariables] = useState({})
|
||||
const [selectedDatasetId, setSelectedDatasetId] = useState('')
|
||||
|
||||
const [datasetSchema, setDatasetSchema] = useState(null)
|
||||
const [datasetUiSchema, setDatasetUiSchema] = useState({})
|
||||
const [variableSchema, setVariableSchema] = useState(null)
|
||||
const [variableUiSchema, setVariableUiSchema] = useState({})
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
const [liveValues, setLiveValues] = useState({})
|
||||
const sseRef = React.useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
// Subscribe to SSE for live variable values of the selected dataset
|
||||
useEffect(() => {
|
||||
// Close previous stream
|
||||
if (sseRef.current) {
|
||||
try { sseRef.current.close() } catch { /* ignore */ }
|
||||
sseRef.current = null
|
||||
}
|
||||
|
||||
// Only stream when online and dataset selected
|
||||
const plcConnected = !!status?.plc_connected
|
||||
if (!plcConnected || !selectedDatasetId) {
|
||||
setLiveValues({})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const es = new EventSource(`/api/stream/variables?dataset_id=${encodeURIComponent(selectedDatasetId)}&interval=1.0`)
|
||||
sseRef.current = es
|
||||
es.onmessage = (evt) => {
|
||||
try {
|
||||
const payload = JSON.parse(evt.data)
|
||||
if (payload?.type === 'values' && payload.values) {
|
||||
setLiveValues(payload.values || {})
|
||||
} else if (payload?.type === 'no_cache' || payload?.type === 'dataset_inactive' || payload?.type === 'plc_disconnected') {
|
||||
setLiveValues({})
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
es.onerror = () => {
|
||||
try { es.close() } catch { /* ignore */ }
|
||||
sseRef.current = null
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
return () => {
|
||||
if (sseRef.current) {
|
||||
try { sseRef.current.close() } catch { /* ignore */ }
|
||||
sseRef.current = null
|
||||
}
|
||||
}
|
||||
}, [status?.plc_connected, selectedDatasetId])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Cargar schemas completos
|
||||
const [datasetSchemaResp, variableSchemaResp] = await Promise.all([
|
||||
getSchema('dataset-definitions'),
|
||||
getSchema('dataset-variables')
|
||||
])
|
||||
|
||||
// Cargar datos
|
||||
const [datasetDataResp, variableDataResp] = await Promise.all([
|
||||
readConfig('dataset-definitions'),
|
||||
readConfig('dataset-variables')
|
||||
])
|
||||
|
||||
// Usar schemas completos
|
||||
setDatasetSchema(datasetSchemaResp.schema)
|
||||
setDatasetUiSchema(datasetSchemaResp.ui_schema || {})
|
||||
|
||||
// Schema para variables individuales
|
||||
const variableSchemaPath = variableSchemaResp.schema?.properties?.dataset_variables?.additionalProperties?.properties?.variables?.additionalProperties
|
||||
const variableUiSchemaPath = variableSchemaResp.ui_schema?.dataset_variables?.additionalProperties?.variables?.additionalProperties
|
||||
|
||||
// FormTable requiere un schema con additionalProperties en la raíz
|
||||
setVariableSchema(variableSchemaPath ? { additionalProperties: variableSchemaPath } : null)
|
||||
setVariableUiSchema(variableUiSchemaPath || {})
|
||||
|
||||
setFullData(datasetDataResp.data || {})
|
||||
setDatasetVariables(variableDataResp.data?.dataset_variables || {})
|
||||
|
||||
// Seleccionar dataset actual
|
||||
const currentId = datasetDataResp.data?.current_dataset_id
|
||||
const datasetIds = Object.keys(datasetDataResp.data?.datasets || {})
|
||||
if (currentId && datasetIds.includes(currentId)) {
|
||||
setSelectedDatasetId(currentId)
|
||||
} else if (datasetIds.length > 0) {
|
||||
setSelectedDatasetId(datasetIds[0])
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading complete data:', error)
|
||||
setMessage(`Error loading data: ${error.message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveFullData = async (newData) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await writeConfig('dataset-definitions', newData)
|
||||
setFullData(newData)
|
||||
setMessage('Datasets saved successfully')
|
||||
setTimeout(() => setMessage(''), 3000)
|
||||
} catch (error) {
|
||||
console.error('Error saving data:', error)
|
||||
setMessage(`Error saving datasets: ${error.message}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveDatasets = async (newDatasets) => {
|
||||
try {
|
||||
// Detectar cambios en "enabled" para llamar a endpoints de activación, evitando estados inconsistentes
|
||||
const prev = fullData.datasets || {}
|
||||
const changedIds = []
|
||||
for (const [id, cfg] of Object.entries(newDatasets)) {
|
||||
const before = prev[id]?.enabled === true
|
||||
const after = cfg?.enabled === true
|
||||
if (before !== after) changedIds.push({ id, after })
|
||||
}
|
||||
|
||||
// Persistir datasets primero
|
||||
const newFullData = { datasets: newDatasets }
|
||||
await saveFullData(newFullData)
|
||||
|
||||
// Aplicar activación/desactivación en backend para arrancar/parar hilos
|
||||
await Promise.allSettled(changedIds.map(({ id, after }) => after ? activateDataset(id) : deactivateDataset(id)))
|
||||
|
||||
// Refrescar selección y datos locales tras cambios
|
||||
setFullData(newFullData)
|
||||
if (selectedDatasetId && !newDatasets[selectedDatasetId]) {
|
||||
const ids = Object.keys(newDatasets)
|
||||
setSelectedDatasetId(ids[0] || '')
|
||||
}
|
||||
setMessage('Datasets saved and activation applied')
|
||||
setTimeout(() => setMessage(''), 2000)
|
||||
} catch (error) {
|
||||
console.error('Error saving datasets:', error)
|
||||
setMessage(`Error saving datasets: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const saveDatasetVariables = async (newVariables) => {
|
||||
try {
|
||||
const updatedDatasetVariables = {
|
||||
...datasetVariables,
|
||||
[selectedDatasetId]: {
|
||||
variables: newVariables,
|
||||
streaming_variables: Object.keys(newVariables).filter(key => newVariables[key]?.streaming)
|
||||
}
|
||||
}
|
||||
|
||||
const saveData = {
|
||||
dataset_variables: updatedDatasetVariables
|
||||
}
|
||||
|
||||
await writeConfig('dataset-variables', saveData)
|
||||
setDatasetVariables(updatedDatasetVariables)
|
||||
setMessage('Dataset variables saved successfully')
|
||||
setTimeout(() => setMessage(''), 3000)
|
||||
} catch (error) {
|
||||
console.error('Error saving variables:', error)
|
||||
setMessage(`Error saving variables: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const currentDatasetVariables = useMemo(() => {
|
||||
return selectedDatasetId && datasetVariables[selectedDatasetId]
|
||||
? datasetVariables[selectedDatasetId].variables || {}
|
||||
: {}
|
||||
}, [selectedDatasetId, datasetVariables])
|
||||
|
||||
const datasetOptions = Object.entries(fullData.datasets || {}).map(([id, dataset]) => ({
|
||||
value: id,
|
||||
label: `${dataset.name || id} (${id})`
|
||||
}))
|
||||
|
||||
if (loading) {
|
||||
return <Text>Loading dataset configuration...</Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{message && (
|
||||
<Alert status={message.includes('Error') ? 'error' : 'success'}>
|
||||
<AlertIcon />
|
||||
{message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Tabla de Datasets */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Heading size="sm">📊 Dataset Management</Heading>
|
||||
<Text fontSize="sm" color={muted}>
|
||||
Manage your datasets: create, edit and configure
|
||||
</Text>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{datasetSchema?.properties?.datasets ? (
|
||||
<FormTable
|
||||
schema={datasetSchema.properties.datasets}
|
||||
uiSchema={datasetUiSchema.datasets}
|
||||
data={fullData.datasets || {}}
|
||||
onChange={saveDatasets}
|
||||
title="Datasets"
|
||||
keyField="id"
|
||||
/>
|
||||
) : (
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
Dataset schema for individual datasets not available
|
||||
</Alert>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Variables del Dataset */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="sm">🔧 Dataset Variables</Heading>
|
||||
<HStack>
|
||||
<Text fontSize="sm" color={muted}>Dataset:</Text>
|
||||
<Select
|
||||
size="sm"
|
||||
value={selectedDatasetId}
|
||||
onChange={(e) => setSelectedDatasetId(e.target.value)}
|
||||
placeholder="Select dataset"
|
||||
width="200px"
|
||||
>
|
||||
{datasetOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{!selectedDatasetId ? (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
Select a dataset to manage its variables
|
||||
</Alert>
|
||||
) : variableSchema ? (
|
||||
<FormTable
|
||||
schema={variableSchema}
|
||||
uiSchema={variableUiSchema}
|
||||
data={currentDatasetVariables}
|
||||
onChange={saveDatasetVariables}
|
||||
title={`Variables for: ${fullData.datasets?.[selectedDatasetId]?.name || selectedDatasetId}`}
|
||||
keyField="name"
|
||||
liveValues={liveValues}
|
||||
/>
|
||||
) : (
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
Variable schema not available
|
||||
</Alert>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
)
|
||||
}
|
|
@ -1,237 +0,0 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Select,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Heading,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
useColorModeValue,
|
||||
Divider
|
||||
} from '@chakra-ui/react'
|
||||
import FormTable from './FormTable.jsx'
|
||||
import { getSchema, readConfig, writeConfig } from '../services/api.js'
|
||||
|
||||
/**
|
||||
* DatasetFormManager - Gestiona datasets y variables usando FormTable
|
||||
*/
|
||||
export default function DatasetFormManager() {
|
||||
const [datasets, setDatasets] = useState({})
|
||||
const [datasetVariables, setDatasetVariables] = useState({})
|
||||
const [selectedDatasetId, setSelectedDatasetId] = useState('')
|
||||
|
||||
const [datasetSchema, setDatasetSchema] = useState(null)
|
||||
const [datasetUiSchema, setDatasetUiSchema] = useState({})
|
||||
const [variableSchema, setVariableSchema] = useState(null)
|
||||
const [variableUiSchema, setVariableUiSchema] = useState({})
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Cargar schemas
|
||||
const [datasetSchemaResp, variableSchemaResp] = await Promise.all([
|
||||
getSchema('dataset-definitions'),
|
||||
getSchema('dataset-variables')
|
||||
])
|
||||
|
||||
console.log('Dataset schema response:', datasetSchemaResp)
|
||||
console.log('Variable schema response:', variableSchemaResp)
|
||||
|
||||
// Cargar datos
|
||||
const [datasetDataResp, variableDataResp] = await Promise.all([
|
||||
readConfig('dataset-definitions'),
|
||||
readConfig('dataset-variables')
|
||||
])
|
||||
|
||||
console.log('Dataset data response:', datasetDataResp)
|
||||
console.log('Variable data response:', variableDataResp)
|
||||
|
||||
// Extraer schemas correctamente
|
||||
setDatasetSchema(datasetSchemaResp.schema?.properties?.datasets)
|
||||
setDatasetUiSchema(datasetSchemaResp.ui_schema?.datasets || {})
|
||||
|
||||
console.log('Dataset full schema:', datasetSchemaResp.schema?.properties?.datasets)
|
||||
console.log('Dataset full uiSchema:', datasetSchemaResp.ui_schema?.datasets)
|
||||
|
||||
setVariableSchema(variableSchemaResp.schema?.properties?.dataset_variables?.additionalProperties?.properties?.variables)
|
||||
setVariableUiSchema(variableSchemaResp.ui_schema?.dataset_variables?.additionalProperties?.variables || {})
|
||||
|
||||
console.log('Variable full schema:', variableSchemaResp.schema?.properties?.dataset_variables?.additionalProperties?.properties?.variables)
|
||||
console.log('Variable full uiSchema:', variableSchemaResp.ui_schema?.dataset_variables?.additionalProperties?.variables)
|
||||
|
||||
setDatasets(datasetDataResp.data?.datasets || {})
|
||||
setDatasetVariables(variableDataResp.data?.dataset_variables || {})
|
||||
|
||||
// Seleccionar primer dataset
|
||||
const datasetIds = Object.keys(datasetDataResp.data?.datasets || {})
|
||||
if (datasetIds.length > 0 && !selectedDatasetId) {
|
||||
setSelectedDatasetId(datasetIds[0])
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading data:', error)
|
||||
setMessage(`Error loading data: ${error.message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveDatasets = async (newDatasets) => {
|
||||
try {
|
||||
const currentConfig = await readConfig('dataset-definitions')
|
||||
const saveData = {
|
||||
...currentConfig.data,
|
||||
datasets: newDatasets,
|
||||
last_update: new Date().toISOString()
|
||||
}
|
||||
|
||||
await writeConfig('dataset-definitions', saveData)
|
||||
setDatasets(newDatasets)
|
||||
setMessage('Datasets saved successfully')
|
||||
setTimeout(() => setMessage(''), 3000)
|
||||
} catch (error) {
|
||||
console.error('Error saving datasets:', error)
|
||||
setMessage(`Error saving datasets: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const saveDatasetVariables = async (newVariables) => {
|
||||
try {
|
||||
const currentConfig = await readConfig('dataset-variables')
|
||||
const updatedDatasetVariables = {
|
||||
...datasetVariables,
|
||||
[selectedDatasetId]: {
|
||||
variables: newVariables,
|
||||
streaming_variables: Object.keys(newVariables).filter(key => newVariables[key]?.streaming)
|
||||
}
|
||||
}
|
||||
|
||||
const saveData = {
|
||||
...currentConfig.data,
|
||||
dataset_variables: updatedDatasetVariables,
|
||||
last_update: new Date().toISOString()
|
||||
}
|
||||
|
||||
await writeConfig('dataset-variables', saveData)
|
||||
setDatasetVariables(updatedDatasetVariables)
|
||||
setMessage('Dataset variables saved successfully')
|
||||
setTimeout(() => setMessage(''), 3000)
|
||||
} catch (error) {
|
||||
console.error('Error saving variables:', error)
|
||||
setMessage(`Error saving variables: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const currentDatasetVariables = useMemo(() => {
|
||||
return selectedDatasetId && datasetVariables[selectedDatasetId]
|
||||
? datasetVariables[selectedDatasetId].variables || {}
|
||||
: {}
|
||||
}, [selectedDatasetId, datasetVariables])
|
||||
|
||||
const datasetOptions = Object.entries(datasets).map(([id, dataset]) => ({
|
||||
value: id,
|
||||
label: `${dataset.name || id} (${id})`
|
||||
}))
|
||||
|
||||
if (loading) {
|
||||
return <Text>Loading datasets...</Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{message && (
|
||||
<Alert status={message.includes('Error') ? 'error' : 'success'}>
|
||||
<AlertIcon />
|
||||
{message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Datasets */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Heading size="sm">📊 Dataset Definitions</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{datasetSchema ? (
|
||||
<FormTable
|
||||
schema={datasetSchema}
|
||||
uiSchema={datasetUiSchema}
|
||||
data={datasets}
|
||||
onChange={saveDatasets}
|
||||
title="Datasets"
|
||||
keyField="id"
|
||||
/>
|
||||
) : (
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
Dataset schema not available
|
||||
</Alert>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Variables del Dataset */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="sm">🔧 Dataset Variables</Heading>
|
||||
<HStack>
|
||||
<Text fontSize="sm" color={muted}>Dataset:</Text>
|
||||
<Select
|
||||
size="sm"
|
||||
value={selectedDatasetId}
|
||||
onChange={(e) => setSelectedDatasetId(e.target.value)}
|
||||
placeholder="Select dataset"
|
||||
width="200px"
|
||||
>
|
||||
{datasetOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{!selectedDatasetId ? (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
Select a dataset to manage its variables
|
||||
</Alert>
|
||||
) : variableSchema ? (
|
||||
<FormTable
|
||||
schema={variableSchema}
|
||||
uiSchema={variableUiSchema}
|
||||
data={currentDatasetVariables}
|
||||
onChange={saveDatasetVariables}
|
||||
title={`Variables for: ${datasets[selectedDatasetId]?.name || selectedDatasetId}`}
|
||||
keyField="name"
|
||||
/>
|
||||
) : (
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
Variable schema not available
|
||||
</Alert>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
)
|
||||
}
|
|
@ -1,264 +0,0 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Select,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Heading,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
useColorModeValue,
|
||||
Divider
|
||||
} from '@chakra-ui/react'
|
||||
import EditableTable from './EditableTable.jsx'
|
||||
import { getSchema, readConfig, writeConfig } from '../services/api.js'
|
||||
|
||||
/**
|
||||
* DatasetTableManager - Componente para gestionar datasets y sus variables
|
||||
* Muestra tabla de datasets y tabla de variables del dataset seleccionado
|
||||
*/
|
||||
export default function DatasetTableManager() {
|
||||
const [datasets, setDatasets] = useState({})
|
||||
const [datasetVariables, setDatasetVariables] = useState({})
|
||||
const [selectedDatasetId, setSelectedDatasetId] = useState('')
|
||||
|
||||
const [datasetSchema, setDatasetSchema] = useState(null)
|
||||
const [datasetUiSchema, setDatasetUiSchema] = useState({})
|
||||
const [variableSchema, setVariableSchema] = useState(null)
|
||||
const [variableUiSchema, setVariableUiSchema] = useState({})
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
|
||||
// Cargar schemas y datos al montar
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Cargar schemas
|
||||
const [datasetSchemaResp, variableSchemaResp] = await Promise.all([
|
||||
getSchema('dataset-definitions'),
|
||||
getSchema('dataset-variables')
|
||||
])
|
||||
|
||||
// Cargar datos de configuración
|
||||
const [datasetDataResp, variableDataResp] = await Promise.all([
|
||||
readConfig('dataset-definitions'),
|
||||
readConfig('dataset-variables')
|
||||
])
|
||||
|
||||
setDatasetSchema(datasetSchemaResp.schema?.properties?.datasets)
|
||||
setDatasetUiSchema(datasetSchemaResp.ui_schema?.datasets || {})
|
||||
|
||||
setVariableSchema(variableSchemaResp.schema?.properties?.dataset_variables?.additionalProperties?.properties?.variables)
|
||||
setVariableUiSchema(variableSchemaResp.ui_schema?.dataset_variables?.additionalProperties?.variables || {})
|
||||
|
||||
setDatasets(datasetDataResp.data?.datasets || {})
|
||||
setDatasetVariables(datasetDataResp.data?.dataset_variables || {})
|
||||
|
||||
// Seleccionar el primer dataset si existe
|
||||
const datasetIds = Object.keys(datasetDataResp.data?.datasets || {})
|
||||
if (datasetIds.length > 0 && !selectedDatasetId) {
|
||||
setSelectedDatasetId(datasetIds[0])
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
setMessage(`Error loading data: ${error.message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveDatasets = async (newDatasets) => {
|
||||
setSaving(true)
|
||||
setMessage('')
|
||||
try {
|
||||
// Construir el objeto completo para guardar
|
||||
const saveData = {
|
||||
datasets: newDatasets,
|
||||
// Mantener otros campos existentes
|
||||
active_datasets: [], // Esto se puede gestionar por separado
|
||||
current_dataset_id: selectedDatasetId,
|
||||
version: "1.0",
|
||||
last_update: new Date().toISOString()
|
||||
}
|
||||
|
||||
await writeConfig('dataset-definitions', saveData)
|
||||
setDatasets(newDatasets)
|
||||
setMessage('Datasets saved successfully')
|
||||
} catch (error) {
|
||||
setMessage(`Error saving datasets: ${error.message}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveDatasetVariables = async (newVariables) => {
|
||||
setSaving(true)
|
||||
setMessage('')
|
||||
try {
|
||||
const updatedDatasetVariables = {
|
||||
...datasetVariables,
|
||||
[selectedDatasetId]: {
|
||||
variables: newVariables,
|
||||
streaming_variables: [] // Esto se puede calcular automáticamente
|
||||
}
|
||||
}
|
||||
|
||||
const saveData = {
|
||||
dataset_variables: updatedDatasetVariables,
|
||||
version: "1.0",
|
||||
last_update: new Date().toISOString()
|
||||
}
|
||||
|
||||
await writeConfig('dataset-variables', saveData)
|
||||
setDatasetVariables(updatedDatasetVariables)
|
||||
setMessage('Dataset variables saved successfully')
|
||||
} catch (error) {
|
||||
setMessage(`Error saving variables: ${error.message}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir datos de datasets para el componente EditableTable
|
||||
const datasetsForTable = useMemo(() => {
|
||||
return Object.entries(datasets).map(([id, data]) => ({
|
||||
id,
|
||||
...data
|
||||
}))
|
||||
}, [datasets])
|
||||
|
||||
// Convertir variables del dataset seleccionado para el componente EditableTable
|
||||
const variablesForTable = useMemo(() => {
|
||||
if (!selectedDatasetId || !datasetVariables[selectedDatasetId]) {
|
||||
return []
|
||||
}
|
||||
|
||||
const variables = datasetVariables[selectedDatasetId].variables || {}
|
||||
return Object.entries(variables).map(([name, data]) => ({
|
||||
name,
|
||||
...data
|
||||
}))
|
||||
}, [selectedDatasetId, datasetVariables])
|
||||
|
||||
const datasetOptions = Object.entries(datasets).map(([id, dataset]) => ({
|
||||
value: id,
|
||||
label: `${dataset.name} (${id})`
|
||||
}))
|
||||
|
||||
if (loading) {
|
||||
return <Text>Loading datasets...</Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{message && (
|
||||
<Alert status={message.includes('Error') ? 'error' : 'success'}>
|
||||
<AlertIcon />
|
||||
{message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Tabla de Datasets */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Heading size="sm">📊 Datasets</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{datasetSchema ? (
|
||||
<EditableTable
|
||||
schema={datasetSchema}
|
||||
uiSchema={datasetUiSchema.additionalProperties || {}}
|
||||
data={datasetsForTable}
|
||||
onChange={(newData) => {
|
||||
// Convertir de array a objeto con keys
|
||||
const newDatasets = {}
|
||||
newData.forEach(item => {
|
||||
const { id, ...rest } = item
|
||||
newDatasets[id] = rest
|
||||
})
|
||||
saveDatasets(newDatasets)
|
||||
}}
|
||||
title="Dataset Definitions"
|
||||
keyField="id"
|
||||
/>
|
||||
) : (
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
No dataset schema available
|
||||
</Alert>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Selector de Dataset y Tabla de Variables */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="sm">🔧 Dataset Variables</Heading>
|
||||
<HStack>
|
||||
<Text fontSize="sm" color={muted}>Dataset:</Text>
|
||||
<Select
|
||||
size="sm"
|
||||
value={selectedDatasetId}
|
||||
onChange={(e) => setSelectedDatasetId(e.target.value)}
|
||||
placeholder="Select dataset"
|
||||
width="200px"
|
||||
>
|
||||
{datasetOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{!selectedDatasetId ? (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
Select a dataset to manage its variables
|
||||
</Alert>
|
||||
) : variableSchema ? (
|
||||
<EditableTable
|
||||
schema={variableSchema}
|
||||
uiSchema={variableUiSchema.additionalProperties || {}}
|
||||
data={variablesForTable}
|
||||
onChange={(newData) => {
|
||||
// Convertir de array a objeto con keys
|
||||
const newVariables = {}
|
||||
newData.forEach(item => {
|
||||
const { name, ...rest } = item
|
||||
newVariables[name] = rest
|
||||
})
|
||||
saveDatasetVariables(newVariables)
|
||||
}}
|
||||
title={`Variables for dataset: ${datasets[selectedDatasetId]?.name || selectedDatasetId}`}
|
||||
keyField="name"
|
||||
/>
|
||||
) : (
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
No variable schema available
|
||||
</Alert>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
)
|
||||
}
|
|
@ -1,426 +0,0 @@
|
|||
import React, { useState } from 'react'
|
||||
import {
|
||||
Box, VStack, HStack, Card, CardBody, CardHeader, Heading, Text, Button,
|
||||
Grid, GridItem, Badge, Select, FormControl, FormLabel, Input, NumberInput,
|
||||
NumberInputField, NumberInputStepper, NumberIncrementStepper, NumberDecrementStepper,
|
||||
Checkbox, IconButton, useColorModeValue, Flex, Spacer, useToast,
|
||||
Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter,
|
||||
ModalCloseButton, useDisclosure, Alert, AlertIcon
|
||||
} from '@chakra-ui/react'
|
||||
|
||||
const PLC_AREAS = [
|
||||
{ value: 'db', label: 'DB (Data Block)' },
|
||||
{ value: 'mw', label: 'MW (Memory Word)' },
|
||||
{ value: 'm', label: 'M (Memory)' },
|
||||
{ value: 'pew', label: 'PEW (Process Input Word)' },
|
||||
{ value: 'pe', label: 'PE (Process Input)' },
|
||||
{ value: 'paw', label: 'PAW (Process Output Word)' },
|
||||
{ value: 'pa', label: 'PA (Process Output)' },
|
||||
{ value: 'e', label: 'E (Input)' },
|
||||
{ value: 'a', label: 'A (Output)' },
|
||||
{ value: 'mb', label: 'MB (Memory Byte)' }
|
||||
]
|
||||
|
||||
const DATA_TYPES = [
|
||||
{ value: 'real', label: 'REAL (32-bit float)' },
|
||||
{ value: 'int', label: 'INT (16-bit signed)' },
|
||||
{ value: 'bool', label: 'BOOL (1-bit)' },
|
||||
{ value: 'dint', label: 'DINT (32-bit signed)' },
|
||||
{ value: 'word', label: 'WORD (16-bit unsigned)' },
|
||||
{ value: 'byte', label: 'BYTE (8-bit unsigned)' },
|
||||
{ value: 'uint', label: 'UINT (16-bit unsigned)' },
|
||||
{ value: 'udint', label: 'UDINT (32-bit unsigned)' },
|
||||
{ value: 'sint', label: 'SINT (8-bit signed)' },
|
||||
{ value: 'usint', label: 'USINT (8-bit unsigned)' }
|
||||
]
|
||||
|
||||
export default function DatasetVariableManager({
|
||||
datasets,
|
||||
variables,
|
||||
selectedDatasetId,
|
||||
onSelectDataset,
|
||||
onVariablesUpdate
|
||||
}) {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const [editingVariable, setEditingVariable] = useState(null)
|
||||
const toast = useToast()
|
||||
|
||||
const cardBg = useColorModeValue('white', 'gray.700')
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600')
|
||||
|
||||
const selectedDataset = selectedDatasetId ? datasets[selectedDatasetId] : null
|
||||
const datasetVariables = selectedDatasetId ? variables[selectedDatasetId]?.variables || {} : {}
|
||||
const streamingVariables = selectedDatasetId ? variables[selectedDatasetId]?.streaming_variables || [] : []
|
||||
|
||||
const handleAddVariable = () => {
|
||||
setEditingVariable({
|
||||
name: '',
|
||||
area: 'db',
|
||||
db: 1,
|
||||
offset: 0,
|
||||
type: 'real',
|
||||
streaming: false
|
||||
})
|
||||
onOpen()
|
||||
}
|
||||
|
||||
const handleEditVariable = (varName) => {
|
||||
const variable = datasetVariables[varName]
|
||||
setEditingVariable({
|
||||
name: varName,
|
||||
...variable
|
||||
})
|
||||
onOpen()
|
||||
}
|
||||
|
||||
const handleSaveVariable = (variableData) => {
|
||||
if (!selectedDatasetId) return
|
||||
|
||||
const newVariables = { ...variables }
|
||||
|
||||
// Initialize dataset if it doesn't exist
|
||||
if (!newVariables[selectedDatasetId]) {
|
||||
newVariables[selectedDatasetId] = {
|
||||
variables: {},
|
||||
streaming_variables: []
|
||||
}
|
||||
}
|
||||
|
||||
const oldName = editingVariable?.name
|
||||
const newName = variableData.name
|
||||
|
||||
// Remove old variable if name changed
|
||||
if (oldName && oldName !== newName && newVariables[selectedDatasetId].variables[oldName]) {
|
||||
delete newVariables[selectedDatasetId].variables[oldName]
|
||||
// Update streaming_variables array
|
||||
newVariables[selectedDatasetId].streaming_variables =
|
||||
newVariables[selectedDatasetId].streaming_variables.filter(v => v !== oldName)
|
||||
}
|
||||
|
||||
// Add/update variable
|
||||
const { name, ...varConfig } = variableData
|
||||
newVariables[selectedDatasetId].variables[name] = varConfig
|
||||
|
||||
// Update streaming_variables array
|
||||
const currentStreamingVars = newVariables[selectedDatasetId].streaming_variables.filter(v => v !== name)
|
||||
if (varConfig.streaming) {
|
||||
currentStreamingVars.push(name)
|
||||
}
|
||||
newVariables[selectedDatasetId].streaming_variables = currentStreamingVars
|
||||
|
||||
onVariablesUpdate(newVariables)
|
||||
onClose()
|
||||
setEditingVariable(null)
|
||||
|
||||
toast({
|
||||
title: 'Variable saved',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
isClosable: true
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteVariable = (varName) => {
|
||||
if (!selectedDatasetId) return
|
||||
|
||||
const newVariables = { ...variables }
|
||||
delete newVariables[selectedDatasetId].variables[varName]
|
||||
newVariables[selectedDatasetId].streaming_variables =
|
||||
newVariables[selectedDatasetId].streaming_variables.filter(v => v !== varName)
|
||||
|
||||
onVariablesUpdate(newVariables)
|
||||
|
||||
toast({
|
||||
title: 'Variable deleted',
|
||||
status: 'info',
|
||||
duration: 2000,
|
||||
isClosable: true
|
||||
})
|
||||
}
|
||||
|
||||
const toggleVariableStreaming = (varName) => {
|
||||
if (!selectedDatasetId) return
|
||||
|
||||
const newVariables = { ...variables }
|
||||
const variable = newVariables[selectedDatasetId].variables[varName]
|
||||
variable.streaming = !variable.streaming
|
||||
|
||||
const currentStreamingVars = newVariables[selectedDatasetId].streaming_variables.filter(v => v !== varName)
|
||||
if (variable.streaming) {
|
||||
currentStreamingVars.push(varName)
|
||||
}
|
||||
newVariables[selectedDatasetId].streaming_variables = currentStreamingVars
|
||||
|
||||
onVariablesUpdate(newVariables)
|
||||
}
|
||||
|
||||
if (!selectedDataset) {
|
||||
return (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
Select a dataset from the overview above to manage its variables.
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Card bg={cardBg} borderColor={borderColor}>
|
||||
<CardHeader>
|
||||
<Flex align="center">
|
||||
<Box>
|
||||
<Heading size="md">Variables for "{selectedDataset.name}"</Heading>
|
||||
<Text fontSize="sm" color="gray.500" mt={1}>
|
||||
ID: {selectedDatasetId} • {Object.keys(datasetVariables).length} variables • {streamingVariables.length} streaming
|
||||
</Text>
|
||||
</Box>
|
||||
<Spacer />
|
||||
<Button colorScheme="blue" onClick={handleAddVariable}>
|
||||
➕ Add Variable
|
||||
</Button>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{Object.keys(datasetVariables).length === 0 ? (
|
||||
<Text color="gray.500" textAlign="center" py={8}>
|
||||
No variables configured for this dataset.
|
||||
Click "Add Variable" to get started.
|
||||
</Text>
|
||||
) : (
|
||||
<Grid templateColumns="repeat(auto-fill, minmax(350px, 1fr))" gap={4}>
|
||||
{Object.entries(datasetVariables).map(([varName, variable]) => (
|
||||
<VariableCard
|
||||
key={varName}
|
||||
name={varName}
|
||||
variable={variable}
|
||||
isStreaming={streamingVariables.includes(varName)}
|
||||
onEdit={() => handleEditVariable(varName)}
|
||||
onDelete={() => handleDeleteVariable(varName)}
|
||||
onToggleStreaming={() => toggleVariableStreaming(varName)}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<VariableEditModal
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
onClose()
|
||||
setEditingVariable(null)
|
||||
}}
|
||||
variable={editingVariable}
|
||||
onSave={handleSaveVariable}
|
||||
/>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
|
||||
function VariableCard({ name, variable, isStreaming, onEdit, onDelete, onToggleStreaming }) {
|
||||
const cardBg = useColorModeValue('gray.50', 'gray.600')
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.500')
|
||||
|
||||
const getAreaLabel = (area) => PLC_AREAS.find(a => a.value === area)?.label || area.toUpperCase()
|
||||
const getTypeLabel = (type) => DATA_TYPES.find(t => t.value === type)?.label || type.toUpperCase()
|
||||
|
||||
return (
|
||||
<Card bg={cardBg} borderColor={borderColor} size="sm">
|
||||
<CardBody>
|
||||
<VStack align="start" spacing={3}>
|
||||
<HStack justify="space-between" w="full">
|
||||
<Heading size="sm" color="blue.600">{name}</Heading>
|
||||
<HStack spacing={1}>
|
||||
<IconButton
|
||||
icon="✏️"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={onEdit}
|
||||
aria-label="Edit variable"
|
||||
/>
|
||||
<IconButton
|
||||
icon="🗑️"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={onDelete}
|
||||
aria-label="Delete variable"
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<Grid templateColumns="1fr 1fr" gap={2} w="full" fontSize="sm">
|
||||
<Text><strong>Area:</strong> {getAreaLabel(variable.area)}</Text>
|
||||
<Text><strong>Type:</strong> {getTypeLabel(variable.type)}</Text>
|
||||
|
||||
{variable.area === 'db' && (
|
||||
<Text><strong>DB:</strong> {variable.db}</Text>
|
||||
)}
|
||||
<Text><strong>Offset:</strong> {variable.offset}</Text>
|
||||
|
||||
{variable.bit !== undefined && (
|
||||
<Text><strong>Bit:</strong> {variable.bit}</Text>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
<HStack justify="space-between" w="full">
|
||||
<Badge colorScheme={isStreaming ? 'green' : 'gray'}>
|
||||
{isStreaming ? '📡 Streaming' : '📴 Not streaming'}
|
||||
</Badge>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
colorScheme={isStreaming ? 'red' : 'green'}
|
||||
onClick={onToggleStreaming}
|
||||
>
|
||||
{isStreaming ? 'Disable' : 'Enable'} Streaming
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function VariableEditModal({ isOpen, onClose, variable, onSave }) {
|
||||
const [formData, setFormData] = useState(variable || {})
|
||||
|
||||
React.useEffect(() => {
|
||||
setFormData(variable || {})
|
||||
}, [variable])
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
if (!formData.name || !formData.area || !formData.type) {
|
||||
return
|
||||
}
|
||||
onSave(formData)
|
||||
}
|
||||
|
||||
const isEditing = variable?.name
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="lg">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
{isEditing ? `Edit Variable: ${variable.name}` : 'Add New Variable'}
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<form onSubmit={handleSubmit}>
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Variable Name</FormLabel>
|
||||
<Input
|
||||
value={formData.name || ''}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="e.g., Temperature_Tank_1"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Grid templateColumns="1fr 1fr" gap={4} w="full">
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Memory Area</FormLabel>
|
||||
<Select
|
||||
value={formData.area || 'db'}
|
||||
onChange={(e) => setFormData({ ...formData, area: e.target.value })}
|
||||
>
|
||||
{PLC_AREAS.map(area => (
|
||||
<option key={area.value} value={area.value}>
|
||||
{area.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Data Type</FormLabel>
|
||||
<Select
|
||||
value={formData.type || 'real'}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
||||
>
|
||||
{DATA_TYPES.map(type => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid templateColumns="1fr 1fr 1fr" gap={4} w="full">
|
||||
{formData.area === 'db' && (
|
||||
<FormControl>
|
||||
<FormLabel>DB Number</FormLabel>
|
||||
<NumberInput
|
||||
value={formData.db || 1}
|
||||
onChange={(_, num) => setFormData({ ...formData, db: num })}
|
||||
min={1} max={9999}
|
||||
>
|
||||
<NumberInputField />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Offset</FormLabel>
|
||||
<NumberInput
|
||||
value={formData.offset || 0}
|
||||
onChange={(_, num) => setFormData({ ...formData, offset: num })}
|
||||
min={0} max={8191}
|
||||
>
|
||||
<NumberInputField />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
|
||||
{['e', 'a', 'mb'].includes(formData.area) && (
|
||||
<FormControl>
|
||||
<FormLabel>Bit Position</FormLabel>
|
||||
<NumberInput
|
||||
value={formData.bit || 0}
|
||||
onChange={(_, num) => setFormData({ ...formData, bit: num })}
|
||||
min={0} max={7}
|
||||
>
|
||||
<NumberInputField />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
isChecked={formData.streaming || false}
|
||||
onChange={(e) => setFormData({ ...formData, streaming: e.target.checked })}
|
||||
>
|
||||
Enable streaming to PlotJuggler
|
||||
</Checkbox>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant="outline" mr={3} onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" colorScheme="blue">
|
||||
{isEditing ? 'Update' : 'Add'} Variable
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
|
@ -1,195 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Heading,
|
||||
Text,
|
||||
Select,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
useColorModeValue,
|
||||
Spinner
|
||||
} from '@chakra-ui/react'
|
||||
import Form from '@rjsf/chakra-ui'
|
||||
import validator from '@rjsf/validator-ajv8'
|
||||
import LayoutObjectFieldTemplate from './rjsf/LayoutObjectFieldTemplate.jsx'
|
||||
import { allWidgets } from './widgets/AllWidgets.jsx'
|
||||
|
||||
/**
|
||||
* DatasetVariablesRJSF - Maneja variables de dataset usando RJSF Type 3 pattern
|
||||
*/
|
||||
export default function DatasetVariablesRJSF({
|
||||
datasets = {},
|
||||
selectedDatasetId,
|
||||
onSelectDataset,
|
||||
onVariablesUpdate
|
||||
}) {
|
||||
const [schema, setSchema] = useState(null)
|
||||
const [uiSchema, setUiSchema] = useState(null)
|
||||
const [variables, setVariables] = useState({ variables: [] })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
|
||||
// Load schema and data
|
||||
useEffect(() => {
|
||||
loadSchemaAndData()
|
||||
}, [])
|
||||
|
||||
const loadSchemaAndData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Load schema
|
||||
const schemaResp = await fetch('/api/config/schema/dataset-variables')
|
||||
const schemaData = await schemaResp.json()
|
||||
|
||||
if (schemaData.success) {
|
||||
setSchema(schemaData.schema)
|
||||
setUiSchema(schemaData.ui_schema || {})
|
||||
}
|
||||
|
||||
// Load data
|
||||
const dataResp = await fetch('/api/config/dataset-variables')
|
||||
const data = await dataResp.json()
|
||||
|
||||
if (data.success) {
|
||||
setVariables(data.data || { variables: [] })
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading schema/data:', error)
|
||||
setMessage(`Error loading: ${error.message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveVariables = async (formData) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const response = await fetch('/api/config/dataset-variables', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
setVariables(formData)
|
||||
setMessage('Variables saved successfully')
|
||||
setTimeout(() => setMessage(''), 3000)
|
||||
|
||||
if (onVariablesUpdate) {
|
||||
onVariablesUpdate(formData)
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to save variables')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving variables:', error)
|
||||
setMessage(`Error saving: ${error.message}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Get available datasets for selector
|
||||
const datasetOptions = Object.entries(datasets).map(([id, dataset]) => ({
|
||||
value: id,
|
||||
label: `${dataset.name || id} (${id})`
|
||||
}))
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<VStack spacing={4}>
|
||||
<Spinner />
|
||||
<Text>Loading dataset variables configuration...</Text>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (!schema) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Alert status="error">
|
||||
<AlertIcon />
|
||||
Failed to load schema for dataset variables
|
||||
</Alert>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<VStack align="start" spacing={2}>
|
||||
<Heading size="sm">🔧 Dataset Variables</Heading>
|
||||
<Text fontSize="sm" color={muted}>
|
||||
Configure PLC variables for each dataset - use symbols or manual configuration
|
||||
</Text>
|
||||
|
||||
{datasetOptions.length > 0 && (
|
||||
<HStack>
|
||||
<Text fontSize="sm" color={muted}>Reference Dataset:</Text>
|
||||
<Select
|
||||
size="sm"
|
||||
value={selectedDatasetId || ''}
|
||||
onChange={(e) => onSelectDataset && onSelectDataset(e.target.value)}
|
||||
placeholder="Select dataset for reference"
|
||||
width="250px"
|
||||
>
|
||||
{datasetOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{message && (
|
||||
<Alert status={message.includes('Error') ? 'error' : 'success'} mb={4}>
|
||||
<AlertIcon />
|
||||
{message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Form
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
formData={variables}
|
||||
validator={validator}
|
||||
onChange={({ formData }) => setVariables(formData)}
|
||||
onSubmit={({ formData }) => saveVariables(formData)}
|
||||
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
|
||||
widgets={allWidgets}
|
||||
showErrorList={false}
|
||||
disabled={saving}
|
||||
>
|
||||
<HStack mt={4}>
|
||||
<button type="submit" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Variables'}
|
||||
</button>
|
||||
</HStack>
|
||||
</Form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
|
@ -1,355 +0,0 @@
|
|||
import React, { useState, useMemo } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Checkbox,
|
||||
IconButton,
|
||||
HStack,
|
||||
VStack,
|
||||
Text,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberDecrementStepper,
|
||||
useColorModeValue
|
||||
} from '@chakra-ui/react'
|
||||
import { AddIcon, DeleteIcon, EditIcon } from '@chakra-ui/icons'
|
||||
|
||||
/**
|
||||
* EditableTable - Componente reutilizable para editar arrays de objetos en forma de tabla
|
||||
*
|
||||
* @param {Object} schema - JSON Schema que define la estructura de los objetos
|
||||
* @param {Object} uiSchema - UI Schema con widgets y configuraciones
|
||||
* @param {Array} data - Array de objetos a editar
|
||||
* @param {Function} onChange - Callback que se ejecuta al cambiar los datos: (newData) => void
|
||||
* @param {string} title - Título de la tabla
|
||||
* @param {string} keyField - Campo que actúa como clave única (ej: 'id', 'name')
|
||||
*/
|
||||
export default function EditableTable({
|
||||
schema,
|
||||
uiSchema = {},
|
||||
data = [],
|
||||
onChange,
|
||||
title = "Data",
|
||||
keyField = "id",
|
||||
allowAdd = true,
|
||||
allowEdit = true,
|
||||
allowDelete = true
|
||||
}) {
|
||||
const [editingKey, setEditingKey] = useState(null)
|
||||
const [editingData, setEditingData] = useState({})
|
||||
const [newItem, setNewItem] = useState({})
|
||||
const { isOpen: isAddOpen, onOpen: onAddOpen, onClose: onAddClose } = useDisclosure()
|
||||
const { isOpen: isEditOpen, onOpen: onEditOpen, onClose: onEditClose } = useDisclosure()
|
||||
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600')
|
||||
|
||||
// Extraer propiedades del schema
|
||||
const properties = useMemo(() => {
|
||||
if (!schema?.properties) return {}
|
||||
return schema.properties
|
||||
}, [schema])
|
||||
|
||||
const propertyNames = useMemo(() => {
|
||||
return Object.keys(properties)
|
||||
}, [properties])
|
||||
|
||||
// Convertir array de objetos a formato objeto con keys
|
||||
const dataAsObject = useMemo(() => {
|
||||
if (Array.isArray(data)) {
|
||||
const result = {}
|
||||
data.forEach((item, index) => {
|
||||
const key = item[keyField] || `item_${index}`
|
||||
result[key] = item
|
||||
})
|
||||
return result
|
||||
}
|
||||
return data || {}
|
||||
}, [data, keyField])
|
||||
|
||||
const dataKeys = Object.keys(dataAsObject)
|
||||
|
||||
const handleDelete = (key) => {
|
||||
const newDataObj = { ...dataAsObject }
|
||||
delete newDataObj[key]
|
||||
|
||||
// Convertir de vuelta a array si es necesario
|
||||
const newData = Array.isArray(data)
|
||||
? Object.values(newDataObj)
|
||||
: newDataObj
|
||||
|
||||
onChange(newData)
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!newItem[keyField]) {
|
||||
alert(`Please provide a ${keyField}`)
|
||||
return
|
||||
}
|
||||
|
||||
const newDataObj = { ...dataAsObject }
|
||||
newDataObj[newItem[keyField]] = { ...newItem }
|
||||
|
||||
// Convertir de vuelta a array si es necesario
|
||||
const newData = Array.isArray(data)
|
||||
? Object.values(newDataObj)
|
||||
: newDataObj
|
||||
|
||||
onChange(newData)
|
||||
setNewItem({})
|
||||
onAddClose()
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
if (!editingKey) return
|
||||
|
||||
const newDataObj = { ...dataAsObject }
|
||||
newDataObj[editingKey] = { ...editingData }
|
||||
|
||||
// Convertir de vuelta a array si es necesario
|
||||
const newData = Array.isArray(data)
|
||||
? Object.values(newDataObj)
|
||||
: newDataObj
|
||||
|
||||
onChange(newData)
|
||||
setEditingKey(null)
|
||||
setEditingData({})
|
||||
onEditClose()
|
||||
}
|
||||
|
||||
const openEdit = (key) => {
|
||||
setEditingKey(key)
|
||||
setEditingData({ ...dataAsObject[key] })
|
||||
onEditOpen()
|
||||
}
|
||||
|
||||
const renderInput = (propertyName, value, setValue, itemData = {}) => {
|
||||
const property = properties[propertyName]
|
||||
const uiConfig = uiSchema[propertyName] || {}
|
||||
const widget = uiConfig['ui:widget'] || 'text'
|
||||
|
||||
const commonProps = {
|
||||
size: 'sm',
|
||||
value: value || '',
|
||||
onChange: (e) => setValue({ ...itemData, [propertyName]: e.target.value })
|
||||
}
|
||||
|
||||
if (property?.enum && widget === 'select') {
|
||||
return (
|
||||
<Select {...commonProps} placeholder="Select...">
|
||||
{property.enum.map(option => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
if (property?.type === 'boolean' || widget === 'checkbox') {
|
||||
return (
|
||||
<Checkbox
|
||||
isChecked={!!value}
|
||||
onChange={(e) => setValue({ ...itemData, [propertyName]: e.target.checked })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (property?.type === 'number' || property?.type === 'integer' || widget === 'updown') {
|
||||
return (
|
||||
<NumberInput
|
||||
size="sm"
|
||||
value={value || ''}
|
||||
onChange={(valueString) => setValue({ ...itemData, [propertyName]: parseFloat(valueString) || null })}
|
||||
min={property?.minimum}
|
||||
max={property?.maximum}
|
||||
step={property?.type === 'integer' ? 1 : 0.01}
|
||||
>
|
||||
<NumberInputField />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
)
|
||||
}
|
||||
|
||||
return <Input {...commonProps} placeholder={uiConfig['ui:placeholder'] || ''} />
|
||||
}
|
||||
|
||||
const renderValue = (propertyName, value) => {
|
||||
const property = properties[propertyName]
|
||||
|
||||
if (property?.type === 'boolean') {
|
||||
return <Checkbox isChecked={!!value} isReadOnly />
|
||||
}
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
return <Text color={muted}>-</Text>
|
||||
}
|
||||
|
||||
return <Text>{String(value)}</Text>
|
||||
}
|
||||
|
||||
const getColumnTitle = (propertyName) => {
|
||||
const property = properties[propertyName]
|
||||
return property?.title || propertyName
|
||||
}
|
||||
|
||||
if (propertyNames.length === 0) {
|
||||
return (
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
No schema properties defined for this table
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="semibold">{title}</Text>
|
||||
{allowAdd && (
|
||||
<Button size="sm" leftIcon={<AddIcon />} onClick={onAddOpen}>
|
||||
Add Item
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<Box overflowX="auto" borderWidth="1px" borderRadius="md" borderColor={borderColor}>
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
{propertyNames.map(prop => (
|
||||
<Th key={prop}>{getColumnTitle(prop)}</Th>
|
||||
))}
|
||||
{(allowEdit || allowDelete) && <Th width="100px">Actions</Th>}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{dataKeys.length === 0 ? (
|
||||
<Tr>
|
||||
<Td colSpan={propertyNames.length + 1}>
|
||||
<Text color={muted} textAlign="center">No items</Text>
|
||||
</Td>
|
||||
</Tr>
|
||||
) : (
|
||||
dataKeys.map(key => (
|
||||
<Tr key={key}>
|
||||
{propertyNames.map(prop => (
|
||||
<Td key={prop}>
|
||||
{renderValue(prop, dataAsObject[key][prop])}
|
||||
</Td>
|
||||
))}
|
||||
{(allowEdit || allowDelete) && (
|
||||
<Td>
|
||||
<HStack spacing={1}>
|
||||
{allowEdit && (
|
||||
<IconButton
|
||||
icon={<EditIcon />}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => openEdit(key)}
|
||||
/>
|
||||
)}
|
||||
{allowDelete && (
|
||||
<IconButton
|
||||
icon={<DeleteIcon />}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDelete(key)}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</Td>
|
||||
)}
|
||||
</Tr>
|
||||
))
|
||||
)}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{/* Add Modal */}
|
||||
<Modal isOpen={isAddOpen} onClose={onAddClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Add New Item</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={3}>
|
||||
{propertyNames.map(prop => (
|
||||
<Box key={prop} width="100%">
|
||||
<Text fontSize="sm" fontWeight="medium" mb={1}>
|
||||
{getColumnTitle(prop)}
|
||||
{schema.required?.includes(prop) && <Text as="span" color="red.500">*</Text>}
|
||||
</Text>
|
||||
{renderInput(prop, newItem[prop], setNewItem, newItem)}
|
||||
{uiSchema[prop]?.['ui:help'] && (
|
||||
<Text fontSize="xs" color={muted} mt={1}>
|
||||
{uiSchema[prop]['ui:help']}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onAddClose}>Cancel</Button>
|
||||
<Button colorScheme="blue" onClick={handleAdd}>Add</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal isOpen={isEditOpen} onClose={onEditClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Edit Item: {editingKey}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={3}>
|
||||
{propertyNames.map(prop => (
|
||||
<Box key={prop} width="100%">
|
||||
<Text fontSize="sm" fontWeight="medium" mb={1}>
|
||||
{getColumnTitle(prop)}
|
||||
{schema.required?.includes(prop) && <Text as="span" color="red.500">*</Text>}
|
||||
</Text>
|
||||
{renderInput(prop, editingData[prop], setEditingData, editingData)}
|
||||
{uiSchema[prop]?.['ui:help'] && (
|
||||
<Text fontSize="xs" color={muted} mt={1}>
|
||||
{uiSchema[prop]['ui:help']}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onEditClose}>Cancel</Button>
|
||||
<Button colorScheme="blue" onClick={handleEdit}>Save</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</VStack>
|
||||
)
|
||||
}
|
|
@ -1,254 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Heading,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
IconButton,
|
||||
Flex,
|
||||
Badge
|
||||
} from '@chakra-ui/react'
|
||||
import { AddIcon, DeleteIcon, EditIcon } from '@chakra-ui/icons'
|
||||
import Form from '@rjsf/chakra-ui'
|
||||
import validator from '@rjsf/validator-ajv8'
|
||||
import LayoutObjectFieldTemplate from './rjsf/LayoutObjectFieldTemplate.jsx'
|
||||
import { allWidgets } from './widgets/AllWidgets.jsx'
|
||||
|
||||
/**
|
||||
* FormTable - Muestra objetos como filas de formularios usando schemas RJSF
|
||||
*/
|
||||
export default function FormTable({
|
||||
schema,
|
||||
uiSchema = {},
|
||||
data = {},
|
||||
onChange,
|
||||
title = "Data",
|
||||
keyField = "id",
|
||||
allowAdd = true,
|
||||
allowDelete = true,
|
||||
liveValues = null
|
||||
}) {
|
||||
const [editingKey, setEditingKey] = useState(null)
|
||||
const [addingNew, setAddingNew] = useState(false)
|
||||
const [newKey, setNewKey] = useState('')
|
||||
const [editingFormData, setEditingFormData] = useState(null)
|
||||
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600')
|
||||
|
||||
if (!schema || !schema.additionalProperties) {
|
||||
return (
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
Schema not available for {title}
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
const itemSchema = schema.additionalProperties
|
||||
// Usar el uiSchema directamente ya que se pasa el contenido de additionalProperties desde el parent
|
||||
const itemUiSchema = uiSchema || {}
|
||||
|
||||
|
||||
|
||||
const dataKeys = Object.keys(data)
|
||||
|
||||
const handleAdd = (formData) => {
|
||||
if (!newKey) {
|
||||
alert('Please provide a key/ID')
|
||||
return
|
||||
}
|
||||
|
||||
const newData = {
|
||||
...data,
|
||||
[newKey]: formData
|
||||
}
|
||||
|
||||
onChange(newData)
|
||||
setAddingNew(false)
|
||||
setNewKey('')
|
||||
}
|
||||
|
||||
const handleEdit = (key, formData) => {
|
||||
const newData = {
|
||||
...data,
|
||||
[key]: formData
|
||||
}
|
||||
|
||||
onChange(newData)
|
||||
setEditingKey(null)
|
||||
}
|
||||
|
||||
const handleDelete = (key) => {
|
||||
if (confirm(`¿Eliminar "${key}"?`)) {
|
||||
const newData = { ...data }
|
||||
delete newData[key]
|
||||
onChange(newData)
|
||||
}
|
||||
}
|
||||
|
||||
const generateNewKey = () => {
|
||||
const baseName = keyField === 'id' ? 'item' : 'new'
|
||||
let counter = 1
|
||||
let newKey = `${baseName}_${counter}`
|
||||
|
||||
while (data[newKey]) {
|
||||
counter++
|
||||
newKey = `${baseName}_${counter}`
|
||||
}
|
||||
|
||||
return newKey
|
||||
}
|
||||
|
||||
const startAdd = () => {
|
||||
setNewKey(generateNewKey())
|
||||
setAddingNew(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="semibold">{title}</Text>
|
||||
{allowAdd && (
|
||||
<Button size="sm" leftIcon={<AddIcon />} onClick={startAdd}>
|
||||
Add Item
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{dataKeys.length === 0 && !addingNew && (
|
||||
<Box p={4} borderWidth="1px" borderRadius="md" borderColor={borderColor}>
|
||||
<Text color={muted} textAlign="center">No items found</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Formulario para agregar nuevo item */}
|
||||
{addingNew && (
|
||||
<Card borderColor="blue.200" borderWidth="2px">
|
||||
<CardHeader pb={2}>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="xs" color="blue.600">
|
||||
➕ Adding: {newKey}
|
||||
</Heading>
|
||||
<Button size="xs" variant="ghost" onClick={() => setAddingNew(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody pt={0}>
|
||||
<Form
|
||||
schema={itemSchema}
|
||||
uiSchema={itemUiSchema}
|
||||
formData={{}}
|
||||
validator={validator}
|
||||
onSubmit={({ formData }) => handleAdd(formData)}
|
||||
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
|
||||
widgets={allWidgets}
|
||||
showErrorList={false}
|
||||
>
|
||||
<HStack mt={3}>
|
||||
<Button type="submit" size="sm" colorScheme="blue">
|
||||
Save
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setAddingNew(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</HStack>
|
||||
</Form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Formularios para items existentes */}
|
||||
{dataKeys.map(key => (
|
||||
<Card key={key} borderWidth="1px" borderColor={borderColor}>
|
||||
<CardHeader pb={2}>
|
||||
<HStack justify="space-between">
|
||||
<HStack>
|
||||
<Heading size="xs">{key}</Heading>
|
||||
{data[key] && typeof data[key].enabled === 'boolean' && (
|
||||
<Badge colorScheme={data[key].enabled ? 'green' : 'red'} size="sm">
|
||||
{data[key].enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
)}
|
||||
{editingKey === key && (
|
||||
<Badge colorScheme="orange" size="sm">Editing</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
<HStack spacing={1}>
|
||||
{editingKey === key ? (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => setEditingKey(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<IconButton
|
||||
icon={<EditIcon />}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => { setEditingFormData({ ...(data[key] || {}) }); setEditingKey(key) }}
|
||||
/>
|
||||
{allowDelete && (
|
||||
<IconButton
|
||||
icon={<DeleteIcon />}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDelete(key)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody pt={0}>
|
||||
{liveValues && liveValues[key] !== undefined && (
|
||||
<Box mb={2}>
|
||||
<Text fontSize="sm" color={muted}>
|
||||
Live value: <Text as="span" fontWeight="semibold">{String(liveValues[key])}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Form
|
||||
schema={itemSchema}
|
||||
uiSchema={itemUiSchema}
|
||||
formData={editingKey === key ? (editingFormData || {}) : (data[key] || {})}
|
||||
validator={validator}
|
||||
onChange={editingKey === key ? ({ formData }) => setEditingFormData(formData) : () => { }}
|
||||
onSubmit={editingKey === key ? ({ formData }) => handleEdit(key, formData) : undefined}
|
||||
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
|
||||
widgets={allWidgets}
|
||||
readonly={editingKey !== key}
|
||||
showErrorList={false}
|
||||
>
|
||||
{editingKey === key && (
|
||||
<HStack mt={3}>
|
||||
<Button type="submit" size="sm" colorScheme="blue">
|
||||
Save
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => { setEditingKey(null); setEditingFormData(null) }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</Form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</VStack>
|
||||
)
|
||||
}
|
|
@ -1,264 +0,0 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Select,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Heading,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
Button
|
||||
} from '@chakra-ui/react'
|
||||
// No necesitamos Form completo, solo FormTable
|
||||
import FormTable from './FormTable.jsx'
|
||||
import { getSchema, readConfig, writeConfig } from '../services/api.js'
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* PlotCompleteManager - Gestiona plots y variables de forma simplificada
|
||||
* Incluye: tabla de plots individuales + variables (sin campos estáticos de configuración)
|
||||
*/
|
||||
export default function PlotCompleteManager() {
|
||||
const [fullData, setFullData] = useState({})
|
||||
const [plotVariables, setPlotVariables] = useState({})
|
||||
const [selectedPlotId, setSelectedPlotId] = useState('')
|
||||
|
||||
const [plotSchema, setPlotSchema] = useState(null)
|
||||
const [plotUiSchema, setPlotUiSchema] = useState({})
|
||||
const [variableSchema, setVariableSchema] = useState(null)
|
||||
const [variableUiSchema, setVariableUiSchema] = useState({})
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Cargar schemas completos
|
||||
const [plotSchemaResp, variableSchemaResp] = await Promise.all([
|
||||
getSchema('plot-definitions'),
|
||||
getSchema('plot-variables')
|
||||
])
|
||||
|
||||
console.log('Complete plot schema response:', plotSchemaResp)
|
||||
console.log('Complete variable schema response:', variableSchemaResp)
|
||||
|
||||
// Cargar datos
|
||||
const [plotDataResp, variableDataResp] = await Promise.all([
|
||||
readConfig('plot-definitions'),
|
||||
readConfig('plot-variables')
|
||||
])
|
||||
|
||||
console.log('Complete plot data response:', plotDataResp)
|
||||
console.log('Complete variable data response:', variableDataResp)
|
||||
|
||||
// Usar schemas completos
|
||||
setPlotSchema(plotSchemaResp.schema)
|
||||
setPlotUiSchema(plotSchemaResp.ui_schema || {})
|
||||
|
||||
// Debug para schema de variables
|
||||
console.log('Variable schema structure:', variableSchemaResp.schema)
|
||||
console.log('Variable schema props:', variableSchemaResp.schema?.properties)
|
||||
console.log('Plot variables props:', variableSchemaResp.schema?.properties?.plot_variables)
|
||||
console.log('Additional props:', variableSchemaResp.schema?.properties?.plot_variables?.additionalProperties)
|
||||
console.log('Variables property:', variableSchemaResp.schema?.properties?.plot_variables?.additionalProperties?.properties?.variables)
|
||||
console.log('Final variables schema:', variableSchemaResp.schema?.properties?.plot_variables?.additionalProperties?.properties?.variables?.additionalProperties)
|
||||
|
||||
// Debug para UI schema de variables
|
||||
console.log('Variable ui schema structure:', variableSchemaResp.ui_schema)
|
||||
console.log('UI schema plot_variables:', variableSchemaResp.ui_schema?.plot_variables)
|
||||
console.log('UI schema additionalProperties:', variableSchemaResp.ui_schema?.plot_variables?.additionalProperties)
|
||||
console.log('UI schema variables:', variableSchemaResp.ui_schema?.plot_variables?.additionalProperties?.variables)
|
||||
console.log('Final ui schema:', variableSchemaResp.ui_schema?.plot_variables?.additionalProperties?.variables?.additionalProperties)
|
||||
|
||||
// Schema para variables individuales (debe contener additionalProperties)
|
||||
setVariableSchema(variableSchemaResp.schema?.properties?.plot_variables?.additionalProperties?.properties?.variables)
|
||||
setVariableUiSchema(variableSchemaResp.ui_schema?.plot_variables?.additionalProperties?.variables?.additionalProperties || {})
|
||||
|
||||
setFullData(plotDataResp.data || {})
|
||||
setPlotVariables(variableDataResp.data?.plot_variables || {})
|
||||
|
||||
// Seleccionar primer plot
|
||||
const plotIds = Object.keys(plotDataResp.data?.plots || {})
|
||||
if (plotIds.length > 0 && !selectedPlotId) {
|
||||
setSelectedPlotId(plotIds[0])
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading complete plot data:', error)
|
||||
setMessage(`Error loading data: ${error.message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveFullData = async (newData) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await writeConfig('plot-definitions', newData)
|
||||
setFullData(newData)
|
||||
setMessage('Plot configuration saved successfully')
|
||||
setTimeout(() => setMessage(''), 3000)
|
||||
} catch (error) {
|
||||
console.error('Error saving full plot data:', error)
|
||||
setMessage(`Error saving plot configuration: ${error.message}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const savePlots = async (newPlots) => {
|
||||
try {
|
||||
// Solo enviar plots, mantener campos técnicos automáticamente
|
||||
const newFullData = {
|
||||
plots: newPlots,
|
||||
session_counter: fullData.session_counter || 0,
|
||||
last_saved: new Date().toISOString(),
|
||||
version: fullData.version || "1.0"
|
||||
}
|
||||
await saveFullData(newFullData)
|
||||
} catch (error) {
|
||||
console.error('Error saving plots:', error)
|
||||
setMessage(`Error saving plots: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const savePlotVariables = async (newVariables) => {
|
||||
try {
|
||||
const updatedPlotVariables = {
|
||||
...plotVariables,
|
||||
[selectedPlotId]: {
|
||||
variables: newVariables
|
||||
}
|
||||
}
|
||||
|
||||
const saveData = {
|
||||
plot_variables: updatedPlotVariables
|
||||
}
|
||||
|
||||
await writeConfig('plot-variables', saveData)
|
||||
setPlotVariables(updatedPlotVariables)
|
||||
setMessage('Plot variables saved successfully')
|
||||
setTimeout(() => setMessage(''), 3000)
|
||||
} catch (error) {
|
||||
console.error('Error saving plot variables:', error)
|
||||
setMessage(`Error saving variables: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const currentPlotVariables = useMemo(() => {
|
||||
return selectedPlotId && plotVariables[selectedPlotId]
|
||||
? plotVariables[selectedPlotId].variables || {}
|
||||
: {}
|
||||
}, [selectedPlotId, plotVariables])
|
||||
|
||||
const plotOptions = Object.entries(fullData.plots || {}).map(([id, plot]) => ({
|
||||
value: id,
|
||||
label: `${plot.name || id} (${id})`
|
||||
}))
|
||||
|
||||
if (loading) {
|
||||
return <Text>Loading plot configuration...</Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{message && (
|
||||
<Alert status={message.includes('Error') ? 'error' : 'success'}>
|
||||
<AlertIcon />
|
||||
{message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Tabla de Plots */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Heading size="sm">📈 Plot Management</Heading>
|
||||
<Text fontSize="sm" color={muted}>
|
||||
Manage your plots: create, edit and configure
|
||||
</Text>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{plotSchema?.properties?.plots ? (
|
||||
<FormTable
|
||||
schema={plotSchema.properties.plots}
|
||||
uiSchema={plotUiSchema.plots}
|
||||
data={fullData.plots || {}}
|
||||
onChange={savePlots}
|
||||
title="Plots"
|
||||
keyField="session_id"
|
||||
/>
|
||||
) : (
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
Plot schema for individual plots not available
|
||||
</Alert>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Variables del Plot */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="sm">🔧 Plot Variables</Heading>
|
||||
<HStack>
|
||||
<Text fontSize="sm" color={muted}>Plot:</Text>
|
||||
<Select
|
||||
size="sm"
|
||||
value={selectedPlotId}
|
||||
onChange={(e) => setSelectedPlotId(e.target.value)}
|
||||
placeholder="Select plot"
|
||||
width="200px"
|
||||
>
|
||||
{plotOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{!selectedPlotId ? (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
Select a plot to manage its variables
|
||||
</Alert>
|
||||
) : variableSchema ? (
|
||||
<FormTable
|
||||
schema={variableSchema}
|
||||
uiSchema={variableUiSchema}
|
||||
data={currentPlotVariables}
|
||||
onChange={savePlotVariables}
|
||||
title={`Variables for: ${fullData.plots?.[selectedPlotId]?.name || selectedPlotId}`}
|
||||
keyField="variable_name"
|
||||
/>
|
||||
) : (
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
Variable schema not available
|
||||
</Alert>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
)
|
||||
}
|
|
@ -1,402 +0,0 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Select,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Heading,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
Button,
|
||||
Input,
|
||||
IconButton,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
ModalCloseButton,
|
||||
useDisclosure
|
||||
} from '@chakra-ui/react'
|
||||
import { AddIcon, DeleteIcon, EditIcon } from '@chakra-ui/icons'
|
||||
import FormTable from './FormTable.jsx'
|
||||
import { getSchema, readConfig, writeConfig } from '../services/api.js'
|
||||
|
||||
/**
|
||||
* PlotVariablesManager - Componente para gestionar array de strings (variables de plot)
|
||||
*/
|
||||
function PlotVariablesManager({ variables = [], onChange, title = "Variables" }) {
|
||||
const [newVariable, setNewVariable] = useState('')
|
||||
const [editingIndex, setEditingIndex] = useState(null)
|
||||
const [editingValue, setEditingValue] = useState('')
|
||||
|
||||
const { isOpen: isAddOpen, onOpen: onAddOpen, onClose: onAddClose } = useDisclosure()
|
||||
const { isOpen: isEditOpen, onOpen: onEditOpen, onClose: onEditClose } = useDisclosure()
|
||||
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600')
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!newVariable.trim()) return
|
||||
|
||||
const newVariables = [...variables, newVariable.trim()]
|
||||
onChange(newVariables)
|
||||
setNewVariable('')
|
||||
onAddClose()
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
if (editingIndex === null || !editingValue.trim()) return
|
||||
|
||||
const newVariables = [...variables]
|
||||
newVariables[editingIndex] = editingValue.trim()
|
||||
onChange(newVariables)
|
||||
setEditingIndex(null)
|
||||
setEditingValue('')
|
||||
onEditClose()
|
||||
}
|
||||
|
||||
const handleDelete = (index) => {
|
||||
if (confirm('¿Eliminar esta variable?')) {
|
||||
const newVariables = variables.filter((_, i) => i !== index)
|
||||
onChange(newVariables)
|
||||
}
|
||||
}
|
||||
|
||||
const openEdit = (index) => {
|
||||
setEditingIndex(index)
|
||||
setEditingValue(variables[index])
|
||||
onEditOpen()
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="semibold">{title}</Text>
|
||||
<Button size="sm" leftIcon={<AddIcon />} onClick={onAddOpen}>
|
||||
Add Variable
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
<Box overflowX="auto" borderWidth="1px" borderRadius="md" borderColor={borderColor}>
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Variable Name</Th>
|
||||
<Th width="100px">Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{variables.length === 0 ? (
|
||||
<Tr>
|
||||
<Td colSpan={2}>
|
||||
<Text color={muted} textAlign="center">No variables</Text>
|
||||
</Td>
|
||||
</Tr>
|
||||
) : (
|
||||
variables.map((variable, index) => (
|
||||
<Tr key={index}>
|
||||
<Td>{variable}</Td>
|
||||
<Td>
|
||||
<HStack spacing={1}>
|
||||
<IconButton
|
||||
icon={<EditIcon />}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => openEdit(index)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<DeleteIcon />}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDelete(index)}
|
||||
/>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
))
|
||||
)}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{/* Add Modal */}
|
||||
<Modal isOpen={isAddOpen} onClose={onAddClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Add Variable</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={3}>
|
||||
<Box width="100%">
|
||||
<Text fontSize="sm" fontWeight="medium" mb={1}>
|
||||
Variable Name *
|
||||
</Text>
|
||||
<Input
|
||||
size="sm"
|
||||
value={newVariable}
|
||||
onChange={(e) => setNewVariable(e.target.value)}
|
||||
placeholder="e.g., UR29_Brix"
|
||||
/>
|
||||
<Text fontSize="xs" color={muted} mt={1}>
|
||||
Enter the name of the variable to plot
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onAddClose}>Cancel</Button>
|
||||
<Button colorScheme="blue" onClick={handleAdd}>Add</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal isOpen={isEditOpen} onClose={onEditClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Edit Variable</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={3}>
|
||||
<Box width="100%">
|
||||
<Text fontSize="sm" fontWeight="medium" mb={1}>
|
||||
Variable Name *
|
||||
</Text>
|
||||
<Input
|
||||
size="sm"
|
||||
value={editingValue}
|
||||
onChange={(e) => setEditingValue(e.target.value)}
|
||||
placeholder="e.g., UR29_Brix"
|
||||
/>
|
||||
<Text fontSize="xs" color={muted} mt={1}>
|
||||
Enter the name of the variable to plot
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onEditClose}>Cancel</Button>
|
||||
<Button colorScheme="blue" onClick={handleEdit}>Save</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* PlotFormManager - Gestiona plots y variables usando FormTable
|
||||
*/
|
||||
export default function PlotFormManager() {
|
||||
const [plots, setPlots] = useState({})
|
||||
const [plotVariables, setPlotVariables] = useState({})
|
||||
const [selectedPlotId, setSelectedPlotId] = useState('')
|
||||
|
||||
const [plotSchema, setPlotSchema] = useState(null)
|
||||
const [plotUiSchema, setPlotUiSchema] = useState({})
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Cargar schemas
|
||||
const [plotSchemaResp, plotVariableSchemaResp] = await Promise.all([
|
||||
getSchema('plot-definitions'),
|
||||
getSchema('plot-variables')
|
||||
])
|
||||
|
||||
console.log('Plot schema response:', plotSchemaResp)
|
||||
console.log('Plot variable schema response:', plotVariableSchemaResp)
|
||||
|
||||
// Cargar datos
|
||||
const [plotDataResp, plotVariableDataResp] = await Promise.all([
|
||||
readConfig('plot-definitions'),
|
||||
readConfig('plot-variables')
|
||||
])
|
||||
|
||||
console.log('Plot data response:', plotDataResp)
|
||||
console.log('Plot variable data response:', plotVariableDataResp)
|
||||
|
||||
// Extraer schemas
|
||||
setPlotSchema(plotSchemaResp.schema?.properties?.plots)
|
||||
setPlotUiSchema(plotSchemaResp.ui_schema?.plots || {})
|
||||
|
||||
console.log('Plot full schema:', plotSchemaResp.schema?.properties?.plots)
|
||||
console.log('Plot full uiSchema:', plotSchemaResp.ui_schema?.plots)
|
||||
|
||||
setPlots(plotDataResp.data?.plots || {})
|
||||
setPlotVariables(plotVariableDataResp.data?.plot_variables || {})
|
||||
|
||||
// Seleccionar primer plot
|
||||
const plotIds = Object.keys(plotDataResp.data?.plots || {})
|
||||
if (plotIds.length > 0 && !selectedPlotId) {
|
||||
setSelectedPlotId(plotIds[0])
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading plot data:', error)
|
||||
setMessage(`Error loading data: ${error.message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const savePlots = async (newPlots) => {
|
||||
try {
|
||||
const currentConfig = await readConfig('plot-definitions')
|
||||
const saveData = {
|
||||
...currentConfig.data,
|
||||
plots: newPlots,
|
||||
last_saved: new Date().toISOString()
|
||||
}
|
||||
|
||||
await writeConfig('plot-definitions', saveData)
|
||||
setPlots(newPlots)
|
||||
setMessage('Plots saved successfully')
|
||||
setTimeout(() => setMessage(''), 3000)
|
||||
} catch (error) {
|
||||
console.error('Error saving plots:', error)
|
||||
setMessage(`Error saving plots: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const savePlotVariables = async (newVariables) => {
|
||||
try {
|
||||
const currentConfig = await readConfig('plot-variables')
|
||||
const updatedPlotVariables = {
|
||||
...plotVariables,
|
||||
[selectedPlotId]: {
|
||||
variables: newVariables
|
||||
}
|
||||
}
|
||||
|
||||
const saveData = {
|
||||
...currentConfig.data,
|
||||
plot_variables: updatedPlotVariables,
|
||||
last_update: new Date().toISOString()
|
||||
}
|
||||
|
||||
await writeConfig('plot-variables', saveData)
|
||||
setPlotVariables(updatedPlotVariables)
|
||||
setMessage('Plot variables saved successfully')
|
||||
setTimeout(() => setMessage(''), 3000)
|
||||
} catch (error) {
|
||||
console.error('Error saving plot variables:', error)
|
||||
setMessage(`Error saving variables: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const currentPlotVariables = useMemo(() => {
|
||||
return selectedPlotId && plotVariables[selectedPlotId]
|
||||
? plotVariables[selectedPlotId].variables || []
|
||||
: []
|
||||
}, [selectedPlotId, plotVariables])
|
||||
|
||||
const plotOptions = Object.entries(plots).map(([id, plot]) => ({
|
||||
value: id,
|
||||
label: `${plot.name || id} (${id})`
|
||||
}))
|
||||
|
||||
if (loading) {
|
||||
return <Text>Loading plots...</Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{message && (
|
||||
<Alert status={message.includes('Error') ? 'error' : 'success'}>
|
||||
<AlertIcon />
|
||||
{message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Plots */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Heading size="sm">📈 Plot Definitions</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{plotSchema ? (
|
||||
<FormTable
|
||||
schema={plotSchema}
|
||||
uiSchema={plotUiSchema}
|
||||
data={plots}
|
||||
onChange={savePlots}
|
||||
title="Plots"
|
||||
keyField="session_id"
|
||||
/>
|
||||
) : (
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
Plot schema not available
|
||||
</Alert>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Variables del Plot */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="sm">🔧 Plot Variables</Heading>
|
||||
<HStack>
|
||||
<Text fontSize="sm" color={muted}>Plot:</Text>
|
||||
<Select
|
||||
size="sm"
|
||||
value={selectedPlotId}
|
||||
onChange={(e) => setSelectedPlotId(e.target.value)}
|
||||
placeholder="Select plot"
|
||||
width="200px"
|
||||
>
|
||||
{plotOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{!selectedPlotId ? (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
Select a plot to manage its variables
|
||||
</Alert>
|
||||
) : (
|
||||
<PlotVariablesManager
|
||||
variables={currentPlotVariables}
|
||||
onChange={savePlotVariables}
|
||||
title={`Variables for: ${plots[selectedPlotId]?.name || selectedPlotId}`}
|
||||
/>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,325 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Heading,
|
||||
useColorModeValue,
|
||||
Badge,
|
||||
Divider,
|
||||
Grid,
|
||||
GridItem,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
useToast,
|
||||
Spinner,
|
||||
Center
|
||||
} from '@chakra-ui/react'
|
||||
import { TimeIcon, AddIcon } from '@chakra-ui/icons'
|
||||
import PlotHistoricalSession from './PlotHistoricalSession'
|
||||
import * as api from '../services/api'
|
||||
|
||||
/**
|
||||
* PlotHistoricalManager - Manages historical plot sessions
|
||||
* Allows users to select plot definitions and create historical analysis sessions
|
||||
*/
|
||||
export default function PlotHistoricalManager() {
|
||||
const [plotDefinitions, setPlotDefinitions] = useState([])
|
||||
const [plotVariables, setPlotVariables] = useState([])
|
||||
const [activeSessions, setActiveSessions] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const toast = useToast()
|
||||
const cardBg = useColorModeValue('white', 'gray.800')
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600')
|
||||
|
||||
// Load plot definitions and variables on mount
|
||||
useEffect(() => {
|
||||
loadPlotConfigurations()
|
||||
}, [])
|
||||
|
||||
const loadPlotConfigurations = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Load plot definitions
|
||||
const plotDefsResponse = await api.getConfig('plot-definitions')
|
||||
const plotDefs = plotDefsResponse.data?.plots || []
|
||||
|
||||
// Load plot variables
|
||||
const plotVarsResponse = await api.getConfig('plot-variables')
|
||||
const plotVars = plotVarsResponse.data?.plot_variables || []
|
||||
|
||||
setPlotDefinitions(plotDefs)
|
||||
setPlotVariables(plotVars)
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading plot configurations:', err)
|
||||
setError('Failed to load plot configurations')
|
||||
toast({
|
||||
title: 'Error Loading Plots',
|
||||
description: 'Failed to load plot definitions and variables',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Get variables for a specific plot
|
||||
const getVariablesForPlot = (plotId) => {
|
||||
const plotVarConfig = plotVariables.find(pv => pv.plot_id === plotId)
|
||||
return plotVarConfig?.variables || []
|
||||
}
|
||||
|
||||
// Create a new historical session
|
||||
const createSession = (plotDefinition) => {
|
||||
const sessionId = `historical_${plotDefinition.id}_${Date.now()}`
|
||||
const variables = getVariablesForPlot(plotDefinition.id)
|
||||
|
||||
if (variables.length === 0) {
|
||||
toast({
|
||||
title: 'No Variables Configured',
|
||||
description: `Plot "${plotDefinition.name}" has no variables configured`,
|
||||
status: 'warning',
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const newSession = {
|
||||
id: sessionId,
|
||||
plotDefinition,
|
||||
variables,
|
||||
createdAt: new Date()
|
||||
}
|
||||
|
||||
setActiveSessions(prev => [...prev, newSession])
|
||||
|
||||
toast({
|
||||
title: 'Historical Session Created',
|
||||
description: `Created session for "${plotDefinition.name}"`,
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true
|
||||
})
|
||||
}
|
||||
|
||||
// Remove a session
|
||||
const removeSession = (sessionId) => {
|
||||
setActiveSessions(prev => prev.filter(session => session.id !== sessionId))
|
||||
|
||||
toast({
|
||||
title: 'Session Removed',
|
||||
description: 'Historical plot session removed',
|
||||
status: 'info',
|
||||
duration: 3000,
|
||||
isClosable: true
|
||||
})
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Center py={10}>
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="lg" />
|
||||
<Text>Loading plot configurations...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert status="error" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<VStack align="start" spacing={2}>
|
||||
<Text fontWeight="medium">Configuration Error</Text>
|
||||
<Text fontSize="sm">{error}</Text>
|
||||
<Button size="sm" onClick={loadPlotConfigurations}>
|
||||
Retry Loading
|
||||
</Button>
|
||||
</VStack>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* Header */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<VStack spacing={3} align="start">
|
||||
<HStack>
|
||||
<TimeIcon boxSize={6} color="blue.500" />
|
||||
<Heading size="lg">Historical Plot Analysis</Heading>
|
||||
</HStack>
|
||||
<Text color="gray.600" fontSize="md">
|
||||
Create historical analysis sessions from CSV data using existing plot definitions
|
||||
</Text>
|
||||
<Divider />
|
||||
<HStack spacing={4}>
|
||||
<Badge colorScheme="blue" variant="subtle">
|
||||
{plotDefinitions.length} Plot{plotDefinitions.length !== 1 ? 's' : ''} Available
|
||||
</Badge>
|
||||
<Badge colorScheme="green" variant="subtle">
|
||||
{activeSessions.length} Active Session{activeSessions.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* Plot Selection Grid */}
|
||||
{plotDefinitions.length === 0 ? (
|
||||
<Alert status="info" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<VStack align="start" spacing={2}>
|
||||
<Text fontWeight="medium">No Plot Definitions Found</Text>
|
||||
<Text fontSize="sm">
|
||||
Please configure plot definitions in the Configuration tab first.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Alert>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Heading size="md">Available Plot Definitions</Heading>
|
||||
<Text fontSize="sm" color="gray.600" mt={1}>
|
||||
Select a plot definition to create a historical analysis session
|
||||
</Text>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Grid templateColumns="repeat(auto-fill, minmax(300px, 1fr))" gap={4}>
|
||||
{plotDefinitions.map((plotDef) => {
|
||||
const variables = getVariablesForPlot(plotDef.id)
|
||||
const hasSession = activeSessions.some(s => s.plotDefinition.id === plotDef.id)
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={plotDef.id}
|
||||
variant="outline"
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
borderColor: 'blue.300',
|
||||
transform: 'translateY(-2px)',
|
||||
shadow: 'md'
|
||||
}}
|
||||
>
|
||||
<CardHeader pb={2}>
|
||||
<VStack align="start" spacing={2}>
|
||||
<HStack justify="space-between" w="full">
|
||||
<Text fontWeight="bold" fontSize="md">
|
||||
{plotDef.name}
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={variables.length > 0 ? "green" : "gray"}
|
||||
variant="subtle"
|
||||
>
|
||||
{variables.length} vars
|
||||
</Badge>
|
||||
</HStack>
|
||||
{plotDef.description && (
|
||||
<Text fontSize="sm" color="gray.600" noOfLines={2}>
|
||||
{plotDef.description}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</CardHeader>
|
||||
<CardBody pt={0}>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{variables.length > 0 && (
|
||||
<Box>
|
||||
<Text fontSize="xs" color="gray.500" mb={1}>
|
||||
Variables:
|
||||
</Text>
|
||||
<HStack spacing={1} flexWrap="wrap">
|
||||
{variables.slice(0, 3).map((variable, idx) => (
|
||||
<Badge key={idx} size="sm" variant="outline">
|
||||
{variable.name}
|
||||
</Badge>
|
||||
))}
|
||||
{variables.length > 3 && (
|
||||
<Badge size="sm" variant="outline" colorScheme="gray">
|
||||
+{variables.length - 3} more
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
variant={hasSession ? "outline" : "solid"}
|
||||
leftIcon={<AddIcon />}
|
||||
isDisabled={variables.length === 0}
|
||||
onClick={() => createSession(plotDef)}
|
||||
>
|
||||
{hasSession ? 'Create Another' : 'Create Session'}
|
||||
</Button>
|
||||
|
||||
{variables.length === 0 && (
|
||||
<Text fontSize="xs" color="red.500">
|
||||
No variables configured
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</Grid>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Active Sessions */}
|
||||
{activeSessions.length > 0 && (
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Divider />
|
||||
<Heading size="md">Active Historical Sessions</Heading>
|
||||
|
||||
{activeSessions.map((session) => (
|
||||
<PlotHistoricalSession
|
||||
key={session.id}
|
||||
plotDefinition={session.plotDefinition}
|
||||
plotVariables={session.variables}
|
||||
onRemove={() => removeSession(session.id)}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* Empty State for Sessions */}
|
||||
{activeSessions.length === 0 && plotDefinitions.length > 0 && (
|
||||
<Card variant="outline" borderStyle="dashed">
|
||||
<CardBody>
|
||||
<VStack spacing={4} py={8}>
|
||||
<TimeIcon boxSize={12} color="gray.400" />
|
||||
<Text color="gray.500" fontSize="lg" fontWeight="medium">
|
||||
No Active Historical Sessions
|
||||
</Text>
|
||||
<Text color="gray.400" fontSize="sm" textAlign="center">
|
||||
Select a plot definition above to create your first historical analysis session
|
||||
</Text>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</VStack>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,370 @@
|
|||
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Heading,
|
||||
useColorModeValue,
|
||||
Badge,
|
||||
IconButton,
|
||||
Divider,
|
||||
Spacer,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
NumberInputStepper,
|
||||
NumberIncrementStepper,
|
||||
NumberDecrementStepper,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Switch,
|
||||
Grid,
|
||||
GridItem,
|
||||
Flex,
|
||||
useToast,
|
||||
Checkbox,
|
||||
Slider,
|
||||
SliderTrack,
|
||||
SliderFilledTrack,
|
||||
SliderThumb,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
useDisclosure,
|
||||
Input,
|
||||
Select,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Spinner
|
||||
} from '@chakra-ui/react'
|
||||
import { SettingsIcon, ViewIcon, CloseIcon, TimeIcon } from '@chakra-ui/icons'
|
||||
import ChartjsHistoricalPlot from './ChartjsHistoricalPlot.jsx'
|
||||
import * as api from '../services/api'
|
||||
|
||||
/**
|
||||
* PlotHistoricalSession - Individual historical Chart.js plot component
|
||||
* Loads data from CSV files with time range selection and zoom/pan capabilities
|
||||
*/
|
||||
export default function PlotHistoricalSession({
|
||||
plotDefinition,
|
||||
plotVariables = [],
|
||||
onRemove
|
||||
}) {
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const { isOpen: isFullscreen, onOpen: openFullscreen, onClose: closeFullscreen } = useDisclosure()
|
||||
|
||||
// Historical-specific state
|
||||
const [timeRange, setTimeRange] = useState({
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
startTime: '00:00',
|
||||
endTime: '23:59'
|
||||
})
|
||||
|
||||
const [availableDateRange, setAvailableDateRange] = useState({
|
||||
minDate: '',
|
||||
maxDate: ''
|
||||
})
|
||||
|
||||
const [loadingData, setLoadingData] = useState(false)
|
||||
|
||||
const [localConfig, setLocalConfig] = useState({
|
||||
time_window: plotDefinition.time_window || 3600, // seconds for historical view
|
||||
y_min: plotDefinition.y_min,
|
||||
y_max: plotDefinition.y_max,
|
||||
// Visual style properties
|
||||
line_tension: plotDefinition.line_tension !== undefined ? plotDefinition.line_tension : 0.4,
|
||||
stepped: plotDefinition.stepped || false,
|
||||
stacked: plotDefinition.stacked || false,
|
||||
point_radius: plotDefinition.point_radius !== undefined ? plotDefinition.point_radius : 1,
|
||||
point_hover_radius: plotDefinition.point_hover_radius !== undefined ? plotDefinition.point_hover_radius : 4
|
||||
})
|
||||
|
||||
const chartControlsRef = useRef(null)
|
||||
const toast = useToast()
|
||||
|
||||
const cardBg = useColorModeValue('white', 'gray.700')
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600')
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
const settingsBg = useColorModeValue('gray.50', 'gray.600')
|
||||
|
||||
// Enhanced session object for ChartjsHistoricalPlot
|
||||
const enhancedSession = useMemo(() => ({
|
||||
session_id: `historical_${plotDefinition.id}`,
|
||||
plot_id: plotDefinition.id,
|
||||
name: plotDefinition.name,
|
||||
variables_count: plotVariables.length,
|
||||
isFullscreen: isFullscreen,
|
||||
isHistorical: true,
|
||||
timeRange: timeRange,
|
||||
config: {
|
||||
...plotDefinition,
|
||||
...localConfig,
|
||||
variables: plotVariables
|
||||
},
|
||||
onChartReady: (controls) => {
|
||||
chartControlsRef.current = controls
|
||||
},
|
||||
onTimeRangeChange: handleChartTimeRangeChange,
|
||||
onDataRequest: handleDataRequest
|
||||
}), [
|
||||
plotDefinition,
|
||||
plotVariables,
|
||||
localConfig,
|
||||
isFullscreen,
|
||||
timeRange
|
||||
])
|
||||
|
||||
// Initialize with last 24 hours
|
||||
useEffect(() => {
|
||||
const now = new Date()
|
||||
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
|
||||
setTimeRange({
|
||||
startDate: yesterday.toISOString().split('T')[0],
|
||||
endDate: now.toISOString().split('T')[0],
|
||||
startTime: yesterday.toTimeString().substr(0, 5),
|
||||
endTime: now.toTimeString().substr(0, 5)
|
||||
})
|
||||
|
||||
// Set available range (last 30 days)
|
||||
setAvailableDateRange({
|
||||
minDate: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
maxDate: now.toISOString().split('T')[0]
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Handle time range changes from chart zoom/pan
|
||||
const handleChartTimeRangeChange = useCallback((newStartTime, newEndTime) => {
|
||||
const startDate = new Date(newStartTime)
|
||||
const endDate = new Date(newEndTime)
|
||||
|
||||
setTimeRange({
|
||||
startDate: startDate.toISOString().split('T')[0],
|
||||
endDate: endDate.toISOString().split('T')[0],
|
||||
startTime: startDate.toTimeString().substr(0, 5),
|
||||
endTime: endDate.toTimeString().substr(0, 5)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Handle data requests from chart (zoom/pan events)
|
||||
const handleDataRequest = useCallback(async (startTime, endTime) => {
|
||||
try {
|
||||
const response = await api.getHistoricalDataOptimized({
|
||||
variables: plotVariables.map(v => v.name),
|
||||
start_time: new Date(startTime).toISOString(),
|
||||
end_time: new Date(endTime).toISOString()
|
||||
})
|
||||
|
||||
return response?.success ? response : null
|
||||
} catch (error) {
|
||||
console.error('Error loading historical data:', error)
|
||||
return null
|
||||
}
|
||||
}, [plotVariables])
|
||||
|
||||
const handleTimeRangeSubmit = () => {
|
||||
if (chartControlsRef.current?.loadHistoricalRange) {
|
||||
const startDateTime = new Date(`${timeRange.startDate}T${timeRange.startTime}:00`)
|
||||
const endDateTime = new Date(`${timeRange.endDate}T${timeRange.endTime}:00`)
|
||||
|
||||
chartControlsRef.current.loadHistoricalRange(
|
||||
startDateTime.getTime(),
|
||||
endDateTime.getTime()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDateRange = () => {
|
||||
if (!timeRange.startDate || !timeRange.endDate) return 'No range selected'
|
||||
|
||||
const start = new Date(`${timeRange.startDate}T${timeRange.startTime}`)
|
||||
const end = new Date(`${timeRange.endDate}T${timeRange.endTime}`)
|
||||
|
||||
if (timeRange.startDate === timeRange.endDate) {
|
||||
return `${timeRange.startDate} ${timeRange.startTime} - ${timeRange.endTime}`
|
||||
}
|
||||
|
||||
return `${start.toLocaleDateString()} ${timeRange.startTime} - ${end.toLocaleDateString()} ${timeRange.endTime}`
|
||||
}
|
||||
|
||||
const refreshData = () => {
|
||||
if (chartControlsRef.current?.refreshData) {
|
||||
chartControlsRef.current.refreshData()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card bg={cardBg} borderColor={borderColor} mb={4}>
|
||||
<CardHeader pb={2}>
|
||||
<Flex align="center">
|
||||
<Box mr={3} fontSize="2xl">🕰️</Box>
|
||||
<VStack spacing={0} align="start" flex={1}>
|
||||
<Heading size="md">{plotDefinition.name}</Heading>
|
||||
<HStack spacing={4} fontSize="sm" color={muted}>
|
||||
<Text>{plotVariables.length} variables</Text>
|
||||
<Text>Historical Mode</Text>
|
||||
<Text>{formatDateRange()}</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
<HStack spacing={2}>
|
||||
<IconButton
|
||||
aria-label="Settings"
|
||||
icon={<SettingsIcon />}
|
||||
size="sm"
|
||||
variant={showSettings ? "solid" : "ghost"}
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Fullscreen"
|
||||
icon={<ViewIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={openFullscreen}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="Remove"
|
||||
icon={<CloseIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={onRemove}
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
|
||||
<Collapse in={showSettings} animateOpacity>
|
||||
<Box px={6} pb={4} bg={settingsBg} borderRadius="md" mx={6} mb={4}>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Text fontWeight="medium" fontSize="sm">Historical Data Controls</Text>
|
||||
|
||||
{/* Time Range Controls */}
|
||||
<Grid templateColumns="1fr 1fr" gap={4}>
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">Start Date</FormLabel>
|
||||
<Input
|
||||
type="date"
|
||||
size="sm"
|
||||
value={timeRange.startDate}
|
||||
min={availableDateRange.minDate}
|
||||
max={availableDateRange.maxDate}
|
||||
onChange={(e) => setTimeRange(prev => ({...prev, startDate: e.target.value}))}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">End Date</FormLabel>
|
||||
<Input
|
||||
type="date"
|
||||
size="sm"
|
||||
value={timeRange.endDate}
|
||||
min={availableDateRange.minDate}
|
||||
max={availableDateRange.maxDate}
|
||||
onChange={(e) => setTimeRange(prev => ({...prev, endDate: e.target.value}))}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid templateColumns="1fr 1fr" gap={4}>
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">Start Time</FormLabel>
|
||||
<Input
|
||||
type="time"
|
||||
size="sm"
|
||||
value={timeRange.startTime}
|
||||
onChange={(e) => setTimeRange(prev => ({...prev, startTime: e.target.value}))}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">End Time</FormLabel>
|
||||
<Input
|
||||
type="time"
|
||||
size="sm"
|
||||
value={timeRange.endTime}
|
||||
onChange={(e) => setTimeRange(prev => ({...prev, endTime: e.target.value}))}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<HStack spacing={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
onClick={handleTimeRangeSubmit}
|
||||
isLoading={loadingData}
|
||||
leftIcon={<TimeIcon />}
|
||||
>
|
||||
Load Time Range
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={refreshData}
|
||||
isLoading={isRefreshing}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{availableDateRange.minDate && (
|
||||
<Text fontSize="xs" color={muted}>
|
||||
Available data: {availableDateRange.minDate} to {availableDateRange.maxDate}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
</Collapse>
|
||||
|
||||
<CardBody pt={0}>
|
||||
{loadingData ? (
|
||||
<Flex align="center" justify="center" py={8}>
|
||||
<Spinner size="lg" mr={4} />
|
||||
<VStack spacing={2} align="start">
|
||||
<Text fontSize="lg" fontWeight="medium">Loading Historical Data...</Text>
|
||||
<Text fontSize="sm" color={muted}>Scanning CSV files for available data</Text>
|
||||
</VStack>
|
||||
</Flex>
|
||||
) : (
|
||||
<Box height="400px">
|
||||
<ChartjsHistoricalPlot session={enhancedSession} height="400px" />
|
||||
</Box>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Fullscreen Modal */}
|
||||
<Modal isOpen={isFullscreen} onClose={closeFullscreen} size="full">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<Flex align="center">
|
||||
<Box mr={3} fontSize="2xl">🕰️</Box>
|
||||
<VStack spacing={0} align="start">
|
||||
<Heading size="lg">{plotDefinition.name}</Heading>
|
||||
<Text fontSize="sm" color={muted}>Historical Analysis - {formatDateRange()}</Text>
|
||||
</VStack>
|
||||
</Flex>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<Box height="calc(100vh - 120px)">
|
||||
<ChartjsHistoricalPlot session={enhancedSession} height="100%" />
|
||||
</Box>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -1,796 +0,0 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Button,
|
||||
Text,
|
||||
Grid,
|
||||
Flex,
|
||||
Spacer,
|
||||
HStack,
|
||||
VStack,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
Heading,
|
||||
Badge,
|
||||
Divider,
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionButton,
|
||||
AccordionPanel,
|
||||
Collapse,
|
||||
useDisclosure,
|
||||
Spinner,
|
||||
Select
|
||||
} from '@chakra-ui/react'
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons'
|
||||
import Form from '@rjsf/chakra-ui'
|
||||
import validator from '@rjsf/validator-ajv8'
|
||||
import allWidgets from './widgets/AllWidgets'
|
||||
import LayoutObjectFieldTemplate from './rjsf/LayoutObjectFieldTemplate'
|
||||
import PlotRealtimeSession from './PlotRealtimeSession'
|
||||
import { useVariableContext } from '../contexts/VariableContext'
|
||||
import * as api from '../services/api'
|
||||
|
||||
// Collapsible Plot Items Form - Each item in the array is individually collapsible
|
||||
function CollapsiblePlotItemsForm({ data, schema, uiSchema, onSave, title, icon, getItemLabel, isExpanded, onToggleExpansion }) {
|
||||
const [formData, setFormData] = useState(data)
|
||||
const [expandedItems, setExpandedItems] = useState(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
// Solo actualizar formData si data realmente cambió en contenido
|
||||
if (JSON.stringify(data) !== JSON.stringify(formData)) {
|
||||
setFormData(data)
|
||||
}
|
||||
}, [data]) // Removed formData from dependencies to avoid infinite loop
|
||||
|
||||
if (!schema || !formData) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Flex align="center" justify="center" py={4}>
|
||||
<Spinner size="sm" mr={2} />
|
||||
<Text>Loading {title}...</Text>
|
||||
</Flex>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const arrayKey = Object.keys(formData)[0]
|
||||
const items = formData[arrayKey] || []
|
||||
|
||||
const toggleItemExpansion = (index) => {
|
||||
const newExpanded = new Set(expandedItems)
|
||||
if (newExpanded.has(index)) {
|
||||
newExpanded.delete(index)
|
||||
} else {
|
||||
newExpanded.add(index)
|
||||
}
|
||||
setExpandedItems(newExpanded)
|
||||
}
|
||||
|
||||
const updateItem = (index, newItemData) => {
|
||||
const newItems = [...items]
|
||||
newItems[index] = newItemData
|
||||
const newFormData = { ...formData, [arrayKey]: newItems }
|
||||
setFormData(newFormData)
|
||||
}
|
||||
|
||||
const addItem = () => {
|
||||
const newItem = {}
|
||||
const newItems = [...items, newItem]
|
||||
const newFormData = { ...formData, [arrayKey]: newItems }
|
||||
setFormData(newFormData)
|
||||
setExpandedItems(new Set([...expandedItems, items.length]))
|
||||
}
|
||||
|
||||
const removeItem = (index) => {
|
||||
const newItems = items.filter((_, i) => i !== index)
|
||||
const newFormData = { ...formData, [arrayKey]: newItems }
|
||||
setFormData(newFormData)
|
||||
// Update expanded items indices
|
||||
const newExpanded = new Set()
|
||||
expandedItems.forEach(i => {
|
||||
if (i < index) newExpanded.add(i)
|
||||
else if (i > index) newExpanded.add(i - 1)
|
||||
})
|
||||
setExpandedItems(newExpanded)
|
||||
}
|
||||
|
||||
const saveChanges = async () => {
|
||||
try {
|
||||
await onSave(formData)
|
||||
// No hacer nada con la expansión aquí - será manejado por el componente padre
|
||||
} catch (error) {
|
||||
console.error('Error saving:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Get item schema from the array schema
|
||||
const itemSchema = schema?.properties?.[arrayKey]?.items || {}
|
||||
|
||||
// Get item UI schema - extract from the nested structure
|
||||
const arrayUiSchema = uiSchema?.[arrayKey] || {}
|
||||
const itemUiSchema = arrayUiSchema.items || {}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Flex align="center" justify="space-between">
|
||||
<Box>
|
||||
<Heading size="md">{icon} {title}</Heading>
|
||||
<Text fontSize="sm" color="gray.500" mt={1}>
|
||||
{items.length} item{items.length !== 1 ? 's' : ''} configured
|
||||
</Text>
|
||||
</Box>
|
||||
<HStack spacing={2}>
|
||||
{/* Si se proporciona toggle de expansión externa, agregar botón de colapso */}
|
||||
{onToggleExpansion && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onToggleExpansion}
|
||||
rightIcon={isExpanded ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
>
|
||||
{isExpanded ? 'Collapse' : 'Expand'}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" colorScheme="green" onClick={addItem}>
|
||||
➕ Add Item
|
||||
</Button>
|
||||
<Button size="sm" colorScheme="blue" onClick={saveChanges}>
|
||||
💾 Save All
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
|
||||
{/* Usar Collapse si se proporciona estado de expansión externo */}
|
||||
{onToggleExpansion ? (
|
||||
<Collapse in={isExpanded}>
|
||||
<CardBody>
|
||||
{items.length === 0 ? (
|
||||
<Box textAlign="center" py={8}>
|
||||
<Text color="gray.500" mb={4}>
|
||||
No items configured yet
|
||||
</Text>
|
||||
<Button colorScheme="green" onClick={addItem}>
|
||||
➕ Add First Item
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<VStack spacing={3} align="stretch">
|
||||
{items.map((item, index) => {
|
||||
const isItemExpanded = expandedItems.has(index)
|
||||
const itemLabel = getItemLabel ? getItemLabel(item) : (item.name || item.id || `Item ${index + 1}`)
|
||||
|
||||
return (
|
||||
<Card key={index} variant="outline" size="sm">
|
||||
<CardHeader py={2}>
|
||||
<Flex align="center" justify="space-between">
|
||||
<HStack spacing={2}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => toggleItemExpansion(index)}
|
||||
rightIcon={isItemExpanded ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
>
|
||||
{itemLabel}
|
||||
</Button>
|
||||
<Badge colorScheme="green" size="sm">#{index + 1}</Badge>
|
||||
</HStack>
|
||||
<Button
|
||||
size="xs"
|
||||
colorScheme="red"
|
||||
variant="ghost"
|
||||
onClick={() => removeItem(index)}
|
||||
>
|
||||
🗑️
|
||||
</Button>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
|
||||
<Collapse in={isItemExpanded}>
|
||||
<CardBody pt={0}>
|
||||
<Form
|
||||
schema={itemSchema}
|
||||
uiSchema={itemUiSchema}
|
||||
formData={item}
|
||||
validator={validator}
|
||||
widgets={allWidgets}
|
||||
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
|
||||
onChange={({ formData: newItemData }) => updateItem(index, newItemData)}
|
||||
>
|
||||
<div></div> {/* Prevents form buttons from showing */}
|
||||
</Form>
|
||||
</CardBody>
|
||||
</Collapse>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</VStack>
|
||||
)}
|
||||
</CardBody>
|
||||
</Collapse>
|
||||
) : (
|
||||
<CardBody>
|
||||
{items.length === 0 ? (
|
||||
<Box textAlign="center" py={8}>
|
||||
<Text color="gray.500" mb={4}>
|
||||
No items configured yet
|
||||
</Text>
|
||||
<Button colorScheme="green" onClick={addItem}>
|
||||
➕ Add First Item
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<VStack spacing={3} align="stretch">
|
||||
{items.map((item, index) => {
|
||||
const isItemExpanded = expandedItems.has(index)
|
||||
const itemLabel = getItemLabel ? getItemLabel(item) : (item.name || item.id || `Item ${index + 1}`)
|
||||
|
||||
return (
|
||||
<Card key={index} variant="outline" size="sm">
|
||||
<CardHeader py={2}>
|
||||
<Flex align="center" justify="space-between">
|
||||
<HStack spacing={2}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() => toggleItemExpansion(index)}
|
||||
rightIcon={isItemExpanded ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
>
|
||||
{itemLabel}
|
||||
</Button>
|
||||
<Badge colorScheme="green" size="sm">#{index + 1}</Badge>
|
||||
</HStack>
|
||||
<Button
|
||||
size="xs"
|
||||
colorScheme="red"
|
||||
variant="ghost"
|
||||
onClick={() => removeItem(index)}
|
||||
>
|
||||
🗑️
|
||||
</Button>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
|
||||
<Collapse in={isItemExpanded}>
|
||||
<CardBody pt={0}>
|
||||
<Form
|
||||
schema={itemSchema}
|
||||
uiSchema={itemUiSchema}
|
||||
formData={item}
|
||||
validator={validator}
|
||||
widgets={allWidgets}
|
||||
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
|
||||
onChange={({ formData: newItemData }) => updateItem(index, newItemData)}
|
||||
>
|
||||
<div></div> {/* Prevents form buttons from showing */}
|
||||
</Form>
|
||||
</CardBody>
|
||||
</Collapse>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</VStack>
|
||||
)}
|
||||
</CardBody>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Collapsible Plot Component
|
||||
function CollapsiblePlotChart({ plotDefinition, plotVariables, onConfigUpdate, onReloadConfig, onRemove, isExpanded, onToggleExpansion }) {
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Flex align="center" justify="space-between">
|
||||
<Box>
|
||||
<Heading size="sm">📈 {plotDefinition.name || plotDefinition.id}</Heading>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{plotDefinition.time_window}s window • {plotVariables?.length || 0} variables
|
||||
</Text>
|
||||
</Box>
|
||||
<HStack>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => onToggleExpansion(plotDefinition.id)}
|
||||
rightIcon={isExpanded ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
>
|
||||
{isExpanded ? 'Hide' : 'Show'} Chart
|
||||
</Button>
|
||||
<Button size="xs" colorScheme="red" variant="outline" onClick={() => onRemove(plotDefinition.id)}>
|
||||
❌
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
|
||||
<Collapse in={isExpanded}>
|
||||
<CardBody pt={0}>
|
||||
<PlotRealtimeSession
|
||||
plotDefinition={plotDefinition}
|
||||
plotVariables={plotVariables}
|
||||
onConfigUpdate={onConfigUpdate}
|
||||
onReloadConfig={onReloadConfig}
|
||||
onRemove={onRemove}
|
||||
isCollapsed={true}
|
||||
/>
|
||||
</CardBody>
|
||||
</Collapse>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Simple Plot Manager Component
|
||||
export default function PlotManager() {
|
||||
const { triggerVariableRefresh } = useVariableContext()
|
||||
const [plotsConfig, setPlotsConfig] = useState(null)
|
||||
const [plotVariablesConfig, setPlotVariablesConfig] = useState(null)
|
||||
const [plotsSchemaData, setPlotsSchemaData] = useState(null)
|
||||
const [plotVariablesSchemaData, setPlotVariablesSchemaData] = useState(null)
|
||||
const [selectedPlotId, setSelectedPlotId] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
// Estado para preservar qué plots están expandidos/colapsados
|
||||
const [expandedPlots, setExpandedPlots] = useState(new Set())
|
||||
// Estado para preservar si la configuración de plot definitions está expandida
|
||||
const [configExpanded, setConfigExpanded] = useState(false)
|
||||
const toast = useToast()
|
||||
|
||||
const loadPlotData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [plotsData, plotVariablesData, plotsSchemaResponse, plotVariablesSchemaResponse] = await Promise.all([
|
||||
api.readConfig('plot-definitions'),
|
||||
api.readConfig('plot-variables'),
|
||||
api.getSchema('plot-definitions'),
|
||||
api.getSchema('plot-variables')
|
||||
])
|
||||
|
||||
setPlotsConfig(plotsData)
|
||||
setPlotVariablesConfig(plotVariablesData)
|
||||
setPlotsSchemaData(plotsSchemaResponse)
|
||||
setPlotVariablesSchemaData(plotVariablesSchemaResponse)
|
||||
|
||||
// Auto-select first plot if none selected
|
||||
if (!selectedPlotId && plotsData?.plots?.length > 0) {
|
||||
setSelectedPlotId(plotsData.plots[0].id)
|
||||
}
|
||||
|
||||
console.log('✅ Plot data loaded:', {
|
||||
plots: plotsData?.plots?.length || 0,
|
||||
plotVariables: plotVariablesData?.variables?.length || 0
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to load plot configurations',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 5000
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [selectedPlotId, toast])
|
||||
|
||||
// Función para actualizar configuración de un plot específico sin recargar todo
|
||||
const updatePlotConfig = async (plotId, newConfig) => {
|
||||
try {
|
||||
// Actualizar solo el plot específico en la configuración local
|
||||
const updatedPlots = plotsConfig?.plots?.map(plot =>
|
||||
plot.id === plotId ? { ...plot, ...newConfig } : plot
|
||||
) || []
|
||||
|
||||
const updatedConfig = {
|
||||
...plotsConfig,
|
||||
plots: updatedPlots
|
||||
}
|
||||
|
||||
// Guardar en el backend
|
||||
await api.writeConfig('plot-definitions', updatedConfig)
|
||||
|
||||
// Actualizar estado local
|
||||
setPlotsConfig(updatedConfig)
|
||||
|
||||
console.log(`✅ Plot ${plotId} configuration updated locally`)
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to update plot ${plotId} config:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const savePlotsConfig = async (formData) => {
|
||||
try {
|
||||
await api.writeConfig('plot-definitions', formData)
|
||||
setPlotsConfig(formData)
|
||||
// Mantener la configuración expandida después de Apply
|
||||
setConfigExpanded(true)
|
||||
toast({
|
||||
title: '✅ Plot definitions saved',
|
||||
status: 'success',
|
||||
duration: 3000
|
||||
})
|
||||
triggerVariableRefresh()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to save plot definitions',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 5000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const savePlotVariables = async (formData) => {
|
||||
try {
|
||||
await api.writeConfig('plot-variables', formData)
|
||||
setPlotVariablesConfig(formData)
|
||||
toast({
|
||||
title: '✅ Plot variables saved',
|
||||
status: 'success',
|
||||
duration: 3000
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to save plot variables',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 5000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get plot definitions from config
|
||||
const getPlotDefinitions = () => {
|
||||
return plotsConfig?.plots || []
|
||||
}
|
||||
|
||||
// Helper to get dataset IDs for dropdown
|
||||
const getDatasetIds = () => {
|
||||
// This would normally come from dataset definitions
|
||||
// For now, return some placeholder values
|
||||
return ['DAR', 'Fast', 'Slow'] // TODO: Get from actual dataset definitions
|
||||
}
|
||||
|
||||
// Helper to get plot variables for any plot ID
|
||||
const getPlotVariables = useCallback((plotId) => {
|
||||
if (!plotId || !plotVariablesConfig?.variables) return []
|
||||
|
||||
const plotVars = plotVariablesConfig.variables.find(
|
||||
item => item.plot_id === plotId
|
||||
)
|
||||
// Return the variables array directly, not the wrapper object
|
||||
return plotVars?.variables || []
|
||||
}, [plotVariablesConfig])
|
||||
|
||||
// Functions to handle plot expansion state
|
||||
const togglePlotExpansion = (plotId) => {
|
||||
const newExpanded = new Set(expandedPlots)
|
||||
if (newExpanded.has(plotId)) {
|
||||
newExpanded.delete(plotId)
|
||||
} else {
|
||||
newExpanded.add(plotId)
|
||||
}
|
||||
setExpandedPlots(newExpanded)
|
||||
}
|
||||
|
||||
const isPlotExpanded = (plotId) => {
|
||||
return expandedPlots.has(plotId)
|
||||
}
|
||||
|
||||
// Function to handle configuration expansion toggle
|
||||
const toggleConfigExpansion = () => {
|
||||
setConfigExpanded(!configExpanded)
|
||||
}
|
||||
|
||||
// Helper functions for Type 3 form pattern (Plot Variables)
|
||||
const getSelectedPlotVariables = () => {
|
||||
if (!selectedPlotId || !plotVariablesConfig?.variables) return { variables: [] }
|
||||
|
||||
const plotVars = plotVariablesConfig.variables.find(
|
||||
item => item.plot_id === selectedPlotId
|
||||
)
|
||||
return plotVars || { plot_id: selectedPlotId, variables: [] }
|
||||
}
|
||||
|
||||
const updateSelectedPlotVariables = (formData) => {
|
||||
if (!plotVariablesConfig?.variables) {
|
||||
// Initialize plotVariablesConfig if it doesn't exist
|
||||
const newConfig = {
|
||||
variables: [{ plot_id: selectedPlotId, ...formData }]
|
||||
}
|
||||
setPlotVariablesConfig(newConfig)
|
||||
return newConfig
|
||||
}
|
||||
|
||||
const existingIndex = plotVariablesConfig.variables.findIndex(
|
||||
item => item.plot_id === selectedPlotId
|
||||
)
|
||||
|
||||
const updatedVars = [...plotVariablesConfig.variables]
|
||||
const newVarData = { plot_id: selectedPlotId, ...formData }
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
updatedVars[existingIndex] = newVarData
|
||||
} else {
|
||||
updatedVars.push(newVarData)
|
||||
}
|
||||
|
||||
const updatedConfig = {
|
||||
...plotVariablesConfig,
|
||||
variables: updatedVars
|
||||
}
|
||||
|
||||
setPlotVariablesConfig(updatedConfig)
|
||||
return updatedConfig
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadPlotData()
|
||||
}, [loadPlotData])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Flex align="center" justify="center" py={8}>
|
||||
<Spinner mr={3} />
|
||||
<Text>Loading plot configurations...</Text>
|
||||
</Flex>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={6} align="stretch">
|
||||
<Flex align="center">
|
||||
<Heading size="lg">📈 Plot Manager</Heading>
|
||||
<Spacer />
|
||||
<Button size="sm" variant="outline" onClick={loadPlotData}>
|
||||
🔄 Refresh
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{/* Real-time Charts Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Heading size="md">🔴 Real-time Plot Sessions</Heading>
|
||||
<Text fontSize="sm" color="gray.500" mt={1}>
|
||||
Live charts for configured plot sessions - click to expand/collapse
|
||||
</Text>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{getPlotDefinitions().length === 0 ? (
|
||||
<Text color="gray.500" textAlign="center" py={8}>
|
||||
No plot sessions configured. Create plot definitions below to get started.
|
||||
</Text>
|
||||
) : (
|
||||
<VStack spacing={4} align="stretch">
|
||||
{getPlotDefinitions().map((plotDef) => (
|
||||
<CollapsiblePlotChart
|
||||
key={plotDef.id}
|
||||
plotDefinition={plotDef}
|
||||
plotVariables={getPlotVariables(plotDef.id)}
|
||||
onConfigUpdate={updatePlotConfig}
|
||||
onReloadConfig={loadPlotData}
|
||||
onRemove={() => {}}
|
||||
isExpanded={isPlotExpanded(plotDef.id)}
|
||||
onToggleExpansion={togglePlotExpansion}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Plot Definitions - Collapsible */}
|
||||
<CollapsiblePlotItemsForm
|
||||
data={plotsConfig}
|
||||
schema={plotsSchemaData?.schema}
|
||||
uiSchema={plotsSchemaData?.uiSchema}
|
||||
onSave={savePlotsConfig}
|
||||
title="Plot Definitions"
|
||||
icon="📋"
|
||||
getItemLabel={(item) => `${item.name || item.id} (${item.time_window || 60}s)`}
|
||||
isExpanded={configExpanded}
|
||||
onToggleExpansion={toggleConfigExpansion}
|
||||
/>
|
||||
|
||||
{/* Plot Variables Configuration - Type 3 Form Pattern */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Heading size="md">⚙️ Plot Variables Configuration</Heading>
|
||||
<Text fontSize="sm" color="gray.500" mt={1}>
|
||||
Select a plot, then configure which variables are displayed in that plot session
|
||||
</Text>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{/* Step 1: Plot Selector (Combo) */}
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Box>
|
||||
<Text fontSize="sm" fontWeight="bold" mb={2}>
|
||||
🎯 Select Plot Session
|
||||
</Text>
|
||||
<Select
|
||||
value={selectedPlotId}
|
||||
onChange={(e) => setSelectedPlotId(e.target.value)}
|
||||
placeholder="Choose a plot to configure..."
|
||||
size="md"
|
||||
>
|
||||
{getPlotDefinitions().map(plot => (
|
||||
<option key={plot.id} value={plot.id}>
|
||||
📈 {plot.name} ({plot.id})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{getPlotDefinitions().length === 0 && (
|
||||
<Text fontSize="sm" color="orange.500" mt={2}>
|
||||
⚠️ No plots available. Configure plot definitions first above.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Variables Configuration Form */}
|
||||
{selectedPlotId && (
|
||||
<Box>
|
||||
<Divider mb={4} />
|
||||
<Text fontSize="sm" fontWeight="bold" mb={2}>
|
||||
⚙️ Configure Variables for Plot "{selectedPlotId}"
|
||||
</Text>
|
||||
|
||||
{/* Simplified schema for selected plot variables */}
|
||||
{(() => {
|
||||
const selectedPlotVars = getSelectedPlotVariables()
|
||||
|
||||
// Schema for this plot's variables - match the real schema
|
||||
const singlePlotSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
variables: {
|
||||
type: "array",
|
||||
title: "Variables",
|
||||
description: `Variables to display in plot ${selectedPlotId}`,
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
variable_name: {
|
||||
type: "string",
|
||||
title: "Variable Name",
|
||||
description: "Name of the variable from the dataset"
|
||||
},
|
||||
label: {
|
||||
type: "string",
|
||||
title: "Display Label",
|
||||
description: "Label shown in the plot legend"
|
||||
},
|
||||
color: {
|
||||
type: "string",
|
||||
title: "Plot Color",
|
||||
pattern: "^#[0-9A-Fa-f]{6}$",
|
||||
default: "#3498db"
|
||||
},
|
||||
line_width: {
|
||||
type: "number",
|
||||
title: "Line Width",
|
||||
default: 2,
|
||||
minimum: 1,
|
||||
maximum: 10
|
||||
},
|
||||
y_axis: {
|
||||
type: "string",
|
||||
title: "Y-Axis",
|
||||
enum: ["left", "right"],
|
||||
default: "left"
|
||||
},
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
title: "Show in Plot",
|
||||
default: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UI Schema for layout
|
||||
const singlePlotUiSchema = {
|
||||
"ui:layout": [[
|
||||
{ "name": "variables", "width": 12 }
|
||||
]],
|
||||
variables: {
|
||||
items: {
|
||||
"ui:layout": [[
|
||||
{ "name": "variable_name", "width": 4 },
|
||||
{ "name": "label", "width": 3 },
|
||||
{ "name": "color", "width": 2 },
|
||||
{ "name": "line_width", "width": 1 },
|
||||
{ "name": "y_axis", "width": 1 },
|
||||
{ "name": "enabled", "width": 1 }
|
||||
]],
|
||||
variable_name: {
|
||||
"ui:widget": "variableSelector",
|
||||
"ui:placeholder": "Search and select variable from datasets...",
|
||||
"ui:help": "🔍 Search and select a variable from the configured datasets (includes live values and metadata)"
|
||||
},
|
||||
label: {
|
||||
"ui:widget": "text",
|
||||
"ui:placeholder": "Chart legend label...",
|
||||
"ui:help": "📊 Label shown in the plot legend for this variable"
|
||||
},
|
||||
color: {
|
||||
"ui:widget": "color",
|
||||
"ui:help": "🎨 Select the color for this variable in the plot",
|
||||
"ui:placeholder": "#3498db"
|
||||
},
|
||||
line_width: {
|
||||
"ui:widget": "updown",
|
||||
"ui:help": "📏 Thickness of the line in the plot (1-10)",
|
||||
"ui:options": { "step": 1, "min": 1, "max": 10 }
|
||||
},
|
||||
y_axis: {
|
||||
"ui:widget": "select",
|
||||
"ui:help": "📈 Which Y-axis to use for this variable"
|
||||
},
|
||||
enabled: {
|
||||
"ui:widget": "checkbox",
|
||||
"ui:help": "✅ Whether to show this variable in the plot"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form
|
||||
schema={singlePlotSchema}
|
||||
uiSchema={singlePlotUiSchema}
|
||||
formData={selectedPlotVars}
|
||||
validator={validator}
|
||||
widgets={allWidgets}
|
||||
templates={{ ObjectFieldTemplate: LayoutObjectFieldTemplate }}
|
||||
onSubmit={({ formData }) => {
|
||||
const updatedConfig = updateSelectedPlotVariables(formData)
|
||||
savePlotVariables(updatedConfig).then(() => {
|
||||
// Additional trigger after successful save
|
||||
triggerVariableRefresh?.()
|
||||
})
|
||||
}}
|
||||
onChange={({ formData }) => updateSelectedPlotVariables(formData)}
|
||||
>
|
||||
<HStack spacing={2} mt={4}>
|
||||
<Button type="submit" colorScheme="blue">
|
||||
💾 Save Variables for {selectedPlotId}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={loadPlotData}>
|
||||
🔄 Reset
|
||||
</Button>
|
||||
</HStack>
|
||||
</Form>
|
||||
)
|
||||
})()}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!selectedPlotId && getPlotDefinitions().length > 0 && (
|
||||
<Box textAlign="center" py={8}>
|
||||
<Text color="gray.500">
|
||||
👆 Select a plot above to configure its variables
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
)
|
||||
}
|
|
@ -54,6 +54,12 @@ export default function PlotRealtimeSession({
|
|||
onConfigUpdate,
|
||||
onReloadConfig // Nueva prop para recargar configuración desde backend
|
||||
}) {
|
||||
// Generate unique browser/tab identifier to avoid conflicts between multiple instances
|
||||
const [browserTabId] = useState(() => {
|
||||
// Use timestamp + random number for uniqueness across tabs/browsers
|
||||
return `tab_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
})
|
||||
|
||||
const [session, setSession] = useState({
|
||||
session_id: plotDefinition.id,
|
||||
name: plotDefinition.name,
|
||||
|
@ -133,9 +139,13 @@ export default function PlotRealtimeSession({
|
|||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
const settingsBg = useColorModeValue('gray.50', 'gray.600')
|
||||
|
||||
// State for the actual session ID returned from backend
|
||||
const [actualSessionId, setActualSessionId] = useState(null)
|
||||
|
||||
// Enhanced session object for ChartjsPlot - memoized to prevent recreations
|
||||
const enhancedSession = useMemo(() => ({
|
||||
session_id: plotDefinition.id,
|
||||
session_id: actualSessionId || plotDefinition.id, // Use actual session ID when available
|
||||
plot_id: plotDefinition.id, // Keep original plot ID for reference
|
||||
name: plotDefinition.name,
|
||||
is_active: session.is_active,
|
||||
is_paused: session.is_paused,
|
||||
|
@ -150,6 +160,7 @@ export default function PlotRealtimeSession({
|
|||
chartControlsRef.current = controls
|
||||
}
|
||||
}), [
|
||||
actualSessionId, // Include actualSessionId in dependencies
|
||||
plotDefinition.id,
|
||||
plotDefinition.name,
|
||||
plotDefinition,
|
||||
|
@ -162,8 +173,14 @@ export default function PlotRealtimeSession({
|
|||
|
||||
// Load session status from backend (optional - session may not exist until started)
|
||||
const refreshSessionStatus = useCallback(async () => {
|
||||
// Only refresh if we have an actual session ID
|
||||
if (!actualSessionId) {
|
||||
// No session yet - this is expected for new instances
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.getPlotSession(plotDefinition.id)
|
||||
const response = await api.getPlotSession(actualSessionId)
|
||||
if (response?.config) {
|
||||
setSession(prev => ({
|
||||
...prev,
|
||||
|
@ -172,16 +189,21 @@ export default function PlotRealtimeSession({
|
|||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
// Session may not exist in backend yet
|
||||
// Session may not exist in backend yet or may have been cleaned up
|
||||
if (error.message.includes('404')) {
|
||||
// Try to create the session automatically
|
||||
await createPlotSessionFromConfig()
|
||||
// Reset actual session ID - will create new one when needed
|
||||
setActualSessionId(null)
|
||||
setSession(prev => ({
|
||||
...prev,
|
||||
is_active: false,
|
||||
is_paused: false
|
||||
}))
|
||||
} else {
|
||||
// Backend not available - use local state silently
|
||||
// This allows the component to work even when backend is offline
|
||||
// Backend not available - maintain current state
|
||||
console.log('Backend not available for session status check')
|
||||
}
|
||||
}
|
||||
}, [plotDefinition.id])
|
||||
}, [actualSessionId])
|
||||
|
||||
// Create plot session in backend based on static configuration
|
||||
const createPlotSessionFromConfig = useCallback(async () => {
|
||||
|
@ -191,6 +213,7 @@ export default function PlotRealtimeSession({
|
|||
|
||||
const plotConfig = {
|
||||
id: plotDefinition.id, // Use id instead of session_id
|
||||
browser_tab_id: browserTabId, // Include unique tab identifier
|
||||
name: plotDefinition.name,
|
||||
variables: variableNames,
|
||||
time_window: plotDefinition.time_window || 60,
|
||||
|
@ -198,17 +221,24 @@ export default function PlotRealtimeSession({
|
|||
trigger_variable: plotDefinition.trigger_variable,
|
||||
trigger_on_true: plotDefinition.trigger_on_true || true,
|
||||
y_min: plotDefinition.y_min,
|
||||
y_max: plotDefinition.y_max
|
||||
y_max: plotDefinition.y_max,
|
||||
allow_multiple: true // Always allow multiple instances
|
||||
}
|
||||
|
||||
// Create the plot session
|
||||
await api.createPlot(plotConfig)
|
||||
const result = await api.createPlot(plotConfig)
|
||||
|
||||
console.log(`✅ Created plot session: ${plotDefinition.id}`)
|
||||
// Store the actual session ID returned by the backend
|
||||
if (result?.session_id) {
|
||||
setActualSessionId(result.session_id)
|
||||
console.log(`✅ Created plot session: ${result.session_id} for plot: ${plotDefinition.id} (tab: ${browserTabId})`)
|
||||
} else {
|
||||
console.log(`✅ Created plot session: ${plotDefinition.id} (tab: ${browserTabId})`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Could not create plot session ${plotDefinition.id}:`, error)
|
||||
console.warn(`Could not create plot session ${plotDefinition.id} (tab: ${browserTabId}):`, error)
|
||||
}
|
||||
}, [plotDefinition, plotVariables])
|
||||
}, [plotDefinition, plotVariables, browserTabId])
|
||||
|
||||
// Control plot session (start, pause, stop, clear)
|
||||
const handleControlClick = useCallback(async (action) => {
|
||||
|
@ -218,8 +248,9 @@ export default function PlotRealtimeSession({
|
|||
if (action === 'start') {
|
||||
try {
|
||||
// Try to create the plot session with current configuration
|
||||
await api.createPlot({
|
||||
const result = await api.createPlot({
|
||||
id: plotDefinition.id, // Use id instead of session_id
|
||||
browser_tab_id: browserTabId, // Include unique tab identifier
|
||||
name: plotDefinition.name,
|
||||
variables: plotVariables.map(v => v.variable_name), // Simplified format
|
||||
time_window: localConfig.time_window,
|
||||
|
@ -227,26 +258,38 @@ export default function PlotRealtimeSession({
|
|||
trigger_variable: localConfig.trigger_variable,
|
||||
trigger_on_true: localConfig.trigger_on_true,
|
||||
y_min: localConfig.y_min,
|
||||
y_max: localConfig.y_max
|
||||
y_max: localConfig.y_max,
|
||||
allow_multiple: true // Always allow multiple instances
|
||||
})
|
||||
// Store the actual session ID if returned
|
||||
if (result?.session_id) {
|
||||
setActualSessionId(result.session_id)
|
||||
}
|
||||
} catch (createError) {
|
||||
// Plot may already exist, that's OK
|
||||
console.log('Plot session may already exist:', createError.message)
|
||||
}
|
||||
}
|
||||
|
||||
// Use the actual session ID if available, fallback to plot ID
|
||||
const sessionIdToUse = actualSessionId || plotDefinition.id
|
||||
|
||||
// Send control command to backend
|
||||
await api.controlPlotSession(plotDefinition.id, action)
|
||||
await api.controlPlotSession(sessionIdToUse, action)
|
||||
|
||||
// For 'start' action, verify that the session is actually active
|
||||
if (action === 'start') {
|
||||
// Wait a bit and verify the session started
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
const verifyResponse = await api.getPlotSession(plotDefinition.id)
|
||||
const verifyResponse = await api.getPlotSession(sessionIdToUse)
|
||||
if (!verifyResponse?.config?.is_active) {
|
||||
// Try the control command once more if not active
|
||||
console.log('Session not active, retrying control command...')
|
||||
await api.controlPlotSession(plotDefinition.id, action)
|
||||
await api.controlPlotSession(sessionIdToUse, action)
|
||||
}
|
||||
// Update session ID if different
|
||||
if (verifyResponse?.config?.session_id && verifyResponse.config.session_id !== plotDefinition.id) {
|
||||
setActualSessionId(verifyResponse.config.session_id)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -298,17 +341,31 @@ export default function PlotRealtimeSession({
|
|||
try {
|
||||
console.log(`🔄 Applying configuration changes for plot ${plotDefinition.id}...`)
|
||||
console.log(`📊 Plot was active: ${wasActive}, paused: ${wasPaused}`)
|
||||
console.log(`📋 Local config to apply:`, localConfig)
|
||||
|
||||
// Update backend configuration
|
||||
// First, update backend configuration and wait for it to complete
|
||||
console.log(`💾 Saving configuration to backend...`)
|
||||
await onConfigUpdate?.(plotDefinition.id, localConfig)
|
||||
console.log(`✅ Configuration saved to backend successfully`)
|
||||
|
||||
// Apply changes to chart if possible
|
||||
if (chartControlsRef.current?.updateConfig) {
|
||||
chartControlsRef.current.updateConfig(localConfig)
|
||||
// Wait a moment for the configuration to be fully persisted
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
|
||||
// Then reload configuration from backend (same as Refresh button)
|
||||
if (onReloadConfig) {
|
||||
console.log(`📥 Reloading plot configuration from backend...`)
|
||||
await onReloadConfig()
|
||||
console.log(`✅ Configuration reloaded from backend`)
|
||||
}
|
||||
|
||||
// Wait a moment for the configuration to be applied
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
// Finally refresh the chart configuration (same as Refresh button)
|
||||
if (chartControlsRef.current?.refreshConfiguration) {
|
||||
console.log(`🔄 Refreshing chart configuration...`)
|
||||
await chartControlsRef.current.refreshConfiguration()
|
||||
console.log(`✅ Chart configuration refreshed successfully`)
|
||||
} else {
|
||||
console.warn(`⚠️ chartControlsRef.current.refreshConfiguration not available`)
|
||||
}
|
||||
|
||||
// Refresh session status to get the latest state
|
||||
await refreshSessionStatus()
|
||||
|
@ -340,7 +397,7 @@ export default function PlotRealtimeSession({
|
|||
applyingChangesRef.current = false
|
||||
}, 1000)
|
||||
}
|
||||
}, [plotDefinition.id, localConfig, onConfigUpdate, session.is_active, session.is_paused, refreshSessionStatus, handleControlClick, toast])
|
||||
}, [plotDefinition.id, localConfig, onConfigUpdate, onReloadConfig, session.is_active, session.is_paused, refreshSessionStatus, handleControlClick, toast])
|
||||
|
||||
const resetConfigChanges = () => {
|
||||
setLocalConfig({
|
||||
|
@ -432,17 +489,20 @@ export default function PlotRealtimeSession({
|
|||
}
|
||||
}, [plotDefinition.id, refreshSessionStatus, handleControlClick, session.is_active, session.is_paused, onReloadConfig, toast])
|
||||
|
||||
// Auto-refresh session status
|
||||
// Auto-refresh session status only if we have a session
|
||||
useEffect(() => {
|
||||
// Try to get session status first, if it fails, create the session
|
||||
refreshSessionStatus()
|
||||
intervalRef.current = setInterval(refreshSessionStatus, 5000)
|
||||
// Only start auto-refresh if we have an actual session ID
|
||||
if (actualSessionId) {
|
||||
refreshSessionStatus()
|
||||
intervalRef.current = setInterval(refreshSessionStatus, 5000)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [refreshSessionStatus])
|
||||
}, [actualSessionId, refreshSessionStatus])
|
||||
|
||||
return (
|
||||
<Card bg={cardBg} borderColor={borderColor} shadow="md">
|
||||
|
@ -490,14 +550,6 @@ export default function PlotRealtimeSession({
|
|||
aria-label="Settings"
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="red"
|
||||
variant="ghost"
|
||||
onClick={() => onRemove?.(plotDefinition.id)}
|
||||
>
|
||||
❌
|
||||
</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
|
|
|
@ -1,289 +0,0 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Heading,
|
||||
useColorModeValue,
|
||||
Badge,
|
||||
IconButton,
|
||||
Divider,
|
||||
Spacer,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react'
|
||||
import { EditIcon, SettingsIcon, DeleteIcon, ViewIcon } from '@chakra-ui/icons'
|
||||
import ChartjsPlot from './ChartjsPlot.jsx'
|
||||
|
||||
export default function PlotRealtimeViewer() {
|
||||
const [sessions, setSessions] = useState(new Map())
|
||||
const [loading, setLoading] = useState(false)
|
||||
const intervalRef = useRef(null)
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
|
||||
const loadSessions = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await fetch('/api/plots')
|
||||
const data = await res.json()
|
||||
if (data && data.sessions) {
|
||||
setSessions(prev => {
|
||||
const next = new Map(prev)
|
||||
const incomingIds = new Set()
|
||||
for (const s of data.sessions) {
|
||||
incomingIds.add(s.session_id)
|
||||
const existing = next.get(s.session_id)
|
||||
if (existing) {
|
||||
// Mutate existing object to preserve reference
|
||||
existing.name = s.name
|
||||
existing.is_active = s.is_active
|
||||
existing.is_paused = s.is_paused
|
||||
existing.variables_count = s.variables_count
|
||||
} else {
|
||||
next.set(s.session_id, { ...s })
|
||||
}
|
||||
}
|
||||
// Remove sessions not present anymore
|
||||
for (const id of Array.from(next.keys())) {
|
||||
if (!incomingIds.has(id)) next.delete(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
setSessions(new Map())
|
||||
}
|
||||
} catch {
|
||||
setSessions(new Map())
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshSession = async (sessionId) => {
|
||||
try {
|
||||
const res = await fetch(`/api/plots/${sessionId}/config`)
|
||||
const data = await res.json()
|
||||
if (data && data.success && data.config) {
|
||||
setSessions(prev => {
|
||||
const n = new Map(prev)
|
||||
const existing = n.get(sessionId)
|
||||
const varsCount = Array.isArray(data.config.variables)
|
||||
? data.config.variables.length
|
||||
: (data.config.variables ? Object.keys(data.config.variables).length : (existing?.variables_count || 0))
|
||||
if (existing) {
|
||||
existing.name = data.config.name
|
||||
existing.is_active = data.config.is_active
|
||||
existing.is_paused = data.config.is_paused
|
||||
existing.variables_count = varsCount
|
||||
} else {
|
||||
n.set(sessionId, {
|
||||
session_id: sessionId,
|
||||
name: data.config.name,
|
||||
is_active: data.config.is_active,
|
||||
is_paused: data.config.is_paused,
|
||||
variables_count: varsCount,
|
||||
})
|
||||
}
|
||||
return n
|
||||
})
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const controlSession = async (sessionId, action) => {
|
||||
try {
|
||||
await fetch(`/api/plots/${sessionId}/control`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action }),
|
||||
})
|
||||
await refreshSession(sessionId)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions()
|
||||
intervalRef.current = setInterval(loadSessions, 5000)
|
||||
return () => { if (intervalRef.current) clearInterval(intervalRef.current) }
|
||||
}, [])
|
||||
|
||||
const sessionsList = useMemo(() => Array.from(sessions.values()), [sessions])
|
||||
|
||||
if (loading && sessionsList.length === 0) {
|
||||
return <Text color={muted}>Cargando sesiones de plots…</Text>
|
||||
}
|
||||
|
||||
if (sessionsList.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Text color={muted}>No hay sesiones de plot. Cree o edite plots en la sección superior.</Text>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
{sessionsList.map((session) => (
|
||||
<PlotRealtimeCard
|
||||
key={session.session_id}
|
||||
session={session}
|
||||
onControl={controlSession}
|
||||
onRefresh={refreshSession}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
|
||||
function PlotRealtimeCard({ session, onControl, onRefresh }) {
|
||||
const cardBg = useColorModeValue('white', 'gray.700')
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600')
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
const chartControlsRef = useRef(null)
|
||||
const { isOpen: isFullscreen, onOpen: openFullscreen, onClose: closeFullscreen } = useDisclosure()
|
||||
|
||||
const handleChartReady = (controls) => {
|
||||
chartControlsRef.current = controls
|
||||
}
|
||||
|
||||
const enhancedSession = {
|
||||
...session,
|
||||
onChartReady: handleChartReady,
|
||||
isFullscreen: isFullscreen,
|
||||
}
|
||||
|
||||
const handleControlClick = async (action) => {
|
||||
if (chartControlsRef.current) {
|
||||
switch (action) {
|
||||
case 'pause':
|
||||
chartControlsRef.current.pauseStreaming()
|
||||
break
|
||||
case 'start':
|
||||
case 'resume':
|
||||
chartControlsRef.current.resumeStreaming()
|
||||
break
|
||||
case 'clear':
|
||||
chartControlsRef.current.clearChart()
|
||||
break
|
||||
case 'stop':
|
||||
chartControlsRef.current.pauseStreaming()
|
||||
break
|
||||
}
|
||||
}
|
||||
// No esperar a que el backend responda para aplicar efecto local
|
||||
onControl(session.session_id, action)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card bg={cardBg} borderColor={borderColor}>
|
||||
<CardHeader>
|
||||
<FlexHeader
|
||||
session={session}
|
||||
muted={muted}
|
||||
onRefresh={() => onRefresh(session.session_id)}
|
||||
onFullscreen={openFullscreen}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<ChartjsPlot session={enhancedSession} height="360px" />
|
||||
<HStack mt={3} spacing={2}>
|
||||
<Button size="sm" onClick={() => handleControlClick('start')} colorScheme="green">▶️ Start</Button>
|
||||
<Button size="sm" onClick={() => handleControlClick('pause')} colorScheme="yellow">⏸️ Pause</Button>
|
||||
<Button size="sm" onClick={() => handleControlClick('clear')} variant="outline">🗑️ Clear</Button>
|
||||
<Button size="sm" onClick={() => handleControlClick('stop')} colorScheme="red">⏹️ Stop</Button>
|
||||
<Spacer />
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={openFullscreen}
|
||||
colorScheme="blue"
|
||||
variant="solid"
|
||||
>
|
||||
🔍 Fullscreen
|
||||
</Button>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
|
||||
{/* Fullscreen Modal */}
|
||||
<Modal isOpen={isFullscreen} onClose={closeFullscreen} size="full">
|
||||
<ModalOverlay bg="blackAlpha.800" />
|
||||
<ModalContent bg={cardBg} m={0} borderRadius={0}>
|
||||
<ModalHeader>
|
||||
<HStack>
|
||||
<Text>📈 {session.name || session.session_id} - Fullscreen Mode</Text>
|
||||
<Spacer />
|
||||
<Text fontSize="sm" color={muted}>
|
||||
Zoom: Drag to select area | Pan: Shift + Drag | Double-click to reset
|
||||
</Text>
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton size="lg" />
|
||||
<ModalBody p={4}>
|
||||
<VStack spacing={4} h="full">
|
||||
<ChartjsPlot session={enhancedSession} height="calc(100vh - 120px)" />
|
||||
<HStack spacing={2}>
|
||||
<Button size="sm" onClick={() => handleControlClick('start')} colorScheme="green">▶️ Start</Button>
|
||||
<Button size="sm" onClick={() => handleControlClick('pause')} colorScheme="yellow">⏸️ Pause</Button>
|
||||
<Button size="sm" onClick={() => handleControlClick('clear')} variant="outline">🗑️ Clear</Button>
|
||||
<Button size="sm" onClick={() => handleControlClick('stop')} colorScheme="red">⏹️ Stop</Button>
|
||||
{chartControlsRef.current && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => chartControlsRef.current.resetZoom?.()}
|
||||
variant="outline"
|
||||
>
|
||||
🔄 Reset Zoom
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function FlexHeader({ session, muted, onRefresh, onFullscreen }) {
|
||||
return (
|
||||
<HStack align="center">
|
||||
<Box>
|
||||
<Heading size="sm">📈 {session.name || session.session_id}</Heading>
|
||||
<Text fontSize="sm" color={muted} mt={1}>
|
||||
Variables: {session.variables_count || 0} | Status: <strong>{session.is_active ? (session.is_paused ? 'Paused' : 'Active') : 'Stopped'}</strong>
|
||||
</Text>
|
||||
</Box>
|
||||
<Spacer />
|
||||
<HStack>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onFullscreen}
|
||||
colorScheme="blue"
|
||||
>
|
||||
🔍 Fullscreen
|
||||
</Button>
|
||||
<IconButton
|
||||
icon={<SettingsIcon />}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label="Refresh status"
|
||||
onClick={onRefresh}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1,414 +0,0 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Select,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Heading,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
ModalCloseButton,
|
||||
useDisclosure
|
||||
} from '@chakra-ui/react'
|
||||
import { AddIcon, DeleteIcon, EditIcon } from '@chakra-ui/icons'
|
||||
import EditableTable from './EditableTable.jsx'
|
||||
import { getSchema, readConfig, writeConfig } from '../services/api.js'
|
||||
|
||||
/**
|
||||
* PlotVariablesTable - Componente especializado para editar variables de plots (array de strings)
|
||||
*/
|
||||
function PlotVariablesTable({ variables = [], onChange, title = "Variables" }) {
|
||||
const [newVariable, setNewVariable] = useState('')
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const [editingIndex, setEditingIndex] = useState(null)
|
||||
const [editingValue, setEditingValue] = useState('')
|
||||
const { isOpen: isEditOpen, onOpen: onEditOpen, onClose: onEditClose } = useDisclosure()
|
||||
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600')
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!newVariable.trim()) return
|
||||
|
||||
const newVariables = [...variables, newVariable.trim()]
|
||||
onChange(newVariables)
|
||||
setNewVariable('')
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
if (editingIndex === null || !editingValue.trim()) return
|
||||
|
||||
const newVariables = [...variables]
|
||||
newVariables[editingIndex] = editingValue.trim()
|
||||
onChange(newVariables)
|
||||
setEditingIndex(null)
|
||||
setEditingValue('')
|
||||
onEditClose()
|
||||
}
|
||||
|
||||
const handleDelete = (index) => {
|
||||
const newVariables = variables.filter((_, i) => i !== index)
|
||||
onChange(newVariables)
|
||||
}
|
||||
|
||||
const openEdit = (index) => {
|
||||
setEditingIndex(index)
|
||||
setEditingValue(variables[index])
|
||||
onEditOpen()
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="semibold">{title}</Text>
|
||||
<Button size="sm" leftIcon={<AddIcon />} onClick={onOpen}>
|
||||
Add Variable
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
<Box overflowX="auto" borderWidth="1px" borderRadius="md" borderColor={borderColor}>
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Variable Name</Th>
|
||||
<Th width="100px">Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{variables.length === 0 ? (
|
||||
<Tr>
|
||||
<Td colSpan={2}>
|
||||
<Text color={muted} textAlign="center">No variables</Text>
|
||||
</Td>
|
||||
</Tr>
|
||||
) : (
|
||||
variables.map((variable, index) => (
|
||||
<Tr key={index}>
|
||||
<Td>{variable}</Td>
|
||||
<Td>
|
||||
<HStack spacing={1}>
|
||||
<IconButton
|
||||
icon={<EditIcon />}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => openEdit(index)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<DeleteIcon />}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDelete(index)}
|
||||
/>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
))
|
||||
)}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{/* Add Modal */}
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Add Variable</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={3}>
|
||||
<Box width="100%">
|
||||
<Text fontSize="sm" fontWeight="medium" mb={1}>
|
||||
Variable Name *
|
||||
</Text>
|
||||
<Input
|
||||
size="sm"
|
||||
value={newVariable}
|
||||
onChange={(e) => setNewVariable(e.target.value)}
|
||||
placeholder="e.g., UR29_Brix"
|
||||
/>
|
||||
<Text fontSize="xs" color={muted} mt={1}>
|
||||
Enter the name of the variable to plot
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onClose}>Cancel</Button>
|
||||
<Button colorScheme="blue" onClick={handleAdd}>Add</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal isOpen={isEditOpen} onClose={onEditClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Edit Variable</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={3}>
|
||||
<Box width="100%">
|
||||
<Text fontSize="sm" fontWeight="medium" mb={1}>
|
||||
Variable Name *
|
||||
</Text>
|
||||
<Input
|
||||
size="sm"
|
||||
value={editingValue}
|
||||
onChange={(e) => setEditingValue(e.target.value)}
|
||||
placeholder="e.g., UR29_Brix"
|
||||
/>
|
||||
<Text fontSize="xs" color={muted} mt={1}>
|
||||
Enter the name of the variable to plot
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onEditClose}>Cancel</Button>
|
||||
<Button colorScheme="blue" onClick={handleEdit}>Save</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* PlotTableManager - Componente para gestionar plots y sus variables
|
||||
* Muestra tabla de plots y tabla de variables del plot seleccionado
|
||||
*/
|
||||
export default function PlotTableManager() {
|
||||
const [plots, setPlots] = useState({})
|
||||
const [plotVariables, setPlotVariables] = useState({})
|
||||
const [selectedPlotId, setSelectedPlotId] = useState('')
|
||||
|
||||
const [plotSchema, setPlotSchema] = useState(null)
|
||||
const [plotUiSchema, setPlotUiSchema] = useState({})
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
const muted = useColorModeValue('gray.600', 'gray.300')
|
||||
|
||||
// Cargar schemas y datos al montar
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Cargar schemas
|
||||
const [plotSchemaResp, plotVariableSchemaResp] = await Promise.all([
|
||||
getSchema('plot-definitions'),
|
||||
getSchema('plot-variables')
|
||||
])
|
||||
|
||||
// Cargar datos de configuración
|
||||
const [plotDataResp, plotVariableDataResp] = await Promise.all([
|
||||
readConfig('plot-definitions'),
|
||||
readConfig('plot-variables')
|
||||
])
|
||||
|
||||
setPlotSchema(plotSchemaResp.schema?.properties?.plots)
|
||||
setPlotUiSchema(plotSchemaResp.ui_schema?.plots || {})
|
||||
|
||||
setPlots(plotDataResp.data?.plots || {})
|
||||
setPlotVariables(plotVariableDataResp.data?.plot_variables || {})
|
||||
|
||||
// Seleccionar el primer plot si existe
|
||||
const plotIds = Object.keys(plotDataResp.data?.plots || {})
|
||||
if (plotIds.length > 0 && !selectedPlotId) {
|
||||
setSelectedPlotId(plotIds[0])
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
setMessage(`Error loading data: ${error.message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const savePlots = async (newPlots) => {
|
||||
setSaving(true)
|
||||
setMessage('')
|
||||
try {
|
||||
// Construir el objeto completo para guardar
|
||||
const saveData = {
|
||||
plots: newPlots,
|
||||
session_counter: 0, // Esto se puede gestionar por separado
|
||||
last_saved: new Date().toISOString(),
|
||||
version: "1.0"
|
||||
}
|
||||
|
||||
await writeConfig('plot-definitions', saveData)
|
||||
setPlots(newPlots)
|
||||
setMessage('Plots saved successfully')
|
||||
} catch (error) {
|
||||
setMessage(`Error saving plots: ${error.message}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const savePlotVariables = async (newVariables) => {
|
||||
setSaving(true)
|
||||
setMessage('')
|
||||
try {
|
||||
const updatedPlotVariables = {
|
||||
...plotVariables,
|
||||
[selectedPlotId]: {
|
||||
variables: newVariables
|
||||
}
|
||||
}
|
||||
|
||||
const saveData = {
|
||||
plot_variables: updatedPlotVariables,
|
||||
version: "1.0",
|
||||
last_update: new Date().toISOString()
|
||||
}
|
||||
|
||||
await writeConfig('plot-variables', saveData)
|
||||
setPlotVariables(updatedPlotVariables)
|
||||
setMessage('Plot variables saved successfully')
|
||||
} catch (error) {
|
||||
setMessage(`Error saving variables: ${error.message}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir datos de plots para el componente EditableTable
|
||||
const plotsForTable = useMemo(() => {
|
||||
return Object.entries(plots).map(([id, data]) => ({
|
||||
id,
|
||||
...data
|
||||
}))
|
||||
}, [plots])
|
||||
|
||||
// Variables del plot seleccionado
|
||||
const variablesForTable = useMemo(() => {
|
||||
if (!selectedPlotId || !plotVariables[selectedPlotId]) {
|
||||
return []
|
||||
}
|
||||
|
||||
return plotVariables[selectedPlotId].variables || []
|
||||
}, [selectedPlotId, plotVariables])
|
||||
|
||||
const plotOptions = Object.entries(plots).map(([id, plot]) => ({
|
||||
value: id,
|
||||
label: `${plot.name} (${id})`
|
||||
}))
|
||||
|
||||
if (loading) {
|
||||
return <Text>Loading plots...</Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{message && (
|
||||
<Alert status={message.includes('Error') ? 'error' : 'success'}>
|
||||
<AlertIcon />
|
||||
{message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Tabla de Plots */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Heading size="sm">📈 Plots</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{plotSchema ? (
|
||||
<EditableTable
|
||||
schema={plotSchema}
|
||||
uiSchema={plotUiSchema.additionalProperties || {}}
|
||||
data={plotsForTable}
|
||||
onChange={(newData) => {
|
||||
// Convertir de array a objeto con keys
|
||||
const newPlots = {}
|
||||
newData.forEach(item => {
|
||||
const { id, ...rest } = item
|
||||
newPlots[id] = rest
|
||||
})
|
||||
savePlots(newPlots)
|
||||
}}
|
||||
title="Plot Definitions"
|
||||
keyField="id"
|
||||
/>
|
||||
) : (
|
||||
<Alert status="warning">
|
||||
<AlertIcon />
|
||||
No plot schema available
|
||||
</Alert>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Selector de Plot y Tabla de Variables */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="sm">🔧 Plot Variables</Heading>
|
||||
<HStack>
|
||||
<Text fontSize="sm" color={muted}>Plot:</Text>
|
||||
<Select
|
||||
size="sm"
|
||||
value={selectedPlotId}
|
||||
onChange={(e) => setSelectedPlotId(e.target.value)}
|
||||
placeholder="Select plot"
|
||||
width="200px"
|
||||
>
|
||||
{plotOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{!selectedPlotId ? (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
Select a plot to manage its variables
|
||||
</Alert>
|
||||
) : (
|
||||
<PlotVariablesTable
|
||||
variables={variablesForTable}
|
||||
onChange={savePlotVariables}
|
||||
title={`Variables for plot: ${plots[selectedPlotId]?.name || selectedPlotId}`}
|
||||
/>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
)
|
||||
}
|
|
@ -6,6 +6,7 @@ import {
|
|||
import { SearchIcon, RepeatIcon } from '@chakra-ui/icons'
|
||||
import { readConfig, readExpandedDatasetVariables } from '../../services/api.js'
|
||||
import { useVariableContext } from '../../contexts/VariableContext.jsx'
|
||||
import { useCoordinatedSSE } from '../../hooks/useCoordinatedConnection'
|
||||
|
||||
// Widget for selecting existing dataset variables with filtering and search
|
||||
export function VariableSelectorWidget(props) {
|
||||
|
@ -30,7 +31,6 @@ export function VariableSelectorWidget(props) {
|
|||
const [liveValue, setLiveValue] = useState(undefined)
|
||||
const [liveStatus, setLiveStatus] = useState('idle')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const esRef = useRef(null)
|
||||
|
||||
const borderColor = useColorModeValue('gray.300', 'gray.600')
|
||||
const focusBorderColor = useColorModeValue('blue.500', 'blue.300')
|
||||
|
@ -117,57 +117,41 @@ export function VariableSelectorWidget(props) {
|
|||
return variables
|
||||
}, [datasetVariables])
|
||||
|
||||
// Subscribe to SSE for live value of selected variable
|
||||
useEffect(() => {
|
||||
// close previous stream
|
||||
if (esRef.current) {
|
||||
try { esRef.current.close() } catch { /* ignore */ }
|
||||
esRef.current = null
|
||||
}
|
||||
// Determinar dataset y variable para SSE coordinado
|
||||
const variable = value && allVariables.find(v => v.name === value)
|
||||
const datasetId = variable?.dataset
|
||||
|
||||
// Usar SSE coordinado para valor en vivo de la variable seleccionada
|
||||
const sseUrl = datasetId && value ?
|
||||
`/api/stream/variables?dataset_id=${encodeURIComponent(datasetId)}&interval=1.0` :
|
||||
null
|
||||
|
||||
const variable = value && allVariables.find(v => v.name === value)
|
||||
const datasetId = variable?.dataset
|
||||
if (!datasetId || !value) {
|
||||
const { data: liveData } = useCoordinatedSSE(
|
||||
`variable_live_${datasetId}_${value}`,
|
||||
sseUrl,
|
||||
[datasetId, value]
|
||||
)
|
||||
|
||||
// Procesar datos SSE recibidos
|
||||
useEffect(() => {
|
||||
if (!liveData) {
|
||||
setLiveValue(undefined)
|
||||
setLiveStatus('idle')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const es = new EventSource(`/api/stream/variables?dataset_id=${encodeURIComponent(datasetId)}&interval=1.0`)
|
||||
esRef.current = es
|
||||
if (liveData?.type === 'values' && liveData.values) {
|
||||
setLiveValue(liveData.values[value])
|
||||
setLiveStatus('ok')
|
||||
} else if (liveData?.type === 'no_cache') {
|
||||
setLiveStatus('waiting')
|
||||
} else if (liveData?.type === 'plc_disconnected' || liveData?.type === 'dataset_inactive') {
|
||||
setLiveValue(undefined)
|
||||
setLiveStatus('offline')
|
||||
} else if (liveData?.type === 'connected') {
|
||||
setLiveStatus('connecting')
|
||||
|
||||
es.onmessage = (evt) => {
|
||||
try {
|
||||
const payload = JSON.parse(evt.data)
|
||||
if (payload?.type === 'values' && payload.values) {
|
||||
setLiveValue(payload.values[value])
|
||||
setLiveStatus('ok')
|
||||
} else if (payload?.type === 'no_cache') {
|
||||
setLiveStatus('waiting')
|
||||
} else if (payload?.type === 'plc_disconnected' || payload?.type === 'dataset_inactive') {
|
||||
setLiveValue(undefined)
|
||||
setLiveStatus('offline')
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
es.onerror = () => {
|
||||
try { es.close() } catch { /* ignore */ }
|
||||
esRef.current = null
|
||||
setLiveStatus('error')
|
||||
}
|
||||
} catch {
|
||||
setLiveStatus('error')
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (esRef.current) {
|
||||
try { esRef.current.close() } catch { /* ignore */ }
|
||||
esRef.current = null
|
||||
}
|
||||
}
|
||||
}, [value, allVariables])
|
||||
}, [liveData, value])
|
||||
|
||||
// Filter variables based on search term and selected dataset
|
||||
const filteredVariables = useMemo(() => {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react'
|
||||
import {
|
||||
FormControl, FormLabel, FormHelperText, Input, Textarea, Select, Checkbox,
|
||||
FormControl, FormLabel, FormHelperText, Input, Textarea, Select, Checkbox, Switch,
|
||||
NumberInput, NumberInputField, NumberInputStepper, NumberIncrementStepper, NumberDecrementStepper,
|
||||
useColorModeValue
|
||||
} from '@chakra-ui/react'
|
||||
|
@ -147,12 +147,30 @@ export const CheckboxWidget = ({ id, label, value, required, disabled, readonly,
|
|||
</FormControl>
|
||||
)
|
||||
|
||||
export const SwitchWidget = ({ id, label, value, required, disabled, readonly, onChange, rawErrors = [] }) => (
|
||||
<FormControl display="flex" alignItems="center" isRequired={required} isDisabled={disabled} isReadOnly={readonly} isInvalid={rawErrors.length > 0}>
|
||||
<FormLabel htmlFor={id} mb="0" mr={3}>
|
||||
{label}
|
||||
</FormLabel>
|
||||
<Switch
|
||||
id={id}
|
||||
isChecked={!!value}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
colorScheme="blue"
|
||||
/>
|
||||
{rawErrors.length > 0 && (
|
||||
<FormHelperText color="red.500">{rawErrors[0]}</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
)
|
||||
|
||||
// Map keys must match RJSF default widget names to override them automatically by type
|
||||
export const widgets = {
|
||||
TextWidget,
|
||||
UpDownWidget,
|
||||
SelectWidget,
|
||||
CheckboxWidget,
|
||||
SwitchWidget,
|
||||
TextareaWidget,
|
||||
// Custom PLC widgets
|
||||
PlcAreaWidget,
|
||||
|
|
|
@ -20,6 +20,7 @@ export const allWidgets = {
|
|||
textarea: widgets.TextareaWidget,
|
||||
select: widgets.SelectWidget,
|
||||
checkbox: widgets.CheckboxWidget,
|
||||
switch: widgets.SwitchWidget,
|
||||
|
||||
// Variable selector aliases - use the advanced version with search and metadata
|
||||
variableSelector: VariableSelectorWidget,
|
||||
|
|
|
@ -0,0 +1,179 @@
|
|||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { getTabCoordinator } from '../utils/TabCoordinator'
|
||||
|
||||
/**
|
||||
* Hook para manejar conexiones coordinadas entre pestañas
|
||||
* Solo la pestaña líder hace las conexiones reales, las demás reciben los datos
|
||||
*/
|
||||
export function useCoordinatedConnection(source, connectionFactory, dependencies = []) {
|
||||
const [data, setData] = useState(null)
|
||||
const [isLeader, setIsLeader] = useState(false)
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
const connectionRef = useRef(null)
|
||||
const coordinatorRef = useRef(null)
|
||||
const subscriptionRef = useRef(null)
|
||||
|
||||
// Obtener el coordinador
|
||||
useEffect(() => {
|
||||
coordinatorRef.current = getTabCoordinator()
|
||||
setIsLeader(coordinatorRef.current.getIsLeader())
|
||||
|
||||
// Subscribirse a cambios de liderazgo
|
||||
const unsubscribeLeadership = coordinatorRef.current.subscribe('leadership', ({ type, data: leadershipData }) => {
|
||||
if (type === 'leadership_change') {
|
||||
setIsLeader(leadershipData.isLeader)
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribeLeadership
|
||||
}, [])
|
||||
|
||||
// Crear/recrear conexión cuando cambia el liderazgo o dependencias
|
||||
useEffect(() => {
|
||||
if (!coordinatorRef.current) return
|
||||
|
||||
// Limpiar conexión anterior
|
||||
if (connectionRef.current && typeof connectionRef.current.close === 'function') {
|
||||
connectionRef.current.close()
|
||||
}
|
||||
if (subscriptionRef.current) {
|
||||
subscriptionRef.current()
|
||||
}
|
||||
|
||||
// Subscribirse a datos de este source
|
||||
subscriptionRef.current = coordinatorRef.current.subscribe(source, ({ type, data: receivedData }) => {
|
||||
if (type === 'data_received') {
|
||||
setData(receivedData.data)
|
||||
setIsConnected(true)
|
||||
}
|
||||
})
|
||||
|
||||
// Crear conexión coordinada
|
||||
const coordinator = coordinatorRef.current
|
||||
const currentIsLeader = coordinator.getIsLeader()
|
||||
|
||||
if (currentIsLeader) {
|
||||
// Somos líder, crear conexión real
|
||||
try {
|
||||
connectionRef.current = connectionFactory((newData) => {
|
||||
// Actualizar estado local
|
||||
setData(newData)
|
||||
setIsConnected(true)
|
||||
// Broadcast a otras pestañas
|
||||
coordinator.broadcastData(source, newData)
|
||||
})
|
||||
setIsConnected(true)
|
||||
console.log(`👑 Leader connection created for ${source}`)
|
||||
} catch (error) {
|
||||
console.error(`Error creating leader connection for ${source}:`, error)
|
||||
setIsConnected(false)
|
||||
}
|
||||
} else {
|
||||
// Somos seguidores, solo escuchar broadcasts
|
||||
connectionRef.current = null
|
||||
console.log(`👥 Follower listening for ${source}`)
|
||||
// El estado de conectado se pondrá en true cuando recibamos el primer dato
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (connectionRef.current && typeof connectionRef.current.close === 'function') {
|
||||
connectionRef.current.close()
|
||||
}
|
||||
if (subscriptionRef.current) {
|
||||
subscriptionRef.current()
|
||||
}
|
||||
}
|
||||
}, [source, isLeader, ...dependencies])
|
||||
|
||||
// Cleanup final
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (connectionRef.current && typeof connectionRef.current.close === 'function') {
|
||||
connectionRef.current.close()
|
||||
}
|
||||
if (subscriptionRef.current) {
|
||||
subscriptionRef.current()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { data, isLeader, isConnected }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook simplificado para polling coordinado
|
||||
*/
|
||||
export function useCoordinatedPolling(source, fetchFunction, interval = 5000, dependencies = []) {
|
||||
return useCoordinatedConnection(
|
||||
source,
|
||||
useCallback((onData) => {
|
||||
let intervalId = null
|
||||
let isActive = true
|
||||
|
||||
const poll = async () => {
|
||||
if (!isActive) return
|
||||
try {
|
||||
const data = await fetchFunction()
|
||||
if (isActive) {
|
||||
onData(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Polling error for ${source}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Poll inicial
|
||||
poll()
|
||||
|
||||
// Configurar intervalo
|
||||
intervalId = setInterval(poll, interval)
|
||||
|
||||
return {
|
||||
close: () => {
|
||||
isActive = false
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [fetchFunction, interval]),
|
||||
dependencies
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para SSE coordinado
|
||||
*/
|
||||
export function useCoordinatedSSE(source, url, dependencies = []) {
|
||||
return useCoordinatedConnection(
|
||||
source,
|
||||
useCallback((onData) => {
|
||||
// Don't create EventSource if URL is null or undefined
|
||||
if (!url) {
|
||||
console.log(`Skipping SSE connection - URL is ${url}`)
|
||||
return {
|
||||
close: () => {} // Return mock connection with close method
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Creating SSE connection to ${url}`)
|
||||
const eventSource = new EventSource(url)
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
onData(data)
|
||||
} catch (error) {
|
||||
console.error('SSE data parse error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE error:', error)
|
||||
}
|
||||
|
||||
return eventSource
|
||||
}, [url]),
|
||||
dependencies
|
||||
)
|
||||
}
|
|
@ -43,21 +43,78 @@ import {
|
|||
AccordionButton,
|
||||
AccordionPanel,
|
||||
Collapse,
|
||||
useDisclosure
|
||||
useDisclosure,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton
|
||||
} from '@chakra-ui/react'
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons'
|
||||
import Form from '@rjsf/chakra-ui'
|
||||
import validator from '@rjsf/validator-ajv8'
|
||||
import PlotManager from '../components/PlotManagerSimple'
|
||||
import PlotManager from '../components/PlotManager'
|
||||
import PlotHistoricalManager from '../components/PlotHistoricalManager'
|
||||
import allWidgets from '../components/widgets/AllWidgets'
|
||||
import LayoutObjectFieldTemplate from '../components/rjsf/LayoutObjectFieldTemplate'
|
||||
import CsvFileBrowser from '../components/CsvFileBrowser'
|
||||
import { VariableProvider, useVariableContext } from '../contexts/VariableContext'
|
||||
import * as api from '../services/api'
|
||||
import { useCoordinatedPolling } from '../hooks/useCoordinatedConnection'
|
||||
|
||||
// Confirmation Dialog Component for deletion operations
|
||||
function ConfirmationDialog({ isOpen, onClose, onConfirm, title, message, itemName, confirmButtonText = "Delete" }) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} isCentered>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<Flex align="center">
|
||||
<Box mr={3} fontSize="2xl">⚠️</Box>
|
||||
{title}
|
||||
</Flex>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={3} align="stretch">
|
||||
<Text>
|
||||
{message}
|
||||
</Text>
|
||||
{itemName && (
|
||||
<Box p={3} bg="gray.50" borderRadius="md" borderLeft="4px solid" borderColor="red.400">
|
||||
<Text fontWeight="bold" color="red.600">
|
||||
Item to delete: "{itemName}"
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Alert status="warning" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<Text fontSize="sm">
|
||||
This action cannot be undone. Make sure you want to proceed.
|
||||
</Text>
|
||||
</Alert>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="outline" mr={3} onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button colorScheme="red" onClick={onConfirm}>
|
||||
🗑️ {confirmButtonText}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// Collapsible Array Items Form - Each item in the array is individually collapsible
|
||||
function CollapsibleArrayItemsForm({ data, schema, uiSchema, onSave, title, icon, getItemLabel }) {
|
||||
const [formData, setFormData] = useState(data)
|
||||
const [expandedItems, setExpandedItems] = useState(new Set())
|
||||
const [confirmDelete, setConfirmDelete] = useState({ isOpen: false, index: null, itemName: '' })
|
||||
|
||||
useEffect(() => {
|
||||
setFormData(data)
|
||||
|
@ -115,6 +172,28 @@ function CollapsibleArrayItemsForm({ data, schema, uiSchema, onSave, title, icon
|
|||
else if (i > index) newExpanded.add(i - 1)
|
||||
})
|
||||
setExpandedItems(newExpanded)
|
||||
// Close confirmation dialog
|
||||
setConfirmDelete({ isOpen: false, index: null, itemName: '' })
|
||||
}
|
||||
|
||||
const handleDeleteClick = (index) => {
|
||||
const item = items[index]
|
||||
const itemName = getItemLabel ? getItemLabel(item) : (item.name || item.id || `Item ${index + 1}`)
|
||||
setConfirmDelete({
|
||||
isOpen: true,
|
||||
index,
|
||||
itemName
|
||||
})
|
||||
}
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (confirmDelete.index !== null) {
|
||||
removeItem(confirmDelete.index)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
setConfirmDelete({ isOpen: false, index: null, itemName: '' })
|
||||
}
|
||||
|
||||
const saveChanges = () => {
|
||||
|
@ -142,7 +221,7 @@ function CollapsibleArrayItemsForm({ data, schema, uiSchema, onSave, title, icon
|
|||
<Button size="sm" colorScheme="green" onClick={addItem}>
|
||||
➕ Add Item
|
||||
</Button>
|
||||
<Button size="sm" colorScheme="blue" onClick={saveChanges}>
|
||||
<Button size="sm" colorScheme="red" onClick={saveChanges}>
|
||||
💾 Save All
|
||||
</Button>
|
||||
</HStack>
|
||||
|
@ -184,7 +263,8 @@ function CollapsibleArrayItemsForm({ data, schema, uiSchema, onSave, title, icon
|
|||
size="xs"
|
||||
colorScheme="red"
|
||||
variant="ghost"
|
||||
onClick={() => removeItem(index)}
|
||||
onClick={() => handleDeleteClick(index)}
|
||||
title="Delete this item"
|
||||
>
|
||||
🗑️
|
||||
</Button>
|
||||
|
@ -212,6 +292,17 @@ function CollapsibleArrayItemsForm({ data, schema, uiSchema, onSave, title, icon
|
|||
</VStack>
|
||||
)}
|
||||
</CardBody>
|
||||
|
||||
{/* Confirmation Dialog for Deletion */}
|
||||
<ConfirmationDialog
|
||||
isOpen={confirmDelete.isOpen}
|
||||
onClose={handleCancelDelete}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title={`Delete ${title.replace(/📊|📋|⚙️|📈|🗂️/g, '').trim()} Item`}
|
||||
message={`Are you sure you want to delete this ${title.toLowerCase().replace(/📊|📋|⚙️|📈|🗂️/g, '').trim()} item?`}
|
||||
itemName={confirmDelete.itemName}
|
||||
confirmButtonText="Delete Item"
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
@ -308,17 +399,31 @@ function CollapsibleArrayForm({ data, schema, uiSchema, onSave, title, icon, get
|
|||
}
|
||||
|
||||
// StatusBar Component - Real-time PLC status with action buttons
|
||||
function StatusBar({ status, onRefresh }) {
|
||||
function StatusBar({ status, isConnected, isLeader }) {
|
||||
const plcConnected = !!status?.plc_connected
|
||||
const streaming = !!status?.streaming
|
||||
const csvRecording = !!status?.csv_recording
|
||||
const [actionLoading, setActionLoading] = useState({})
|
||||
const [plotJugglerFound, setPlotJugglerFound] = useState(false)
|
||||
const toast = useToast()
|
||||
|
||||
const setLoading = (action, loading) => {
|
||||
setActionLoading(prev => ({ ...prev, [action]: loading }))
|
||||
}
|
||||
|
||||
// Check if PlotJuggler is available on component mount
|
||||
useEffect(() => {
|
||||
const checkPlotJuggler = async () => {
|
||||
try {
|
||||
const result = await api.getPlotJugglerPath()
|
||||
setPlotJugglerFound(result.found)
|
||||
} catch (error) {
|
||||
setPlotJugglerFound(false)
|
||||
}
|
||||
}
|
||||
checkPlotJuggler()
|
||||
}, [])
|
||||
|
||||
const handleConnectPlc = async () => {
|
||||
setLoading('connect', true)
|
||||
try {
|
||||
|
@ -329,7 +434,7 @@ function StatusBar({ status, onRefresh }) {
|
|||
status: 'info',
|
||||
duration: 2000
|
||||
})
|
||||
setTimeout(() => onRefresh?.(), 1000)
|
||||
// El polling coordinado actualizará automáticamente el estado
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to connect PLC',
|
||||
|
@ -352,7 +457,7 @@ function StatusBar({ status, onRefresh }) {
|
|||
status: 'info',
|
||||
duration: 2000
|
||||
})
|
||||
setTimeout(() => onRefresh?.(), 1000)
|
||||
// El polling coordinado actualizará automáticamente el estado
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to disconnect PLC',
|
||||
|
@ -375,7 +480,7 @@ function StatusBar({ status, onRefresh }) {
|
|||
status: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
setTimeout(() => onRefresh?.(), 1000)
|
||||
// El polling coordinado actualizará automáticamente el estado
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to start streaming',
|
||||
|
@ -398,7 +503,7 @@ function StatusBar({ status, onRefresh }) {
|
|||
status: 'info',
|
||||
duration: 2000
|
||||
})
|
||||
setTimeout(() => onRefresh?.(), 1000)
|
||||
// El polling coordinado actualizará automáticamente el estado
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to stop streaming',
|
||||
|
@ -411,8 +516,30 @@ function StatusBar({ status, onRefresh }) {
|
|||
}
|
||||
}
|
||||
|
||||
const handleLaunchPlotJuggler = async () => {
|
||||
setLoading('launchPlotJuggler', true)
|
||||
try {
|
||||
const result = await api.launchPlotJugglerStreamer()
|
||||
toast({
|
||||
title: '🚀 PlotJuggler launched',
|
||||
description: result.message || 'PlotJuggler started with UDP streamer',
|
||||
status: 'success',
|
||||
duration: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '❌ Failed to launch PlotJuggler',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
} finally {
|
||||
setLoading('launchPlotJuggler', false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={4} mb={6}>
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 4 }} spacing={4} mb={6}>
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Stat>
|
||||
|
@ -462,29 +589,49 @@ function StatusBar({ status, onRefresh }) {
|
|||
{streaming ? 'Active' : 'Inactive'}
|
||||
</StatNumber>
|
||||
<Box mt={2}>
|
||||
{streaming ? (
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="red"
|
||||
variant="outline"
|
||||
onClick={handleStopStreaming}
|
||||
isLoading={actionLoading.stopStream}
|
||||
loadingText="Stopping..."
|
||||
>
|
||||
⏹️ Stop
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
onClick={handleStartStreaming}
|
||||
isLoading={actionLoading.startStream}
|
||||
loadingText="Starting..."
|
||||
>
|
||||
▶️ Start
|
||||
</Button>
|
||||
)}
|
||||
<VStack spacing={2} align="stretch">
|
||||
{streaming ? (
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="red"
|
||||
variant="outline"
|
||||
onClick={handleStopStreaming}
|
||||
isLoading={actionLoading.stopStream}
|
||||
loadingText="Stopping..."
|
||||
>
|
||||
⏹️ Stop
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
onClick={handleStartStreaming}
|
||||
isLoading={actionLoading.startStream}
|
||||
loadingText="Starting..."
|
||||
>
|
||||
▶️ Start
|
||||
</Button>
|
||||
)}
|
||||
{plotJugglerFound && (
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
variant="outline"
|
||||
onClick={handleLaunchPlotJuggler}
|
||||
isLoading={actionLoading.launchPlotJuggler}
|
||||
loadingText="Launching..."
|
||||
title="Launch PlotJuggler with UDP streamer configured"
|
||||
>
|
||||
🚀 PlotJuggler
|
||||
</Button>
|
||||
)}
|
||||
{!plotJugglerFound && (
|
||||
<Text fontSize="xs" color="gray.500" textAlign="center">
|
||||
PlotJuggler not found
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
</Stat>
|
||||
</CardBody>
|
||||
|
@ -506,6 +653,21 @@ function StatusBar({ status, onRefresh }) {
|
|||
</Stat>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Stat>
|
||||
<StatLabel>🔗 Tab Coordination</StatLabel>
|
||||
<StatNumber fontSize="lg" color={isConnected ? 'green.500' : 'orange.500'}>
|
||||
{isLeader ? '👑 Leader' : '👥 Follower'}
|
||||
</StatNumber>
|
||||
<StatHelpText>
|
||||
{isConnected ? '✅ Connected' : '⏳ Connecting...'}<br/>
|
||||
{isLeader ? 'Making connections' : 'Receiving data'}
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
)
|
||||
}
|
||||
|
@ -542,7 +704,7 @@ function ConfigurationPanel({ schemaData, formData, onFormChange, onSave, saving
|
|||
)}
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Form
|
||||
<Form
|
||||
schema={schemaData.schema}
|
||||
uiSchema={schemaData.uiSchema}
|
||||
formData={formData}
|
||||
|
@ -554,8 +716,8 @@ function ConfigurationPanel({ schemaData, formData, onFormChange, onSave, saving
|
|||
>
|
||||
<HStack spacing={2} mt={4}>
|
||||
<Button
|
||||
type="submit"
|
||||
colorScheme="blue"
|
||||
type="submit"
|
||||
colorScheme="red"
|
||||
isLoading={saving}
|
||||
loadingText="Saving..."
|
||||
>
|
||||
|
@ -738,14 +900,23 @@ function DatasetManager() {
|
|||
{/* Step 1: Dataset Selector (Combo) */}
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Box>
|
||||
<Text fontSize="sm" fontWeight="bold" mb={2}>
|
||||
<Text fontSize="lg" fontWeight="bold" mb={3}>
|
||||
🎯 Select Dataset
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.600" mb={3} fontStyle="italic">
|
||||
⚠️ Important: The variables configuration below depends on this selection
|
||||
</Text>
|
||||
<Select
|
||||
value={selectedDatasetId}
|
||||
onChange={(e) => setSelectedDatasetId(e.target.value)}
|
||||
onChange={(e) => {
|
||||
const newDatasetId = e.target.value
|
||||
setSelectedDatasetId(newDatasetId)
|
||||
console.log(`🎯 Dataset selection changed to: ${newDatasetId}`)
|
||||
}}
|
||||
placeholder="Choose a dataset to configure..."
|
||||
size="md"
|
||||
size="lg"
|
||||
fontSize="lg"
|
||||
fontWeight="semibold"
|
||||
>
|
||||
{availableDatasets.map(dataset => (
|
||||
<option key={dataset.id} value={dataset.id}>
|
||||
|
@ -765,7 +936,7 @@ function DatasetManager() {
|
|||
<Box>
|
||||
<Divider mb={4} />
|
||||
<Text fontSize="sm" fontWeight="bold" mb={2}>
|
||||
⚙️ Configure Variables for Dataset "{selectedDatasetId}"
|
||||
⚙️ Configure Variables for Dataset "{availableDatasets.find(d => d.id === selectedDatasetId)?.name || selectedDatasetId}"
|
||||
</Text>
|
||||
|
||||
{/* Simplified schema for selected dataset variables */}
|
||||
|
@ -778,7 +949,7 @@ function DatasetManager() {
|
|||
properties: {
|
||||
variables: {
|
||||
type: "array",
|
||||
title: "Variables",
|
||||
title: `Variables for Dataset: ${availableDatasets.find(d => d.id === selectedDatasetId)?.name || selectedDatasetId}`,
|
||||
description: `PLC variables to record in dataset ${selectedDatasetId}`,
|
||||
items: {
|
||||
type: "object",
|
||||
|
@ -885,13 +1056,168 @@ function DatasetManager() {
|
|||
"ui:help": "PLC data type"
|
||||
},
|
||||
"streaming": {
|
||||
"ui:widget": "checkbox",
|
||||
"ui:widget": "switch",
|
||||
"ui:help": "Enable UDP streaming to PlotJuggler"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Function to expand symbol data using backend API
|
||||
const expandSymbolToManualConfig = async (symbolName, currentVariable = {}) => {
|
||||
try {
|
||||
// Create a temporary variable array with just this symbol
|
||||
const tempVariables = [{
|
||||
symbol: symbolName,
|
||||
streaming: currentVariable.streaming || false,
|
||||
configType: "symbol"
|
||||
}]
|
||||
|
||||
// Call backend API to process the symbol
|
||||
const response = await fetch('/api/symbols/process-variables', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
variables: tempVariables
|
||||
})
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success && result.processed_variables.length > 0) {
|
||||
const processedVar = result.processed_variables[0]
|
||||
|
||||
// Build the configuration object, only including relevant fields
|
||||
const config = {
|
||||
name: processedVar.name || symbolName,
|
||||
area: processedVar.area || "db",
|
||||
offset: processedVar.offset !== undefined && processedVar.offset !== null ? processedVar.offset : 0,
|
||||
type: processedVar.type || "real",
|
||||
streaming: currentVariable.streaming || false
|
||||
}
|
||||
|
||||
// Only include db field if it's actually present and area requires it
|
||||
if (processedVar.db !== undefined && processedVar.db !== null) {
|
||||
config.db = processedVar.db
|
||||
} else if (config.area === "db") {
|
||||
// Default to 1 only for DB area if no DB number was provided
|
||||
config.db = 1
|
||||
}
|
||||
|
||||
// Only include bit field if it's actually present
|
||||
if (processedVar.bit !== undefined && processedVar.bit !== null) {
|
||||
config.bit = processedVar.bit
|
||||
} else {
|
||||
// Default to 0 for bit position when not specified
|
||||
config.bit = 0
|
||||
}
|
||||
|
||||
return config
|
||||
} else {
|
||||
// If backend processing failed, return basic defaults
|
||||
const fallbackConfig = {
|
||||
name: currentVariable.name || symbolName,
|
||||
area: "db", // Default to DB area
|
||||
offset: 0,
|
||||
type: "real",
|
||||
bit: 0,
|
||||
streaming: currentVariable.streaming || false
|
||||
}
|
||||
|
||||
// Only add db field for DB area
|
||||
if (fallbackConfig.area === "db") {
|
||||
fallbackConfig.db = 1
|
||||
}
|
||||
|
||||
return fallbackConfig
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error expanding symbol:', error)
|
||||
// Return basic defaults on error
|
||||
const errorConfig = {
|
||||
name: currentVariable.name || symbolName,
|
||||
area: "db", // Default to DB area
|
||||
offset: 0,
|
||||
type: "real",
|
||||
bit: 0,
|
||||
streaming: currentVariable.streaming || false
|
||||
}
|
||||
|
||||
// Only add db field for DB area
|
||||
if (errorConfig.area === "db") {
|
||||
errorConfig.db = 1
|
||||
}
|
||||
|
||||
return errorConfig
|
||||
}
|
||||
}
|
||||
|
||||
// Custom onChange handler that detects configType changes and auto-fills data
|
||||
const handleFormChange = async ({ formData }) => {
|
||||
// Check if there are variables and if any configType changed from symbol to manual
|
||||
if (formData?.variables && selectedDatasetVars?.variables) {
|
||||
const updatedVariables = []
|
||||
let hasSymbolToManualChange = false
|
||||
|
||||
for (let index = 0; index < formData.variables.length; index++) {
|
||||
const newVar = formData.variables[index]
|
||||
const oldVar = selectedDatasetVars.variables[index]
|
||||
|
||||
// Detect if configType changed from "symbol" to "manual"
|
||||
if (oldVar?.configType === "symbol" &&
|
||||
newVar?.configType === "manual" &&
|
||||
oldVar?.symbol) {
|
||||
|
||||
hasSymbolToManualChange = true
|
||||
|
||||
try {
|
||||
// Auto-fill manual fields from symbol data using backend API
|
||||
const symbolData = await expandSymbolToManualConfig(oldVar.symbol, oldVar)
|
||||
|
||||
// Add the variable with auto-filled data
|
||||
updatedVariables.push({
|
||||
...newVar,
|
||||
...symbolData,
|
||||
configType: "manual", // Ensure configType is set correctly
|
||||
symbol: undefined // Clear symbol field to avoid confusion
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error expanding symbol:', error)
|
||||
// Fallback: add variable as-is on error
|
||||
updatedVariables.push(newVar)
|
||||
}
|
||||
} else {
|
||||
// For other cases, return the variable as-is
|
||||
updatedVariables.push(newVar)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasSymbolToManualChange) {
|
||||
// Show toast notification about the auto-fill
|
||||
toast({
|
||||
title: '🔄 Auto-filled from symbol',
|
||||
description: 'Symbol data has been copied to manual configuration fields',
|
||||
status: 'success',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
|
||||
// Update with the modified variables
|
||||
const updatedFormData = {
|
||||
...formData,
|
||||
variables: updatedVariables
|
||||
}
|
||||
|
||||
updateSelectedDatasetVariables(updatedFormData)
|
||||
} else {
|
||||
// Normal update without special processing
|
||||
updateSelectedDatasetVariables(formData)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form
|
||||
schema={singleDatasetSchema}
|
||||
|
@ -907,11 +1233,18 @@ function DatasetManager() {
|
|||
triggerVariableRefresh()
|
||||
})
|
||||
}}
|
||||
onChange={({ formData }) => updateSelectedDatasetVariables(formData)}
|
||||
onChange={({ formData }) => {
|
||||
// Call the async handler
|
||||
handleFormChange({ formData }).catch(error => {
|
||||
console.error('Error in form change handler:', error)
|
||||
// Fallback to normal update on error
|
||||
updateSelectedDatasetVariables(formData)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<HStack spacing={2} mt={4}>
|
||||
<Button type="submit" colorScheme="blue">
|
||||
💾 Save Variables for {selectedDatasetId}
|
||||
<Button type="submit" colorScheme="red">
|
||||
💾 Save Variables for {availableDatasets.find(d => d.id === selectedDatasetId)?.name || selectedDatasetId}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={loadDatasetData}>
|
||||
🔄 Reset
|
||||
|
@ -1021,10 +1354,7 @@ export default function Dashboard() {
|
|||
|
||||
// Dashboard Content Component (separated to use context)
|
||||
function DashboardContent() {
|
||||
const [status, setStatus] = useState(null)
|
||||
const [statusLoading, setStatusLoading] = useState(true)
|
||||
const [statusError, setStatusError] = useState('')
|
||||
|
||||
// Estado para configuración PLC
|
||||
const [schemaData, setSchemaData] = useState(null)
|
||||
const [formData, setFormData] = useState(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
@ -1033,37 +1363,12 @@ function DashboardContent() {
|
|||
const [events, setEvents] = useState([])
|
||||
const [eventsLoading, setEventsLoading] = useState(false)
|
||||
|
||||
// Load status once
|
||||
const loadStatus = useCallback(async () => {
|
||||
try {
|
||||
setStatusLoading(true)
|
||||
setStatusError('')
|
||||
const statusData = await api.getStatus()
|
||||
setStatus(statusData)
|
||||
} catch (error) {
|
||||
setStatusError(error.message)
|
||||
} finally {
|
||||
setStatusLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Real-time status updates via polling
|
||||
const subscribeSSE = useCallback(() => {
|
||||
// Use polling for real-time updates (every 5 seconds)
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const statusData = await api.getStatus()
|
||||
setStatus(statusData)
|
||||
setStatusError('')
|
||||
} catch (error) {
|
||||
console.error('Status polling error:', error)
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [])
|
||||
// Usar polling coordinado para el status
|
||||
const { data: status, isLeader, isConnected } = useCoordinatedPolling(
|
||||
'dashboard_status',
|
||||
api.getStatus,
|
||||
5000 // 5 segundos
|
||||
)
|
||||
|
||||
// Load PLC config
|
||||
const loadConfig = useCallback(async () => {
|
||||
|
@ -1110,15 +1415,11 @@ function DashboardContent() {
|
|||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
loadStatus()
|
||||
loadConfig()
|
||||
loadEvents()
|
||||
|
||||
const cleanup = subscribeSSE()
|
||||
return cleanup
|
||||
}, [loadStatus, loadConfig, loadEvents, subscribeSSE])
|
||||
}, [loadConfig, loadEvents])
|
||||
|
||||
if (statusLoading) {
|
||||
if (!isConnected && !status) {
|
||||
return (
|
||||
<Container maxW="container.xl" py={6}>
|
||||
<Flex align="center" justify="center" minH="200px">
|
||||
|
@ -1138,25 +1439,23 @@ function DashboardContent() {
|
|||
🏭 PLC S7-31x Streamer & Logger
|
||||
</Heading>
|
||||
<Spacer />
|
||||
<Button size="sm" variant="outline" onClick={loadStatus}>
|
||||
🔄 Refresh Status
|
||||
</Button>
|
||||
{isLeader && (
|
||||
<Badge colorScheme="green" mr={2}>👑 Leader Tab</Badge>
|
||||
)}
|
||||
{isConnected && (
|
||||
<Badge colorScheme="blue" mr={2}>🔗 Connected</Badge>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{statusError && (
|
||||
<Alert status="error">
|
||||
<AlertIcon />
|
||||
Failed to load status: {statusError}
|
||||
</Alert>
|
||||
)}
|
||||
<StatusBar status={status} isConnected={isConnected} isLeader={isLeader} />
|
||||
|
||||
<StatusBar status={status} onRefresh={loadStatus} />
|
||||
|
||||
<Tabs variant="enclosed" colorScheme="blue">
|
||||
<Tabs variant="enclosed" colorScheme="orange" defaultIndex={2}>
|
||||
<TabList>
|
||||
<Tab>🔧 Configuration</Tab>
|
||||
<Tab>📊 Datasets</Tab>
|
||||
<Tab>📈 Plotting</Tab>
|
||||
<Tab>🕰️ Historical Plots</Tab>
|
||||
<Tab>📄 CSV Files</Tab>
|
||||
<Tab>📋 Events</Tab>
|
||||
</TabList>
|
||||
|
||||
|
@ -1183,6 +1482,16 @@ function DashboardContent() {
|
|||
<PlotManager />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel p={0} pt={4}>
|
||||
{/* Historical Plot Management Section */}
|
||||
<PlotHistoricalManager />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel p={0} pt={4}>
|
||||
{/* CSV File Browser Section */}
|
||||
<CsvFileBrowser />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel p={0} pt={4}>
|
||||
{/* Events Section */}
|
||||
<EventsDisplay
|
||||
|
|
|
@ -227,4 +227,86 @@ export async function controlPlotSession(sessionId, action) {
|
|||
return await controlPlot(sessionId, action)
|
||||
}
|
||||
|
||||
// CSV File Browser API
|
||||
export async function getCsvFiles() {
|
||||
const res = await fetch(`${BASE_URL}/api/csv/files`, { headers: { 'Accept': 'application/json' } })
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
// PlotJuggler integration
|
||||
export async function launchPlotJuggler(filePaths) {
|
||||
const res = await fetch(`${BASE_URL}/api/plotjuggler/launch`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ files: filePaths })
|
||||
})
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function getPlotJugglerPath() {
|
||||
const res = await fetch(`${BASE_URL}/api/plotjuggler/path`, { headers: { 'Accept': 'application/json' } })
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function setPlotJugglerPath(path) {
|
||||
const res = await fetch(`${BASE_URL}/api/plotjuggler/path`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ path })
|
||||
})
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function launchPlotJugglerStreamer() {
|
||||
const res = await fetch(`${BASE_URL}/api/plotjuggler/launch-streamer`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Open CSV in Excel
|
||||
export async function openCsvInExcel(filePath) {
|
||||
const res = await fetch(`${BASE_URL}/api/csv/open-excel`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ file_path: filePath })
|
||||
})
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Configuration aliases for compatibility
|
||||
export async function getConfig(configId) {
|
||||
return readConfig(configId)
|
||||
}
|
||||
|
||||
// Enhanced historical data loading with time range support
|
||||
export async function getHistoricalDataOptimized(variables, options = {}) {
|
||||
const requestBody = {
|
||||
variables: variables,
|
||||
...options
|
||||
}
|
||||
|
||||
const res = await fetch(`${BASE_URL}/api/plots/historical`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
})
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,298 @@
|
|||
/**
|
||||
* TabCoordinator - Coordina múltiples pestañas para evitar sobrecarga de conexiones
|
||||
*
|
||||
* Problema: Los navegadores limitan a ~6 conexiones HTTP concurrentes por dominio.
|
||||
* Cada pestaña crea múltiples conexiones (SSE + polling), causando bloqueos.
|
||||
*
|
||||
* Solución: Solo la pestaña "líder" hace las conexiones reales, las demás reciben
|
||||
* los datos via BroadcastChannel.
|
||||
*/
|
||||
|
||||
class TabCoordinator {
|
||||
constructor() {
|
||||
this.tabId = `tab_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
this.isLeader = false
|
||||
this.channel = new BroadcastChannel('plc_streamer_coordination')
|
||||
this.subscribers = new Map()
|
||||
this.leaderHeartbeatInterval = null
|
||||
this.leaderCheckInterval = null
|
||||
|
||||
// Configuración
|
||||
this.HEARTBEAT_INTERVAL = 3000 // 3 segundos
|
||||
this.LEADER_TIMEOUT = 6000 // 6 segundos sin heartbeat = cambio de líder
|
||||
|
||||
console.log(`🔗 TabCoordinator initialized for tab ${this.tabId}`)
|
||||
|
||||
this.setupEventListeners()
|
||||
this.electLeader()
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Escuchar mensajes de otras pestañas
|
||||
this.channel.addEventListener('message', (event) => {
|
||||
const { type, data, fromTab } = event.data
|
||||
|
||||
if (fromTab === this.tabId) return // Ignorar nuestros propios mensajes
|
||||
|
||||
switch (type) {
|
||||
case 'leader_heartbeat':
|
||||
this.handleLeaderHeartbeat(data, fromTab)
|
||||
break
|
||||
case 'leader_election':
|
||||
this.handleLeaderElection(fromTab)
|
||||
break
|
||||
case 'data_broadcast':
|
||||
this.handleDataBroadcast(data)
|
||||
break
|
||||
case 'leader_resignation':
|
||||
this.handleLeaderResignation(fromTab)
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup al cerrar pestaña
|
||||
window.addEventListener('beforeunload', () => {
|
||||
this.resignLeadership()
|
||||
})
|
||||
|
||||
// Cleanup al ocultar pestaña (cambio de tab)
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden && this.isLeader) {
|
||||
// Tab oculta, considerar resignar liderazgo tras un delay
|
||||
setTimeout(() => {
|
||||
if (document.hidden && this.isLeader) {
|
||||
this.resignLeadership()
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
electLeader() {
|
||||
// Verificar si ya hay un líder activo
|
||||
const lastHeartbeat = localStorage.getItem('plc_leader_heartbeat')
|
||||
const lastTab = localStorage.getItem('plc_leader_tab')
|
||||
|
||||
if (lastHeartbeat && lastTab) {
|
||||
const timeSinceHeartbeat = Date.now() - parseInt(lastHeartbeat)
|
||||
if (timeSinceHeartbeat < this.LEADER_TIMEOUT) {
|
||||
// Hay un líder activo, somos seguidores
|
||||
this.becomeFollower()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// No hay líder o expiró, intentar ser líder
|
||||
this.becomeLeader()
|
||||
}
|
||||
|
||||
becomeLeader() {
|
||||
if (this.isLeader) return
|
||||
|
||||
console.log(`👑 Tab ${this.tabId} becoming leader`)
|
||||
this.isLeader = true
|
||||
|
||||
// Anunciar liderazgo
|
||||
this.broadcast('leader_election', { timestamp: Date.now() })
|
||||
|
||||
// Iniciar heartbeat
|
||||
this.startHeartbeat()
|
||||
|
||||
// Notificar a subscribers que somos el líder
|
||||
this.notifySubscribers('leadership_change', { isLeader: true })
|
||||
}
|
||||
|
||||
becomeFollower() {
|
||||
if (!this.isLeader) return
|
||||
|
||||
console.log(`👥 Tab ${this.tabId} becoming follower`)
|
||||
this.isLeader = false
|
||||
|
||||
// Detener heartbeat
|
||||
this.stopHeartbeat()
|
||||
|
||||
// Notificar a subscribers que ya no somos líder
|
||||
this.notifySubscribers('leadership_change', { isLeader: false })
|
||||
}
|
||||
|
||||
resignLeadership() {
|
||||
if (!this.isLeader) return
|
||||
|
||||
console.log(`📤 Tab ${this.tabId} resigning leadership`)
|
||||
|
||||
// Anunciar resignación
|
||||
this.broadcast('leader_resignation', { timestamp: Date.now() })
|
||||
|
||||
// Limpiar localStorage
|
||||
localStorage.removeItem('plc_leader_heartbeat')
|
||||
localStorage.removeItem('plc_leader_tab')
|
||||
|
||||
this.becomeFollower()
|
||||
}
|
||||
|
||||
startHeartbeat() {
|
||||
this.stopHeartbeat() // Cleanup previo
|
||||
|
||||
this.leaderHeartbeatInterval = setInterval(() => {
|
||||
const timestamp = Date.now()
|
||||
|
||||
// Guardar en localStorage para verificación de otras pestañas
|
||||
localStorage.setItem('plc_leader_heartbeat', timestamp.toString())
|
||||
localStorage.setItem('plc_leader_tab', this.tabId)
|
||||
|
||||
// Broadcast a otras pestañas
|
||||
this.broadcast('leader_heartbeat', { timestamp })
|
||||
}, this.HEARTBEAT_INTERVAL)
|
||||
|
||||
// Inmediatamente hacer el primer heartbeat
|
||||
const timestamp = Date.now()
|
||||
localStorage.setItem('plc_leader_heartbeat', timestamp.toString())
|
||||
localStorage.setItem('plc_leader_tab', this.tabId)
|
||||
}
|
||||
|
||||
stopHeartbeat() {
|
||||
if (this.leaderHeartbeatInterval) {
|
||||
clearInterval(this.leaderHeartbeatInterval)
|
||||
this.leaderHeartbeatInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
handleLeaderHeartbeat(data, fromTab) {
|
||||
if (this.isLeader && fromTab !== this.tabId) {
|
||||
// Otro tab dice ser líder, verificar timestamps
|
||||
const ourTimestamp = parseInt(localStorage.getItem('plc_leader_heartbeat') || '0')
|
||||
const theirTimestamp = data.timestamp
|
||||
|
||||
if (theirTimestamp > ourTimestamp + 1000) { // 1 segundo de tolerancia
|
||||
// Su heartbeat es más reciente, renunciar
|
||||
console.log(`👑 Tab ${this.tabId} yielding leadership to ${fromTab}`)
|
||||
this.resignLeadership()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleLeaderElection(fromTab) {
|
||||
if (this.isLeader) {
|
||||
// Conflicto de liderazgo, resolver por timestamp
|
||||
const ourTimestamp = parseInt(localStorage.getItem('plc_leader_heartbeat') || '0')
|
||||
const currentTime = Date.now()
|
||||
|
||||
// Si nuestro heartbeat es muy viejo, renunciar
|
||||
if (currentTime - ourTimestamp > this.HEARTBEAT_INTERVAL * 2) {
|
||||
this.resignLeadership()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleLeaderResignation(fromTab) {
|
||||
// Si el líder renuncia, intentar tomar el liderazgo tras un delay
|
||||
setTimeout(() => {
|
||||
this.electLeader()
|
||||
}, 1000 + Math.random() * 1000) // 1-2 segundos aleatorio para evitar conflictos
|
||||
}
|
||||
|
||||
handleDataBroadcast(data) {
|
||||
// Retransmitir datos a subscribers locales
|
||||
const { source, payload } = data
|
||||
this.notifySubscribers('data_received', { source, data: payload })
|
||||
}
|
||||
|
||||
broadcast(type, data) {
|
||||
this.channel.postMessage({
|
||||
type,
|
||||
data,
|
||||
fromTab: this.tabId,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
// API para que los componentes se subscriban a datos
|
||||
subscribe(source, callback) {
|
||||
if (!this.subscribers.has(source)) {
|
||||
this.subscribers.set(source, new Set())
|
||||
}
|
||||
this.subscribers.get(source).add(callback)
|
||||
|
||||
// Retornar función de cleanup
|
||||
return () => {
|
||||
const subs = this.subscribers.get(source)
|
||||
if (subs) {
|
||||
subs.delete(callback)
|
||||
if (subs.size === 0) {
|
||||
this.subscribers.delete(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
notifySubscribers(type, data) {
|
||||
// Notificar a todos los subscribers
|
||||
for (const [source, callbacks] of this.subscribers.entries()) {
|
||||
for (const callback of callbacks) {
|
||||
try {
|
||||
callback({ type, source, data })
|
||||
} catch (error) {
|
||||
console.error(`Error in subscriber callback for ${source}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// API para que el líder envíe datos a otras pestañas
|
||||
broadcastData(source, data) {
|
||||
if (!this.isLeader) return
|
||||
|
||||
this.broadcast('data_broadcast', {
|
||||
source,
|
||||
payload: data
|
||||
})
|
||||
}
|
||||
|
||||
// API para verificar si somos el líder
|
||||
getIsLeader() {
|
||||
return this.isLeader
|
||||
}
|
||||
|
||||
// API para hacer una conexión "coordinada" - solo el líder la hace realmente
|
||||
createCoordinatedConnection(source, connectionFactory) {
|
||||
if (this.isLeader) {
|
||||
// Somos líder, crear conexión real
|
||||
console.log(`🔗 Tab ${this.tabId} (leader) creating real connection for ${source}`)
|
||||
return connectionFactory((data) => {
|
||||
// Cuando recibimos datos, enviarlos a otras pestañas
|
||||
this.broadcastData(source, data)
|
||||
// Y también procesarlos localmente
|
||||
this.notifySubscribers('data_received', { source, data })
|
||||
})
|
||||
} else {
|
||||
// Somos seguidores, retornar conexión "fake" que escucha broadcasts
|
||||
console.log(`👥 Tab ${this.tabId} (follower) creating coordinated connection for ${source}`)
|
||||
return {
|
||||
close: () => {}, // No-op
|
||||
// Las pestañas seguidoras reciben datos via broadcasts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.resignLeadership()
|
||||
this.stopHeartbeat()
|
||||
this.channel.close()
|
||||
|
||||
if (this.leaderCheckInterval) {
|
||||
clearInterval(this.leaderCheckInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton global
|
||||
let globalCoordinator = null
|
||||
|
||||
export function getTabCoordinator() {
|
||||
if (!globalCoordinator) {
|
||||
globalCoordinator = new TabCoordinator()
|
||||
}
|
||||
return globalCoordinator
|
||||
}
|
||||
|
||||
export default TabCoordinator
|
424
main.py
424
main.py
|
@ -115,6 +115,16 @@ def serve_public_record_png():
|
|||
return Response("record.png not found", status=404, mimetype="text/plain")
|
||||
|
||||
|
||||
@app.route("/SIDEL.png")
|
||||
def serve_public_sidel_png():
|
||||
"""Serve /SIDEL.png from the React public folder."""
|
||||
public_dir = project_path("frontend", "public")
|
||||
sidel_file = os.path.join(public_dir, "SIDEL.png")
|
||||
if os.path.exists(sidel_file):
|
||||
return send_from_directory(public_dir, "SIDEL.png")
|
||||
return Response("SIDEL.png not found", status=404, mimetype="text/plain")
|
||||
|
||||
|
||||
# ==============================
|
||||
# Frontend (React SPA)
|
||||
# ==============================
|
||||
|
@ -1605,7 +1615,12 @@ def create_plot():
|
|||
if config["y_max"] is not None:
|
||||
config["y_max"] = float(config["y_max"])
|
||||
|
||||
session_id = streamer.data_streamer.plot_manager.create_session(config)
|
||||
# Check if multiple sessions should be allowed (default: True for backward compatibility)
|
||||
allow_multiple = data.get("allow_multiple", True)
|
||||
|
||||
session_id = streamer.data_streamer.plot_manager.create_session(
|
||||
config, allow_multiple
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
|
@ -2152,6 +2167,55 @@ def get_historical_data():
|
|||
return jsonify({"error": f"Internal server error: {str(e)}"}), 500
|
||||
|
||||
|
||||
@app.route("/api/plots/sessions/<plot_id>", methods=["GET"])
|
||||
def get_plot_sessions(plot_id):
|
||||
"""Get all session IDs for a specific plot ID"""
|
||||
error_response = check_streamer_initialized()
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
try:
|
||||
session_ids = streamer.data_streamer.plot_manager.get_sessions_by_plot_id(
|
||||
plot_id
|
||||
)
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"plot_id": plot_id,
|
||||
"session_ids": session_ids,
|
||||
"session_count": len(session_ids),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/plots/cleanup", methods=["POST"])
|
||||
def cleanup_plot_sessions():
|
||||
"""Manually cleanup inactive plot sessions"""
|
||||
error_response = check_streamer_initialized()
|
||||
if error_response:
|
||||
return error_response
|
||||
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
max_age_seconds = data.get("max_age_seconds", 3600) # Default 1 hour
|
||||
|
||||
removed_count = streamer.data_streamer.plot_manager.cleanup_inactive_sessions(
|
||||
max_age_seconds
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"removed_sessions": removed_count,
|
||||
"message": f"Cleaned up {removed_count} inactive plot sessions",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/status")
|
||||
def get_status():
|
||||
"""Get current status"""
|
||||
|
@ -2714,10 +2778,16 @@ def search_symbols():
|
|||
def process_symbol_variables():
|
||||
"""Process dataset variables, expanding symbol-based ones."""
|
||||
try:
|
||||
print(f"[DEBUG] process_symbol_variables called")
|
||||
|
||||
data = request.get_json()
|
||||
print(f"[DEBUG] Request data: {data}")
|
||||
|
||||
variables = data.get("variables", [])
|
||||
print(f"[DEBUG] Variables to process: {len(variables)}")
|
||||
|
||||
if not variables:
|
||||
print(f"[DEBUG] No variables provided")
|
||||
return (
|
||||
jsonify({"success": False, "error": "Variables array is required"}),
|
||||
400,
|
||||
|
@ -2729,15 +2799,20 @@ def process_symbol_variables():
|
|||
logger = (
|
||||
streamer.event_logger if "streamer" in globals() and streamer else None
|
||||
)
|
||||
except:
|
||||
except Exception as e:
|
||||
print(f"[DEBUG] Logger setup failed: {e}")
|
||||
pass # Use None logger if streamer is not available
|
||||
|
||||
print(f"[DEBUG] Creating SymbolProcessor with logger: {logger is not None}")
|
||||
symbol_processor = SymbolProcessor(logger)
|
||||
|
||||
# Get symbols path
|
||||
symbols_path = project_path("config", "data", "plc_symbols.json")
|
||||
print(f"[DEBUG] Symbols path: {symbols_path}")
|
||||
print(f"[DEBUG] Symbols file exists: {os.path.exists(symbols_path)}")
|
||||
|
||||
if not os.path.exists(symbols_path):
|
||||
print(f"[DEBUG] Symbols file not found")
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
|
@ -2749,23 +2824,362 @@ def process_symbol_variables():
|
|||
)
|
||||
|
||||
# Process variables
|
||||
print(f"[DEBUG] Processing variables...")
|
||||
processed_variables = symbol_processor.process_dataset_variables(
|
||||
variables, symbols_path
|
||||
)
|
||||
print(f"[DEBUG] Processed {len(processed_variables)} variables")
|
||||
|
||||
# Validate the processed variables
|
||||
print(f"[DEBUG] Validating variables...")
|
||||
validation = symbol_processor.validate_symbol_variables(variables, symbols_path)
|
||||
print(f"[DEBUG] Validation result: {validation}")
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"processed_variables": processed_variables,
|
||||
"validation": validation,
|
||||
}
|
||||
print(
|
||||
f"[DEBUG] Returning result with {len(processed_variables)} processed variables"
|
||||
)
|
||||
return jsonify(result)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Exception in process_symbol_variables: {str(e)}")
|
||||
print(f"[ERROR] Exception type: {type(e)}")
|
||||
import traceback
|
||||
|
||||
print(f"[ERROR] Traceback: {traceback.format_exc()}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
# CSV File Browser Endpoints
|
||||
@app.route("/api/csv/files", methods=["GET"])
|
||||
def get_csv_files():
|
||||
"""Get structured list of CSV files organized by dataset, date, and hour"""
|
||||
try:
|
||||
records_dir = resource_path("records")
|
||||
if not os.path.exists(records_dir):
|
||||
return jsonify({"files": [], "tree": []})
|
||||
|
||||
file_tree = []
|
||||
files_flat = []
|
||||
|
||||
# Scan records directory
|
||||
for date_dir in os.listdir(records_dir):
|
||||
date_path = os.path.join(records_dir, date_dir)
|
||||
if not os.path.isdir(date_path):
|
||||
continue
|
||||
|
||||
date_node = {
|
||||
"id": f"date_{date_dir}",
|
||||
"name": f"📅 {date_dir}",
|
||||
"value": date_dir,
|
||||
"type": "date",
|
||||
"children": [],
|
||||
}
|
||||
|
||||
# Group files by dataset
|
||||
datasets = {}
|
||||
for filename in os.listdir(date_path):
|
||||
if not filename.endswith(".csv"):
|
||||
continue
|
||||
|
||||
file_path = os.path.join(date_path, filename)
|
||||
|
||||
# Extract dataset name (everything before the first underscore + number)
|
||||
dataset_name = filename.split("_")[0]
|
||||
if len(filename.split("_")) > 1:
|
||||
# If there's a second part, it might be part of dataset name
|
||||
parts = filename.split("_")
|
||||
if not parts[-1].replace(".csv", "").isdigit():
|
||||
dataset_name = "_".join(parts[:-1])
|
||||
|
||||
# Get file info
|
||||
try:
|
||||
stat = os.stat(file_path)
|
||||
file_size = stat.st_size
|
||||
file_mtime = datetime.fromtimestamp(stat.st_mtime)
|
||||
|
||||
# Try to get CSV info (first 3 columns and row count)
|
||||
csv_info = get_csv_file_info(file_path)
|
||||
|
||||
file_info = {
|
||||
"id": f"file_{date_dir}_{filename}",
|
||||
"name": f"📊 {filename}",
|
||||
"value": filename,
|
||||
"type": "file",
|
||||
"path": file_path,
|
||||
"date": date_dir,
|
||||
"dataset": dataset_name,
|
||||
"size": file_size,
|
||||
"size_human": format_file_size(file_size),
|
||||
"modified": file_mtime.isoformat(),
|
||||
"modified_human": file_mtime.strftime("%H:%M:%S"),
|
||||
**csv_info,
|
||||
}
|
||||
|
||||
files_flat.append(file_info)
|
||||
|
||||
if dataset_name not in datasets:
|
||||
datasets[dataset_name] = {
|
||||
"id": f"dataset_{date_dir}_{dataset_name}",
|
||||
"name": f"📊 {dataset_name}",
|
||||
"value": dataset_name,
|
||||
"type": "dataset",
|
||||
"children": [],
|
||||
}
|
||||
|
||||
datasets[dataset_name]["children"].append(file_info)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing file {filename}: {e}")
|
||||
continue
|
||||
|
||||
# Add datasets to date node
|
||||
date_node["children"] = list(datasets.values())
|
||||
if date_node["children"]: # Only add dates that have files
|
||||
file_tree.append(date_node)
|
||||
|
||||
# Sort by date (newest first)
|
||||
file_tree.sort(key=lambda x: x["value"], reverse=True)
|
||||
|
||||
return jsonify(
|
||||
{"files": files_flat, "tree": file_tree, "total_files": len(files_flat)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e), "files": [], "tree": []}), 500
|
||||
|
||||
|
||||
def get_csv_file_info(file_path):
|
||||
"""Get CSV file information: first 3 columns and row count"""
|
||||
try:
|
||||
import csv
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
reader = csv.reader(f)
|
||||
header = next(reader, [])
|
||||
|
||||
# Count rows (approximately, for performance)
|
||||
row_count = sum(1 for _ in reader)
|
||||
|
||||
# Get first 3 column names
|
||||
first_columns = header[:3] if header else []
|
||||
|
||||
return {
|
||||
"columns": first_columns,
|
||||
"column_count": len(header),
|
||||
"row_count": row_count,
|
||||
"preview": ", ".join(first_columns[:3]),
|
||||
}
|
||||
except Exception:
|
||||
return {"columns": [], "column_count": 0, "row_count": 0, "preview": "Unknown"}
|
||||
|
||||
|
||||
def format_file_size(size_bytes):
|
||||
"""Format file size in human readable format"""
|
||||
if size_bytes == 0:
|
||||
return "0 B"
|
||||
size_names = ["B", "KB", "MB", "GB"]
|
||||
import math
|
||||
|
||||
i = int(math.floor(math.log(size_bytes, 1024)))
|
||||
p = math.pow(1024, i)
|
||||
s = round(size_bytes / p, 2)
|
||||
return f"{s} {size_names[i]}"
|
||||
|
||||
|
||||
@app.route("/api/plotjuggler/launch", methods=["POST"])
|
||||
def launch_plotjuggler():
|
||||
"""Launch PlotJuggler with selected CSV files"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
file_paths = data.get("files", [])
|
||||
|
||||
if not file_paths:
|
||||
return jsonify({"error": "No files provided"}), 400
|
||||
|
||||
# Get PlotJuggler path from system state
|
||||
plotjuggler_path = get_plotjuggler_path()
|
||||
if not plotjuggler_path:
|
||||
return jsonify({"error": "PlotJuggler not found"}), 404
|
||||
|
||||
# Launch PlotJuggler with files
|
||||
import subprocess
|
||||
|
||||
if len(file_paths) == 1:
|
||||
# Single file - use --datafile for compatibility
|
||||
cmd = [plotjuggler_path, "--nosplash", "--datafile", file_paths[0]]
|
||||
else:
|
||||
# Multiple files - use single instance with multiple -d flags
|
||||
cmd = [plotjuggler_path, "-n"] # -n for no splash
|
||||
for file_path in file_paths:
|
||||
cmd.extend(["-d", file_path])
|
||||
|
||||
# Execute command
|
||||
subprocess.Popen(cmd, shell=True)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"processed_variables": processed_variables,
|
||||
"validation": validation,
|
||||
"message": f"PlotJuggler launched with {len(file_paths)} file(s)",
|
||||
"command": " ".join([f'"{arg}"' if " " in arg else arg for arg in cmd]),
|
||||
"files_count": len(file_paths),
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/plotjuggler/launch-streamer", methods=["POST"])
|
||||
def launch_plotjuggler_streamer():
|
||||
"""Launch PlotJuggler with UDP streamer configured"""
|
||||
try:
|
||||
# Get PlotJuggler path from system state
|
||||
plotjuggler_path = get_plotjuggler_path()
|
||||
if not plotjuggler_path:
|
||||
return jsonify({"error": "PlotJuggler not found"}), 404
|
||||
|
||||
# Launch PlotJuggler with UDP streamer parameters
|
||||
import subprocess
|
||||
|
||||
cmd = [
|
||||
plotjuggler_path,
|
||||
"-n", # no recent files menu
|
||||
"--start_streamer",
|
||||
"UDP streamer",
|
||||
]
|
||||
|
||||
subprocess.Popen(cmd, shell=True)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"message": "PlotJuggler launched with UDP streamer",
|
||||
"command": " ".join(cmd),
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/plotjuggler/path", methods=["GET"])
|
||||
def get_plotjuggler_path_endpoint():
|
||||
"""Get PlotJuggler executable path"""
|
||||
try:
|
||||
path = get_plotjuggler_path()
|
||||
return jsonify({"path": path, "found": path is not None})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/plotjuggler/path", methods=["POST"])
|
||||
def set_plotjuggler_path():
|
||||
"""Set PlotJuggler executable path"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
path = data.get("path", "")
|
||||
|
||||
if not path or not os.path.exists(path):
|
||||
return jsonify({"error": "Invalid path provided"}), 400
|
||||
|
||||
# Save to system state
|
||||
save_plotjuggler_path(path)
|
||||
|
||||
return jsonify(
|
||||
{"success": True, "message": "PlotJuggler path saved", "path": path}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
def get_plotjuggler_path():
|
||||
"""Get PlotJuggler executable path, search if not found"""
|
||||
try:
|
||||
# Load from system state
|
||||
state_file = resource_path("system_state.json")
|
||||
if os.path.exists(state_file):
|
||||
with open(state_file, "r") as f:
|
||||
state = json.load(f)
|
||||
saved_path = state.get("plotjuggler_path")
|
||||
if saved_path and os.path.exists(saved_path):
|
||||
return saved_path
|
||||
|
||||
# Search for PlotJuggler
|
||||
search_paths = [
|
||||
r"C:\Program Files\PlotJuggler\plotjuggler.exe",
|
||||
r"C:\Program Files (x86)\PlotJuggler\plotjuggler.exe",
|
||||
r"C:\PlotJuggler\plotjuggler.exe",
|
||||
]
|
||||
|
||||
for path in search_paths:
|
||||
if os.path.exists(path):
|
||||
save_plotjuggler_path(path)
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting PlotJuggler path: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def save_plotjuggler_path(path):
|
||||
"""Save PlotJuggler path to system state"""
|
||||
try:
|
||||
state_file = resource_path("system_state.json")
|
||||
state = {}
|
||||
|
||||
if os.path.exists(state_file):
|
||||
with open(state_file, "r") as f:
|
||||
state = json.load(f)
|
||||
|
||||
state["plotjuggler_path"] = path
|
||||
state["last_update"] = datetime.now().isoformat()
|
||||
|
||||
with open(state_file, "w") as f:
|
||||
json.dump(state, f, indent=4)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error saving PlotJuggler path: {e}")
|
||||
|
||||
|
||||
@app.route("/api/csv/open-excel", methods=["POST"])
|
||||
def open_csv_in_excel():
|
||||
"""Open CSV file in Excel (or default spreadsheet app)"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
file_path = data.get("file_path", "")
|
||||
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
|
||||
import subprocess
|
||||
import platform
|
||||
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
# Use os.startfile on Windows
|
||||
os.startfile(file_path)
|
||||
elif system == "Darwin": # macOS
|
||||
subprocess.Popen(["open", file_path])
|
||||
else: # Linux
|
||||
subprocess.Popen(["xdg-open", file_path])
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Opened {os.path.basename(file_path)} in default application",
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
@ -1,13 +1,10 @@
|
|||
{
|
||||
"last_state": {
|
||||
"should_connect": true,
|
||||
"should_connect": false,
|
||||
"should_stream": false,
|
||||
"active_datasets": [
|
||||
"Test",
|
||||
"Fast",
|
||||
"DAR"
|
||||
]
|
||||
"active_datasets": []
|
||||
},
|
||||
"auto_recovery_enabled": true,
|
||||
"last_update": "2025-08-15T13:14:31.493157"
|
||||
"last_update": "2025-08-15T20:58:45.862859",
|
||||
"plotjuggler_path": "C:\\Program Files\\PlotJuggler\\plotjuggler.exe"
|
||||
}
|
|
@ -1,129 +0,0 @@
|
|||
"""
|
||||
Test script to validate automatic configuration reloading
|
||||
"""
|
||||
|
||||
import json
|
||||
import requests
|
||||
import time
|
||||
|
||||
# Configuration
|
||||
BASE_URL = "http://localhost:5000"
|
||||
TEST_DATASET_ID = "TestReload"
|
||||
|
||||
|
||||
def test_config_reload():
|
||||
"""Test that backend automatically reloads configuration when datasets are updated"""
|
||||
|
||||
print("🧪 Testing automatic configuration reload...")
|
||||
|
||||
try:
|
||||
# Step 1: Get current dataset definitions
|
||||
print("📖 Reading current dataset definitions...")
|
||||
response = requests.get(f"{BASE_URL}/api/config/dataset-definitions")
|
||||
if not response.ok:
|
||||
print(f"❌ Failed to read dataset definitions: {response.status_code}")
|
||||
return False
|
||||
|
||||
current_config = response.json()
|
||||
datasets = current_config.get("data", {}).get("datasets", [])
|
||||
print(f"Current datasets: {[d.get('id') for d in datasets]}")
|
||||
|
||||
# Step 2: Add a test dataset
|
||||
print(f"➕ Adding test dataset: {TEST_DATASET_ID}")
|
||||
test_dataset = {
|
||||
"id": TEST_DATASET_ID,
|
||||
"name": "Test Reload Dataset",
|
||||
"prefix": "test_reload",
|
||||
"sampling_interval": 1.0,
|
||||
"enabled": False,
|
||||
}
|
||||
|
||||
# Add to datasets list
|
||||
new_datasets = [
|
||||
d for d in datasets if d.get("id") != TEST_DATASET_ID
|
||||
] # Remove if exists
|
||||
new_datasets.append(test_dataset)
|
||||
|
||||
new_config = {
|
||||
"datasets": new_datasets,
|
||||
"version": "1.0",
|
||||
"last_update": f"{time.time()}",
|
||||
}
|
||||
|
||||
# Save configuration
|
||||
response = requests.put(
|
||||
f"{BASE_URL}/api/config/dataset-definitions",
|
||||
headers={"Content-Type": "application/json"},
|
||||
json=new_config,
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
print(f"❌ Failed to save dataset definitions: {response.status_code}")
|
||||
return False
|
||||
|
||||
print("✅ Dataset definitions saved")
|
||||
|
||||
# Step 3: Check if backend has reloaded the configuration
|
||||
print("🔍 Checking if backend reloaded configuration...")
|
||||
time.sleep(1) # Give backend a moment to reload
|
||||
|
||||
# Get status from backend
|
||||
response = requests.get(f"{BASE_URL}/api/status")
|
||||
if not response.ok:
|
||||
print(f"❌ Failed to get status: {response.status_code}")
|
||||
return False
|
||||
|
||||
status = response.json()
|
||||
backend_datasets = status.get("datasets", {})
|
||||
|
||||
if TEST_DATASET_ID in backend_datasets:
|
||||
print(f"✅ Backend successfully loaded new dataset: {TEST_DATASET_ID}")
|
||||
print(f"Dataset details: {backend_datasets[TEST_DATASET_ID]}")
|
||||
|
||||
# Step 4: Clean up - remove test dataset
|
||||
print("🧹 Cleaning up test dataset...")
|
||||
cleanup_datasets = [
|
||||
d for d in new_datasets if d.get("id") != TEST_DATASET_ID
|
||||
]
|
||||
cleanup_config = {
|
||||
"datasets": cleanup_datasets,
|
||||
"version": "1.0",
|
||||
"last_update": f"{time.time()}",
|
||||
}
|
||||
|
||||
response = requests.put(
|
||||
f"{BASE_URL}/api/config/dataset-definitions",
|
||||
headers={"Content-Type": "application/json"},
|
||||
json=cleanup_config,
|
||||
)
|
||||
|
||||
if response.ok:
|
||||
print("✅ Test dataset cleaned up")
|
||||
else:
|
||||
print(
|
||||
f"⚠️ Warning: Failed to clean up test dataset: {response.status_code}"
|
||||
)
|
||||
|
||||
return True
|
||||
else:
|
||||
print(
|
||||
f"❌ Backend did not reload configuration. Available datasets: {list(backend_datasets.keys())}"
|
||||
)
|
||||
return False
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
print(
|
||||
"❌ Could not connect to backend. Make sure the Flask server is running on http://localhost:5000"
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Test failed with error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = test_config_reload()
|
||||
if success:
|
||||
print("\n🎉 Configuration reload test PASSED!")
|
||||
else:
|
||||
print("\n💥 Configuration reload test FAILED!")
|
|
@ -1,23 +0,0 @@
|
|||
import requests
|
||||
import json
|
||||
|
||||
# Test the symbols load endpoint
|
||||
url = "http://localhost:5050/api/symbols/load"
|
||||
data = {
|
||||
"asc_file_path": "D:/Proyectos/Scripts/Siemens/S7_snap7_Stremer_n_Log/config/data/test_symbols.asc"
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, json=data)
|
||||
print(f"Status Code: {response.status_code}")
|
||||
print(f"Response Headers: {response.headers}")
|
||||
print(f"Response Text: {response.text}")
|
||||
|
||||
if response.headers.get("content-type", "").startswith("application/json"):
|
||||
result = response.json()
|
||||
print(f"JSON Response: {json.dumps(result, indent=2)}")
|
||||
else:
|
||||
print("Response is not JSON, probably HTML error page")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
|
@ -1,105 +0,0 @@
|
|||
"""
|
||||
Test script for CSV header validation functionality
|
||||
"""
|
||||
|
||||
import csv
|
||||
import os
|
||||
import tempfile
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def test_header_validation():
|
||||
"""Test the header validation logic without full system dependencies"""
|
||||
|
||||
# Create a temporary test directory
|
||||
test_dir = tempfile.mkdtemp()
|
||||
|
||||
try:
|
||||
# Test 1: Create a CSV file with old headers
|
||||
old_csv_path = os.path.join(test_dir, "test_data_14.csv")
|
||||
old_headers = ["timestamp", "var1", "var2"]
|
||||
|
||||
with open(old_csv_path, "w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(old_headers)
|
||||
writer.writerow(["2025-08-14 12:00:00", "100", "200"])
|
||||
writer.writerow(["2025-08-14 12:00:01", "101", "201"])
|
||||
|
||||
print(f"✅ Created test CSV file: {old_csv_path}")
|
||||
|
||||
# Test 2: Read headers function
|
||||
def read_csv_headers(file_path):
|
||||
try:
|
||||
with open(file_path, "r", newline="", encoding="utf-8") as file:
|
||||
reader = csv.reader(file)
|
||||
headers = next(reader, [])
|
||||
return headers
|
||||
except (IOError, StopIteration) as e:
|
||||
print(f"Could not read headers from {file_path}: {e}")
|
||||
return []
|
||||
|
||||
# Test 3: Compare headers function
|
||||
def compare_headers(existing_headers, new_headers):
|
||||
return existing_headers == new_headers
|
||||
|
||||
# Test 4: Rename file function
|
||||
def rename_csv_file_with_timestamp(original_path, prefix):
|
||||
directory = os.path.dirname(original_path)
|
||||
timestamp = datetime.now().strftime("%H_%M_%S")
|
||||
new_filename = f"{prefix}_to_{timestamp}.csv"
|
||||
new_path = os.path.join(directory, new_filename)
|
||||
|
||||
# Ensure the new filename is unique
|
||||
counter = 1
|
||||
while os.path.exists(new_path):
|
||||
new_filename = f"{prefix}_to_{timestamp}_{counter}.csv"
|
||||
new_path = os.path.join(directory, new_filename)
|
||||
counter += 1
|
||||
|
||||
shutil.move(original_path, new_path)
|
||||
return new_path
|
||||
|
||||
# Test the functions
|
||||
existing_headers = read_csv_headers(old_csv_path)
|
||||
new_headers = ["timestamp", "var1", "var2", "var3"] # Different headers
|
||||
|
||||
print(f"Existing headers: {existing_headers}")
|
||||
print(f"New headers: {new_headers}")
|
||||
print(f"Headers match: {compare_headers(existing_headers, new_headers)}")
|
||||
|
||||
# Test header mismatch scenario
|
||||
if not compare_headers(existing_headers, new_headers):
|
||||
print("❌ Header mismatch detected! Renaming file...")
|
||||
renamed_path = rename_csv_file_with_timestamp(old_csv_path, "test_data")
|
||||
print(f"✅ File renamed to: {os.path.basename(renamed_path)}")
|
||||
|
||||
# Create new file with correct headers
|
||||
new_csv_path = old_csv_path # Same original path
|
||||
with open(new_csv_path, "w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(new_headers)
|
||||
writer.writerow(["2025-08-14 12:00:02", "102", "202", "302"])
|
||||
|
||||
print(
|
||||
f"✅ Created new CSV file with correct headers: {os.path.basename(new_csv_path)}"
|
||||
)
|
||||
|
||||
# Verify the files
|
||||
print(f"\nFiles in test directory:")
|
||||
for file in os.listdir(test_dir):
|
||||
if file.endswith(".csv"):
|
||||
file_path = os.path.join(test_dir, file)
|
||||
headers = read_csv_headers(file_path)
|
||||
print(f" {file}: {headers}")
|
||||
|
||||
print("\n✅ All tests passed!")
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
shutil.rmtree(test_dir)
|
||||
print(f"🧹 Cleaned up test directory: {test_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_header_validation()
|
Binary file not shown.
|
@ -1,67 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for symbol loader functionality.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the project root to Python path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from utils.symbol_loader import SymbolLoader
|
||||
|
||||
|
||||
def test_symbol_loader():
|
||||
"""Test the symbol loader with the sample ASC file."""
|
||||
|
||||
# Create a simple logger for testing
|
||||
class SimpleLogger:
|
||||
def info(self, msg):
|
||||
print(f"INFO: {msg}")
|
||||
|
||||
def warning(self, msg):
|
||||
print(f"WARNING: {msg}")
|
||||
|
||||
def error(self, msg):
|
||||
print(f"ERROR: {msg}")
|
||||
|
||||
logger = SimpleLogger()
|
||||
loader = SymbolLoader(logger)
|
||||
|
||||
# Test file paths
|
||||
test_asc_file = "config/data/test_symbols.asc"
|
||||
output_json_file = "config/data/test_output.json"
|
||||
|
||||
try:
|
||||
print(f"Testing symbol loading from: {test_asc_file}")
|
||||
|
||||
if not os.path.exists(test_asc_file):
|
||||
print(f"ERROR: Test file not found: {test_asc_file}")
|
||||
return False
|
||||
|
||||
# Load symbols
|
||||
symbols_count = loader.load_asc_and_save_json(test_asc_file, output_json_file)
|
||||
|
||||
print(f"SUCCESS: Loaded {symbols_count} symbols")
|
||||
print(f"Output saved to: {output_json_file}")
|
||||
|
||||
# Verify output file
|
||||
if os.path.exists(output_json_file):
|
||||
with open(output_json_file, "r", encoding="utf-8") as f:
|
||||
import json
|
||||
|
||||
data = json.load(f)
|
||||
print(f"JSON contains {len(data.get('symbols', []))} symbols")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_symbol_loader()
|
|
@ -33,13 +33,29 @@ class SymbolProcessor:
|
|||
self._symbols_cache_path = symbols_path
|
||||
|
||||
if self.logger:
|
||||
self.logger.info(
|
||||
f"Loaded {len(self._symbols_cache.get('symbols', []))} symbols"
|
||||
)
|
||||
# Check if logger is EventLogger (has log_event method) or standard logger
|
||||
if hasattr(self.logger, "log_event"):
|
||||
self.logger.log_event(
|
||||
"info",
|
||||
"symbols_loaded",
|
||||
f"Loaded {len(self._symbols_cache.get('symbols', []))} symbols",
|
||||
)
|
||||
else:
|
||||
self.logger.info(
|
||||
f"Loaded {len(self._symbols_cache.get('symbols', []))} symbols"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
if self.logger:
|
||||
self.logger.error(f"Error loading symbols: {str(e)}")
|
||||
# Check if logger is EventLogger (has log_event method) or standard logger
|
||||
if hasattr(self.logger, "log_event"):
|
||||
self.logger.log_event(
|
||||
"error",
|
||||
"symbols_load_error",
|
||||
f"Error loading symbols: {str(e)}",
|
||||
)
|
||||
else:
|
||||
self.logger.error(f"Error loading symbols: {str(e)}")
|
||||
self._symbols_cache = {"symbols": [], "total_count": 0}
|
||||
|
||||
return self._symbols_cache
|
||||
|
@ -81,7 +97,15 @@ class SymbolProcessor:
|
|||
symbol = self.find_symbol(symbol_name, symbols_data)
|
||||
if not symbol:
|
||||
if self.logger:
|
||||
self.logger.warning(f"Symbol '{symbol_name}' not found")
|
||||
# Check if logger is EventLogger (has log_event method) or standard logger
|
||||
if hasattr(self.logger, "log_event"):
|
||||
self.logger.log_event(
|
||||
"warning",
|
||||
"symbol_not_found",
|
||||
f"Symbol '{symbol_name}' not found",
|
||||
)
|
||||
else:
|
||||
self.logger.warning(f"Symbol '{symbol_name}' not found")
|
||||
return variable_config
|
||||
|
||||
# Create expanded configuration
|
||||
|
|
Loading…
Reference in New Issue