Compare commits

...

15 Commits

Author SHA1 Message Date
Miguel 86b4add6ab feat: Implement historical plot management with session creation, data loading, and enhanced charting capabilities 2025-08-16 01:01:10 +02:00
Miguel 8ac87c8f98 feat: Add plot session creation event logging and enhance plot selection handling in PlotManager 2025-08-15 23:14:34 +02:00
Miguel 3417056b06 Refactor: Remove PlotRealtimeViewer and PlotTableManager components
- Deleted PlotRealtimeViewer.jsx and PlotTableManager.jsx components to streamline the codebase.
- Removed associated imports and references in Dashboard.jsx.
- Cleaned up unused TabCoordinationDemo component.
- Updated main.py to serve SIDEL.png from the public folder.
- Removed obsolete test scripts related to configuration reload, endpoint testing, header validation, and symbol loading.
2025-08-15 22:55:03 +02:00
Miguel a4f74b70ed feat: Add confirmation dialog for deletion operations, enhance logging in symbol processor, and update button color schemes 2025-08-15 22:31:25 +02:00
Miguel 0f2b9b8fb4 feat: Enhance logging and error handling in symbol processing, update system state and dataset variables, and add symbol data expansion functionality 2025-08-15 21:05:58 +02:00
Miguel e7cee49b1e feat: Enhance PlotJuggler integration by launching with multiple files in a single instance, improve logging for application events, and update system state for active datasets 2025-08-15 20:38:05 +02:00
Miguel 02b622cb20 feat: Add file path copying functionality to CSV File Browser and update PlotJuggler launch command 2025-08-15 20:29:17 +02:00
Miguel 3a830fe100 feat: Add PlotJuggler integration with UDP streaming support, update system state and dataset variables for streaming, and enhance dashboard functionality 2025-08-15 20:23:10 +02:00
Miguel 60db337284 feat: Implement CSV File Browser component, add API endpoints for CSV file management, and update system state for dataset activation 2025-08-15 20:16:05 +02:00
Miguel 4481eb33a7 feat: Enhance logging for plot sessions, update UI widgets to use switches, and reorder active datasets in system state 2025-08-15 19:55:57 +02:00
Miguel 609ae865de feat: Add detailed logging for plot sessions and implement TabCoordinationDemo component for coordination status display 2025-08-15 19:41:01 +02:00
Miguel e4908396be feat: Add detailed logging for plot session creation and remove unused TabCoordinationDemo component 2025-08-15 19:19:24 +02:00
Miguel 696b79ba0d feat: Implement coordinated connection and polling for real-time data updates across tabs
- Introduced `useCoordinatedConnection` and `useCoordinatedPolling` hooks to manage data fetching and state synchronization between tabs.
- Refactored `DatasetCompleteManager`, `PlotRealtimeViewer`, and `VariableSelectorWidget` components to utilize SSE and polling for live data updates.
- Added `TabCoordinationDemo` component to visualize tab coordination status and leadership.
- Updated `Dashboard` to leverage coordinated polling for status updates, improving performance and reducing redundant connections.
- Enhanced `TabCoordinator` utility to manage leadership and data broadcasting between tabs effectively.
2025-08-15 18:55:58 +02:00
Miguel e97cd5260b feat: Implement multi-browser support for plot sessions
- Enhanced session ID generation to include browser tab identifiers, allowing multiple instances of the same plot in different tabs.
- Updated PlotManager to manage sessions more effectively, including cleanup of inactive sessions.
- Modified frontend components to handle dynamic session IDs and ensure independent operation across tabs.
- Added new API endpoints for retrieving sessions by plot ID and manual cleanup of inactive sessions.
- Improved error handling and logging for better debugging and user experience.
2025-08-15 16:56:02 +02:00
Miguel 405edd682e feat: Enhance logging and configuration for plot sessions, update dataset handling, and improve chart rendering logic 2025-08-15 15:49:13 +02:00
46 changed files with 7664 additions and 5541 deletions

View File

@ -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

View File

@ -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
}
]
}

View File

@ -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,

View File

@ -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.0110). Leave empty to use the global PLC sampling interval.",

View File

@ -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"
}
}

View File

@ -18,7 +18,7 @@
},
"rotation_enabled": {
"ui:column": 3,
"ui:widget": "checkbox"
"ui:widget": "switch"
},
"ui:order": [
"max_days",

View File

@ -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": {

View File

@ -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"
}
}

View File

@ -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"""

View File

@ -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()

View File

@ -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;

View File

@ -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%',

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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(() => {

View File

@ -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,

View File

@ -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,

View File

@ -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
)
}

View File

@ -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

View File

@ -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)
}

View File

View File

@ -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
View File

@ -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__":

View File

@ -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"
}

View File

@ -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!")

View File

@ -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}")

View File

@ -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.

View File

@ -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()

View File

@ -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