""" Dockable panels for StreamLens GUI """ import os from typing import Optional, List, TYPE_CHECKING, Dict from PySide6.QtWidgets import ( QDockWidget, QWidget, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem, QLabel, QHeaderView, QSplitter, QTextEdit ) from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QFont, QColor try: # Matplotlib integration - lazy loaded matplotlib = None FigureCanvas = None NavigationToolbar = None Figure = None plt = None def _ensure_matplotlib_gui_loaded(): """Lazy load matplotlib for GUI mode""" global matplotlib, FigureCanvas, NavigationToolbar, Figure, plt if matplotlib is not None: return True try: import matplotlib as mpl matplotlib = mpl matplotlib.use('Qt5Agg') # Use Qt backend for matplotlib from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FC from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NT from matplotlib.figure import Figure as Fig import matplotlib.pyplot as pyplot # Turn off interactive mode to prevent floating windows pyplot.ioff() # Ensure no figure windows are created mpl.rcParams['figure.max_open_warning'] = 0 mpl.rcParams['backend'] = 'Qt5Agg' # Prevent any automatic figure display mpl.pyplot.show = lambda *args, **kwargs: None pyplot.show = lambda *args, **kwargs: None # Also prevent figure.show() original_figure_show = Fig.show Fig.show = lambda self, *args, **kwargs: None FigureCanvas = FC NavigationToolbar = NT Figure = Fig plt = pyplot return True except ImportError as e: print(f"Matplotlib GUI integration not available: {e}") return False except ImportError as e: print(f"GUI dependencies not available: {e}") if TYPE_CHECKING: from ..analysis.core import EthernetAnalyzer from ..models.flow_stats import FlowStats class FlowListDockWidget(QWidget): """Widget containing the flow list and file information with integrated plots""" def __init__(self, parent=None): super().__init__(parent) self.analyzer: Optional['EthernetAnalyzer'] = None self.integrated_mode = True # Always use integrated mode self._signal_cache = {} # Cache for processed signal data # Set up compact main layout layout = QVBoxLayout() layout.setContentsMargins(4, 4, 4, 4) # Tight margins layout.setSpacing(2) # Minimal spacing between elements # Compact file info section self.file_info_label = QLabel("No file loaded") self.file_info_label.setWordWrap(True) self.file_info_label.setStyleSheet(""" QLabel { padding: 6px 12px; background-color: #2d2d2d; border: 1px solid #404040; border-radius: 6px; font-size: 11px; color: #cccccc; font-weight: 500; } """) self.file_info_label.setMaximumHeight(35) # Compact height layout.addWidget(self.file_info_label) # Compact flow table with comprehensive data self.flows_table = QTableWidget() self.flows_table.setColumnCount(10) # More columns for dense data self.flows_table.setHorizontalHeaderLabels([ "Source IP", "Dest IP", "Pkts", "Bytes", "Max σ", "Avg ΔT", "Std ΔT", "Outliers", "Protocols", "Signal Plot" ]) # Configure table for compact, dense display with user-adjustable columns header = self.flows_table.horizontalHeader() # Make all columns user-resizable by dragging header.setSectionResizeMode(QHeaderView.Interactive) # Auto-resize headers to fit content first for i in range(self.flows_table.columnCount()): header.resizeSection(i, header.sectionSizeHint(i)) # Then resize to content for data columns self.flows_table.resizeColumnsToContents() # Set minimum widths for important columns and ensure plot column gets priority header.setMinimumSectionSize(50) # Minimum for any column self.flows_table.setColumnWidth(2, max(60, self.flows_table.columnWidth(2))) # Pkts self.flows_table.setColumnWidth(3, max(80, self.flows_table.columnWidth(3))) # Bytes self.flows_table.setColumnWidth(4, max(60, self.flows_table.columnWidth(4))) # Max σ self.flows_table.setColumnWidth(7, max(60, self.flows_table.columnWidth(7))) # Outliers self.flows_table.setColumnWidth(9, max(600, self.flows_table.columnWidth(9))) # Signal Plot gets 600px minimum # Ensure the table uses the full available width header.setStretchLastSection(True) # Last column (plots) stretches to fill remaining space # Increased row height (25% taller: 24px -> 30px) and styling self.flows_table.setSelectionBehavior(QTableWidget.SelectRows) self.flows_table.setAlternatingRowColors(True) self.flows_table.verticalHeader().setDefaultSectionSize(30) # 25% taller rows self.flows_table.setShowGrid(True) self.flows_table.setGridStyle(Qt.DotLine) # Subtle grid lines self.flows_table.itemSelectionChanged.connect(self.on_flow_selected) # Enable sorting self.flows_table.setSortingEnabled(True) # Modern dark theme styling for table self.flows_table.setStyleSheet(""" QTableWidget { background-color: #1e1e1e; color: #ffffff; font-size: 11px; gridline-color: #404040; border: 1px solid #404040; border-radius: 6px; selection-background-color: #0078d4; alternate-background-color: #252525; } QTableWidget::item { padding: 6px 8px; border: none; } QTableWidget::item:selected { background-color: #0078d4; color: #ffffff; } QTableWidget::item:hover { background-color: #2d2d2d; } QHeaderView::section { background-color: #2d2d2d; color: #ffffff; padding: 8px 12px; border: none; border-right: 1px solid #404040; border-bottom: 1px solid #404040; font-weight: bold; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; } QHeaderView::section:hover { background-color: #404040; } QHeaderView::section:pressed { background-color: #505050; } /* Scrollbar styling */ QScrollBar:vertical { background-color: #2d2d2d; width: 12px; border-radius: 6px; } QScrollBar::handle:vertical { background-color: #505050; border-radius: 6px; min-height: 20px; } QScrollBar::handle:vertical:hover { background-color: #606060; } QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { border: none; background: none; } QScrollBar:horizontal { background-color: #2d2d2d; height: 12px; border-radius: 6px; } QScrollBar::handle:horizontal { background-color: #505050; border-radius: 6px; min-width: 20px; } QScrollBar::handle:horizontal:hover { background-color: #606060; } QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { border: none; background: none; } """) layout.addWidget(self.flows_table) # Compact status section self.status_label = QLabel("Chapter 10 flows (highlighted) show integrated signal plots • All columns are resizable") self.status_label.setStyleSheet(""" QLabel { color: #888888; font-style: italic; padding: 4px 8px; font-size: 10px; background-color: #252525; border-radius: 4px; } """) self.status_label.setMaximumHeight(25) layout.addWidget(self.status_label) self.setLayout(layout) def set_analyzer(self, analyzer: 'EthernetAnalyzer'): """Set the analyzer and populate flow data""" self.analyzer = analyzer # Clear cache when analyzer changes self._signal_cache.clear() self.populate_flows_table() def update_file_info(self, file_path: str): """Update file information display""" if not self.analyzer: return summary = self.analyzer.get_summary() file_info = f"File: {os.path.basename(file_path)}
" file_info += f"Packets: {summary['total_packets']:,}
" file_info += f"Flows: {summary['unique_flows']}
" file_info += f"IPs: {summary['unique_ips']}" self.file_info_label.setText(file_info) def populate_flows_table(self): """Populate the flows table with data""" if not self.analyzer: return summary = self.analyzer.get_summary() flows_list = list(summary['flows'].values()) # Sort by maximum sigma deviation flows_list.sort(key=lambda x: ( self.analyzer.statistics_engine.get_max_sigma_deviation(x), x.frame_count ), reverse=True) self.flows_table.setRowCount(len(flows_list)) for row, flow in enumerate(flows_list): # Source IP (column 0) src_item = QTableWidgetItem(flow.src_ip) has_ch10 = any('CH10' in ft or 'TMATS' in ft for ft in flow.frame_types.keys()) if has_ch10: font = QFont() font.setBold(True) src_item.setFont(font) src_item.setBackground(QColor(0, 120, 212, 80)) # Modern blue highlight src_item.setData(Qt.UserRole, flow) # Store flow object self.flows_table.setItem(row, 0, src_item) # Destination IP (column 1) dst_item = QTableWidgetItem(flow.dst_ip) if has_ch10: font = QFont() font.setBold(True) dst_item.setFont(font) dst_item.setBackground(QColor(0, 120, 212, 80)) # Modern blue highlight self.flows_table.setItem(row, 1, dst_item) # Packets (column 2) packets_item = QTableWidgetItem(f"{flow.frame_count:,}") packets_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) self.flows_table.setItem(row, 2, packets_item) # Total Bytes (column 3) bytes_item = QTableWidgetItem(f"{flow.total_bytes:,}") bytes_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) self.flows_table.setItem(row, 3, bytes_item) # Max sigma deviation (column 4) max_sigma = self.analyzer.statistics_engine.get_max_sigma_deviation(flow) sigma_item = QTableWidgetItem(f"{max_sigma:.2f}σ") sigma_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) # Color code sigma values with modern colors if max_sigma > 5.0: sigma_item.setBackground(QColor(220, 53, 69, 120)) # Modern red for high sigma sigma_item.setForeground(QColor(255, 255, 255)) # White text for contrast elif max_sigma > 3.0: sigma_item.setBackground(QColor(255, 193, 7, 120)) # Modern amber for medium sigma sigma_item.setForeground(QColor(0, 0, 0)) # Black text for contrast self.flows_table.setItem(row, 4, sigma_item) # Average inter-arrival time (column 5) avg_time = f"{flow.avg_inter_arrival:.6f}s" if flow.avg_inter_arrival > 0 else "N/A" avg_item = QTableWidgetItem(avg_time) avg_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) self.flows_table.setItem(row, 5, avg_item) # Standard deviation (column 6) std_time = f"{flow.std_inter_arrival:.6f}s" if flow.std_inter_arrival > 0 else "N/A" std_item = QTableWidgetItem(std_time) std_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) self.flows_table.setItem(row, 6, std_item) # Outlier count (column 7) outlier_count = len(flow.outlier_frames) outlier_item = QTableWidgetItem(str(outlier_count)) outlier_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) if outlier_count > 0: outlier_item.setBackground(QColor(220, 53, 69, 100)) # Modern red for outliers outlier_item.setForeground(QColor(255, 255, 255)) # White text for contrast self.flows_table.setItem(row, 7, outlier_item) # Protocols (column 8) - compact format protocols = [] if flow.detected_protocol_types: protocols.extend(flow.detected_protocol_types) protocols.extend([p for p in flow.protocols if p not in protocols]) protocols_text = ", ".join(protocols[:3]) # Limit to first 3 protocols if len(protocols) > 3: protocols_text += f" (+{len(protocols)-3})" protocols_item = QTableWidgetItem(protocols_text) self.flows_table.setItem(row, 8, protocols_item) # Signal plot column (column 9) if has_ch10: plot_widget = self._create_integrated_plot_widget(flow, f"{flow.src_ip} → {flow.dst_ip}") self.flows_table.setCellWidget(row, 9, plot_widget) else: plot_item = QTableWidgetItem("N/A") plot_item.setTextAlignment(Qt.AlignCenter) plot_item.setFlags(plot_item.flags() & ~Qt.ItemIsEditable) self.flows_table.setItem(row, 9, plot_item) # Auto-resize numeric columns to fit content for col in [2, 3, 4, 7]: # Packets, Bytes, Max σ, Outliers self.flows_table.resizeColumnToContents(col) # Update header with time range information if we have flows self._update_signal_plot_header(flows_list) def populate_flows_table_quick(self): """Populate the flows table quickly without plots for immediate display""" if not self.analyzer: return summary = self.analyzer.get_summary() flows_list = list(summary['flows'].values()) # Sort by maximum sigma deviation flows_list.sort(key=lambda x: ( self.analyzer.statistics_engine.get_max_sigma_deviation(x), x.frame_count ), reverse=True) self.flows_table.setRowCount(len(flows_list)) for row, flow in enumerate(flows_list): # Use same logic as populate_flows_table but with placeholder for plots has_ch10 = any('CH10' in ft or 'TMATS' in ft for ft in flow.frame_types.keys()) # Source IP (column 0) src_item = QTableWidgetItem(flow.src_ip) if has_ch10: font = QFont() font.setBold(True) src_item.setFont(font) src_item.setBackground(QColor(0, 120, 212, 80)) src_item.setData(Qt.UserRole, flow) self.flows_table.setItem(row, 0, src_item) # Destination IP (column 1) dst_item = QTableWidgetItem(flow.dst_ip) if has_ch10: font = QFont() font.setBold(True) dst_item.setFont(font) dst_item.setBackground(QColor(0, 120, 212, 80)) self.flows_table.setItem(row, 1, dst_item) # Fill all other columns as in full populate method for col, value in [ (2, f"{flow.frame_count:,}"), (3, f"{flow.total_bytes:,}"), (4, f"{self.analyzer.statistics_engine.get_max_sigma_deviation(flow):.2f}σ"), (5, f"{flow.avg_inter_arrival:.6f}s" if flow.avg_inter_arrival > 0 else "N/A"), (6, f"{flow.std_inter_arrival:.6f}s" if flow.std_inter_arrival > 0 else "N/A"), (7, str(len(flow.outlier_frames))), (8, ", ".join(list(flow.detected_protocol_types)[:2] if flow.detected_protocol_types else list(flow.protocols)[:2])) ]: item = QTableWidgetItem(value) if col in [2, 3, 4, 5, 6, 7]: # Right-align numeric columns item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) self.flows_table.setItem(row, col, item) # Plot column placeholder if has_ch10: plot_item = QTableWidgetItem("Loading plot...") plot_item.setTextAlignment(Qt.AlignCenter) plot_item.setForeground(QColor(128, 128, 128)) plot_item.setFlags(plot_item.flags() & ~Qt.ItemIsEditable) self.flows_table.setItem(row, 9, plot_item) else: plot_item = QTableWidgetItem("N/A") plot_item.setTextAlignment(Qt.AlignCenter) plot_item.setFlags(plot_item.flags() & ~Qt.ItemIsEditable) self.flows_table.setItem(row, 9, plot_item) # Auto-resize numeric columns to fit content for col in [2, 3, 4, 7]: # Packets, Bytes, Max σ, Outliers self.flows_table.resizeColumnToContents(col) def _update_signal_plot_header(self, flows_list): """Update the Signal Plot column header with time range information""" if not flows_list or not self.analyzer: return # Find earliest and latest timestamps across all flows earliest_time = float('inf') latest_time = float('-inf') for flow in flows_list: if flow.timestamps: flow_start = min(flow.timestamps) flow_end = max(flow.timestamps) earliest_time = min(earliest_time, flow_start) latest_time = max(latest_time, flow_end) if earliest_time != float('inf') and latest_time != float('-inf'): # Convert timestamps to time-of-day format (assuming they're epoch seconds) import datetime try: start_time = datetime.datetime.fromtimestamp(earliest_time).strftime('%H:%M:%S') end_time = datetime.datetime.fromtimestamp(latest_time).strftime('%H:%M:%S') header_text = f"Signal Plot\n{start_time} → {end_time}" except (ValueError, OSError): # If timestamp conversion fails, show duration instead duration = latest_time - earliest_time header_text = f"Signal Plot\n{duration:.1f}s span" else: header_text = "Signal Plot" # Update the header self.flows_table.setHorizontalHeaderLabels([ "Source IP", "Dest IP", "Pkts", "Bytes", "Max σ", "Avg ΔT", "Std ΔT", "Outliers", "Protocols", header_text ]) def on_flow_selected(self): """Handle flow selection""" selected_rows = self.flows_table.selectionModel().selectedRows() if not selected_rows: self.status_label.setText("Chapter 10 flows (highlighted) show integrated signal plots • All columns are resizable") return # Get selected flow row = selected_rows[0].row() flow_item = self.flows_table.item(row, 0) flow = flow_item.data(Qt.UserRole) if flow: # Check if flow has Chapter 10 data has_ch10 = any('CH10' in ft or 'TMATS' in ft for ft in flow.frame_types.keys()) if has_ch10: self.status_label.setText("Chapter 10 flow selected - signal plot shown in table") else: self.status_label.setText("Selected flow does not contain Chapter 10 telemetry data") def _create_integrated_plot_widget(self, flow: 'FlowStats', flow_key: str) -> QWidget: """Create a small matplotlib widget for integrated plots""" if not _ensure_matplotlib_gui_loaded(): placeholder = QLabel("Matplotlib not available") placeholder.setAlignment(Qt.AlignCenter) return placeholder # Create a compact matplotlib widget plot_widget = QWidget() layout = QVBoxLayout() layout.setContentsMargins(2, 2, 2, 2) # Create wider figure for integrated display with dark theme figure = Figure(figsize=(8, 2.5), facecolor='#1e1e1e') # Wider and slightly taller canvas = FigureCanvas(figure) canvas.setMaximumHeight(150) # Increased height to match taller rows # Apply dark theme to matplotlib canvas.setStyleSheet("background-color: #1e1e1e; border: 1px solid #404040; border-radius: 4px;") layout.addWidget(canvas) plot_widget.setLayout(layout) # Load and plot data immediately self._populate_integrated_plot(figure, canvas, flow, flow_key) return plot_widget def _populate_integrated_plot(self, figure, canvas, flow: 'FlowStats', flow_key: str): """Populate an integrated plot with actual signal data""" try: if not self.analyzer: self._create_error_plot(figure, canvas, "No analyzer available") return # Check cache first cache_key = f"{flow.src_ip}_{flow.dst_ip}" if cache_key in self._signal_cache: signal_data_list = self._signal_cache[cache_key] else: # Get flow packets flow_packets = self._get_flow_packets(flow) if not flow_packets: self._create_error_plot(figure, canvas, "No packets found") return # Extract signal data using the same method as floating plots from ..utils.signal_visualizer import signal_visualizer # Extract TMATS metadata and signals tmats_metadata = signal_visualizer._extract_tmats_from_flow(flow_packets) signal_data_list = signal_visualizer._extract_signals_from_flow(flow_packets, tmats_metadata) # Cache the result self._signal_cache[cache_key] = signal_data_list if not signal_data_list: self._create_error_plot(figure, canvas, "No decodable\nsignal data") return # Plot the first signal with dark theme styling ax = figure.add_subplot(111, facecolor='#1e1e1e') # Use the first signal for the integrated plot signal_data = signal_data_list[0] # Plot the first channel in the signal if signal_data.channels: channel_name, data = next(iter(signal_data.channels.items())) # Downsample if too many points for integrated view timestamps = signal_data.timestamps if len(timestamps) > 200: # Simple downsampling by taking every nth point step = len(timestamps) // 200 timestamps = timestamps[::step] data = data[::step] # Modern color scheme - blue for primary data ax.plot(timestamps, data, color='#0078d4', linewidth=1.2, alpha=0.9) # Dark theme styling with larger, more legible text ax.set_xlabel('Time (s)', fontsize=9, color='#ffffff') # ax.set_ylabel('Amplitude', fontsize=9, color='#ffffff') # Removed for cleaner embedded view ax.tick_params(labelsize=8, colors='#cccccc', left=False, labelleft=False, bottom=False, labelbottom=False) # Hide all tick labels ax.grid(True, alpha=0.15, color='#404040') # No legend needed for embedded plots - cleaner appearance # Add Y-axis min/max indicators for better readability y_min, y_max = ax.get_ylim() ax.text(0.02, 0.95, f'Max: {y_max:.2f}', transform=ax.transAxes, fontsize=7, color='#00ff88', weight='bold', bbox=dict(boxstyle='round,pad=0.2', facecolor='#2d2d2d', alpha=0.8)) ax.text(0.02, 0.02, f'Min: {y_min:.2f}', transform=ax.transAxes, fontsize=7, color='#ff6b6b', weight='bold', bbox=dict(boxstyle='round,pad=0.2', facecolor='#2d2d2d', alpha=0.8)) # Style the spines for spine in ax.spines.values(): spine.set_color('#404040') spine.set_linewidth(0.5) # Add compact title with time range info time_start = timestamps[0] if len(timestamps) > 0 else 0 time_end = timestamps[-1] if len(timestamps) > 0 else 0 duration = time_end - time_start title_text = f'{channel_name}' if len(signal_data_list) > 1: title_text += f' (+{len(signal_data_list)-1} more)' title_text += f' | {duration:.2f}s span' ax.set_title(title_text, fontsize=8, color='#ffffff', pad=8) else: self._create_error_plot(figure, canvas, "No channel data") return # Maximum plot area with ultra-minimal margins on all sides figure.subplots_adjust(left=0.01, right=0.99, top=0.92, bottom=0.15) canvas.draw_idle() # Use draw_idle to avoid window creation except Exception as e: self._create_error_plot(figure, canvas, f"Error:\n{str(e)[:30]}...") def _get_flow_packets(self, flow: 'FlowStats'): """Get all packets for a specific flow""" if not self.analyzer or not hasattr(self.analyzer, 'all_packets') or not self.analyzer.all_packets: return [] flow_packets = [] for packet in self.analyzer.all_packets: try: if hasattr(packet, 'haslayer'): from scapy.all import IP if packet.haslayer(IP): ip_layer = packet[IP] if ip_layer.src == flow.src_ip and ip_layer.dst == flow.dst_ip: flow_packets.append(packet) except: continue return flow_packets def _create_error_plot(self, figure, canvas, message: str): """Create an error message plot with dark theme""" ax = figure.add_subplot(111, facecolor='#1e1e1e') ax.text(0.5, 0.5, message, ha='center', va='center', transform=ax.transAxes, fontsize=8, color='#ff6b6b', style='italic') # Modern red for errors ax.set_xlim(0, 1) ax.set_ylim(0, 1) ax.axis('off') figure.subplots_adjust(left=0, right=1, top=1, bottom=0) canvas.draw_idle() # Use draw_idle to avoid window creation class FlowDetailDockWidget(QDockWidget): """Dockable widget showing detailed information about selected flow""" def __init__(self, parent=None): super().__init__("Flow Details", parent) # Create main widget main_widget = QWidget() layout = QVBoxLayout() # Detail text area with dark theme self.detail_text = QTextEdit() self.detail_text.setReadOnly(True) self.detail_text.setMaximumHeight(150) self.detail_text.setStyleSheet(""" QTextEdit { background-color: #1e1e1e; color: #ffffff; border: 1px solid #404040; border-radius: 6px; padding: 8px; font-family: 'Monaco', 'Menlo', 'DejaVu Sans Mono', monospace; font-size: 11px; selection-background-color: #0078d4; } """) layout.addWidget(self.detail_text) main_widget.setLayout(layout) self.setWidget(main_widget) # Set dock properties self.setFeatures(QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable) self.setAllowedAreas(Qt.BottomDockWidgetArea | Qt.TopDockWidgetArea) # Initial content with dark theme colors self.detail_text.setHtml('Select a flow to view detailed information') def update_flow_details(self, flow: 'FlowStats', analyzer: 'EthernetAnalyzer'): """Update the detail view with flow information""" if not flow or not analyzer: self.detail_text.setHtml('No flow selected') return max_sigma = analyzer.statistics_engine.get_max_sigma_deviation(flow) html = f"""

{flow.src_ip} → {flow.dst_ip}

""" if flow.detected_protocol_types: html += f'' if flow.avg_inter_arrival > 0: html += f""" """ if flow.outlier_frames: html += f'' html += "
Packets:{flow.frame_count:,}
Total Bytes:{flow.total_bytes:,}
Max Sigma Deviation:{max_sigma:.2f}σ
Protocols:{', '.join(flow.protocols)}
Enhanced Protocols:{", ".join(flow.detected_protocol_types)}
Avg Inter-arrival:{flow.avg_inter_arrival:.6f}s
Std Deviation:{flow.std_inter_arrival:.6f}s
Outlier Frames:{len(flow.outlier_frames)}
" # Frame type breakdown if flow.frame_types: html += '

Frame Types:

' html += '" html += "
" self.detail_text.setHtml(html)