feat: Add TimePointSelector component for time navigation and selection
- Introduced a new TimePointSelector component to allow users to select a specific date and time using a date picker and a slider. - Integrated the TimePointSelector into the PlotHistoricalSession component for enhanced time navigation. - Implemented state management for central time and time range in PlotHistoricalSession. - Added API endpoint to fetch available date range for historical data. - Updated ChartjsHistoricalPlot to improve data processing and logging. - Enhanced error handling and logging in the backend for better debugging. - Updated frontend dependencies, including the addition of react-datepicker.
This commit is contained in:
parent
ae1fc0508d
commit
43d125bea1
|
@ -8219,8 +8219,258 @@
|
|||
"event_type": "application_started",
|
||||
"message": "Application initialization completed successfully",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-16T11:04:58.577892",
|
||||
"level": "info",
|
||||
"event_type": "application_started",
|
||||
"message": "Application initialization completed successfully",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-16T11:19:49.627904",
|
||||
"level": "info",
|
||||
"event_type": "application_started",
|
||||
"message": "Application initialization completed successfully",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-16T11:27:39.324986",
|
||||
"level": "info",
|
||||
"event_type": "application_started",
|
||||
"message": "Application initialization completed successfully",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-16T11:43:47.182067",
|
||||
"level": "info",
|
||||
"event_type": "application_started",
|
||||
"message": "Application initialization completed successfully",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-16T11:48:02.324605",
|
||||
"level": "info",
|
||||
"event_type": "application_started",
|
||||
"message": "Application initialization completed successfully",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-16T11:55:16.082269",
|
||||
"level": "info",
|
||||
"event_type": "application_started",
|
||||
"message": "Application initialization completed successfully",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-16T12:06:39.185973",
|
||||
"level": "info",
|
||||
"event_type": "application_started",
|
||||
"message": "Application initialization completed successfully",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-16T12:10:04.492953",
|
||||
"level": "info",
|
||||
"event_type": "application_started",
|
||||
"message": "Application initialization completed successfully",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-16T12:15:30.073387",
|
||||
"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-16T12:15:30.095330",
|
||||
"level": "info",
|
||||
"event_type": "dataset_activated",
|
||||
"message": "Dataset activated: Fast",
|
||||
"details": {
|
||||
"dataset_id": "Fast",
|
||||
"variables_count": 2,
|
||||
"streaming_count": 2,
|
||||
"prefix": "fast"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-16T12:15:30.115826",
|
||||
"level": "info",
|
||||
"event_type": "csv_recording_started",
|
||||
"message": "CSV recording started: 2 datasets activated",
|
||||
"details": {
|
||||
"activated_datasets": 2,
|
||||
"total_datasets": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-16T12:15:30.136928",
|
||||
"level": "info",
|
||||
"event_type": "plc_connection",
|
||||
"message": "Successfully connected to PLC 10.1.33.11 and auto-started CSV recording for 3 datasets",
|
||||
"details": {
|
||||
"ip": "10.1.33.11",
|
||||
"rack": 0,
|
||||
"slot": 2,
|
||||
"symbols_path": "C:/Users/migue/Downloads/symSAE452.asc",
|
||||
"auto_started_recording": true,
|
||||
"recording_datasets": 3,
|
||||
"dataset_names": [
|
||||
"test",
|
||||
"Fast",
|
||||
"DAR"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-16T12:15:42.775388",
|
||||
"level": "info",
|
||||
"event_type": "plot_session_created",
|
||||
"message": "Plot session 'UR29' created and started",
|
||||
"details": {
|
||||
"session_id": "plot_1_1755339342774_2",
|
||||
"variables": [
|
||||
"UR29_Brix",
|
||||
"UR29_ma",
|
||||
"AUX Blink_1.0S",
|
||||
"AUX Blink_1.6S"
|
||||
],
|
||||
"time_window": 36,
|
||||
"trigger_variable": null,
|
||||
"auto_started": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-16T12:21:32.502577",
|
||||
"level": "info",
|
||||
"event_type": "application_started",
|
||||
"message": "Application initialization completed successfully",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-16T12:21:32.569959",
|
||||
"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-16T12:21:32.581865",
|
||||
"level": "info",
|
||||
"event_type": "dataset_activated",
|
||||
"message": "Dataset activated: Fast",
|
||||
"details": {
|
||||
"dataset_id": "Fast",
|
||||
"variables_count": 2,
|
||||
"streaming_count": 2,
|
||||
"prefix": "fast"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-16T12:21:32.594474",
|
||||
"level": "info",
|
||||
"event_type": "csv_recording_started",
|
||||
"message": "CSV recording started: 2 datasets activated",
|
||||
"details": {
|
||||
"activated_datasets": 2,
|
||||
"total_datasets": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-16T12:30:07.191405",
|
||||
"level": "info",
|
||||
"event_type": "application_started",
|
||||
"message": "Application initialization completed successfully",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-16T12:30:07.241616",
|
||||
"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-16T12:30:07.253448",
|
||||
"level": "info",
|
||||
"event_type": "dataset_activated",
|
||||
"message": "Dataset activated: Fast",
|
||||
"details": {
|
||||
"dataset_id": "Fast",
|
||||
"variables_count": 2,
|
||||
"streaming_count": 2,
|
||||
"prefix": "fast"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-16T12:30:07.264199",
|
||||
"level": "info",
|
||||
"event_type": "csv_recording_started",
|
||||
"message": "CSV recording started: 2 datasets activated",
|
||||
"details": {
|
||||
"activated_datasets": 2,
|
||||
"total_datasets": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-16T12:35:14.697258",
|
||||
"level": "info",
|
||||
"event_type": "application_started",
|
||||
"message": "Application initialization completed successfully",
|
||||
"details": {}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-16T12:35:14.761107",
|
||||
"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-16T12:35:14.774962",
|
||||
"level": "info",
|
||||
"event_type": "dataset_activated",
|
||||
"message": "Dataset activated: Fast",
|
||||
"details": {
|
||||
"dataset_id": "Fast",
|
||||
"variables_count": 2,
|
||||
"streaming_count": 2,
|
||||
"prefix": "fast"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp": "2025-08-16T12:35:14.785650",
|
||||
"level": "info",
|
||||
"event_type": "csv_recording_started",
|
||||
"message": "CSV recording started: 2 datasets activated",
|
||||
"details": {
|
||||
"activated_datasets": 2,
|
||||
"total_datasets": 3
|
||||
}
|
||||
}
|
||||
],
|
||||
"last_updated": "2025-08-16T10:01:50.009210",
|
||||
"total_entries": 685
|
||||
"last_updated": "2025-08-16T12:35:14.785650",
|
||||
"total_entries": 710
|
||||
}
|
|
@ -22,6 +22,7 @@
|
|||
"framer-motion": "^11.2.12",
|
||||
"luxon": "^2.5.2",
|
||||
"react": "^18.2.0",
|
||||
"react-datepicker": "^8.5.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^6.26.1"
|
||||
|
|
|
@ -110,6 +110,7 @@ const ChartjsHistoricalPlot = ({
|
|||
|
||||
const createOrUpdateChart = useCallback(() => {
|
||||
if (!canvasRef.current || !historicalData || historicalData.length === 0) {
|
||||
console.log('📊 Chart creation skipped - missing canvas or data');
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -120,17 +121,40 @@ const ChartjsHistoricalPlot = ({
|
|||
chartRef.current = null;
|
||||
}
|
||||
|
||||
console.log(`📊 Processing ${historicalData.length} historical data points for variables:`, session?.variables);
|
||||
|
||||
// Process historical data into Chart.js format
|
||||
const processedData = processHistoricalData(historicalData, session?.variables || []);
|
||||
setDataPointsCount(historicalData.length);
|
||||
|
||||
if (processedData.datasets.length === 0) {
|
||||
console.log('📊 No datasets created from data');
|
||||
setError('No valid data to display');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`📊 Created ${processedData.datasets.length} datasets:`, processedData.datasets.map(d => ({ label: d.label, points: d.data.length })));
|
||||
|
||||
// Log sample data for debugging
|
||||
processedData.datasets.forEach((dataset, index) => {
|
||||
if (dataset.data.length > 0) {
|
||||
console.log(`📊 Dataset ${index} (${dataset.label}):`, {
|
||||
totalPoints: dataset.data.length,
|
||||
firstPoint: dataset.data[0],
|
||||
lastPoint: dataset.data[dataset.data.length - 1],
|
||||
samplePoints: dataset.data.slice(0, 3)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Create chart configuration
|
||||
const chartConfig = createChartConfig(processedData, config, isZoomEnabled);
|
||||
|
||||
console.log('📊 Chart config created:', {
|
||||
datasetCount: chartConfig.data.datasets.length,
|
||||
hasData: chartConfig.data.datasets.some(d => d.data.length > 0),
|
||||
decimationEnabled: chartConfig.options.plugins.decimation?.enabled
|
||||
});
|
||||
|
||||
// Create new chart
|
||||
const ctx = canvasRef.current.getContext('2d');
|
||||
|
@ -151,6 +175,8 @@ const ChartjsHistoricalPlot = ({
|
|||
}, [historicalData, session?.variables, config, isZoomEnabled]);
|
||||
|
||||
const processHistoricalData = (data, variables) => {
|
||||
console.log(`📊 Processing data - Input: ${data.length} points, Variables: [${variables.join(', ')}]`);
|
||||
|
||||
const datasets = [];
|
||||
const colors = [
|
||||
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF',
|
||||
|
@ -167,12 +193,15 @@ const ChartjsHistoricalPlot = ({
|
|||
y: point.value
|
||||
}))
|
||||
.sort((a, b) => a.x - b.x);
|
||||
|
||||
console.log(`📊 Variable ${variable}: ${variableData[variable].length} points after processing`);
|
||||
});
|
||||
|
||||
// Create datasets for each variable
|
||||
variables.forEach((variable, index) => {
|
||||
const points = variableData[variable];
|
||||
if (points && points.length > 0) {
|
||||
console.log(`📊 Dataset for ${variable}: ${points.length} points`);
|
||||
datasets.push({
|
||||
label: variable,
|
||||
data: points,
|
||||
|
@ -181,20 +210,18 @@ const ChartjsHistoricalPlot = ({
|
|||
borderWidth: 2,
|
||||
fill: false,
|
||||
tension: 0.1,
|
||||
pointRadius: 0, // Always hide points - decimation will handle visualization
|
||||
pointRadius: 0, // Let decimation handle point display
|
||||
pointHoverRadius: 4,
|
||||
pointBackgroundColor: colors[index % colors.length],
|
||||
pointBorderColor: colors[index % colors.length],
|
||||
spanGaps: true,
|
||||
// Enable parsing for decimation
|
||||
parsing: {
|
||||
xAxisKey: 'x',
|
||||
yAxisKey: 'y'
|
||||
}
|
||||
spanGaps: true
|
||||
});
|
||||
} else {
|
||||
console.log(`📊 No data for variable: ${variable}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`📊 Final result: ${datasets.length} datasets created`);
|
||||
return { datasets };
|
||||
};
|
||||
|
||||
|
@ -202,6 +229,12 @@ const ChartjsHistoricalPlot = ({
|
|||
const minTime = currentTimeRange?.start ? new Date(currentTimeRange.start) : null;
|
||||
const maxTime = currentTimeRange?.end ? new Date(currentTimeRange.end) : null;
|
||||
|
||||
console.log('📊 Chart time range:', {
|
||||
minTime: minTime?.toISOString(),
|
||||
maxTime: maxTime?.toISOString(),
|
||||
currentTimeRange
|
||||
});
|
||||
|
||||
const config = {
|
||||
type: 'line',
|
||||
data: data,
|
||||
|
@ -215,24 +248,6 @@ const ChartjsHistoricalPlot = ({
|
|||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
// Enable decimation for better performance with large datasets
|
||||
datasets: {
|
||||
line: {
|
||||
parsing: {
|
||||
xAxisKey: 'x',
|
||||
yAxisKey: 'y'
|
||||
}
|
||||
}
|
||||
},
|
||||
elements: {
|
||||
point: {
|
||||
radius: function(context) {
|
||||
// Hide points if there are many data points
|
||||
const dataset = context.dataset;
|
||||
return dataset && dataset.data && dataset.data.length > 1000 ? 0 : 2;
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
// Configure decimation for better performance with large datasets
|
||||
decimation: {
|
||||
|
@ -416,15 +431,10 @@ const ChartjsHistoricalPlot = ({
|
|||
|
||||
{isZoomEnabled && (
|
||||
<button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={resetZoom}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
fontSize: '12px',
|
||||
backgroundColor: '#e2e8f0',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
colorScheme="blue"
|
||||
>
|
||||
Reset Zoom
|
||||
</button>
|
||||
|
|
|
@ -47,6 +47,7 @@ import {
|
|||
} from '@chakra-ui/react'
|
||||
import { SettingsIcon, RepeatIcon, ViewIcon, DeleteIcon, TimeIcon, CalendarIcon } from '@chakra-ui/icons'
|
||||
import ChartjsHistoricalPlot from './ChartjsHistoricalPlot.jsx'
|
||||
import TimePointSelector from './TimePointSelector.jsx'
|
||||
import * as api from '../services/api'
|
||||
|
||||
/**
|
||||
|
@ -70,6 +71,23 @@ export default function PlotHistoricalSession({
|
|||
loadTime: null
|
||||
})
|
||||
|
||||
// NEW: Time navigation state (centro + rango en segundos)
|
||||
const [centralTime, setCentralTime] = useState(() => {
|
||||
// Default: 10 minutos atrás desde ahora
|
||||
return new Date(Date.now() - 10 * 60 * 1000)
|
||||
})
|
||||
const [timeRangeSeconds, setTimeRangeSeconds] = useState(1000) // 500 seg atrás + 500 seg adelante
|
||||
const [dateRange, setDateRange] = useState(null) // Min/max dates disponibles del backend
|
||||
|
||||
// Derived time range for data loading
|
||||
const derivedTimeRange = useMemo(() => {
|
||||
const halfRange = timeRangeSeconds / 2
|
||||
return {
|
||||
start: new Date(centralTime.getTime() - halfRange * 1000),
|
||||
end: new Date(centralTime.getTime() + halfRange * 1000)
|
||||
}
|
||||
}, [centralTime, timeRangeSeconds])
|
||||
|
||||
// UI state
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
const [isConfigOpen, setIsConfigOpen] = useState(false)
|
||||
|
@ -89,30 +107,48 @@ export default function PlotHistoricalSession({
|
|||
return fallback
|
||||
}
|
||||
|
||||
const [localTimeRange, setLocalTimeRange] = useState(() => {
|
||||
const defaultStart = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
||||
const defaultEnd = new Date()
|
||||
|
||||
if (session.timeRange) {
|
||||
return {
|
||||
start: ensureValidDate(session.timeRange.start, defaultStart),
|
||||
end: ensureValidDate(session.timeRange.end, defaultEnd)
|
||||
}
|
||||
}
|
||||
|
||||
return { start: defaultStart, end: defaultEnd }
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
const { isOpen: isConfigModalOpen, onOpen: onConfigModalOpen, onClose: onConfigModalClose } = useDisclosure()
|
||||
|
||||
// Keep track of the last loaded data range for optimization
|
||||
const [loadedDataRange, setLoadedDataRange] = useState(null)
|
||||
|
||||
// Load historical data on component mount and when time range changes
|
||||
// Load date range from backend on mount
|
||||
useEffect(() => {
|
||||
loadHistoricalData()
|
||||
}, [session.id, localTimeRange])
|
||||
loadDateRange()
|
||||
}, [])
|
||||
|
||||
// Load historical data when derived time range changes
|
||||
useEffect(() => {
|
||||
if (dateRange) { // Only load after we have date range
|
||||
loadHistoricalData()
|
||||
}
|
||||
}, [session.id, derivedTimeRange, dateRange])
|
||||
|
||||
const loadDateRange = async () => {
|
||||
try {
|
||||
const response = await api.getHistoricalDateRange()
|
||||
if (response.success) {
|
||||
const minDate = new Date(response.date_range.min_date)
|
||||
const maxDate = new Date(response.date_range.max_date)
|
||||
setDateRange({ minDate, maxDate })
|
||||
|
||||
// Adjust central time if it's outside the available range
|
||||
const currentTime = centralTime.getTime()
|
||||
const minTime = minDate.getTime()
|
||||
const maxTime = maxDate.getTime()
|
||||
|
||||
if (currentTime < minTime || currentTime > maxTime) {
|
||||
// Set to middle of available range
|
||||
const middleTime = new Date((minTime + maxTime) / 2)
|
||||
setCentralTime(middleTime)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading date range:', error)
|
||||
setError('Could not load available date range')
|
||||
}
|
||||
}
|
||||
|
||||
// Function to check if a range is contained within another range
|
||||
const isRangeContained = (newRange, existingRange) => {
|
||||
|
@ -132,9 +168,9 @@ export default function PlotHistoricalSession({
|
|||
}
|
||||
|
||||
// Check if the new range is contained within the previously loaded range
|
||||
if (!forceReload && loadedDataRange && isRangeContained(localTimeRange, loadedDataRange)) {
|
||||
if (!forceReload && loadedDataRange && isRangeContained(derivedTimeRange, loadedDataRange)) {
|
||||
console.log('📊 Zoom optimization: New range is contained within loaded data, skipping reload')
|
||||
console.log('📊 New range:', localTimeRange)
|
||||
console.log('📊 New range:', derivedTimeRange)
|
||||
console.log('📊 Loaded range:', loadedDataRange)
|
||||
return
|
||||
}
|
||||
|
@ -147,13 +183,13 @@ export default function PlotHistoricalSession({
|
|||
const startTime = performance.now()
|
||||
|
||||
// Calculate time window in seconds
|
||||
const timeWindowSeconds = Math.floor((localTimeRange.end - localTimeRange.start) / 1000)
|
||||
const timeWindowSeconds = Math.floor((derivedTimeRange.end - derivedTimeRange.start) / 1000)
|
||||
|
||||
const requestData = {
|
||||
variables: session.variables,
|
||||
time_window: timeWindowSeconds,
|
||||
start_time: localTimeRange.start.toISOString(),
|
||||
end_time: localTimeRange.end.toISOString()
|
||||
start_time: derivedTimeRange.start.toISOString(),
|
||||
end_time: derivedTimeRange.end.toISOString()
|
||||
}
|
||||
|
||||
console.log('📊 Loading historical data for session:', session.id)
|
||||
|
@ -174,12 +210,21 @@ export default function PlotHistoricalSession({
|
|||
const loadTime = Math.round(endTime - startTime)
|
||||
|
||||
if (response.data) {
|
||||
console.log('📊 Historical data response:', {
|
||||
dataLength: response.data.length,
|
||||
totalPoints: response.total_points,
|
||||
variablesFound: response.variables_found,
|
||||
timeRange: response.time_range,
|
||||
cached: response.cached,
|
||||
sampleData: response.data.slice(0, 3) // First 3 points for debug
|
||||
})
|
||||
|
||||
setHistoricalData(response.data)
|
||||
|
||||
// Update the loaded data range for optimization
|
||||
setLoadedDataRange({
|
||||
start: new Date(localTimeRange.start),
|
||||
end: new Date(localTimeRange.end)
|
||||
start: new Date(derivedTimeRange.start),
|
||||
end: new Date(derivedTimeRange.end)
|
||||
})
|
||||
|
||||
setDataStats({
|
||||
|
@ -193,16 +238,10 @@ export default function PlotHistoricalSession({
|
|||
points: response.data.length,
|
||||
variables: response.variables_found,
|
||||
loadTime,
|
||||
loadedRange: localTimeRange
|
||||
loadedRange: derivedTimeRange
|
||||
})
|
||||
|
||||
toast({
|
||||
title: "Data Loaded",
|
||||
description: `Loaded ${response.data.length} data points in ${loadTime}ms`,
|
||||
status: "success",
|
||||
duration: 2000,
|
||||
isClosable: true
|
||||
})
|
||||
// Removed toast notification to avoid spam during slider changes
|
||||
} else {
|
||||
setError('No data received from server')
|
||||
}
|
||||
|
@ -210,23 +249,22 @@ export default function PlotHistoricalSession({
|
|||
} catch (error) {
|
||||
console.error('Error loading historical data:', error)
|
||||
setError(error.message || 'Failed to load historical data')
|
||||
toast({
|
||||
title: "Data Load Error",
|
||||
description: error.message || "Failed to load historical data",
|
||||
status: "error",
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
})
|
||||
// Only show error toast for actual errors, not for routine data loading
|
||||
if (!error.message?.includes('contained within loaded data')) {
|
||||
toast({
|
||||
title: "Data Load Error",
|
||||
description: error.message || "Failed to load historical data",
|
||||
status: "error",
|
||||
duration: 5000,
|
||||
isClosable: true
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setLoadingProgress(0)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTimeRangeChange = (newTimeRange) => {
|
||||
setLocalTimeRange(newTimeRange)
|
||||
}
|
||||
|
||||
const handleConfigSave = () => {
|
||||
setConfig({ ...config })
|
||||
onConfigModalClose()
|
||||
|
@ -238,31 +276,47 @@ export default function PlotHistoricalSession({
|
|||
|
||||
const handleZoomToTimeRange = (start, end) => {
|
||||
console.log('📊 Zoom event - evaluating range:', { start, end })
|
||||
const newRange = { start: new Date(start), end: new Date(end) }
|
||||
const newStart = new Date(start)
|
||||
const newEnd = new Date(end)
|
||||
|
||||
// Check if the new range is contained within the loaded data
|
||||
if (loadedDataRange && isRangeContained(newRange, loadedDataRange)) {
|
||||
console.log('📊 Zoom optimization: Range contained in loaded data, skipping reload')
|
||||
setLocalTimeRange(newRange)
|
||||
} else {
|
||||
console.log('📊 Zoom requires data reload - new range outside loaded data')
|
||||
setLocalTimeRange(newRange)
|
||||
}
|
||||
// Calculate new central time and range from zoom
|
||||
const newCentralTime = new Date((newStart.getTime() + newEnd.getTime()) / 2)
|
||||
const newRangeSeconds = Math.floor((newEnd.getTime() - newStart.getTime()) / 1000)
|
||||
|
||||
console.log('📊 New central time:', newCentralTime, 'Range seconds:', newRangeSeconds)
|
||||
|
||||
// Update time navigation state
|
||||
setCentralTime(newCentralTime)
|
||||
setTimeRangeSeconds(newRangeSeconds)
|
||||
}
|
||||
|
||||
const handlePanToTimeRange = (start, end) => {
|
||||
console.log('📊 Pan event - evaluating range:', { start, end })
|
||||
const newRange = { start: new Date(start), end: new Date(end) }
|
||||
const newStart = new Date(start)
|
||||
const newEnd = new Date(end)
|
||||
|
||||
// Pan always requires checking if we need new data
|
||||
console.log('📊 Pan event - loading data for range:', newRange)
|
||||
setLocalTimeRange(newRange)
|
||||
// Calculate new central time (keep same range)
|
||||
const newCentralTime = new Date((newStart.getTime() + newEnd.getTime()) / 2)
|
||||
|
||||
console.log('📊 Pan to central time:', newCentralTime)
|
||||
|
||||
// Update only central time, keep same range
|
||||
setCentralTime(newCentralTime)
|
||||
}
|
||||
|
||||
// Handle time change from TimePointSelector
|
||||
const handleTimePointChange = (newCentralTime) => {
|
||||
console.log('📊 Time selector change:', newCentralTime)
|
||||
setCentralTime(newCentralTime)
|
||||
}
|
||||
|
||||
// Color mode
|
||||
const bgColor = useColorModeValue('white', 'gray.800')
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600')
|
||||
const textColor = useColorModeValue('gray.600', 'gray.300')
|
||||
const infoBgColor = useColorModeValue('gray.50', 'gray.700')
|
||||
const subtleTextColor = useColorModeValue('gray.500', 'gray.400')
|
||||
const smallTextColor = useColorModeValue('gray.400', 'gray.500')
|
||||
|
||||
// Format time range for display
|
||||
const formatTimeRange = (timeRange) => {
|
||||
|
@ -272,6 +326,38 @@ export default function PlotHistoricalSession({
|
|||
return `${start} → ${end}`
|
||||
}
|
||||
|
||||
// Format central time and range for display
|
||||
const formatCentralTimeInfo = () => {
|
||||
const halfRange = timeRangeSeconds / 2
|
||||
return {
|
||||
central: centralTime.toLocaleString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
}),
|
||||
range: `±${halfRange}s (${timeRangeSeconds}s total)`,
|
||||
start: derivedTimeRange.start.toLocaleString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
}),
|
||||
end: derivedTimeRange.end.toLocaleString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get status color
|
||||
const getStatusColor = () => {
|
||||
if (isLoading) return 'yellow'
|
||||
|
@ -297,9 +383,13 @@ export default function PlotHistoricalSession({
|
|||
)}
|
||||
</HStack>
|
||||
<HStack spacing={4} fontSize="xs" color={textColor}>
|
||||
<HStack>
|
||||
<TimeIcon />
|
||||
<Text>Centro: {formatCentralTimeInfo().central}</Text>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<CalendarIcon />
|
||||
<Text>{formatTimeRange(dataStats.timeRange || localTimeRange)}</Text>
|
||||
<Text>{formatCentralTimeInfo().range}</Text>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<Text>📊</Text>
|
||||
|
@ -376,6 +466,26 @@ export default function PlotHistoricalSession({
|
|||
|
||||
{isExpanded && (
|
||||
<CardBody pt={0}>
|
||||
{/* Time Navigation Controls */}
|
||||
{dateRange && (
|
||||
<Box mb={4}>
|
||||
<TimePointSelector
|
||||
minDate={dateRange.minDate}
|
||||
maxDate={dateRange.maxDate}
|
||||
initial={centralTime}
|
||||
stepMinutes={1}
|
||||
onTimeChange={handleTimePointChange}
|
||||
/>
|
||||
<Box mt={2} p={2} bg={infoBgColor} borderRadius="md" border="1px solid" borderColor={borderColor}>
|
||||
<HStack justify="space-between" fontSize="sm" color={textColor}>
|
||||
<Text><strong>Rango:</strong> {timeRangeSeconds}s</Text>
|
||||
<Text><strong>Desde:</strong> {formatCentralTimeInfo().start}</Text>
|
||||
<Text><strong>Hasta:</strong> {formatCentralTimeInfo().end}</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert status="error" mb={4} borderRadius="md">
|
||||
<AlertIcon />
|
||||
|
@ -387,24 +497,24 @@ export default function PlotHistoricalSession({
|
|||
)}
|
||||
|
||||
{showDataPreview && dataStats.totalPoints > 0 && (
|
||||
<Box mb={4} p={3} bg="gray.50" borderRadius="md">
|
||||
<Text fontSize="sm" fontWeight="medium" mb={2}>📊 Data Summary</Text>
|
||||
<Box mb={4} p={3} bg={infoBgColor} borderRadius="md" border="1px solid" borderColor={borderColor}>
|
||||
<Text fontSize="sm" fontWeight="medium" mb={2} color={textColor}>📊 Data Summary</Text>
|
||||
<Grid templateColumns="repeat(auto-fit, minmax(200px, 1fr))" gap={3} fontSize="xs">
|
||||
<Box>
|
||||
<Text fontWeight="medium">Total Points:</Text>
|
||||
<Text>{dataStats.totalPoints.toLocaleString()}</Text>
|
||||
<Text fontWeight="medium" color={textColor}>Total Points:</Text>
|
||||
<Text color={textColor}>{dataStats.totalPoints.toLocaleString()}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fontWeight="medium">Variables Found:</Text>
|
||||
<Text>{dataStats.variablesFound.join(', ')}</Text>
|
||||
<Text fontWeight="medium" color={textColor}>Variables Found:</Text>
|
||||
<Text color={textColor}>{dataStats.variablesFound.join(', ')}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fontWeight="medium">Load Time:</Text>
|
||||
<Text>{dataStats.loadTime}ms</Text>
|
||||
<Text fontWeight="medium" color={textColor}>Load Time:</Text>
|
||||
<Text color={textColor}>{dataStats.loadTime}ms</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fontWeight="medium">Time Range:</Text>
|
||||
<Text>{formatTimeRange(dataStats.timeRange)}</Text>
|
||||
<Text fontWeight="medium" color={textColor}>Time Range:</Text>
|
||||
<Text color={textColor}>{formatTimeRange(dataStats.timeRange)}</Text>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
@ -415,7 +525,7 @@ export default function PlotHistoricalSession({
|
|||
<ChartjsHistoricalPlot
|
||||
session={session}
|
||||
historicalData={historicalData}
|
||||
timeRange={localTimeRange}
|
||||
timeRange={derivedTimeRange}
|
||||
config={config}
|
||||
onZoomToTimeRange={handleZoomToTimeRange}
|
||||
onPanToTimeRange={handlePanToTimeRange}
|
||||
|
@ -428,10 +538,10 @@ export default function PlotHistoricalSession({
|
|||
<Box height="400px" display="flex" alignItems="center" justifyContent="center">
|
||||
<VStack>
|
||||
<Spinner size="lg" color="blue.500" />
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
<Text fontSize="sm" color={subtleTextColor}>
|
||||
Loading historical data...
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.400">
|
||||
<Text fontSize="xs" color={smallTextColor}>
|
||||
{loadingProgress}% complete
|
||||
</Text>
|
||||
</VStack>
|
||||
|
@ -454,34 +564,32 @@ export default function PlotHistoricalSession({
|
|||
<Text fontWeight="medium" mb={3}>📅 Time Range</Text>
|
||||
<Grid templateColumns="1fr 1fr" gap={4}>
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">Start Time</FormLabel>
|
||||
<FormLabel fontSize="sm">Central Time</FormLabel>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={localTimeRange.start instanceof Date && !isNaN(localTimeRange.start.getTime())
|
||||
? localTimeRange.start.toISOString().slice(0, 16)
|
||||
value={centralTime instanceof Date && !isNaN(centralTime.getTime())
|
||||
? centralTime.toISOString().slice(0, 16)
|
||||
: ''
|
||||
}
|
||||
onChange={(e) => setLocalTimeRange(prev => ({
|
||||
...prev,
|
||||
start: new Date(e.target.value)
|
||||
}))}
|
||||
onChange={(e) => setCentralTime(new Date(e.target.value))}
|
||||
size="sm"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">End Time</FormLabel>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={localTimeRange.end instanceof Date && !isNaN(localTimeRange.end.getTime())
|
||||
? localTimeRange.end.toISOString().slice(0, 16)
|
||||
: ''
|
||||
}
|
||||
onChange={(e) => setLocalTimeRange(prev => ({
|
||||
...prev,
|
||||
end: new Date(e.target.value)
|
||||
}))}
|
||||
<FormLabel fontSize="sm">Range (seconds)</FormLabel>
|
||||
<NumberInput
|
||||
value={timeRangeSeconds}
|
||||
onChange={(valueStr) => setTimeRangeSeconds(parseInt(valueStr) || 1000)}
|
||||
min={60}
|
||||
max={86400}
|
||||
size="sm"
|
||||
/>
|
||||
>
|
||||
<NumberInputField />
|
||||
<NumberInputStepper>
|
||||
<NumberIncrementStepper />
|
||||
<NumberDecrementStepper />
|
||||
</NumberInputStepper>
|
||||
</NumberInput>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
|
|
@ -0,0 +1,203 @@
|
|||
import { useMemo, useState, useCallback, useRef, useEffect } from "react";
|
||||
import { Box, Flex, Text, Slider, SliderTrack, SliderFilledTrack, SliderThumb, useColorModeValue } from "@chakra-ui/react";
|
||||
import DatePicker from "react-datepicker";
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
|
||||
export default function TimePointSelector({
|
||||
minDate,
|
||||
maxDate,
|
||||
initial,
|
||||
stepMinutes = 5,
|
||||
onTimeChange,
|
||||
}) {
|
||||
// Color mode values
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.700');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const textColor = useColorModeValue('gray.800', 'gray.200');
|
||||
|
||||
// Valores numéricos en ms para el slider
|
||||
const [minMs, maxMs] = useMemo(() => [minDate.getTime(), maxDate.getTime()], [minDate, maxDate]);
|
||||
const stepMs = useMemo(() => stepMinutes * 60 * 1000, [stepMinutes]);
|
||||
|
||||
// Estado único (Date)
|
||||
const [value, setValue] = useState(() => {
|
||||
// clamp al rango por si initial cae fuera
|
||||
const t = initial.getTime();
|
||||
return new Date(Math.min(Math.max(t, minMs), maxMs));
|
||||
});
|
||||
|
||||
const valueMs = value.getTime();
|
||||
|
||||
// Cooldown para evitar múltiples solicitudes
|
||||
const cooldownRef = useRef(null);
|
||||
const lastCallbackValueRef = useRef(null);
|
||||
|
||||
// Redondea al paso del slider
|
||||
const snapToStep = useCallback((ms) => {
|
||||
const snapped = Math.round(ms / stepMs) * stepMs;
|
||||
return Math.min(Math.max(snapped, minMs), maxMs);
|
||||
}, [stepMs, minMs, maxMs]);
|
||||
|
||||
// Función con cooldown para llamar al callback
|
||||
const debouncedOnTimeChange = useCallback((newValue) => {
|
||||
// Si ya hay un timer, cancelarlo
|
||||
if (cooldownRef.current) {
|
||||
clearTimeout(cooldownRef.current);
|
||||
}
|
||||
|
||||
// Guardar el valor actual para llamar al callback después del cooldown
|
||||
lastCallbackValueRef.current = newValue;
|
||||
|
||||
// Establecer nuevo timer
|
||||
cooldownRef.current = setTimeout(() => {
|
||||
if (onTimeChange && lastCallbackValueRef.current) {
|
||||
console.log('📊 TimeSelector: Calling onChange after cooldown', lastCallbackValueRef.current);
|
||||
onTimeChange(lastCallbackValueRef.current);
|
||||
}
|
||||
cooldownRef.current = null;
|
||||
}, 1000); // 1 segundo de cooldown
|
||||
}, [onTimeChange]);
|
||||
|
||||
// Cleanup del timer al desmontar
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (cooldownRef.current) {
|
||||
clearTimeout(cooldownRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Cambio desde el DatePicker (sin cooldown, cambio directo)
|
||||
const onPick = (d) => {
|
||||
if (!d) return;
|
||||
const newValue = new Date(snapToStep(d.getTime()));
|
||||
setValue(newValue);
|
||||
|
||||
// DatePicker no necesita cooldown, es cambio directo
|
||||
if (onTimeChange) {
|
||||
console.log('📊 TimeSelector: DatePicker change (immediate)', newValue);
|
||||
onTimeChange(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
// Cambio desde el Slider (con cooldown)
|
||||
const onSlide = (ms) => {
|
||||
const newValue = new Date(ms);
|
||||
setValue(newValue);
|
||||
|
||||
// Usar cooldown para el slider
|
||||
debouncedOnTimeChange(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box p={4} bg={bgColor} borderRadius="md" border="1px solid" borderColor={borderColor}>
|
||||
<Flex gap={4} align="center" mb={3} wrap="wrap">
|
||||
<Box>
|
||||
<Text fontWeight="semibold" mb={1} color={textColor}>Seleccionar fecha y hora</Text>
|
||||
<Box
|
||||
sx={{
|
||||
'& .react-datepicker-wrapper': {
|
||||
width: 'auto'
|
||||
},
|
||||
'& .react-datepicker__input-container input': {
|
||||
padding: '8px 12px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid',
|
||||
borderColor: borderColor,
|
||||
backgroundColor: useColorModeValue('white', 'gray.800'),
|
||||
color: textColor,
|
||||
fontSize: '14px',
|
||||
'&:focus': {
|
||||
outline: 'none',
|
||||
borderColor: 'blue.500',
|
||||
boxShadow: '0 0 0 1px blue.500'
|
||||
}
|
||||
},
|
||||
'& .react-datepicker': {
|
||||
backgroundColor: useColorModeValue('white', 'gray.800'),
|
||||
border: '1px solid',
|
||||
borderColor: borderColor,
|
||||
color: textColor
|
||||
},
|
||||
'& .react-datepicker__header': {
|
||||
backgroundColor: useColorModeValue('gray.50', 'gray.700'),
|
||||
borderBottom: '1px solid',
|
||||
borderColor: borderColor
|
||||
},
|
||||
'& .react-datepicker__day': {
|
||||
color: textColor,
|
||||
'&:hover': {
|
||||
backgroundColor: useColorModeValue('blue.50', 'blue.800')
|
||||
}
|
||||
},
|
||||
'& .react-datepicker__day--selected': {
|
||||
backgroundColor: 'blue.500',
|
||||
color: 'white'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DatePicker
|
||||
selected={value}
|
||||
onChange={onPick}
|
||||
showTimeSelect
|
||||
timeFormat="HH:mm"
|
||||
timeIntervals={stepMinutes}
|
||||
timeCaption="Hora"
|
||||
dateFormat="dd-MM-yyyy HH:mm"
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
// Opcional: fuerzas el rango también por hora al navegar
|
||||
minTime={new Date(new Date(value).setHours(0, 0, 0, 0))}
|
||||
maxTime={new Date(new Date(value).setHours(23, 59, 59, 999))}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box flex="1" minW="260px">
|
||||
<Text mb={1} color={textColor}>Navegar con slider</Text>
|
||||
<Slider
|
||||
min={minMs}
|
||||
max={maxMs}
|
||||
step={stepMs}
|
||||
value={valueMs}
|
||||
onChange={onSlide}
|
||||
colorScheme="blue"
|
||||
>
|
||||
<SliderTrack bg={useColorModeValue('gray.200', 'gray.600')}>
|
||||
<SliderFilledTrack bg="blue.500" />
|
||||
</SliderTrack>
|
||||
<SliderThumb bg="blue.500" />
|
||||
</Slider>
|
||||
<Text mt={2} fontSize="sm" color={textColor}>
|
||||
{value.toLocaleString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.500', 'gray.400')}>
|
||||
Rango: {minDate.toLocaleString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})} → {maxDate.toLocaleString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})} | Paso: {stepMinutes} min
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
|
@ -229,6 +229,14 @@ export async function getHistoricalData(requestData) {
|
|||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Get available date range for historical data
|
||||
export async function getHistoricalDateRange() {
|
||||
const res = await fetch(`${BASE_URL}/api/plots/historical/date-range`, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
})
|
||||
return toJsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Plot session status and control (aliases for existing functions)
|
||||
export async function getPlotSession(sessionId) {
|
||||
// Use existing getPlotConfig to get session info
|
||||
|
|
266
main.py
266
main.py
|
@ -8,9 +8,15 @@ from flask import (
|
|||
from flask_cors import CORS
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
|
||||
# Configure logging to show only errors for cleaner historical data logs
|
||||
logging.basicConfig(level=logging.ERROR)
|
||||
# Reduce Flask's request logging to ERROR level only
|
||||
logging.getLogger('werkzeug').setLevel(logging.ERROR)
|
||||
|
||||
try:
|
||||
import tkinter as tk
|
||||
|
@ -1512,7 +1518,7 @@ def health_check():
|
|||
@app.route("/api/plots", methods=["GET"])
|
||||
def get_plots():
|
||||
"""Get all plot sessions status"""
|
||||
print("🔍 DEBUG: /api/plots endpoint called")
|
||||
# print("🔍 DEBUG: /api/plots endpoint called")
|
||||
|
||||
error_response = check_streamer_initialized()
|
||||
if error_response:
|
||||
|
@ -1522,11 +1528,11 @@ def get_plots():
|
|||
print("✅ DEBUG: Streamer is initialized")
|
||||
|
||||
try:
|
||||
print("🔍 DEBUG: Accessing streamer.data_streamer.plot_manager...")
|
||||
# print("🔍 DEBUG: Accessing streamer.data_streamer.plot_manager...")
|
||||
plot_manager = streamer.data_streamer.plot_manager
|
||||
print(f"✅ DEBUG: Plot manager obtained: {type(plot_manager)}")
|
||||
|
||||
print("🔍 DEBUG: Calling get_all_sessions_status()...")
|
||||
# print("🔍 DEBUG: Calling get_all_sessions_status()...")
|
||||
sessions = plot_manager.get_all_sessions_status()
|
||||
print(f"✅ DEBUG: Sessions obtained: {len(sessions)} sessions")
|
||||
print(f"📊 DEBUG: Sessions data: {sessions}")
|
||||
|
@ -1879,11 +1885,10 @@ def get_historical_data():
|
|||
|
||||
# Import required modules
|
||||
try:
|
||||
print("🔍 DEBUG: Importing modules...")
|
||||
# print("🔍 DEBUG: Importing modules...")
|
||||
import pandas as pd
|
||||
import glob
|
||||
from datetime import timedelta
|
||||
print("🔍 DEBUG: All imports successful")
|
||||
# print("🔍 DEBUG: All imports successful")
|
||||
except ImportError as e:
|
||||
print(f"❌ DEBUG: Import failed: {e}")
|
||||
return jsonify({"error": f"pandas import failed: {str(e)}"}), 500
|
||||
|
@ -1898,17 +1903,21 @@ def get_historical_data():
|
|||
|
||||
# Calculate time range
|
||||
try:
|
||||
print("🔍 DEBUG: Calculating time range...")
|
||||
# print("🔍 DEBUG: Calculating time range...")
|
||||
|
||||
if start_time_param and end_time_param:
|
||||
start_time = datetime.fromisoformat(start_time_param.replace('Z', '+00:00'))
|
||||
end_time = datetime.fromisoformat(end_time_param.replace('Z', '+00:00'))
|
||||
# Convert to local timezone if needed
|
||||
if start_time.tzinfo:
|
||||
start_time = start_time.replace(tzinfo=None)
|
||||
if end_time.tzinfo:
|
||||
end_time = end_time.replace(tzinfo=None)
|
||||
print(f"🔍 DEBUG: Using explicit time range: {start_time} to {end_time}")
|
||||
# Parse timestamps from frontend (UTC) and convert to local time
|
||||
|
||||
# Parse as UTC timestamps (frontend sends them with 'Z')
|
||||
start_time_utc = datetime.fromisoformat(start_time_param.replace('Z', '+00:00'))
|
||||
end_time_utc = datetime.fromisoformat(end_time_param.replace('Z', '+00:00'))
|
||||
|
||||
# Convert to local time (remove timezone info since CSV data has no timezone)
|
||||
start_time = start_time_utc.astimezone().replace(tzinfo=None)
|
||||
end_time = end_time_utc.astimezone().replace(tzinfo=None)
|
||||
|
||||
print(f"🔍 DEBUG: UTC timestamps: {start_time_utc} to {end_time_utc}")
|
||||
print(f"🔍 DEBUG: Local timestamps: {start_time} to {end_time}")
|
||||
|
||||
# Validate time range
|
||||
if start_time >= end_time:
|
||||
|
@ -1947,10 +1956,11 @@ def get_historical_data():
|
|||
csv_files.extend(glob.glob(os.path.join(folder_path, "*.csv")))
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
print(f"🔍 DEBUG: Found {len(csv_files)} CSV files for cache checking")
|
||||
# print(f"🔍 DEBUG: Found {len(csv_files)} CSV files for cache checking")
|
||||
|
||||
# Try to get data from cache first
|
||||
cached_data = historical_cache.get_cached_data(variables, start_time, end_time, csv_files)
|
||||
# TEMPORARY: Disable cache to debug data display issues
|
||||
cached_data = None # historical_cache.get_cached_data(variables, start_time, end_time, csv_files)
|
||||
|
||||
if cached_data is not None:
|
||||
print("<EFBFBD> DEBUG: Cache hit! Returning cached data")
|
||||
|
@ -2010,10 +2020,12 @@ def get_historical_data():
|
|||
date_folders.append(folder_path)
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
print(f"🔍 DEBUG: Processing {len(date_folders)} date folders with buffer")
|
||||
# print(f"🔍 DEBUG: Processing {len(date_folders)} date folders with buffer")
|
||||
|
||||
# Process CSV files and collect all data (including buffer)
|
||||
all_data_for_cache = []
|
||||
# Use a dictionary to collect data by timestamp, then fill missing values with NaN
|
||||
timestamp_data = {} # {timestamp: {variable: value}}
|
||||
all_timestamps = set()
|
||||
|
||||
for folder_path in date_folders:
|
||||
csv_files_in_folder = glob.glob(os.path.join(folder_path, "*.csv"))
|
||||
|
@ -2046,13 +2058,13 @@ def get_historical_data():
|
|||
continue
|
||||
|
||||
# Convert timestamps
|
||||
df[timestamp_col] = pd.to_datetime(df[timestamp_col], errors="coerce")
|
||||
df[timestamp_col] = pd.to_datetime(df[timestamp_col], errors='coerce')
|
||||
df = df.dropna(subset=[timestamp_col])
|
||||
|
||||
if df.empty:
|
||||
continue
|
||||
|
||||
# Normalize column name
|
||||
# Rename timestamp column for consistency
|
||||
if timestamp_col != "timestamp":
|
||||
df = df.rename(columns={timestamp_col: "timestamp"})
|
||||
|
||||
|
@ -2069,42 +2081,63 @@ def get_historical_data():
|
|||
if not matching_vars:
|
||||
continue
|
||||
|
||||
# Extract data for cache
|
||||
# print(f"🔍 DEBUG: File {csv_file} - Found {len(matching_vars)} matching variables: {matching_vars}")
|
||||
|
||||
# Extract data for each timestamp
|
||||
for _, row in filtered_df.iterrows():
|
||||
timestamp = row["timestamp"]
|
||||
all_timestamps.add(timestamp)
|
||||
|
||||
if timestamp not in timestamp_data:
|
||||
timestamp_data[timestamp] = {}
|
||||
|
||||
# Store data for available variables, others will be NaN
|
||||
for var in matching_vars:
|
||||
if var in row and pd.notna(row[var]):
|
||||
try:
|
||||
value = row[var]
|
||||
# Type conversion
|
||||
if isinstance(value, str):
|
||||
value_lower = value.lower().strip()
|
||||
if value_lower == "true":
|
||||
value = True
|
||||
elif value_lower == "false":
|
||||
value = False
|
||||
if var in row:
|
||||
raw_value = row[var]
|
||||
if pd.notna(raw_value):
|
||||
try:
|
||||
# Type conversion
|
||||
if isinstance(raw_value, str):
|
||||
value_lower = raw_value.lower().strip()
|
||||
if value_lower == "true":
|
||||
value = True
|
||||
elif value_lower == "false":
|
||||
value = False
|
||||
else:
|
||||
try:
|
||||
value = float(raw_value)
|
||||
except ValueError:
|
||||
value = None
|
||||
elif isinstance(raw_value, (int, float)):
|
||||
value = float(raw_value)
|
||||
else:
|
||||
try:
|
||||
value = float(value)
|
||||
except ValueError:
|
||||
continue
|
||||
elif isinstance(value, (int, float)):
|
||||
value = float(value)
|
||||
else:
|
||||
continue
|
||||
|
||||
all_data_for_cache.append({
|
||||
"timestamp": timestamp,
|
||||
"variable": var,
|
||||
"value": value,
|
||||
})
|
||||
except:
|
||||
continue
|
||||
value = None
|
||||
|
||||
timestamp_data[timestamp][var] = value
|
||||
except:
|
||||
timestamp_data[timestamp][var] = None
|
||||
else:
|
||||
timestamp_data[timestamp][var] = None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not read CSV file {csv_file}: {e}")
|
||||
continue
|
||||
|
||||
# Convert to list format for DataFrame creation
|
||||
all_data_for_cache = []
|
||||
for timestamp in sorted(all_timestamps):
|
||||
for var in variables:
|
||||
# Get value or None if not available
|
||||
value = timestamp_data[timestamp].get(var, None)
|
||||
all_data_for_cache.append({
|
||||
"timestamp": timestamp,
|
||||
"variable": var,
|
||||
"value": value,
|
||||
})
|
||||
|
||||
print(f"🔍 DEBUG: Collected {len(all_data_for_cache)} total data points from {len(all_timestamps)} timestamps for {len(variables)} variables")
|
||||
|
||||
# Convert to DataFrame for caching
|
||||
if all_data_for_cache:
|
||||
cache_df = pd.DataFrame(all_data_for_cache)
|
||||
|
@ -2118,11 +2151,18 @@ def get_historical_data():
|
|||
response_df = cache_df[response_mask]
|
||||
|
||||
# Convert to response format
|
||||
historical_data = []
|
||||
for _, row in response_df.iterrows():
|
||||
# Include all data points, even with None values (converted to null in JSON)
|
||||
value = row["value"]
|
||||
# Convert pandas NaN to None for proper JSON serialization
|
||||
if pd.isna(value):
|
||||
value = None
|
||||
|
||||
historical_data.append({
|
||||
"timestamp": row["timestamp"].isoformat(),
|
||||
"variable": row["variable"],
|
||||
"value": row["value"]
|
||||
"value": value
|
||||
})
|
||||
|
||||
print(f"🔍 DEBUG: Loaded {len(historical_data)} data points for response")
|
||||
|
@ -2188,6 +2228,132 @@ def clear_cache():
|
|||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/plots/historical/date-range", methods=["GET"])
|
||||
def get_historical_date_range():
|
||||
"""Get the available date range from CSV files"""
|
||||
try:
|
||||
import pandas as pd
|
||||
import glob
|
||||
|
||||
# Get records directory
|
||||
records_dir = os.path.join(os.path.dirname(__file__), "records")
|
||||
|
||||
if not os.path.exists(records_dir):
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "No records directory found"
|
||||
}), 404
|
||||
|
||||
# Find all date folders (format: DD-MM-YYYY)
|
||||
date_folders = []
|
||||
for item in os.listdir(records_dir):
|
||||
folder_path = os.path.join(records_dir, item)
|
||||
if os.path.isdir(folder_path):
|
||||
try:
|
||||
# Try to parse the folder name as a date
|
||||
date_obj = datetime.strptime(item, "%d-%m-%Y")
|
||||
date_folders.append((date_obj, folder_path))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if not date_folders:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "No valid date folders found"
|
||||
}), 404
|
||||
|
||||
# Sort by date
|
||||
date_folders.sort(key=lambda x: x[0])
|
||||
|
||||
# Get the earliest and latest dates
|
||||
earliest_date = date_folders[0][0]
|
||||
latest_date = date_folders[-1][0]
|
||||
|
||||
# For more precise range, check actual CSV file timestamps
|
||||
min_timestamp = None
|
||||
max_timestamp = None
|
||||
|
||||
# Check files more thoroughly to get precise timestamp range
|
||||
for date_obj, folder_path in date_folders:
|
||||
csv_files = glob.glob(os.path.join(folder_path, "*.csv"))
|
||||
|
||||
for csv_file in csv_files:
|
||||
try:
|
||||
# Try to read timestamp range from CSV with better sampling
|
||||
# Read first and last few rows to get min/max more accurately
|
||||
df_head = pd.read_csv(csv_file, nrows=100, encoding='utf-8-sig')
|
||||
df_tail = pd.read_csv(csv_file, encoding='utf-8-sig').tail(100)
|
||||
|
||||
# Combine head and tail for better range detection
|
||||
df_sample = pd.concat([df_head, df_tail]).drop_duplicates()
|
||||
|
||||
# Find timestamp column
|
||||
timestamp_col = None
|
||||
for col in df_sample.columns:
|
||||
if 'timestamp' in col.lower():
|
||||
timestamp_col = col
|
||||
break
|
||||
|
||||
if timestamp_col:
|
||||
# Convert timestamp with multiple format attempts
|
||||
df_sample[timestamp_col] = pd.to_datetime(df_sample[timestamp_col],
|
||||
errors='coerce')
|
||||
df_sample = df_sample.dropna(subset=[timestamp_col])
|
||||
|
||||
if not df_sample.empty:
|
||||
file_min = df_sample[timestamp_col].min()
|
||||
file_max = df_sample[timestamp_col].max()
|
||||
|
||||
# Convert to timezone-naive datetime if needed
|
||||
if hasattr(file_min, 'tz_localize') and file_min.tz is not None:
|
||||
file_min = file_min.tz_localize(None)
|
||||
if hasattr(file_max, 'tz_localize') and file_max.tz is not None:
|
||||
file_max = file_max.tz_localize(None)
|
||||
|
||||
if min_timestamp is None or file_min < min_timestamp:
|
||||
min_timestamp = file_min
|
||||
if max_timestamp is None or file_max > max_timestamp:
|
||||
max_timestamp = file_max
|
||||
|
||||
# print(f"🔍 DEBUG: File {csv_file} - Range: {file_min} to {file_max}")
|
||||
|
||||
except Exception as e:
|
||||
# print(f"🔍 DEBUG: Error reading {csv_file}: {e}")
|
||||
continue
|
||||
|
||||
# Use folder dates as fallback if we couldn't read CSV timestamps
|
||||
if min_timestamp is None:
|
||||
min_timestamp = earliest_date
|
||||
print(f"🔍 DEBUG: Using earliest folder date as min: {earliest_date}")
|
||||
if max_timestamp is None:
|
||||
max_timestamp = latest_date + timedelta(days=1)
|
||||
print(f"🔍 DEBUG: Using latest folder date as max: {latest_date}")
|
||||
|
||||
print(f"🔍 DEBUG: Final timestamp range: {min_timestamp} to {max_timestamp}")
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"date_range": {
|
||||
"min_date": min_timestamp.isoformat(),
|
||||
"max_date": max_timestamp.isoformat(),
|
||||
"folders_count": len(date_folders),
|
||||
"earliest_folder": earliest_date.strftime("%d-%m-%Y"),
|
||||
"latest_folder": latest_date.strftime("%d-%m-%Y")
|
||||
}
|
||||
})
|
||||
|
||||
except ImportError:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "pandas is required for date range calculation"
|
||||
}), 500
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"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"""
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
{
|
||||
"last_state": {
|
||||
"should_connect": false,
|
||||
"should_connect": true,
|
||||
"should_stream": false,
|
||||
"active_datasets": []
|
||||
"active_datasets": [
|
||||
"Test",
|
||||
"Fast",
|
||||
"DAR"
|
||||
]
|
||||
},
|
||||
"auto_recovery_enabled": true,
|
||||
"last_update": "2025-08-15T20:58:45.862859",
|
||||
"last_update": "2025-08-16T12:35:20.372281",
|
||||
"plotjuggler_path": "C:\\Program Files\\PlotJuggler\\plotjuggler.exe"
|
||||
}
|
Loading…
Reference in New Issue