""" Main GUI window for StreamLens """ import sys import os from typing import Optional, List, TYPE_CHECKING try: from PySide6.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSplitter, QTableWidget, QTableWidgetItem, QTextEdit, QMenuBar, QMenu, QFileDialog, QMessageBox, QProgressBar, QStatusBar, QLabel, QHeaderView, QPushButton, QGroupBox ) from PySide6.QtCore import Qt, QThread, Signal, QTimer from PySide6.QtGui import QAction, QIcon, QFont # 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 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}") sys.exit(1) if TYPE_CHECKING: from ..analysis.core import EthernetAnalyzer from ..models.flow_stats import FlowStats class PlotWidget(QWidget): """Widget containing matplotlib plot with toolbar""" def __init__(self, parent=None): super().__init__(parent) self.figure = None self.canvas = None self.toolbar = None self._initialized = False # Just create basic layout initially self.layout = QVBoxLayout() self.setLayout(self.layout) def _ensure_initialized(self): """Initialize matplotlib components when first needed""" if self._initialized: return True if not _ensure_matplotlib_gui_loaded(): return False self.figure = Figure(figsize=(12, 8)) self.canvas = FigureCanvas(self.figure) self.toolbar = NavigationToolbar(self.canvas, self) self.layout.addWidget(self.toolbar) self.layout.addWidget(self.canvas) self._initialized = True self.clear_plot() return True def clear_plot(self): """Clear the plot and show initial message""" if not self._ensure_initialized(): return self.figure.clear() # Add a centered message when no plot is shown ax = self.figure.add_subplot(111) ax.text(0.5, 0.5, 'Select a flow with Chapter 10 data to view signal visualization', ha='center', va='center', transform=ax.transAxes, fontsize=12, color='gray', style='italic') ax.set_xlim(0, 1) ax.set_ylim(0, 1) ax.axis('off') self.canvas.draw() def plot_flow_signals(self, flow: 'FlowStats', signal_data_list: List, flow_key: str): """Plot Chapter 10 signals for a flow""" if not self._ensure_initialized(): return self.figure.clear() if not signal_data_list: # Show message when no data ax = self.figure.add_subplot(111) ax.text(0.5, 0.5, 'No Chapter 10 signal data found in selected flow', ha='center', va='center', transform=ax.transAxes, fontsize=14) ax.set_xlim(0, 1) ax.set_ylim(0, 1) ax.axis('off') self.canvas.draw() return # Create subplots for each signal n_signals = len(signal_data_list) axes = [] for idx, signal_data in enumerate(signal_data_list): ax = self.figure.add_subplot(n_signals, 1, idx + 1) axes.append(ax) # Plot each channel in the signal data for channel_name, data in signal_data.channels.items(): ax.plot(signal_data.timestamps, data, label=channel_name, linewidth=0.8) ax.set_xlabel('Time (s)') ax.set_ylabel('Amplitude') ax.grid(True, alpha=0.3) ax.legend() # Add metadata info if signal_data.metadata and signal_data.metadata.channel_configs: config_info = [] for ch_id, config in signal_data.metadata.channel_configs.items(): if 'units' in config: config_info.append(f"CH{ch_id}: {config.get('units', 'Unknown')}") if config_info: ax.set_title(f"Channels: {', '.join(config_info)}") self.figure.suptitle(f'Chapter 10 Signals - Flow: {flow_key}', fontsize=14) self.figure.tight_layout() self.canvas.draw() class PCAPLoadThread(QThread): """Thread for loading PCAP files without blocking UI""" progress_updated = Signal(int) loading_finished = Signal(object) # Emits the analyzer error_occurred = Signal(str) def __init__(self, file_path: str, parent=None): super().__init__(parent) self.file_path = file_path def run(self): try: from ..analysis.core import EthernetAnalyzer from ..utils.pcap_loader import PCAPLoader # Create analyzer analyzer = EthernetAnalyzer() # Load PCAP loader = PCAPLoader(self.file_path) if not loader.validate_file(): self.error_occurred.emit(f"Invalid PCAP file: {self.file_path}") return packets = loader.load_all() analyzer.all_packets = packets # Process packets with progress updates total_packets = len(packets) for i, packet in enumerate(packets, 1): analyzer._process_single_packet(packet, i) # Update progress every 1000 packets if i % 1000 == 0 or i == total_packets: progress = int((i / total_packets) * 100) self.progress_updated.emit(progress) # Calculate statistics analyzer.calculate_statistics() self.loading_finished.emit(analyzer) except Exception as e: self.error_occurred.emit(str(e)) class StreamLensMainWindow(QMainWindow): """Main application window""" def __init__(self): super().__init__() self.analyzer: Optional['EthernetAnalyzer'] = None self.current_file = None self.loading_thread = None self.setWindowTitle("StreamLens - Ethernet Traffic Analyzer") self.setGeometry(100, 100, 1400, 900) self.setup_ui() self.setup_menus() self.setup_status_bar() def setup_ui(self): """Set up the main UI layout""" central_widget = QWidget() self.setCentralWidget(central_widget) # Create main horizontal splitter main_splitter = QSplitter(Qt.Horizontal) # Left panel - Flow list and controls left_panel = self.create_left_panel() left_panel.setMaximumWidth(600) left_panel.setMinimumWidth(400) # Right panel - Plot area right_panel = self.create_right_panel() main_splitter.addWidget(left_panel) main_splitter.addWidget(right_panel) main_splitter.setStretchFactor(0, 0) # Don't stretch left panel main_splitter.setStretchFactor(1, 1) # Stretch right panel layout = QHBoxLayout() layout.addWidget(main_splitter) central_widget.setLayout(layout) def create_left_panel(self): """Create the left panel with flow list and info""" widget = QWidget() layout = QVBoxLayout() # File info group file_group = QGroupBox("File Information") file_layout = QVBoxLayout() self.file_info_label = QLabel("No file loaded") self.file_info_label.setWordWrap(True) file_layout.addWidget(self.file_info_label) file_group.setLayout(file_layout) layout.addWidget(file_group) # Flow list group flows_group = QGroupBox("IP Flows (sorted by max sigma deviation)") flows_layout = QVBoxLayout() self.flows_table = QTableWidget() self.flows_table.setColumnCount(5) self.flows_table.setHorizontalHeaderLabels([ "Source → Destination", "Packets", "Max σ", "Protocols", "Frame Types" ]) # Configure table header = self.flows_table.horizontalHeader() header.setStretchLastSection(True) header.setSectionResizeMode(0, QHeaderView.Stretch) self.flows_table.setSelectionBehavior(QTableWidget.SelectRows) self.flows_table.setAlternatingRowColors(True) self.flows_table.itemSelectionChanged.connect(self.on_flow_selected) flows_layout.addWidget(self.flows_table) # Status label for visualization feedback self.viz_status_label = QLabel("Select a flow to view signal visualization") self.viz_status_label.setStyleSheet("color: gray; font-style: italic;") flows_layout.addWidget(self.viz_status_label) flows_group.setLayout(flows_layout) layout.addWidget(flows_group) widget.setLayout(layout) return widget def create_right_panel(self): """Create the right panel with plot area""" widget = QWidget() layout = QVBoxLayout() plot_group = QGroupBox("Signal Visualization") plot_layout = QVBoxLayout() self.plot_widget = PlotWidget() plot_layout.addWidget(self.plot_widget) plot_group.setLayout(plot_layout) layout.addWidget(plot_group) widget.setLayout(layout) return widget def setup_menus(self): """Set up application menus""" menubar = self.menuBar() # File menu file_menu = menubar.addMenu("File") open_action = QAction("Open PCAP...", self) open_action.setShortcut("Ctrl+O") open_action.triggered.connect(self.open_pcap_file) file_menu.addAction(open_action) file_menu.addSeparator() exit_action = QAction("Exit", self) exit_action.setShortcut("Ctrl+Q") exit_action.triggered.connect(self.close) file_menu.addAction(exit_action) # View menu view_menu = menubar.addMenu("View") refresh_action = QAction("Refresh", self) refresh_action.setShortcut("F5") refresh_action.triggered.connect(self.refresh_data) view_menu.addAction(refresh_action) def setup_status_bar(self): """Set up status bar""" self.status_bar = QStatusBar() self.setStatusBar(self.status_bar) self.progress_bar = QProgressBar() self.progress_bar.setVisible(False) self.status_bar.addPermanentWidget(self.progress_bar) self.status_bar.showMessage("Ready") def open_pcap_file(self): """Open PCAP file dialog""" file_path, _ = QFileDialog.getOpenFileName( self, "Open PCAP File", "", "PCAP Files (*.pcap *.pcapng);;All Files (*)" ) if file_path: self.load_pcap_file(file_path) def load_pcap_file(self, file_path: str): """Load PCAP file in background thread""" if self.loading_thread and self.loading_thread.isRunning(): return self.current_file = file_path self.status_bar.showMessage(f"Loading {os.path.basename(file_path)}...") self.progress_bar.setVisible(True) self.progress_bar.setValue(0) # Disable UI during loading self.flows_table.setEnabled(False) # Start loading thread self.loading_thread = PCAPLoadThread(file_path) self.loading_thread.progress_updated.connect(self.progress_bar.setValue) self.loading_thread.loading_finished.connect(self.on_pcap_loaded) self.loading_thread.error_occurred.connect(self.on_loading_error) self.loading_thread.start() def on_pcap_loaded(self, analyzer: 'EthernetAnalyzer'): """Handle PCAP loading completion""" self.analyzer = analyzer self.progress_bar.setVisible(False) self.flows_table.setEnabled(True) # Update file info summary = analyzer.get_summary() file_info = f"File: {os.path.basename(self.current_file)}\n" file_info += f"Packets: {summary['total_packets']:,}\n" file_info += f"Flows: {summary['unique_flows']}\n" file_info += f"IPs: {summary['unique_ips']}" self.file_info_label.setText(file_info) # Populate flows table self.populate_flows_table() self.status_bar.showMessage(f"Loaded {summary['total_packets']:,} packets from {os.path.basename(self.current_file)}") def on_loading_error(self, error_message: str): """Handle loading error""" self.progress_bar.setVisible(False) self.flows_table.setEnabled(True) QMessageBox.critical(self, "Loading Error", f"Error loading PCAP file:\n{error_message}") self.status_bar.showMessage("Error loading file") 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 -> Destination flow_item = QTableWidgetItem(f"{flow.src_ip} → {flow.dst_ip}") self.flows_table.setItem(row, 0, flow_item) # Packets packets_item = QTableWidgetItem(str(flow.frame_count)) packets_item.setData(Qt.UserRole, flow) # Store flow object self.flows_table.setItem(row, 1, packets_item) # Max sigma deviation max_sigma = self.analyzer.statistics_engine.get_max_sigma_deviation(flow) sigma_item = QTableWidgetItem(f"{max_sigma:.2f}σ") self.flows_table.setItem(row, 2, sigma_item) # Protocols protocols = ", ".join(flow.protocols) if flow.detected_protocol_types: protocols += f" ({', '.join(flow.detected_protocol_types)})" protocols_item = QTableWidgetItem(protocols) self.flows_table.setItem(row, 3, protocols_item) # Frame types frame_types = ", ".join(flow.frame_types.keys()) frame_types_item = QTableWidgetItem(frame_types) self.flows_table.setItem(row, 4, frame_types_item) # Resize columns to content self.flows_table.resizeColumnsToContents() def on_flow_selected(self): """Handle flow selection and automatically render plots""" selected_rows = self.flows_table.selectionModel().selectedRows() if not selected_rows: # No selection - clear plot and show message self.plot_widget.clear_plot() self.viz_status_label.setText("Select a flow to view signal visualization") self.viz_status_label.setStyleSheet("color: gray; font-style: italic;") return # Get selected flow and automatically visualize self.visualize_selected_flow() def visualize_selected_flow(self): """Visualize the selected flow""" if not self.analyzer: return selected_rows = self.flows_table.selectionModel().selectedRows() if not selected_rows: return # Get selected flow row = selected_rows[0].row() flow_item = self.flows_table.item(row, 1) # Packets column has flow data flow = flow_item.data(Qt.UserRole) if not flow: return # 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 not has_ch10: # Clear plot and show informative message self.plot_widget.clear_plot() self.viz_status_label.setText("Selected flow does not contain Chapter 10 telemetry data") self.viz_status_label.setStyleSheet("color: orange; font-style: italic;") return # Get flow packets flow_packets = self.get_flow_packets(flow) if not flow_packets: # Clear plot and show error message self.plot_widget.clear_plot() self.viz_status_label.setText("No packets found for selected flow") self.viz_status_label.setStyleSheet("color: red; font-style: italic;") return # Use signal visualizer to extract and process signals from ..utils.signal_visualizer import signal_visualizer flow_key = f"{flow.src_ip} → {flow.dst_ip}" self.status_bar.showMessage(f"Extracting signals from flow {flow_key}...") self.viz_status_label.setText("Processing Chapter 10 telemetry data...") self.viz_status_label.setStyleSheet("color: blue; font-style: italic;") try: # Extract signals tmats_metadata = signal_visualizer._extract_tmats_from_flow(flow_packets) signal_data_list = signal_visualizer._extract_signals_from_flow(flow_packets, tmats_metadata) # Plot in GUI self.plot_widget.plot_flow_signals(flow, signal_data_list, flow_key) if signal_data_list: self.status_bar.showMessage(f"Plotted {len(signal_data_list)} signal(s) from {flow_key}") self.viz_status_label.setText(f"Displaying {len(signal_data_list)} signal channel(s)") self.viz_status_label.setStyleSheet("color: green; font-weight: bold;") else: self.status_bar.showMessage(f"No decodable signal data found in {flow_key}") self.viz_status_label.setText("No decodable signal data found in selected flow") self.viz_status_label.setStyleSheet("color: orange; font-style: italic;") except Exception as e: # Show error in status label instead of popup self.plot_widget.clear_plot() self.viz_status_label.setText(f"Visualization error: {str(e)}") self.viz_status_label.setStyleSheet("color: red; font-style: italic;") self.status_bar.showMessage("Visualization error") def get_flow_packets(self, flow: 'FlowStats') -> List: """Get all packets for a specific flow""" if not self.analyzer 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 refresh_data(self): """Refresh the current data""" if self.current_file: self.load_pcap_file(self.current_file) def closeEvent(self, event): """Handle application close""" if self.loading_thread and self.loading_thread.isRunning(): self.loading_thread.quit() self.loading_thread.wait() event.accept()