Files
StreamLens/analyzer/gui/dock_panels.py
2025-07-26 00:02:25 -04:00

697 lines
30 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.

"""
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"<b>File:</b> {os.path.basename(file_path)}<br/>"
file_info += f"<b>Packets:</b> {summary['total_packets']:,}<br/>"
file_info += f"<b>Flows:</b> {summary['unique_flows']}<br/>"
file_info += f"<b>IPs:</b> {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)
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 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
ax.set_xlabel('Time (s)', fontsize=7, color='#ffffff')
ax.set_ylabel('Amplitude', fontsize=7, color='#ffffff')
ax.tick_params(labelsize=6, colors='#cccccc')
ax.grid(True, alpha=0.15, color='#404040')
# Style the spines
for spine in ax.spines.values():
spine.set_color('#404040')
spine.set_linewidth(0.5)
# Add compact title with dark theme
title_text = f'{channel_name} (+{len(signal_data_list)-1} more)' if len(signal_data_list) > 1 else channel_name
ax.set_title(title_text, fontsize=7, color='#ffffff', pad=8)
else:
self._create_error_plot(figure, canvas, "No channel data")
return
# Tight layout with minimal horizontal margins (fixed pixel-equivalent values)
figure.subplots_adjust(left=0.08, right=0.98, top=0.85, bottom=0.25)
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('<span style="color: #888888; font-style: italic;">Select a flow to view detailed information</span>')
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('<span style="color: #888888; font-style: italic;">No flow selected</span>')
return
max_sigma = analyzer.statistics_engine.get_max_sigma_deviation(flow)
html = f"""
<div style="color: #ffffff; font-family: 'Monaco', 'Menlo', 'DejaVu Sans Mono', monospace;">
<h3 style="color: #0078d4; margin-bottom: 12px;">{flow.src_ip}{flow.dst_ip}</h3>
<table border="0" cellpadding="8" cellspacing="0" style="width: 100%; border-collapse: collapse;">
<tr style="background-color: #2d2d2d;"><td style="color: #cccccc; font-weight: bold; border: 1px solid #404040;">Packets:</td><td style="color: #ffffff; border: 1px solid #404040;">{flow.frame_count:,}</td></tr>
<tr style="background-color: #252525;"><td style="color: #cccccc; font-weight: bold; border: 1px solid #404040;">Total Bytes:</td><td style="color: #ffffff; border: 1px solid #404040;">{flow.total_bytes:,}</td></tr>
<tr style="background-color: #2d2d2d;"><td style="color: #cccccc; font-weight: bold; border: 1px solid #404040;">Max Sigma Deviation:</td><td style="color: #ff6b6b; font-weight: bold; border: 1px solid #404040;">{max_sigma:.2f}σ</td></tr>
<tr style="background-color: #252525;"><td style="color: #cccccc; font-weight: bold; border: 1px solid #404040;">Protocols:</td><td style="color: #ffffff; border: 1px solid #404040;">{', '.join(flow.protocols)}</td></tr>
"""
if flow.detected_protocol_types:
html += f'<tr style="background-color: #2d2d2d;"><td style="color: #cccccc; font-weight: bold; border: 1px solid #404040;">Enhanced Protocols:</td><td style="color: #ffffff; border: 1px solid #404040;">{", ".join(flow.detected_protocol_types)}</td></tr>'
if flow.avg_inter_arrival > 0:
html += f"""
<tr style="background-color: #252525;"><td style="color: #cccccc; font-weight: bold; border: 1px solid #404040;">Avg Inter-arrival:</td><td style="color: #ffffff; border: 1px solid #404040;">{flow.avg_inter_arrival:.6f}s</td></tr>
<tr style="background-color: #2d2d2d;"><td style="color: #cccccc; font-weight: bold; border: 1px solid #404040;">Std Deviation:</td><td style="color: #ffffff; border: 1px solid #404040;">{flow.std_inter_arrival:.6f}s</td></tr>
"""
if flow.outlier_frames:
html += f'<tr style="background-color: #252525;"><td style="color: #cccccc; font-weight: bold; border: 1px solid #404040;">Outlier Frames:</td><td style="color: #ff6b6b; font-weight: bold; border: 1px solid #404040;">{len(flow.outlier_frames)}</td></tr>'
html += "</table>"
# Frame type breakdown
if flow.frame_types:
html += '<h4 style="color: #0078d4; margin-top: 16px; margin-bottom: 8px;">Frame Types:</h4>'
html += '<ul style="list-style-type: none; padding-left: 0;">'
for frame_type, ft_stats in flow.frame_types.items():
avg_str = f"{ft_stats.avg_inter_arrival:.3f}s" if ft_stats.avg_inter_arrival > 0 else "N/A"
html += f'<li style="margin-bottom: 4px; padding: 4px 8px; background-color: #252525; border-radius: 4px; border-left: 3px solid #0078d4;"><span style="color: #cccccc; font-weight: bold;">{frame_type}:</span> <span style="color: #ffffff;">{ft_stats.count} packets, avg {avg_str}</span></li>'
html += "</ul>"
html += "</div>"
self.detail_text.setHtml(html)