feat: Implement historical data loading from CSV files, enhance PlotRealtimeViewer component, and improve Chart.js dependency checks

This commit is contained in:
Miguel 2025-08-14 23:32:07 +02:00
parent ea2006666f
commit 91718e7bf7
7 changed files with 1079 additions and 12 deletions

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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