Files
StreamLens/analyzer/gui/main_window.py
2025-07-25 21:45:07 -04:00

568 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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()