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:
parent
e97cd5260b
commit
696b79ba0d
|
@ -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
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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={{
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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(() => {
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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
44
main.py
|
@ -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
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
Loading…
Reference in New Issue