feat: Implement coordinated connection and polling for real-time data updates across tabs

- Introduced `useCoordinatedConnection` and `useCoordinatedPolling` hooks to manage data fetching and state synchronization between tabs.
- Refactored `DatasetCompleteManager`, `PlotRealtimeViewer`, and `VariableSelectorWidget` components to utilize SSE and polling for live data updates.
- Added `TabCoordinationDemo` component to visualize tab coordination status and leadership.
- Updated `Dashboard` to leverage coordinated polling for status updates, improving performance and reducing redundant connections.
- Enhanced `TabCoordinator` utility to manage leadership and data broadcasting between tabs effectively.
This commit is contained in:
Miguel 2025-08-15 18:55:58 +02:00
parent e97cd5260b
commit 696b79ba0d
13 changed files with 1329 additions and 346 deletions

View File

@ -6604,8 +6604,494 @@
"trigger_variable": null,
"auto_started": true
}
},
{
"timestamp": "2025-08-15T17:42:00.705539",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-15T17:42:00.772135",
"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-15T17:42:00.779204",
"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-15T17:42:00.789530",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 2 datasets activated",
"details": {
"activated_datasets": 2,
"total_datasets": 3
}
},
{
"timestamp": "2025-08-15T17:42:31.920722",
"level": "info",
"event_type": "plot_session_created",
"message": "Plot session 'UR29' created and started",
"details": {
"session_id": "plot_1_1755272551920_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-15T17:44:26.728938",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-15T17:44:26.777455",
"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-15T17:44:26.786455",
"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-15T17:44:26.794684",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 2 datasets activated",
"details": {
"activated_datasets": 2,
"total_datasets": 3
}
},
{
"timestamp": "2025-08-15T17:53:24.856979",
"level": "info",
"event_type": "csv_recording_stopped",
"message": "CSV recording stopped (dataset threads continue for UDP streaming)",
"details": {}
},
{
"timestamp": "2025-08-15T17:53:24.872499",
"level": "info",
"event_type": "udp_streaming_stopped",
"message": "UDP streaming to PlotJuggler stopped (CSV recording continues)",
"details": {}
},
{
"timestamp": "2025-08-15T17:53:24.966995",
"level": "info",
"event_type": "dataset_deactivated",
"message": "Dataset deactivated: Fast",
"details": {
"dataset_id": "Fast"
}
},
{
"timestamp": "2025-08-15T17:53:24.977921",
"level": "info",
"event_type": "dataset_deactivated",
"message": "Dataset deactivated: test",
"details": {
"dataset_id": "Test"
}
},
{
"timestamp": "2025-08-15T17:53:25.341401",
"level": "info",
"event_type": "dataset_deactivated",
"message": "Dataset deactivated: DAR",
"details": {
"dataset_id": "DAR"
}
},
{
"timestamp": "2025-08-15T17:53:25.355401",
"level": "info",
"event_type": "plc_disconnection",
"message": "Disconnected from PLC 10.1.33.11 (stopped recording and streaming)",
"details": {}
},
{
"timestamp": "2025-08-15T17:54:30.335124",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-15T17:55:02.184677",
"level": "info",
"event_type": "plot_session_created",
"message": "Plot session 'UR29' created and started",
"details": {
"session_id": "plot_1_1755273302184_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-15T17:55:18.605848",
"level": "info",
"event_type": "plot_session_created",
"message": "Plot session 'UR29' created and started",
"details": {
"session_id": "plot_1_1755273318604_3",
"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-15T17:55:32.567790",
"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-15T17:55:32.670492",
"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-15T17:55:32.766807",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 2 datasets activated",
"details": {
"activated_datasets": 2,
"total_datasets": 3
}
},
{
"timestamp": "2025-08-15T17:55:32.865225",
"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": [
"Fast",
"DAR",
"test"
]
}
},
{
"timestamp": "2025-08-15T17:55:41.765105",
"level": "info",
"event_type": "plot_session_created",
"message": "Plot session 'UR29' created and started",
"details": {
"session_id": "plot_1_1755273341764_4",
"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-15T17:56:09.469159",
"level": "info",
"event_type": "plot_session_created",
"message": "Plot session 'UR29' created and started",
"details": {
"session_id": "plot_1_1755273369469_5",
"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-15T17:58:12.298168",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-15T17:58:12.350158",
"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-15T17:58:12.357167",
"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-15T17:58:12.367234",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 2 datasets activated",
"details": {
"activated_datasets": 2,
"total_datasets": 3
}
},
{
"timestamp": "2025-08-15T17:58:33.564814",
"level": "info",
"event_type": "plot_session_created",
"message": "Plot session 'UR29' created and started",
"details": {
"session_id": "plot_1_1755273513563_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-15T17:58:40.629205",
"level": "info",
"event_type": "plot_session_created",
"message": "Plot session 'UR29' created and started",
"details": {
"session_id": "plot_1_1755273520629_3",
"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-15T18:51:36.078105",
"level": "info",
"event_type": "application_started",
"message": "Application initialization completed successfully",
"details": {}
},
{
"timestamp": "2025-08-15T18:51:36.128341",
"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-15T18:51:36.140887",
"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-15T18:51:36.148889",
"level": "info",
"event_type": "csv_recording_started",
"message": "CSV recording started: 2 datasets activated",
"details": {
"activated_datasets": 2,
"total_datasets": 3
}
},
{
"timestamp": "2025-08-15T18:53:20.148638",
"level": "info",
"event_type": "plot_session_created",
"message": "Plot session 'UR29' created and started",
"details": {
"session_id": "plot_1_1755276800148_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-15T18:53:34.377431",
"level": "info",
"event_type": "plot_session_created",
"message": "Plot session 'UR29' created and started",
"details": {
"session_id": "plot_1_1755276814377_3",
"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-15T18:54:02.935377",
"level": "info",
"event_type": "plot_session_created",
"message": "Plot session 'UR29' created and started",
"details": {
"session_id": "plot_1_1755276842934_4",
"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-15T18:54:06.986356",
"level": "info",
"event_type": "plot_session_created",
"message": "Plot session 'UR29' created and started",
"details": {
"session_id": "plot_1_1755276846986_5",
"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-15T18:54:24.318797",
"level": "info",
"event_type": "plot_session_created",
"message": "Plot session 'Clock' created and started",
"details": {
"session_id": "Clock_1755276864317_6",
"variables": [
"AUX Blink_1.0S",
"AUX Blink_1.6S"
],
"time_window": 10,
"trigger_variable": null,
"auto_started": true
}
}
],
"last_updated": "2025-08-15T16:51:06.118418",
"total_entries": 540
"last_updated": "2025-08-15T18:54:24.318797",
"total_entries": 579
}

View File

@ -6,7 +6,7 @@
"name": "UR29",
"point_hover_radius": 4,
"point_radius": 2.5,
"stacked": true,
"stacked": false,
"stepped": true,
"time_window": 36,
"trigger_enabled": false,

View File

@ -289,33 +289,38 @@ class PlotManager:
# Load existing plots from disk
self.load_plots()
def create_session(self, config: Dict[str, Any], allow_multiple: bool = True) -> str:
def create_session(
self, config: Dict[str, Any], allow_multiple: bool = True
) -> str:
"""Crear una nueva sesión de plotting"""
with self.lock:
base_id = config.get("id") or f"plot_{self.session_counter}"
browser_tab_id = config.get("browser_tab_id", "")
if not allow_multiple:
# Check if there's already an active session for this plot
existing_sessions = [
sid for sid, session in self.sessions.items()
sid
for sid, session in self.sessions.items()
if session.plot_id == base_id and session.is_active
]
if existing_sessions:
# Return the existing active session ID
if self.logger:
self.logger.info(f"Returning existing active session for plot {base_id}: {existing_sessions[0]}")
self.logger.info(
f"Returning existing active session for plot {base_id}: {existing_sessions[0]}"
)
return existing_sessions[0]
# Generate unique session ID to allow multiple browser instances
# Format: {plot_id}_{browser_tab_id}_{timestamp}_{counter} to ensure uniqueness
timestamp = int(time.time() * 1000) # millisecond precision
session_id_parts = [base_id]
if browser_tab_id:
session_id_parts.append(browser_tab_id)
session_id_parts.extend([str(timestamp), str(self.session_counter)])
session_id = "_".join(session_id_parts)
self.session_counter += 1
@ -353,19 +358,23 @@ class PlotManager:
def get_sessions_by_plot_id(self, plot_id: str) -> List[str]:
"""Get all session IDs for a specific plot ID"""
with self.lock:
return [sid for sid, session in self.sessions.items() if session.plot_id == plot_id]
return [
sid
for sid, session in self.sessions.items()
if session.plot_id == plot_id
]
def cleanup_inactive_sessions(self, max_age_seconds: int = 3600) -> int:
"""Remove inactive sessions older than max_age_seconds"""
current_time = time.time()
removed_count = 0
with self.lock:
sessions_to_remove = []
for session_id, session in self.sessions.items():
# Calculate session age from timestamp in session_id
try:
parts = session_id.split('_')
parts = session_id.split("_")
if len(parts) >= 3:
timestamp_ms = int(parts[-2])
session_age = current_time - (timestamp_ms / 1000.0)
@ -374,14 +383,14 @@ class PlotManager:
except (ValueError, IndexError):
# Skip sessions with invalid timestamp format
continue
for session_id in sessions_to_remove:
del self.sessions[session_id]
removed_count += 1
if removed_count > 0 and self.logger:
self.logger.info(f"Cleaned up {removed_count} inactive plot sessions")
return removed_count
def remove_session(self, session_id: str) -> bool:
@ -724,7 +733,7 @@ class PlotManager:
"is_active": session.is_active,
"is_paused": session.is_paused,
}
# If not found, try to find a session by plot_id (backward compatibility)
# This handles cases where frontend queries with plot_id instead of unique session_id
for sid, session in self.sessions.items():
@ -744,7 +753,7 @@ class PlotManager:
"is_active": session.is_active,
"is_paused": session.is_paused,
}
return None
def get_active_sessions_count(self) -> int:

View File

@ -68,7 +68,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
const [isLoadingHistorical, setIsLoadingHistorical] = useState(false);
const [isZoomEnabled, setIsZoomEnabled] = useState(false);
const resolvedConfigRef = useRef(null);
// Chart health monitoring
const chartHealthRef = useRef({
lastDataTimestamp: 0,
@ -81,7 +81,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
console.warn(`🚫 Safe chart ${operation} aborted - invalid DOM state for session ${sessionDataRef.current.sessionId}`);
return false;
}
if (!chart.ctx || !chart.canvas || chart.canvas !== canvasRef.current) {
console.warn(`🚫 Safe chart ${operation} aborted - chart context mismatch for session ${sessionDataRef.current.sessionId}`);
return false;
@ -177,23 +177,23 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
// Chart health monitoring and auto-recovery
const checkChartHealth = useCallback(() => {
if (!chartRef.current) return false;
const health = chartHealthRef.current;
const now = Date.now();
// Check if chart is receiving data
const timeSinceLastData = now - health.lastDataTimestamp;
const hasRecentData = timeSinceLastData < 30000; // 30 seconds
// Check if streaming is properly configured
const realtimeOptions = chartRef.current.options?.scales?.x?.realtime;
const isStreamingConfigured = !!realtimeOptions;
// Check if datasets exist and are properly configured
const hasDatasets = chartRef.current.data?.datasets?.length > 0;
const isHealthy = hasRecentData && isStreamingConfigured && hasDatasets;
if (!isHealthy && health.isHealthy) {
console.warn('⚠️ Chart health check failed:', {
hasRecentData,
@ -203,16 +203,16 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
consecutiveErrors: health.consecutiveErrors
});
}
health.isHealthy = isHealthy;
health.lastHealthCheck = now;
return isHealthy;
}, []);
const attemptAutoRecovery = useCallback(async () => {
console.log('🔄 Attempting chart auto-recovery...');
try {
// Clear all data directly
if (chartRef.current) {
@ -224,10 +224,10 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
chartRef.current.update('quiet');
setDataPointsCount(0);
}
// Wait a moment
await new Promise(resolve => setTimeout(resolve, 100));
// Recreate chart if session is active
if (session?.is_active && !session?.is_paused) {
// Trigger a recreation by clearing refs and letting useEffect handle it
@ -236,18 +236,18 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
// The useEffect will recreate the chart
console.log('✅ Chart auto-recovery completed');
}, 100);
// Reset health counters
chartHealthRef.current.consecutiveErrors = 0;
chartHealthRef.current.isHealthy = true;
return true;
}
} catch (error) {
console.error('❌ Chart auto-recovery failed:', error);
chartHealthRef.current.consecutiveErrors++;
}
return false;
}, [session]);
@ -263,20 +263,20 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
datasets: chartRef.current?.data?.datasets?.length || 0,
realtimeConfig: !!chartRef.current?.options?.scales?.x?.realtime
});
// Check for common issues
const issues = [];
if (!chartRef.current) issues.push('Chart not initialized');
if (!session?.is_active) issues.push('Session not active');
if (session?.is_paused) issues.push('Session is paused');
if (chartHealthRef.current.consecutiveErrors > 5) issues.push(`${chartHealthRef.current.consecutiveErrors} consecutive errors`);
if (issues.length > 0) {
console.warn('⚠️ Issues detected:', issues);
} else {
console.log('✅ No issues detected');
}
return issues;
}, [session]);
@ -342,7 +342,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
if (timestamp instanceof Date) {
return timestamp.getTime();
}
let raw = timestamp;
if (typeof raw === 'string') {
const asNum = Number(raw);
@ -358,12 +358,12 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
}
}
}
if (typeof raw !== 'number' || !Number.isFinite(raw)) {
console.warn('⚠️ Invalid timestamp value:', timestamp);
return Date.now(); // fallback to current time
}
// Normalize seconds to milliseconds
const normalized = raw < 1e12 ? raw * 1000 : raw;
return normalized;
@ -373,10 +373,10 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
// Ensure we have the most up-to-date config
const sessionConfig = session?.config;
const resolvedConfig = resolvedConfigRef.current;
// Prefer session config if it's newer or if resolved config is missing
const cfg = sessionConfig || resolvedConfig;
if (!canvasRef.current || !cfg) return;
// Update resolvedConfigRef to ensure consistency
@ -642,7 +642,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
tension: lineTension,
stepped: stepped,
// Assign Y axis based on configuration and mode
yAxisID: useStackedAxes
yAxisID: useStackedAxes
? `y-axis-${index}` // Stacked mode: each variable gets its own axis
: (variableInfo.y_axis === 'right' ? 'y-right' : 'y-left') // Non-stacked: use left/right configuration
};
@ -688,7 +688,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
// Sort points by timestamp to ensure proper order
historicalPoints.sort((a, b) => a.x - b.x);
datasets[index].data = historicalPoints;
// Update lastPushedX tracking for streaming continuity
const lastPoint = historicalPoints[historicalPoints.length - 1];
if (lastPoint && typeof lastPoint.x === 'number') {
@ -714,7 +714,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
const yMinInitial = (typeof config.y_min === 'number' && isFinite(config.y_min)) ? config.y_min : undefined;
const yMaxInitial = (typeof config.y_max === 'number' && isFinite(config.y_max)) ? config.y_max : undefined;
const chartConfig = {
type: 'line',
data: { datasets },
@ -758,77 +758,77 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
},
...(useStackedAxes && enabledVariables.length > 0
? // When stacked is enabled, create a separate Y axis for each dataset
enabledVariables.reduce((scales, variable, index) => {
// Safe access to variable properties
const variableName = variable.name || variable.label || `Variable ${index}`;
const color = variable.color || getColor(variableName, index);
// Respect the y_axis configuration from variable settings
// If y_axis is specified, use it; otherwise use intelligent distribution
let position;
if (variable.y_axis === 'left' || variable.y_axis === 'right') {
position = variable.y_axis;
} else {
// Fallback to intelligent distribution for variables without y_axis config
const totalAxes = enabledVariables.length;
if (totalAxes <= 2) {
position = index === 0 ? 'left' : 'right';
} else if (totalAxes <= 4) {
position = index % 2 === 0 ? 'left' : 'right';
} else {
const halfPoint = Math.ceil(totalAxes / 2);
position = index < halfPoint ? 'left' : 'right';
}
}
scales[`y-axis-${index}`] = {
type: 'linear',
position: position,
stack: 'stack-group',
stackWeight: 1,
border: {
color: color,
width: 2
},
grid: {
drawOnChartArea: index === 0, // Only show grid for the first axis
color: color + '20' // Semi-transparent grid lines
},
ticks: {
color: color,
font: {
weight: 'bold'
}
},
title: {
display: true,
text: variable.label || variable.name || `Variable ${index}`,
color: color,
font: {
weight: 'bold',
size: 12
}
},
min: yMinInitial,
max: yMaxInitial
};
return scales;
}, {})
: useStackedAxes && enabledVariables.length === 0
? // Stacked is enabled but no variables, add a placeholder axis
{
'y-axis-default': {
type: 'linear',
position: 'left',
title: {
display: true,
text: 'No Variables'
},
min: yMinInitial,
max: yMaxInitial
}
enabledVariables.reduce((scales, variable, index) => {
// Safe access to variable properties
const variableName = variable.name || variable.label || `Variable ${index}`;
const color = variable.color || getColor(variableName, index);
// Respect the y_axis configuration from variable settings
// If y_axis is specified, use it; otherwise use intelligent distribution
let position;
if (variable.y_axis === 'left' || variable.y_axis === 'right') {
position = variable.y_axis;
} else {
// Fallback to intelligent distribution for variables without y_axis config
const totalAxes = enabledVariables.length;
if (totalAxes <= 2) {
position = index === 0 ? 'left' : 'right';
} else if (totalAxes <= 4) {
position = index % 2 === 0 ? 'left' : 'right';
} else {
const halfPoint = Math.ceil(totalAxes / 2);
position = index < halfPoint ? 'left' : 'right';
}
}
: // Non-stacked mode with left/right Y axes
scales[`y-axis-${index}`] = {
type: 'linear',
position: position,
stack: 'stack-group',
stackWeight: 1,
border: {
color: color,
width: 2
},
grid: {
drawOnChartArea: index === 0, // Only show grid for the first axis
color: color + '20' // Semi-transparent grid lines
},
ticks: {
color: color,
font: {
weight: 'bold'
}
},
title: {
display: true,
text: variable.label || variable.name || `Variable ${index}`,
color: color,
font: {
weight: 'bold',
size: 12
}
},
min: yMinInitial,
max: yMaxInitial
};
return scales;
}, {})
: useStackedAxes && enabledVariables.length === 0
? // Stacked is enabled but no variables, add a placeholder axis
{
'y-axis-default': {
type: 'linear',
position: 'left',
title: {
display: true,
text: 'No Variables'
},
min: yMinInitial,
max: yMaxInitial
}
}
: // Non-stacked mode with left/right Y axes
{
'y-left': {
type: 'linear',
@ -875,7 +875,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
enabled: isZoomEnabled,
mode: 'x',
modifierKey: 'shift',
onPanComplete: function(context) {
onPanComplete: function (context) {
// Preserve streaming state after pan
const chart = context.chart;
if (chart.options?.scales?.x?.realtime) {
@ -888,21 +888,21 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
}
},
zoom: {
drag: {
drag: {
enabled: isZoomEnabled,
backgroundColor: 'rgba(128,128,128,0.3)'
},
wheel: {
wheel: {
enabled: isZoomEnabled,
speed: 0.1
},
pinch: { enabled: isZoomEnabled },
mode: 'x',
onZoomComplete: function(context) {
onZoomComplete: function (context) {
// Preserve streaming state and data after zoom
const chart = context.chart;
console.log('🔍 Zoom completed, preserving streaming state...');
if (chart.options?.scales?.x?.realtime) {
const realtime = chart.options.scales.x.realtime;
// Don't auto-pause streaming after zoom
@ -1172,18 +1172,18 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
// Update chart
if (pointsAdded > 0) {
safeChartUpdate(chart, 'quiet', 'data ingestion');
// Update health monitoring
chartHealthRef.current.lastDataTimestamp = Date.now();
chartHealthRef.current.consecutiveErrors = 0;
} else {
// No new data received - increment error counter
chartHealthRef.current.consecutiveErrors++;
// Check if we need auto-recovery after multiple failed attempts
if (chartHealthRef.current.consecutiveErrors > 10) {
console.warn(`⚠️ No data received for ${chartHealthRef.current.consecutiveErrors} consecutive attempts`);
// Attempt auto-recovery if too many consecutive errors
if (chartHealthRef.current.consecutiveErrors > 20) {
console.warn('🚑 Triggering auto-recovery due to consecutive data failures');
@ -1287,7 +1287,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
// Sort points by timestamp to ensure proper order
historicalPoints.sort((a, b) => a.x - b.x);
chart.data.datasets[index].data = historicalPoints;
// Update lastPushedX tracking for streaming continuity
const lastPoint = historicalPoints[historicalPoints.length - 1];
if (lastPoint && typeof lastPoint.x === 'number') {
@ -1348,7 +1348,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
try {
console.log('🔄 Resetting zoom with full data reload...');
// Get current configuration for variable information
const cfg = resolvedConfigRef.current || session?.config;
if (!cfg || !cfg.variables) {
@ -1382,14 +1382,14 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
const enabledVariables = getEnabledVariables(cfg.variables);
if (enabledVariables.length > 0) {
console.log(`📊 Reloading data for ${enabledVariables.length} variables...`);
// Get current time window (use same as initial load)
const timeWindow = cfg.time_window || 3600; // Default 1 hour
const variableNames = enabledVariables.map(v => v.name);
// Load fresh historical data
const historicalData = await loadHistoricalData(variableNames, timeWindow);
if (historicalData.length > 0) {
console.log(`📊 Loaded ${historicalData.length} historical data points for reset`);
@ -1413,7 +1413,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
// Sort points by timestamp to ensure proper order
historicalPoints.sort((a, b) => a.x - b.x);
chartRef.current.data.datasets[index].data = historicalPoints;
// Update lastPushedX tracking for streaming continuity
const lastPoint = historicalPoints[historicalPoints.length - 1];
if (lastPoint && typeof lastPoint.x === 'number') {
@ -1429,7 +1429,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
const totalHistoricalPoints = Object.values(dataByVariable).reduce((sum, points) => sum + points.length, 0);
setDataPointsCount(totalHistoricalPoints);
}
console.log('✅ Historical data reloaded');
}
@ -1454,11 +1454,11 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
const oldConfig = resolvedConfigRef.current;
console.log(`📋 Old config:`, oldConfig);
// Merge new config with current configuration
const mergedConfig = { ...oldConfig, ...newConfig };
console.log(`📋 Merged config:`, mergedConfig);
resolvedConfigRef.current = mergedConfig;
// Always recreate chart to ensure all changes are applied correctly
@ -1574,20 +1574,20 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
// Update zoom configuration when zoom enabled state changes
useEffect(() => {
if (!chartRef.current) return;
const chart = chartRef.current;
const zoomPlugin = chart.options?.plugins?.zoom;
if (zoomPlugin) {
// Update zoom configuration dynamically
zoomPlugin.pan.enabled = isZoomEnabled;
zoomPlugin.zoom.drag.enabled = isZoomEnabled;
zoomPlugin.zoom.wheel.enabled = isZoomEnabled;
zoomPlugin.zoom.pinch.enabled = isZoomEnabled;
// Update the chart to apply the new configuration
safeChartUpdate(chart, 'none', 'zoom configuration');
console.log(`🔍 Zoom/Pan ${isZoomEnabled ? 'enabled' : 'disabled'}`);
}
}, [isZoomEnabled]);
@ -1595,10 +1595,10 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
// Periodic health monitoring
useEffect(() => {
if (!session?.session_id || !chartRef.current) return;
const healthCheckInterval = setInterval(() => {
const isHealthy = checkChartHealth();
// Log health status periodically for debugging
if (!isHealthy) {
console.warn('📊 Chart health check:', {
@ -1610,7 +1610,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
});
}
}, 15000); // Check every 15 seconds
return () => clearInterval(healthCheckInterval);
}, [session?.session_id, checkChartHealth]);
@ -1871,7 +1871,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
>
🔄 Reset Zoom
</button>
{/* Auto-recovery button when chart health is poor */}
{!chartHealthRef.current.isHealthy && (
<>
@ -1893,7 +1893,7 @@ const ChartjsPlot = ({ session, height = '400px' }) => {
>
🚑 Fix Chart
</button>
<button
onClick={runDiagnostics}
style={{

View File

@ -18,6 +18,7 @@ import {
// No necesitamos Form completo, solo FormTable
import FormTable from './FormTable.jsx'
import { getSchema, readConfig, writeConfig, activateDataset, deactivateDataset } from '../services/api.js'
import { useCoordinatedSSE } from '../hooks/useCoordinatedConnection'
/**
* DatasetCompleteManager - Gestiona datasets y variables de forma simplificada
@ -39,53 +40,36 @@ export default function DatasetCompleteManager({ status }) {
const muted = useColorModeValue('gray.600', 'gray.300')
const [liveValues, setLiveValues] = useState({})
const sseRef = React.useRef(null)
// Usar SSE coordinado para valores en vivo del dataset seleccionado
const plcConnected = !!status?.plc_connected
const sseUrl = selectedDatasetId && plcConnected ?
`/api/stream/variables?dataset_id=${encodeURIComponent(selectedDatasetId)}&interval=1.0` :
null
const { data: liveData } = useCoordinatedSSE(
`dataset_variables_${selectedDatasetId}`,
sseUrl,
[selectedDatasetId, plcConnected]
)
// Procesar datos SSE recibidos
useEffect(() => {
loadData()
}, [])
// Subscribe to SSE for live variable values of the selected dataset
useEffect(() => {
// Close previous stream
if (sseRef.current) {
try { sseRef.current.close() } catch { /* ignore */ }
sseRef.current = null
}
// Only stream when online and dataset selected
const plcConnected = !!status?.plc_connected
if (!plcConnected || !selectedDatasetId) {
if (!liveData) {
setLiveValues({})
return
}
try {
const es = new EventSource(`/api/stream/variables?dataset_id=${encodeURIComponent(selectedDatasetId)}&interval=1.0`)
sseRef.current = es
es.onmessage = (evt) => {
try {
const payload = JSON.parse(evt.data)
if (payload?.type === 'values' && payload.values) {
setLiveValues(payload.values || {})
} else if (payload?.type === 'no_cache' || payload?.type === 'dataset_inactive' || payload?.type === 'plc_disconnected') {
setLiveValues({})
}
} catch { /* ignore */ }
}
es.onerror = () => {
try { es.close() } catch { /* ignore */ }
sseRef.current = null
}
} catch { /* ignore */ }
return () => {
if (sseRef.current) {
try { sseRef.current.close() } catch { /* ignore */ }
sseRef.current = null
}
if (liveData?.type === 'values' && liveData.values) {
setLiveValues(liveData.values || {})
} else if (liveData?.type === 'no_cache' || liveData?.type === 'dataset_inactive' || liveData?.type === 'plc_disconnected') {
setLiveValues({})
}
}, [status?.plc_connected, selectedDatasetId])
}, [liveData])
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)

View File

@ -24,50 +24,51 @@ import {
} from '@chakra-ui/react'
import { EditIcon, SettingsIcon, DeleteIcon, ViewIcon } from '@chakra-ui/icons'
import ChartjsPlot from './ChartjsPlot.jsx'
import { useCoordinatedPolling } from '../hooks/useCoordinatedConnection'
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)
// Usar polling coordinado para sesiones
const { data: sessionsData, isLeader, isConnected } = useCoordinatedPolling(
'plot_sessions',
async () => {
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 })
}
return res.json()
},
5000 // 5 segundos
)
// Procesar datos de sesiones cuando llegan
useEffect(() => {
if (sessionsData && sessionsData.sessions) {
setSessions(prev => {
const next = new Map(prev)
const incomingIds = new Set()
for (const s of sessionsData.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 {
}
// 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())
} finally {
setLoading(false)
}
}
}, [sessionsData])
const refreshSession = async (sessionId) => {
try {
@ -111,15 +112,9 @@ export default function PlotRealtimeViewer() {
} 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) {
if (!isConnected && sessionsList.length === 0) {
return <Text color={muted}>Cargando sesiones de plots</Text>
}

View File

@ -0,0 +1,62 @@
import React from 'react'
import { Box, Text, Badge, VStack, HStack, useColorModeValue } from '@chakra-ui/react'
import { getTabCoordinator } from '../utils/TabCoordinator'
/**
* TabCoordinationDemo - Componente de demostración para mostrar el estado de coordinación
*/
export default function TabCoordinationDemo() {
const [coordinator, setCoordinator] = React.useState(null)
const [isLeader, setIsLeader] = React.useState(false)
const [tabInfo, setTabInfo] = React.useState({})
const bgColor = useColorModeValue('gray.50', 'gray.800')
React.useEffect(() => {
const coord = getTabCoordinator()
setCoordinator(coord)
setIsLeader(coord.getIsLeader())
setTabInfo({
tabId: coord.tabId,
isLeader: coord.getIsLeader()
})
// Subscribirse a cambios de liderazgo
const unsubscribe = coord.subscribe('demo', ({ type, data }) => {
if (type === 'leadership_change') {
setIsLeader(data.isLeader)
setTabInfo(prev => ({
...prev,
isLeader: data.isLeader
}))
}
})
return unsubscribe
}, [])
if (!coordinator) {
return <Text>Loading coordinator...</Text>
}
return (
<Box p={3} bg={bgColor} borderRadius="md" border="1px" borderColor="gray.200">
<VStack align="start" spacing={2}>
<HStack>
<Text fontWeight="bold">🔗 Tab Coordination Status</Text>
<Badge colorScheme={isLeader ? 'green' : 'blue'}>
{isLeader ? '👑 Leader' : '👥 Follower'}
</Badge>
</HStack>
<Text fontSize="sm" color="gray.600">
Tab ID: <code>{tabInfo.tabId}</code>
</Text>
<Text fontSize="sm" color="gray.600">
Role: {isLeader ? 'Making real connections to backend' : 'Receiving data from leader tab'}
</Text>
<Text fontSize="xs" color="gray.500">
Only the leader tab creates actual HTTP connections. Other tabs receive data via BroadcastChannel.
</Text>
</VStack>
</Box>
)
}

View File

@ -6,6 +6,7 @@ import {
import { SearchIcon, RepeatIcon } from '@chakra-ui/icons'
import { readConfig, readExpandedDatasetVariables } from '../../services/api.js'
import { useVariableContext } from '../../contexts/VariableContext.jsx'
import { useCoordinatedSSE } from '../../hooks/useCoordinatedConnection'
// Widget for selecting existing dataset variables with filtering and search
export function VariableSelectorWidget(props) {
@ -30,7 +31,6 @@ export function VariableSelectorWidget(props) {
const [liveValue, setLiveValue] = useState(undefined)
const [liveStatus, setLiveStatus] = useState('idle')
const [refreshing, setRefreshing] = useState(false)
const esRef = useRef(null)
const borderColor = useColorModeValue('gray.300', 'gray.600')
const focusBorderColor = useColorModeValue('blue.500', 'blue.300')
@ -117,57 +117,41 @@ export function VariableSelectorWidget(props) {
return variables
}, [datasetVariables])
// Subscribe to SSE for live value of selected variable
useEffect(() => {
// close previous stream
if (esRef.current) {
try { esRef.current.close() } catch { /* ignore */ }
esRef.current = null
}
// Determinar dataset y variable para SSE coordinado
const variable = value && allVariables.find(v => v.name === value)
const datasetId = variable?.dataset
// Usar SSE coordinado para valor en vivo de la variable seleccionada
const sseUrl = datasetId && value ?
`/api/stream/variables?dataset_id=${encodeURIComponent(datasetId)}&interval=1.0` :
null
const variable = value && allVariables.find(v => v.name === value)
const datasetId = variable?.dataset
if (!datasetId || !value) {
const { data: liveData } = useCoordinatedSSE(
`variable_live_${datasetId}_${value}`,
sseUrl,
[datasetId, value]
)
// Procesar datos SSE recibidos
useEffect(() => {
if (!liveData) {
setLiveValue(undefined)
setLiveStatus('idle')
return
}
try {
const es = new EventSource(`/api/stream/variables?dataset_id=${encodeURIComponent(datasetId)}&interval=1.0`)
esRef.current = es
if (liveData?.type === 'values' && liveData.values) {
setLiveValue(liveData.values[value])
setLiveStatus('ok')
} else if (liveData?.type === 'no_cache') {
setLiveStatus('waiting')
} else if (liveData?.type === 'plc_disconnected' || liveData?.type === 'dataset_inactive') {
setLiveValue(undefined)
setLiveStatus('offline')
} else if (liveData?.type === 'connected') {
setLiveStatus('connecting')
es.onmessage = (evt) => {
try {
const payload = JSON.parse(evt.data)
if (payload?.type === 'values' && payload.values) {
setLiveValue(payload.values[value])
setLiveStatus('ok')
} else if (payload?.type === 'no_cache') {
setLiveStatus('waiting')
} else if (payload?.type === 'plc_disconnected' || payload?.type === 'dataset_inactive') {
setLiveValue(undefined)
setLiveStatus('offline')
}
} catch { /* ignore */ }
}
es.onerror = () => {
try { es.close() } catch { /* ignore */ }
esRef.current = null
setLiveStatus('error')
}
} catch {
setLiveStatus('error')
}
return () => {
if (esRef.current) {
try { esRef.current.close() } catch { /* ignore */ }
esRef.current = null
}
}
}, [value, allVariables])
}, [liveData, value])
// Filter variables based on search term and selected dataset
const filteredVariables = useMemo(() => {

View File

@ -0,0 +1,171 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import { getTabCoordinator } from '../utils/TabCoordinator'
/**
* Hook para manejar conexiones coordinadas entre pestañas
* Solo la pestaña líder hace las conexiones reales, las demás reciben los datos
*/
export function useCoordinatedConnection(source, connectionFactory, dependencies = []) {
const [data, setData] = useState(null)
const [isLeader, setIsLeader] = useState(false)
const [isConnected, setIsConnected] = useState(false)
const connectionRef = useRef(null)
const coordinatorRef = useRef(null)
const subscriptionRef = useRef(null)
// Obtener el coordinador
useEffect(() => {
coordinatorRef.current = getTabCoordinator()
setIsLeader(coordinatorRef.current.getIsLeader())
// Subscribirse a cambios de liderazgo
const unsubscribeLeadership = coordinatorRef.current.subscribe('leadership', ({ type, data: leadershipData }) => {
if (type === 'leadership_change') {
setIsLeader(leadershipData.isLeader)
}
})
return unsubscribeLeadership
}, [])
// Crear/recrear conexión cuando cambia el liderazgo o dependencias
useEffect(() => {
if (!coordinatorRef.current) return
// Limpiar conexión anterior
if (connectionRef.current && typeof connectionRef.current.close === 'function') {
connectionRef.current.close()
}
if (subscriptionRef.current) {
subscriptionRef.current()
}
// Subscribirse a datos de este source
subscriptionRef.current = coordinatorRef.current.subscribe(source, ({ type, data: receivedData }) => {
if (type === 'data_received') {
setData(receivedData.data)
setIsConnected(true)
}
})
// Crear conexión coordinada
const coordinator = coordinatorRef.current
const currentIsLeader = coordinator.getIsLeader()
if (currentIsLeader) {
// Somos líder, crear conexión real
try {
connectionRef.current = connectionFactory((newData) => {
// Actualizar estado local
setData(newData)
setIsConnected(true)
// Broadcast a otras pestañas
coordinator.broadcastData(source, newData)
})
setIsConnected(true)
console.log(`👑 Leader connection created for ${source}`)
} catch (error) {
console.error(`Error creating leader connection for ${source}:`, error)
setIsConnected(false)
}
} else {
// Somos seguidores, solo escuchar broadcasts
connectionRef.current = null
console.log(`👥 Follower listening for ${source}`)
// El estado de conectado se pondrá en true cuando recibamos el primer dato
}
return () => {
if (connectionRef.current && typeof connectionRef.current.close === 'function') {
connectionRef.current.close()
}
if (subscriptionRef.current) {
subscriptionRef.current()
}
}
}, [source, isLeader, ...dependencies])
// Cleanup final
useEffect(() => {
return () => {
if (connectionRef.current && typeof connectionRef.current.close === 'function') {
connectionRef.current.close()
}
if (subscriptionRef.current) {
subscriptionRef.current()
}
}
}, [])
return { data, isLeader, isConnected }
}
/**
* Hook simplificado para polling coordinado
*/
export function useCoordinatedPolling(source, fetchFunction, interval = 5000, dependencies = []) {
return useCoordinatedConnection(
source,
useCallback((onData) => {
let intervalId = null
let isActive = true
const poll = async () => {
if (!isActive) return
try {
const data = await fetchFunction()
if (isActive) {
onData(data)
}
} catch (error) {
console.error(`Polling error for ${source}:`, error)
}
}
// Poll inicial
poll()
// Configurar intervalo
intervalId = setInterval(poll, interval)
return {
close: () => {
isActive = false
if (intervalId) {
clearInterval(intervalId)
}
}
}
}, [fetchFunction, interval]),
dependencies
)
}
/**
* Hook para SSE coordinado
*/
export function useCoordinatedSSE(source, url, dependencies = []) {
return useCoordinatedConnection(
source,
useCallback((onData) => {
console.log(`Creating SSE connection to ${url}`)
const eventSource = new EventSource(url)
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
onData(data)
} catch (error) {
console.error('SSE data parse error:', error)
}
}
eventSource.onerror = (error) => {
console.error('SSE error:', error)
}
return eventSource
}, [url]),
dependencies
)
}

View File

@ -53,6 +53,8 @@ import allWidgets from '../components/widgets/AllWidgets'
import LayoutObjectFieldTemplate from '../components/rjsf/LayoutObjectFieldTemplate'
import { VariableProvider, useVariableContext } from '../contexts/VariableContext'
import * as api from '../services/api'
import { useCoordinatedPolling } from '../hooks/useCoordinatedConnection'
import TabCoordinationDemo from '../components/TabCoordinationDemo'
// Collapsible Array Items Form - Each item in the array is individually collapsible
function CollapsibleArrayItemsForm({ data, schema, uiSchema, onSave, title, icon, getItemLabel }) {
@ -308,7 +310,7 @@ function CollapsibleArrayForm({ data, schema, uiSchema, onSave, title, icon, get
}
// StatusBar Component - Real-time PLC status with action buttons
function StatusBar({ status, onRefresh }) {
function StatusBar({ status, isConnected, isLeader }) {
const plcConnected = !!status?.plc_connected
const streaming = !!status?.streaming
const csvRecording = !!status?.csv_recording
@ -329,7 +331,7 @@ function StatusBar({ status, onRefresh }) {
status: 'info',
duration: 2000
})
setTimeout(() => onRefresh?.(), 1000)
// El polling coordinado actualizará automáticamente el estado
} catch (error) {
toast({
title: '❌ Failed to connect PLC',
@ -352,7 +354,7 @@ function StatusBar({ status, onRefresh }) {
status: 'info',
duration: 2000
})
setTimeout(() => onRefresh?.(), 1000)
// El polling coordinado actualizará automáticamente el estado
} catch (error) {
toast({
title: '❌ Failed to disconnect PLC',
@ -375,7 +377,7 @@ function StatusBar({ status, onRefresh }) {
status: 'success',
duration: 2000
})
setTimeout(() => onRefresh?.(), 1000)
// El polling coordinado actualizará automáticamente el estado
} catch (error) {
toast({
title: '❌ Failed to start streaming',
@ -398,7 +400,7 @@ function StatusBar({ status, onRefresh }) {
status: 'info',
duration: 2000
})
setTimeout(() => onRefresh?.(), 1000)
// El polling coordinado actualizará automáticamente el estado
} catch (error) {
toast({
title: '❌ Failed to stop streaming',
@ -412,7 +414,7 @@ function StatusBar({ status, onRefresh }) {
}
return (
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={4} mb={6}>
<SimpleGrid columns={{ base: 1, md: 2, lg: 4 }} spacing={4} mb={6}>
<Card>
<CardBody>
<Stat>
@ -506,6 +508,21 @@ function StatusBar({ status, onRefresh }) {
</Stat>
</CardBody>
</Card>
<Card>
<CardBody>
<Stat>
<StatLabel>🔗 Tab Coordination</StatLabel>
<StatNumber fontSize="lg" color={isConnected ? 'green.500' : 'orange.500'}>
{isLeader ? '👑 Leader' : '👥 Follower'}
</StatNumber>
<StatHelpText>
{isConnected ? '✅ Connected' : '⏳ Connecting...'}<br/>
{isLeader ? 'Making connections' : 'Receiving data'}
</StatHelpText>
</Stat>
</CardBody>
</Card>
</SimpleGrid>
)
}
@ -1021,10 +1038,7 @@ export default function Dashboard() {
// Dashboard Content Component (separated to use context)
function DashboardContent() {
const [status, setStatus] = useState(null)
const [statusLoading, setStatusLoading] = useState(true)
const [statusError, setStatusError] = useState('')
// Estado para configuración PLC
const [schemaData, setSchemaData] = useState(null)
const [formData, setFormData] = useState(null)
const [saving, setSaving] = useState(false)
@ -1033,37 +1047,12 @@ function DashboardContent() {
const [events, setEvents] = useState([])
const [eventsLoading, setEventsLoading] = useState(false)
// Load status once
const loadStatus = useCallback(async () => {
try {
setStatusLoading(true)
setStatusError('')
const statusData = await api.getStatus()
setStatus(statusData)
} catch (error) {
setStatusError(error.message)
} finally {
setStatusLoading(false)
}
}, [])
// Real-time status updates via polling
const subscribeSSE = useCallback(() => {
// Use polling for real-time updates (every 5 seconds)
const interval = setInterval(async () => {
try {
const statusData = await api.getStatus()
setStatus(statusData)
setStatusError('')
} catch (error) {
console.error('Status polling error:', error)
}
}, 5000)
return () => {
clearInterval(interval)
}
}, [])
// Usar polling coordinado para el status
const { data: status, isLeader, isConnected } = useCoordinatedPolling(
'dashboard_status',
api.getStatus,
5000 // 5 segundos
)
// Load PLC config
const loadConfig = useCallback(async () => {
@ -1110,15 +1099,11 @@ function DashboardContent() {
// Effects
useEffect(() => {
loadStatus()
loadConfig()
loadEvents()
const cleanup = subscribeSSE()
return cleanup
}, [loadStatus, loadConfig, loadEvents, subscribeSSE])
}, [loadConfig, loadEvents])
if (statusLoading) {
if (!isConnected && !status) {
return (
<Container maxW="container.xl" py={6}>
<Flex align="center" justify="center" minH="200px">
@ -1138,19 +1123,18 @@ function DashboardContent() {
🏭 PLC S7-31x Streamer & Logger
</Heading>
<Spacer />
<Button size="sm" variant="outline" onClick={loadStatus}>
🔄 Refresh Status
</Button>
{isLeader && (
<Badge colorScheme="green" mr={2}>👑 Leader Tab</Badge>
)}
{isConnected && (
<Badge colorScheme="blue" mr={2}>🔗 Connected</Badge>
)}
</Flex>
{statusError && (
<Alert status="error">
<AlertIcon />
Failed to load status: {statusError}
</Alert>
)}
{/* Tab Coordination Demo */}
<TabCoordinationDemo />
<StatusBar status={status} onRefresh={loadStatus} />
<StatusBar status={status} isConnected={isConnected} isLeader={isLeader} />
<Tabs variant="enclosed" colorScheme="blue">
<TabList>

View File

@ -0,0 +1,298 @@
/**
* TabCoordinator - Coordina múltiples pestañas para evitar sobrecarga de conexiones
*
* Problema: Los navegadores limitan a ~6 conexiones HTTP concurrentes por dominio.
* Cada pestaña crea múltiples conexiones (SSE + polling), causando bloqueos.
*
* Solución: Solo la pestaña "líder" hace las conexiones reales, las demás reciben
* los datos via BroadcastChannel.
*/
class TabCoordinator {
constructor() {
this.tabId = `tab_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
this.isLeader = false
this.channel = new BroadcastChannel('plc_streamer_coordination')
this.subscribers = new Map()
this.leaderHeartbeatInterval = null
this.leaderCheckInterval = null
// Configuración
this.HEARTBEAT_INTERVAL = 3000 // 3 segundos
this.LEADER_TIMEOUT = 6000 // 6 segundos sin heartbeat = cambio de líder
console.log(`🔗 TabCoordinator initialized for tab ${this.tabId}`)
this.setupEventListeners()
this.electLeader()
}
setupEventListeners() {
// Escuchar mensajes de otras pestañas
this.channel.addEventListener('message', (event) => {
const { type, data, fromTab } = event.data
if (fromTab === this.tabId) return // Ignorar nuestros propios mensajes
switch (type) {
case 'leader_heartbeat':
this.handleLeaderHeartbeat(data, fromTab)
break
case 'leader_election':
this.handleLeaderElection(fromTab)
break
case 'data_broadcast':
this.handleDataBroadcast(data)
break
case 'leader_resignation':
this.handleLeaderResignation(fromTab)
break
}
})
// Cleanup al cerrar pestaña
window.addEventListener('beforeunload', () => {
this.resignLeadership()
})
// Cleanup al ocultar pestaña (cambio de tab)
document.addEventListener('visibilitychange', () => {
if (document.hidden && this.isLeader) {
// Tab oculta, considerar resignar liderazgo tras un delay
setTimeout(() => {
if (document.hidden && this.isLeader) {
this.resignLeadership()
}
}, 5000)
}
})
}
electLeader() {
// Verificar si ya hay un líder activo
const lastHeartbeat = localStorage.getItem('plc_leader_heartbeat')
const lastTab = localStorage.getItem('plc_leader_tab')
if (lastHeartbeat && lastTab) {
const timeSinceHeartbeat = Date.now() - parseInt(lastHeartbeat)
if (timeSinceHeartbeat < this.LEADER_TIMEOUT) {
// Hay un líder activo, somos seguidores
this.becomeFollower()
return
}
}
// No hay líder o expiró, intentar ser líder
this.becomeLeader()
}
becomeLeader() {
if (this.isLeader) return
console.log(`👑 Tab ${this.tabId} becoming leader`)
this.isLeader = true
// Anunciar liderazgo
this.broadcast('leader_election', { timestamp: Date.now() })
// Iniciar heartbeat
this.startHeartbeat()
// Notificar a subscribers que somos el líder
this.notifySubscribers('leadership_change', { isLeader: true })
}
becomeFollower() {
if (!this.isLeader) return
console.log(`👥 Tab ${this.tabId} becoming follower`)
this.isLeader = false
// Detener heartbeat
this.stopHeartbeat()
// Notificar a subscribers que ya no somos líder
this.notifySubscribers('leadership_change', { isLeader: false })
}
resignLeadership() {
if (!this.isLeader) return
console.log(`📤 Tab ${this.tabId} resigning leadership`)
// Anunciar resignación
this.broadcast('leader_resignation', { timestamp: Date.now() })
// Limpiar localStorage
localStorage.removeItem('plc_leader_heartbeat')
localStorage.removeItem('plc_leader_tab')
this.becomeFollower()
}
startHeartbeat() {
this.stopHeartbeat() // Cleanup previo
this.leaderHeartbeatInterval = setInterval(() => {
const timestamp = Date.now()
// Guardar en localStorage para verificación de otras pestañas
localStorage.setItem('plc_leader_heartbeat', timestamp.toString())
localStorage.setItem('plc_leader_tab', this.tabId)
// Broadcast a otras pestañas
this.broadcast('leader_heartbeat', { timestamp })
}, this.HEARTBEAT_INTERVAL)
// Inmediatamente hacer el primer heartbeat
const timestamp = Date.now()
localStorage.setItem('plc_leader_heartbeat', timestamp.toString())
localStorage.setItem('plc_leader_tab', this.tabId)
}
stopHeartbeat() {
if (this.leaderHeartbeatInterval) {
clearInterval(this.leaderHeartbeatInterval)
this.leaderHeartbeatInterval = null
}
}
handleLeaderHeartbeat(data, fromTab) {
if (this.isLeader && fromTab !== this.tabId) {
// Otro tab dice ser líder, verificar timestamps
const ourTimestamp = parseInt(localStorage.getItem('plc_leader_heartbeat') || '0')
const theirTimestamp = data.timestamp
if (theirTimestamp > ourTimestamp + 1000) { // 1 segundo de tolerancia
// Su heartbeat es más reciente, renunciar
console.log(`👑 Tab ${this.tabId} yielding leadership to ${fromTab}`)
this.resignLeadership()
}
}
}
handleLeaderElection(fromTab) {
if (this.isLeader) {
// Conflicto de liderazgo, resolver por timestamp
const ourTimestamp = parseInt(localStorage.getItem('plc_leader_heartbeat') || '0')
const currentTime = Date.now()
// Si nuestro heartbeat es muy viejo, renunciar
if (currentTime - ourTimestamp > this.HEARTBEAT_INTERVAL * 2) {
this.resignLeadership()
}
}
}
handleLeaderResignation(fromTab) {
// Si el líder renuncia, intentar tomar el liderazgo tras un delay
setTimeout(() => {
this.electLeader()
}, 1000 + Math.random() * 1000) // 1-2 segundos aleatorio para evitar conflictos
}
handleDataBroadcast(data) {
// Retransmitir datos a subscribers locales
const { source, payload } = data
this.notifySubscribers('data_received', { source, data: payload })
}
broadcast(type, data) {
this.channel.postMessage({
type,
data,
fromTab: this.tabId,
timestamp: Date.now()
})
}
// API para que los componentes se subscriban a datos
subscribe(source, callback) {
if (!this.subscribers.has(source)) {
this.subscribers.set(source, new Set())
}
this.subscribers.get(source).add(callback)
// Retornar función de cleanup
return () => {
const subs = this.subscribers.get(source)
if (subs) {
subs.delete(callback)
if (subs.size === 0) {
this.subscribers.delete(source)
}
}
}
}
notifySubscribers(type, data) {
// Notificar a todos los subscribers
for (const [source, callbacks] of this.subscribers.entries()) {
for (const callback of callbacks) {
try {
callback({ type, source, data })
} catch (error) {
console.error(`Error in subscriber callback for ${source}:`, error)
}
}
}
}
// API para que el líder envíe datos a otras pestañas
broadcastData(source, data) {
if (!this.isLeader) return
this.broadcast('data_broadcast', {
source,
payload: data
})
}
// API para verificar si somos el líder
getIsLeader() {
return this.isLeader
}
// API para hacer una conexión "coordinada" - solo el líder la hace realmente
createCoordinatedConnection(source, connectionFactory) {
if (this.isLeader) {
// Somos líder, crear conexión real
console.log(`🔗 Tab ${this.tabId} (leader) creating real connection for ${source}`)
return connectionFactory((data) => {
// Cuando recibimos datos, enviarlos a otras pestañas
this.broadcastData(source, data)
// Y también procesarlos localmente
this.notifySubscribers('data_received', { source, data })
})
} else {
// Somos seguidores, retornar conexión "fake" que escucha broadcasts
console.log(`👥 Tab ${this.tabId} (follower) creating coordinated connection for ${source}`)
return {
close: () => {}, // No-op
// Las pestañas seguidoras reciben datos via broadcasts
}
}
}
destroy() {
this.resignLeadership()
this.stopHeartbeat()
this.channel.close()
if (this.leaderCheckInterval) {
clearInterval(this.leaderCheckInterval)
}
}
}
// Singleton global
let globalCoordinator = null
export function getTabCoordinator() {
if (!globalCoordinator) {
globalCoordinator = new TabCoordinator()
}
return globalCoordinator
}
export default TabCoordinator

44
main.py
View File

@ -1607,8 +1607,10 @@ def create_plot():
# Check if multiple sessions should be allowed (default: True for backward compatibility)
allow_multiple = data.get("allow_multiple", True)
session_id = streamer.data_streamer.plot_manager.create_session(config, allow_multiple)
session_id = streamer.data_streamer.plot_manager.create_session(
config, allow_multiple
)
return jsonify(
{
@ -2163,13 +2165,17 @@ def get_plot_sessions(plot_id):
return error_response
try:
session_ids = streamer.data_streamer.plot_manager.get_sessions_by_plot_id(plot_id)
return jsonify({
"success": True,
"plot_id": plot_id,
"session_ids": session_ids,
"session_count": len(session_ids)
})
session_ids = streamer.data_streamer.plot_manager.get_sessions_by_plot_id(
plot_id
)
return jsonify(
{
"success": True,
"plot_id": plot_id,
"session_ids": session_ids,
"session_count": len(session_ids),
}
)
except Exception as e:
return jsonify({"error": str(e)}), 500
@ -2184,14 +2190,18 @@ def cleanup_plot_sessions():
try:
data = request.get_json() or {}
max_age_seconds = data.get("max_age_seconds", 3600) # Default 1 hour
removed_count = streamer.data_streamer.plot_manager.cleanup_inactive_sessions(max_age_seconds)
return jsonify({
"success": True,
"removed_sessions": removed_count,
"message": f"Cleaned up {removed_count} inactive plot sessions"
})
removed_count = streamer.data_streamer.plot_manager.cleanup_inactive_sessions(
max_age_seconds
)
return jsonify(
{
"success": True,
"removed_sessions": removed_count,
"message": f"Cleaned up {removed_count} inactive plot sessions",
}
)
except Exception as e:
return jsonify({"error": str(e)}), 500

View File

@ -4,10 +4,10 @@
"should_stream": false,
"active_datasets": [
"DAR",
"Fast",
"Test"
"Test",
"Fast"
]
},
"auto_recovery_enabled": true,
"last_update": "2025-08-15T16:50:35.341984"
"last_update": "2025-08-15T18:51:36.158179"
}