feat: Implement historical data loading from CSV files, enhance PlotRealtimeViewer component, and improve Chart.js dependency checks
This commit is contained in:
parent
ea2006666f
commit
91718e7bf7
|
@ -2319,8 +2319,301 @@
|
|||
"trigger_variable": null,
|
||||
"auto_started": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-14T23:12:13.491544",
|
||||
"level": "info",
|
||||
"event_type": "application_started",
|
||||
"message": "Application initialization completed successfully",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-14T23:12:13.601841",
|
||||
"level": "info",
|
||||
"event_type": "dataset_activated",
|
||||
"message": "Dataset activated: DAR",
|
||||
"details": {
|
||||
"dataset_id": "DAR",
|
||||
"variables_count": 2,
|
||||
"streaming_count": 2,
|
||||
"prefix": "gateway_phoenix"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-14T23:12:13.607414",
|
||||
"level": "info",
|
||||
"event_type": "dataset_activated",
|
||||
"message": "Dataset activated: Fast",
|
||||
"details": {
|
||||
"dataset_id": "Fast",
|
||||
"variables_count": 2,
|
||||
"streaming_count": 1,
|
||||
"prefix": "fast"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-14T23:12:13.611549",
|
||||
"level": "info",
|
||||
"event_type": "csv_recording_started",
|
||||
"message": "CSV recording started: 2 datasets activated",
|
||||
"details": {
|
||||
"activated_datasets": 2,
|
||||
"total_datasets": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-14T23:12:13.619569",
|
||||
"level": "info",
|
||||
"event_type": "udp_streaming_started",
|
||||
"message": "UDP streaming to PlotJuggler started",
|
||||
"details": {
|
||||
"udp_host": "127.0.0.1",
|
||||
"udp_port": 9870,
|
||||
"datasets_available": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-14T23:16:13.052239",
|
||||
"level": "info",
|
||||
"event_type": "plot_session_created",
|
||||
"message": "Plot session 'UR29' created and started",
|
||||
"details": {
|
||||
"session_id": "plot_1",
|
||||
"variables": [
|
||||
"UR29_Brix",
|
||||
"UR29_ma",
|
||||
"AUX Blink_1.0S"
|
||||
],
|
||||
"time_window": 20,
|
||||
"trigger_variable": null,
|
||||
"auto_started": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-14T23:17:26.110874",
|
||||
"level": "info",
|
||||
"event_type": "plot_session_created",
|
||||
"message": "Plot session 'UR29' created and started",
|
||||
"details": {
|
||||
"session_id": "plot_1",
|
||||
"variables": [
|
||||
"UR29_Brix",
|
||||
"UR29_ma",
|
||||
"AUX Blink_1.0S"
|
||||
],
|
||||
"time_window": 20,
|
||||
"trigger_variable": null,
|
||||
"auto_started": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-14T23:17:38.598859",
|
||||
"level": "info",
|
||||
"event_type": "application_started",
|
||||
"message": "Application initialization completed successfully",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-14T23:17:38.710314",
|
||||
"level": "info",
|
||||
"event_type": "dataset_activated",
|
||||
"message": "Dataset activated: DAR",
|
||||
"details": {
|
||||
"dataset_id": "DAR",
|
||||
"variables_count": 2,
|
||||
"streaming_count": 2,
|
||||
"prefix": "gateway_phoenix"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-14T23:17:38.714802",
|
||||
"level": "info",
|
||||
"event_type": "dataset_activated",
|
||||
"message": "Dataset activated: Fast",
|
||||
"details": {
|
||||
"dataset_id": "Fast",
|
||||
"variables_count": 2,
|
||||
"streaming_count": 1,
|
||||
"prefix": "fast"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-14T23:17:38.719873",
|
||||
"level": "info",
|
||||
"event_type": "csv_recording_started",
|
||||
"message": "CSV recording started: 2 datasets activated",
|
||||
"details": {
|
||||
"activated_datasets": 2,
|
||||
"total_datasets": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-14T23:17:38.744433",
|
||||
"level": "info",
|
||||
"event_type": "udp_streaming_started",
|
||||
"message": "UDP streaming to PlotJuggler started",
|
||||
"details": {
|
||||
"udp_host": "127.0.0.1",
|
||||
"udp_port": 9870,
|
||||
"datasets_available": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-14T23:17:48.288119",
|
||||
"level": "info",
|
||||
"event_type": "plot_session_created",
|
||||
"message": "Plot session 'UR29' created and started",
|
||||
"details": {
|
||||
"session_id": "plot_1",
|
||||
"variables": [
|
||||
"UR29_Brix",
|
||||
"UR29_ma",
|
||||
"AUX Blink_1.0S"
|
||||
],
|
||||
"time_window": 20,
|
||||
"trigger_variable": null,
|
||||
"auto_started": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-14T23:22:37.889079",
|
||||
"level": "info",
|
||||
"event_type": "application_started",
|
||||
"message": "Application initialization completed successfully",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-14T23:22:37.970126",
|
||||
"level": "info",
|
||||
"event_type": "dataset_activated",
|
||||
"message": "Dataset activated: DAR",
|
||||
"details": {
|
||||
"dataset_id": "DAR",
|
||||
"variables_count": 2,
|
||||
"streaming_count": 2,
|
||||
"prefix": "gateway_phoenix"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-14T23:22:37.975125",
|
||||
"level": "info",
|
||||
"event_type": "dataset_activated",
|
||||
"message": "Dataset activated: Fast",
|
||||
"details": {
|
||||
"dataset_id": "Fast",
|
||||
"variables_count": 2,
|
||||
"streaming_count": 1,
|
||||
"prefix": "fast"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-14T23:22:37.980125",
|
||||
"level": "info",
|
||||
"event_type": "csv_recording_started",
|
||||
"message": "CSV recording started: 2 datasets activated",
|
||||
"details": {
|
||||
"activated_datasets": 2,
|
||||
"total_datasets": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-14T23:22:37.985255",
|
||||
"level": "info",
|
||||
"event_type": "udp_streaming_started",
|
||||
"message": "UDP streaming to PlotJuggler started",
|
||||
"details": {
|
||||
"udp_host": "127.0.0.1",
|
||||
"udp_port": 9870,
|
||||
"datasets_available": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-14T23:23:30.022619",
|
||||
"level": "info",
|
||||
"event_type": "plot_session_created",
|
||||
"message": "Plot session 'UR29' created and started",
|
||||
"details": {
|
||||
"session_id": "plot_1",
|
||||
"variables": [
|
||||
"UR29_Brix",
|
||||
"UR29_ma",
|
||||
"AUX Blink_1.0S"
|
||||
],
|
||||
"time_window": 20,
|
||||
"trigger_variable": null,
|
||||
"auto_started": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-14T23:27:17.599611",
|
||||
"level": "info",
|
||||
"event_type": "application_started",
|
||||
"message": "Application initialization completed successfully",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-14T23:27:17.681121",
|
||||
"level": "info",
|
||||
"event_type": "dataset_activated",
|
||||
"message": "Dataset activated: DAR",
|
||||
"details": {
|
||||
"dataset_id": "DAR",
|
||||
"variables_count": 2,
|
||||
"streaming_count": 2,
|
||||
"prefix": "gateway_phoenix"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-14T23:27:17.687121",
|
||||
"level": "info",
|
||||
"event_type": "dataset_activated",
|
||||
"message": "Dataset activated: Fast",
|
||||
"details": {
|
||||
"dataset_id": "Fast",
|
||||
"variables_count": 2,
|
||||
"streaming_count": 1,
|
||||
"prefix": "fast"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-14T23:27:17.690131",
|
||||
"level": "info",
|
||||
"event_type": "csv_recording_started",
|
||||
"message": "CSV recording started: 2 datasets activated",
|
||||
"details": {
|
||||
"activated_datasets": 2,
|
||||
"total_datasets": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-14T23:27:17.695121",
|
||||
"level": "info",
|
||||
"event_type": "udp_streaming_started",
|
||||
"message": "UDP streaming to PlotJuggler started",
|
||||
"details": {
|
||||
"udp_host": "127.0.0.1",
|
||||
"udp_port": 9870,
|
||||
"datasets_available": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-14T23:29:18.561423",
|
||||
"level": "info",
|
||||
"event_type": "plot_session_created",
|
||||
"message": "Plot session 'UR29' created and started",
|
||||
"details": {
|
||||
"session_id": "plot_1",
|
||||
"variables": [
|
||||
"UR29_Brix",
|
||||
"UR29_ma",
|
||||
"AUX Blink_1.0S"
|
||||
],
|
||||
"time_window": 20,
|
||||
"trigger_variable": null,
|
||||
"auto_started": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"last_updated": "2025-08-14T22:57:05.817266",
|
||||
"total_entries": 223
|
||||
"last_updated": "2025-08-14T23:29:18.561423",
|
||||
"total_entries": 248
|
||||
}
|
|
@ -1,6 +1,48 @@
|
|||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { Box, Text, useColorModeValue } from '@chakra-ui/react';
|
||||
|
||||
// Global dependencies check - run once
|
||||
let dependenciesChecked = false;
|
||||
let dependenciesValid = false;
|
||||
|
||||
const checkChartDependencies = () => {
|
||||
if (dependenciesChecked) return dependenciesValid;
|
||||
|
||||
try {
|
||||
if (typeof window === 'undefined') {
|
||||
console.warn('⚠️ Window not available, skipping dependency check');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for Chart.js
|
||||
if (!window.Chart) {
|
||||
console.error('❌ Chart.js not loaded');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for chartjs-plugin-streaming
|
||||
const hasStreamingPlugin = !!(
|
||||
window.Chart.registry?.scales?.get?.('realtime') ||
|
||||
window.ChartStreaming ||
|
||||
window.chartjsPluginStreaming
|
||||
);
|
||||
|
||||
if (!hasStreamingPlugin) {
|
||||
console.error('❌ chartjs-plugin-streaming not loaded or realtime scale not registered');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('✅ Chart.js dependencies verified');
|
||||
dependenciesValid = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ Dependency check failed:', error);
|
||||
return false;
|
||||
} finally {
|
||||
dependenciesChecked = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Chart.js Plot Component with Streaming and Zoom
|
||||
const ChartjsPlot = ({ session, height = '400px' }) => {
|
||||
const canvasRef = useRef(null);
|
||||
|
@ -23,6 +65,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
const [error, setError] = useState(null);
|
||||
const [dataPointsCount, setDataPointsCount] = useState(0);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isLoadingHistorical, setIsLoadingHistorical] = useState(false);
|
||||
const resolvedConfigRef = useRef(null);
|
||||
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
|
@ -99,6 +142,36 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
return [];
|
||||
}, [getColor]);
|
||||
|
||||
// Load historical data from CSV files
|
||||
const loadHistoricalData = useCallback(async (variables, timeWindow) => {
|
||||
try {
|
||||
console.log(`📊 Loading historical data for ${variables.length} variables (${timeWindow}s window)...`);
|
||||
|
||||
const response = await fetch('/api/plots/historical', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
variables: variables,
|
||||
time_window: timeWindow
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log(`📊 Historical data response:`, data);
|
||||
return data.data || [];
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Failed to load historical data:', error);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createStreamingChart = useCallback(async () => {
|
||||
const cfg = resolvedConfigRef.current || session?.config;
|
||||
if (!canvasRef.current || !cfg) return;
|
||||
|
@ -141,23 +214,146 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
|
||||
const ctx = canvasRef.current.getContext('2d');
|
||||
|
||||
// Destroy existing chart more safely
|
||||
// CRITICAL: More aggressive cleanup check - StrictMode protection
|
||||
const existingChartInRegistry = Chart.getChart(ctx.canvas);
|
||||
const hasChartReference = !!chartRef.current;
|
||||
const hasCanvasChartProperty = !!ctx.canvas.chartjs;
|
||||
|
||||
if (existingChartInRegistry || hasChartReference || hasCanvasChartProperty) {
|
||||
console.log(`🚨 FORCE CLEANUP - Registry: ${!!existingChartInRegistry}, Ref: ${hasChartReference}, Canvas: ${hasCanvasChartProperty}`);
|
||||
|
||||
// Cleanup existing registry entry
|
||||
if (existingChartInRegistry) {
|
||||
try {
|
||||
const rt = existingChartInRegistry.options?.scales?.x?.realtime;
|
||||
if (rt) {
|
||||
rt.pause = true;
|
||||
if (rt._timer) {
|
||||
clearInterval(rt._timer);
|
||||
rt._timer = null;
|
||||
}
|
||||
existingChartInRegistry.update('none');
|
||||
}
|
||||
existingChartInRegistry.stop();
|
||||
existingChartInRegistry.destroy();
|
||||
console.log('✅ Force destroyed registry chart');
|
||||
} catch (e) {
|
||||
console.warn('⚠️ Error force destroying registry chart:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup reference
|
||||
if (chartRef.current) {
|
||||
try {
|
||||
const rt = chartRef.current.options?.scales?.x?.realtime;
|
||||
if (rt) {
|
||||
rt.pause = true;
|
||||
if (rt._timer) {
|
||||
clearInterval(rt._timer);
|
||||
rt._timer = null;
|
||||
}
|
||||
chartRef.current.update('none');
|
||||
}
|
||||
chartRef.current.stop();
|
||||
chartRef.current.destroy();
|
||||
console.log('✅ Force destroyed reference chart');
|
||||
} catch (e) {
|
||||
console.warn('⚠️ Error force destroying reference chart:', e);
|
||||
} finally {
|
||||
chartRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Force cleanup canvas properties
|
||||
if (ctx.canvas.chartjs) {
|
||||
delete ctx.canvas.chartjs;
|
||||
}
|
||||
|
||||
// Aggressive canvas reset
|
||||
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
ctx.canvas.style.width = '';
|
||||
ctx.canvas.style.height = '';
|
||||
|
||||
// Force blur to remove focus
|
||||
if (document.activeElement === ctx.canvas) {
|
||||
ctx.canvas.blur();
|
||||
}
|
||||
|
||||
console.log('🧹 Force cleanup completed');
|
||||
}
|
||||
|
||||
// Enhanced chart cleanup - check for multiple potential chart instances
|
||||
const existingChart = Chart.getChart(ctx.canvas);
|
||||
if (existingChart) {
|
||||
console.log('🔄 Found existing Chart.js instance, destroying...');
|
||||
try {
|
||||
// Stop streaming plugin timers first
|
||||
const rt = existingChart.options?.scales?.x?.realtime;
|
||||
if (rt) {
|
||||
rt.pause = true;
|
||||
existingChart.update('none');
|
||||
}
|
||||
|
||||
// Stop any running animations and timers
|
||||
existingChart.stop();
|
||||
|
||||
// Destroy the chart instance
|
||||
existingChart.destroy();
|
||||
console.log('✅ Existing Chart.js instance destroyed');
|
||||
} catch (destroyError) {
|
||||
console.warn('⚠️ Error destroying existing chart:', destroyError);
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy our reference too if it exists
|
||||
if (chartRef.current) {
|
||||
try {
|
||||
// Pause streaming before destroying to avoid dangling references
|
||||
console.log('🔄 Destroying chart reference...');
|
||||
|
||||
// Stop streaming plugin timers
|
||||
const rt = chartRef.current.options?.scales?.x?.realtime;
|
||||
if (rt) {
|
||||
rt.pause = true;
|
||||
// Force stop any active timers
|
||||
if (rt._timer) {
|
||||
clearInterval(rt._timer);
|
||||
rt._timer = null;
|
||||
}
|
||||
chartRef.current.update('none');
|
||||
}
|
||||
|
||||
// Clear any animation frames and timers
|
||||
chartRef.current.stop();
|
||||
|
||||
// Destroy the chart
|
||||
chartRef.current.destroy();
|
||||
|
||||
console.log('✅ Chart reference destroyed successfully');
|
||||
} catch (destroyError) {
|
||||
console.warn('⚠️ Error destroying previous chart:', destroyError);
|
||||
console.warn('⚠️ Error destroying chart reference:', destroyError);
|
||||
} finally {
|
||||
chartRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Additional safety: clean up any remaining Chart.js properties
|
||||
if (ctx.canvas.chartjs) {
|
||||
console.warn('⚠️ Canvas still has chartjs reference, cleaning up...');
|
||||
delete ctx.canvas.chartjs;
|
||||
}
|
||||
|
||||
// Clear the canvas completely and reset size
|
||||
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
|
||||
// Reset canvas styling to ensure clean state
|
||||
ctx.canvas.style.width = '';
|
||||
ctx.canvas.style.height = '';
|
||||
|
||||
// Force canvas to lose focus if it has it
|
||||
if (document.activeElement === ctx.canvas) {
|
||||
ctx.canvas.blur();
|
||||
}
|
||||
|
||||
const config = cfg;
|
||||
const enabledVariables = getEnabledVariables(config.variables);
|
||||
|
||||
|
@ -191,6 +387,61 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
sessionDataRef.current.datasetIndex.set(variableInfo.name, index);
|
||||
});
|
||||
|
||||
// Load historical data to pre-populate the chart
|
||||
const timeWindow = config.time_window || 60;
|
||||
const variableNames = enabledVariables.map(v => v.name);
|
||||
|
||||
// TEMPORARILY DISABLED: Historical data loading due to backend HTTP 500 error
|
||||
// TODO: Fix backend /api/plots/historical endpoint
|
||||
console.log(`📊 Historical data loading temporarily disabled for ${variableNames.length} variables`);
|
||||
|
||||
/*
|
||||
if (variableNames.length > 0) {
|
||||
setIsLoadingHistorical(true);
|
||||
try {
|
||||
const historicalData = await loadHistoricalData(variableNames, timeWindow);
|
||||
|
||||
if (historicalData.length > 0) {
|
||||
console.log(`📊 Loaded ${historicalData.length} historical data points`);
|
||||
|
||||
// Group data by variable and add to appropriate dataset
|
||||
const dataByVariable = {};
|
||||
historicalData.forEach(point => {
|
||||
const { variable, timestamp, value } = point;
|
||||
if (!dataByVariable[variable]) {
|
||||
dataByVariable[variable] = [];
|
||||
}
|
||||
dataByVariable[variable].push({
|
||||
x: new Date(timestamp),
|
||||
y: value
|
||||
});
|
||||
});
|
||||
|
||||
// Add historical data to datasets
|
||||
enabledVariables.forEach((variableInfo, index) => {
|
||||
const historicalPoints = dataByVariable[variableInfo.name] || [];
|
||||
if (historicalPoints.length > 0) {
|
||||
// Sort points by timestamp to ensure proper order
|
||||
historicalPoints.sort((a, b) => a.x - b.x);
|
||||
datasets[index].data = historicalPoints;
|
||||
console.log(`📊 Added ${historicalPoints.length} historical points for ${variableInfo.name}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Update data points counter
|
||||
const totalHistoricalPoints = Object.values(dataByVariable).reduce((sum, points) => sum + points.length, 0);
|
||||
setDataPointsCount(totalHistoricalPoints);
|
||||
} else {
|
||||
console.log(`📊 No historical data found for variables: ${variableNames.join(', ')}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Failed to load historical data:', error);
|
||||
} finally {
|
||||
setIsLoadingHistorical(false);
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
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;
|
||||
|
||||
|
@ -288,6 +539,21 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
}
|
||||
};
|
||||
|
||||
// Final safety check before creating new chart
|
||||
if (ctx.canvas.chartjs) {
|
||||
console.warn('⚠️ Canvas still has Chart.js reference, forcing cleanup...');
|
||||
try {
|
||||
const existingChart = window.Chart.getChart(ctx.canvas);
|
||||
if (existingChart) {
|
||||
existingChart.destroy();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('⚠️ Error cleaning up existing chart reference:', e);
|
||||
}
|
||||
delete ctx.canvas.chartjs;
|
||||
}
|
||||
|
||||
console.log('🚀 Creating new Chart.js instance...');
|
||||
chartRef.current = new Chart(ctx, chartConfig);
|
||||
sessionDataRef.current.isRealTimeMode = true;
|
||||
sessionDataRef.current.noDataCycles = 0;
|
||||
|
@ -677,30 +943,101 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
|
|||
|
||||
// Initialize chart when config is resolved - simplified approach
|
||||
useEffect(() => {
|
||||
// Only create chart once when we have a session_id and canvas
|
||||
console.log(`🔍 useEffect triggered - sessionId: ${session?.session_id}, hasCanvas: ${!!canvasRef.current}, hasChart: ${!!chartRef.current}`);
|
||||
|
||||
// Check dependencies first
|
||||
if (!checkChartDependencies()) {
|
||||
setError('Chart.js dependencies not loaded. Please refresh the page.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Only create chart when we have ALL requirements AND no existing chart
|
||||
if (session?.session_id && canvasRef.current && !chartRef.current) {
|
||||
const config = session?.config;
|
||||
if (config) {
|
||||
resolvedConfigRef.current = config;
|
||||
createStreamingChart();
|
||||
console.log(`🎯 Creating chart for session ${session.session_id} - conditions met`);
|
||||
|
||||
// Additional safety check - wait a tiny bit to ensure cleanup is complete
|
||||
setTimeout(() => {
|
||||
// Double-check that we still need to create a chart
|
||||
if (chartRef.current || !canvasRef.current) {
|
||||
console.log('⏭️ Chart creation cancelled - state changed during delay');
|
||||
return;
|
||||
}
|
||||
|
||||
resolvedConfigRef.current = config;
|
||||
createStreamingChart();
|
||||
}, 10);
|
||||
} else {
|
||||
console.log(`⚠️ Session ${session.session_id} has no config, skipping chart creation`);
|
||||
}
|
||||
} else {
|
||||
console.log(`⏭️ Skipping chart creation - sessionId: ${!!session?.session_id}, canvas: ${!!canvasRef.current}, chart: ${!!chartRef.current}`);
|
||||
}
|
||||
|
||||
return () => {
|
||||
console.log('🧹 Cleaning up chart component on unmount...');
|
||||
try {
|
||||
// Enhanced cleanup - check for Chart.js registry first
|
||||
if (canvasRef.current) {
|
||||
const ctx = canvasRef.current.getContext('2d');
|
||||
const existingChart = Chart.getChart(ctx.canvas);
|
||||
|
||||
if (existingChart) {
|
||||
console.log('🧹 Cleaning up Chart.js instance on unmount...');
|
||||
const rt = existingChart.options?.scales?.x?.realtime;
|
||||
if (rt) {
|
||||
rt.pause = true;
|
||||
// Force stop any active timers
|
||||
if (rt._timer) {
|
||||
clearInterval(rt._timer);
|
||||
rt._timer = null;
|
||||
}
|
||||
existingChart.update('none');
|
||||
}
|
||||
existingChart.stop();
|
||||
existingChart.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up our reference too
|
||||
if (chartRef.current) {
|
||||
console.log('🧹 Cleaning up chart reference on unmount...');
|
||||
const rt = chartRef.current.options?.scales?.x?.realtime;
|
||||
if (rt) {
|
||||
rt.pause = true;
|
||||
// Force stop any active timers
|
||||
if (rt._timer) {
|
||||
clearInterval(rt._timer);
|
||||
rt._timer = null;
|
||||
}
|
||||
chartRef.current.update('none');
|
||||
}
|
||||
chartRef.current.stop();
|
||||
chartRef.current.destroy();
|
||||
chartRef.current = null;
|
||||
}
|
||||
|
||||
// Clean up canvas references completely
|
||||
if (canvasRef.current) {
|
||||
const ctx = canvasRef.current.getContext('2d');
|
||||
if (ctx.canvas.chartjs) {
|
||||
delete ctx.canvas.chartjs;
|
||||
}
|
||||
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
ctx.canvas.style.width = '';
|
||||
ctx.canvas.style.height = '';
|
||||
|
||||
// Force canvas to lose focus
|
||||
if (document.activeElement === ctx.canvas) {
|
||||
ctx.canvas.blur();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Chart cleanup error:', error);
|
||||
}
|
||||
|
||||
// Clean up any manual intervals
|
||||
if (sessionDataRef.current.manualInterval) {
|
||||
clearInterval(sessionDataRef.current.manualInterval);
|
||||
sessionDataRef.current.manualInterval = null;
|
||||
|
|
|
@ -0,0 +1,289 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -200,6 +200,22 @@ export async function getPlotVariables() {
|
|||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Historical data loading
|
||||
export async function getHistoricalData(variables, timeWindowSeconds) {
|
||||
const res = await fetch(`${BASE_URL}/api/plots/historical`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
variables: variables,
|
||||
time_window: timeWindowSeconds
|
||||
})
|
||||
})
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Plot session status and control (aliases for existing functions)
|
||||
export async function getPlotSession(sessionId) {
|
||||
// Use existing getPlotConfig to get session info
|
||||
|
|
131
main.py
131
main.py
|
@ -1821,6 +1821,137 @@ def get_plot_variables():
|
|||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/plots/historical", methods=["POST"])
|
||||
def get_historical_data():
|
||||
"""Get historical data from CSV files for plot initialization"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "No data provided"}), 400
|
||||
|
||||
variables = data.get('variables', [])
|
||||
time_window_seconds = data.get('time_window', 60)
|
||||
|
||||
if not variables:
|
||||
return jsonify({"error": "No variables specified"}), 400
|
||||
|
||||
# Import here to avoid circular imports
|
||||
import pandas as pd
|
||||
import glob
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Calculate time range
|
||||
end_time = datetime.now()
|
||||
start_time = end_time - timedelta(seconds=time_window_seconds)
|
||||
|
||||
# Get records directory
|
||||
records_dir = os.path.join(os.path.dirname(__file__), 'records')
|
||||
if not os.path.exists(records_dir):
|
||||
return jsonify({"data": []})
|
||||
|
||||
historical_data = []
|
||||
|
||||
# Get date folders to search (today and yesterday in case time window spans days)
|
||||
today = end_time.strftime('%d-%m-%Y')
|
||||
yesterday = (end_time - timedelta(days=1)).strftime('%d-%m-%Y')
|
||||
|
||||
date_folders = []
|
||||
for date_folder in [yesterday, today]:
|
||||
folder_path = os.path.join(records_dir, date_folder)
|
||||
if os.path.exists(folder_path):
|
||||
date_folders.append(folder_path)
|
||||
|
||||
# Search for CSV files with any of the required variables
|
||||
for folder_path in date_folders:
|
||||
csv_files = glob.glob(os.path.join(folder_path, '*.csv'))
|
||||
|
||||
for csv_file in csv_files:
|
||||
try:
|
||||
# Read first line to check if any required variables are present
|
||||
with open(csv_file, 'r') as f:
|
||||
header_line = f.readline().strip()
|
||||
if not header_line:
|
||||
continue
|
||||
|
||||
headers = [h.strip() for h in header_line.split(',')]
|
||||
|
||||
# Check if any of our variables are in this file
|
||||
matching_vars = [var for var in variables if var in headers]
|
||||
if not matching_vars:
|
||||
continue
|
||||
|
||||
# Read the CSV file
|
||||
df = pd.read_csv(csv_file)
|
||||
|
||||
if 'timestamp' not in df.columns:
|
||||
continue
|
||||
|
||||
# Convert timestamp to datetime
|
||||
df['timestamp'] = pd.to_datetime(df['timestamp'])
|
||||
|
||||
# Filter by time range
|
||||
mask = (df['timestamp'] >= start_time) & (df['timestamp'] <= end_time)
|
||||
filtered_df = df[mask]
|
||||
|
||||
if filtered_df.empty:
|
||||
continue
|
||||
|
||||
# Extract data for matching variables only
|
||||
for _, row in filtered_df.iterrows():
|
||||
timestamp = row['timestamp']
|
||||
for var in matching_vars:
|
||||
if var in row:
|
||||
try:
|
||||
# Convert value to appropriate type
|
||||
value = row[var]
|
||||
if pd.isna(value):
|
||||
continue
|
||||
|
||||
# Handle boolean values
|
||||
if isinstance(value, str):
|
||||
if value.lower() == 'true':
|
||||
value = True
|
||||
elif value.lower() == 'false':
|
||||
value = False
|
||||
else:
|
||||
try:
|
||||
value = float(value)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
historical_data.append({
|
||||
'timestamp': timestamp.isoformat(),
|
||||
'variable': var,
|
||||
'value': value
|
||||
})
|
||||
except Exception as e:
|
||||
# Skip invalid values
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
# Skip files that can't be read
|
||||
print(f"Warning: Could not read CSV file {csv_file}: {e}")
|
||||
continue
|
||||
|
||||
# Sort by timestamp
|
||||
historical_data.sort(key=lambda x: x['timestamp'])
|
||||
|
||||
return jsonify({
|
||||
"data": historical_data,
|
||||
"time_range": {
|
||||
"start": start_time.isoformat(),
|
||||
"end": end_time.isoformat()
|
||||
},
|
||||
"variables_found": list(set([item['variable'] for item in historical_data])),
|
||||
"total_points": len(historical_data)
|
||||
})
|
||||
|
||||
except ImportError:
|
||||
return jsonify({"error": "pandas is required for historical data processing"}), 500
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/status")
|
||||
def get_status():
|
||||
"""Get current status"""
|
||||
|
|
|
@ -3,4 +3,5 @@ python-snap7==1.3
|
|||
psutil==5.9.5
|
||||
flask-socketio==5.3.6
|
||||
jsonschema==4.22.0
|
||||
Flask-Cors==4.0.0
|
||||
Flask-Cors==4.0.0
|
||||
pandas
|
|
@ -3,11 +3,11 @@
|
|||
"should_connect": true,
|
||||
"should_stream": true,
|
||||
"active_datasets": [
|
||||
"Test",
|
||||
"Fast",
|
||||
"DAR",
|
||||
"Test"
|
||||
"DAR"
|
||||
]
|
||||
},
|
||||
"auto_recovery_enabled": true,
|
||||
"last_update": "2025-08-14T22:51:29.383787"
|
||||
"last_update": "2025-08-14T23:27:17.699618"
|
||||
}
|
Loading…
Reference in New Issue