tabbed frametype filtering
This commit is contained in:
728
analyzer/tui/textual/widgets/filtered_flow_view.py
Normal file
728
analyzer/tui/textual/widgets/filtered_flow_view.py
Normal file
@@ -0,0 +1,728 @@
|
||||
"""
|
||||
Filtered Flow View Widget - Grid with frame type filter buttons
|
||||
"""
|
||||
|
||||
from textual.widgets import Button, DataTable, Static
|
||||
from textual.containers import Vertical, Horizontal
|
||||
from textual.reactive import reactive
|
||||
from textual.message import Message
|
||||
from textual.binding import Binding
|
||||
from typing import TYPE_CHECKING, Optional, List, Dict
|
||||
from rich.text import Text
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ....analysis.core import EthernetAnalyzer
|
||||
from ....models import FlowStats
|
||||
|
||||
|
||||
class FrameTypeButton(Button):
|
||||
"""Button for frame type filtering"""
|
||||
|
||||
def __init__(self, frame_type: str, hotkey: str, count: int = 0, **kwargs):
|
||||
self.frame_type = frame_type
|
||||
self.count = count
|
||||
# Shorten frame type names for 1-row buttons
|
||||
short_name = self._shorten_frame_type(frame_type)
|
||||
label = f"{hotkey}.{short_name}({count})" # Remove spaces to be more compact
|
||||
# Create valid ID by removing/replacing invalid characters
|
||||
safe_id = frame_type.replace('-', '_').replace(':', '_').replace('(', '_').replace(')', '_').replace(' ', '_').replace('.', '_')
|
||||
super().__init__(label, id=f"btn-{safe_id}", **kwargs)
|
||||
|
||||
# Ensure proper styling from initialization
|
||||
self.styles.background = "#404040"
|
||||
self.styles.color = "white"
|
||||
|
||||
def _shorten_frame_type(self, frame_type: str) -> str:
|
||||
"""Shorten frame type names for compact 1-row buttons"""
|
||||
abbreviations = {
|
||||
'CH10-Data': 'CH10',
|
||||
'CH10-Multi-Source': 'Multi',
|
||||
'CH10-Extended': 'Ext',
|
||||
'CH10-ACTTS': 'ACTTS',
|
||||
'PTP-Signaling': 'PTP-S',
|
||||
'PTP-FollowUp': 'PTP-F',
|
||||
'PTP-Sync': 'PTP',
|
||||
'PTP-Unknown (0x6)': 'PTP-U',
|
||||
'UDP': 'UDP',
|
||||
'TMATS': 'TMATS',
|
||||
'TCP': 'TCP'
|
||||
}
|
||||
return abbreviations.get(frame_type, frame_type[:6]) # Max 6 chars for unknown types
|
||||
|
||||
|
||||
import time
|
||||
import traceback
|
||||
|
||||
def debug_log(message):
|
||||
"""Debug logging with timestamp"""
|
||||
timestamp = time.strftime("%H:%M:%S.%f")[:-3]
|
||||
print(f"[{timestamp}] 🔍 DEBUG: {message}")
|
||||
|
||||
def debug_button_state(frame_type_buttons, phase):
|
||||
"""Log current button state"""
|
||||
debug_log(f"=== BUTTON STATE - {phase} ===")
|
||||
debug_log(f"Total buttons in dict: {len(frame_type_buttons)}")
|
||||
for name, btn in frame_type_buttons.items():
|
||||
if hasattr(btn, 'parent') and btn.parent:
|
||||
parent_info = f"parent: {btn.parent.__class__.__name__}"
|
||||
else:
|
||||
parent_info = "NO PARENT"
|
||||
debug_log(f" {name}: {btn.__class__.__name__} ({parent_info})")
|
||||
debug_log("=" * 40)
|
||||
class FilteredFlowView(Vertical):
|
||||
"""Flow grid with frame type filter buttons"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("alt+1", "sort_column(0)", "Sort by column 1", show=False),
|
||||
Binding("alt+2", "sort_column(1)", "Sort by column 2", show=False),
|
||||
Binding("alt+3", "sort_column(2)", "Sort by column 3", show=False),
|
||||
Binding("alt+4", "sort_column(3)", "Sort by column 4", show=False),
|
||||
Binding("alt+5", "sort_column(4)", "Sort by column 5", show=False),
|
||||
Binding("alt+6", "sort_column(5)", "Sort by column 6", show=False),
|
||||
Binding("alt+7", "sort_column(6)", "Sort by column 7", show=False),
|
||||
Binding("alt+8", "sort_column(7)", "Sort by column 8", show=False),
|
||||
Binding("alt+9", "sort_column(8)", "Sort by column 9", show=False),
|
||||
Binding("alt+0", "sort_column(9)", "Sort by column 10", show=False),
|
||||
]
|
||||
|
||||
DEFAULT_CSS = """
|
||||
FilteredFlowView {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
#filter-bar {
|
||||
height: 3; /* Fixed height to match button height */
|
||||
min-height: 3;
|
||||
max-height: 3;
|
||||
background: #262626;
|
||||
padding: 0 1;
|
||||
dock: top;
|
||||
layout: horizontal;
|
||||
}
|
||||
|
||||
#filter-bar Button {
|
||||
margin: 0 1 0 0; /* Consistent right spacing */
|
||||
min-width: 10; /* Reduced for compact labels */
|
||||
height: 3; /* Fixed height to ensure text visibility */
|
||||
max-height: 3; /* Prevent button from growing */
|
||||
padding: 0 1; /* Minimal horizontal padding for text readability */
|
||||
text-align: center; /* Center text in button */
|
||||
content-align: center middle;
|
||||
background: #404040; /* Default gray background - not black */
|
||||
color: white;
|
||||
border: solid #666666; /* Visible border - Textual format */
|
||||
}
|
||||
|
||||
#btn-overview {
|
||||
margin: 0 1 0 0; /* Overview button - same spacing */
|
||||
height: 3; /* Fixed height to ensure text visibility */
|
||||
max-height: 3; /* Prevent button from growing */
|
||||
padding: 0 1; /* Minimal horizontal padding for text readability */
|
||||
text-align: center; /* Center text in button */
|
||||
content-align: center middle;
|
||||
background: #404040; /* Default gray background - not black */
|
||||
color: white;
|
||||
border: solid #666666; /* Visible border - Textual format */
|
||||
}
|
||||
|
||||
#filter-bar Button:hover {
|
||||
background: #0080ff;
|
||||
}
|
||||
|
||||
#filter-bar Button.-active {
|
||||
background: #0080ff;
|
||||
color: white; /* Ensure text is visible on active state */
|
||||
text-style: bold;
|
||||
border: solid #0080ff; /* Match border to background - Textual format */
|
||||
}
|
||||
|
||||
#filtered-flow-table {
|
||||
height: 1fr;
|
||||
}
|
||||
"""
|
||||
|
||||
selected_frame_type = reactive("Overview")
|
||||
|
||||
class FrameTypeSelected(Message):
|
||||
"""Message when frame type filter is selected"""
|
||||
def __init__(self, frame_type: str) -> None:
|
||||
self.frame_type = frame_type
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, analyzer: 'EthernetAnalyzer', **kwargs):
|
||||
debug_log("FilteredFlowView.__init__ called")
|
||||
super().__init__(**kwargs)
|
||||
self.analyzer = analyzer
|
||||
self.frame_type_buttons = {}
|
||||
self.flow_table = None
|
||||
self._last_frame_types = set() # Track frame types to avoid unnecessary refreshes
|
||||
self._buttons_created = False # Track if buttons have been created to avoid flicker
|
||||
|
||||
# Table sorting state
|
||||
self.sort_column = None # Index of column to sort by (None = no sorting)
|
||||
self.sort_reverse = False # True for descending, False for ascending
|
||||
|
||||
# Button refresh throttling to prevent race conditions
|
||||
self._last_refresh_time = 0
|
||||
self._refresh_throttle_seconds = 1.0 # Only refresh buttons once per second
|
||||
|
||||
# Predefined frame types that will have buttons created at initialization
|
||||
# Order is now static and will not change based on counts during parsing
|
||||
self.predefined_frame_types = [
|
||||
'UDP', # Most common transport protocol
|
||||
'CH10-Data', # Common Chapter 10 data frames
|
||||
'PTP-Sync', # PTP synchronization
|
||||
'PTP-Signaling', # PTP signaling
|
||||
'TMATS', # Telemetry metadata
|
||||
'TCP', # TCP transport
|
||||
'PTP-FollowUp', # PTP follow-up
|
||||
'CH10-Multi-Source',
|
||||
'CH10-Extended'
|
||||
]
|
||||
|
||||
def compose(self):
|
||||
"""Create the filter bar and flow grid - ALL BUTTONS CREATED ONCE, NEVER DESTROYED"""
|
||||
debug_log("compose() - Creating filter bar and ALL buttons at initialization")
|
||||
debug_button_state(self.frame_type_buttons, "BEFORE_COMPOSE")
|
||||
|
||||
# Filter button bar at top
|
||||
with Horizontal(id="filter-bar"):
|
||||
# Overview button (hotkey 1) - always visible, always active initially
|
||||
overview_btn = Button("1.Overview", id="btn-overview", classes="-active")
|
||||
overview_btn.styles.background = "#0080ff" # Active blue background
|
||||
overview_btn.styles.color = "white"
|
||||
self.frame_type_buttons["Overview"] = overview_btn
|
||||
yield overview_btn
|
||||
|
||||
# Create ALL possible frame type buttons at initialization - NEVER RECREATED
|
||||
# Static order prevents any tab reordering throughout the application lifecycle
|
||||
hotkeys = ['2', '3', '4', '5', '6', '7', '8', '9', '0']
|
||||
|
||||
# Create buttons for ALL predefined frame types
|
||||
for i, frame_type in enumerate(self.predefined_frame_types):
|
||||
if i < len(hotkeys):
|
||||
# Start with 0 count, initially hidden - visibility managed by refresh logic
|
||||
btn = FrameTypeButton(frame_type, hotkeys[i], 0)
|
||||
btn.visible = False # Hidden until data is available
|
||||
self.frame_type_buttons[frame_type] = btn
|
||||
yield btn
|
||||
|
||||
# Create placeholder buttons for dynamic frame types discovered during parsing
|
||||
# These will be activated/shown as new frame types are discovered
|
||||
remaining_hotkeys = len(self.predefined_frame_types)
|
||||
for i in range(remaining_hotkeys, len(hotkeys)):
|
||||
# Create placeholder button that can be reassigned to new frame types
|
||||
placeholder_btn = FrameTypeButton("", hotkeys[i], 0)
|
||||
placeholder_btn.visible = False # Hidden until assigned to a frame type
|
||||
placeholder_btn.placeholder_index = i # Track which placeholder this is
|
||||
# Use a special key for placeholders
|
||||
self.frame_type_buttons[f"__placeholder_{i}__"] = placeholder_btn
|
||||
yield placeholder_btn
|
||||
|
||||
# Flow data table
|
||||
self.flow_table = DataTable(
|
||||
id="filtered-flow-table",
|
||||
cursor_type="row",
|
||||
zebra_stripes=True,
|
||||
show_header=True,
|
||||
show_row_labels=False
|
||||
)
|
||||
yield self.flow_table
|
||||
debug_log("compose() - All widgets created")
|
||||
debug_button_state(self.frame_type_buttons, "AFTER_COMPOSE")
|
||||
|
||||
def on_mount(self):
|
||||
"""Initialize the view"""
|
||||
debug_log("on_mount() - Initializing view")
|
||||
debug_button_state(self.frame_type_buttons, "BEFORE_MOUNT_SETUP")
|
||||
self._setup_flow_table()
|
||||
# Mark buttons as created since we pre-created them in compose()
|
||||
self._buttons_created = True
|
||||
# Update button counts and data
|
||||
self.refresh_frame_types()
|
||||
self.refresh_flow_data()
|
||||
# Ensure Overview button starts highlighted
|
||||
self._update_button_highlighting()
|
||||
debug_log("on_mount() - Initialization complete")
|
||||
debug_button_state(self.frame_type_buttons, "AFTER_MOUNT_COMPLETE")
|
||||
|
||||
def _setup_flow_table(self):
|
||||
"""Setup table columns based on selected frame type"""
|
||||
table = self.flow_table
|
||||
table.clear(columns=True)
|
||||
|
||||
if self.selected_frame_type == "Overview":
|
||||
# Overview columns with individual frame type columns
|
||||
table.add_column("#", width=4, key="num")
|
||||
table.add_column("Source", width=18, key="source")
|
||||
table.add_column("Destination", width=18, key="dest")
|
||||
table.add_column("Protocol", width=8, key="protocol")
|
||||
table.add_column("Total", width=8, key="total_packets")
|
||||
|
||||
# Add columns for each detected frame type
|
||||
all_frame_types = self._get_all_frame_types()
|
||||
for frame_type in sorted(all_frame_types.keys(), key=lambda x: all_frame_types[x], reverse=True):
|
||||
# Shorten column name for better display
|
||||
short_name = self._shorten_frame_type_name(frame_type)
|
||||
# Create safe key for column
|
||||
safe_key = frame_type.replace('-', '_').replace(':', '_').replace('(', '_').replace(')', '_').replace(' ', '_').replace('.', '_')
|
||||
table.add_column(short_name, width=8, key=f"ft_{safe_key}")
|
||||
|
||||
table.add_column("Status", width=10, key="status")
|
||||
else:
|
||||
# Frame type specific columns
|
||||
table.add_column("#", width=4, key="num")
|
||||
table.add_column("Source", width=20, key="source")
|
||||
table.add_column("Destination", width=20, key="dest")
|
||||
table.add_column("Protocol", width=8, key="protocol")
|
||||
table.add_column(f"{self.selected_frame_type} Packets", width=12, key="ft_packets")
|
||||
table.add_column("Avg ΔT", width=10, key="avg_delta")
|
||||
table.add_column("Std ΔT", width=10, key="std_delta")
|
||||
table.add_column("Min ΔT", width=10, key="min_delta")
|
||||
table.add_column("Max ΔT", width=10, key="max_delta")
|
||||
table.add_column("Outliers", width=8, key="outliers")
|
||||
table.add_column("Quality", width=8, key="quality")
|
||||
|
||||
def refresh_frame_types(self):
|
||||
"""Update button visibility and content - NEVER CREATE OR DESTROY BUTTONS"""
|
||||
debug_log("refresh_frame_types() - Starting refresh (VISIBILITY-ONLY MODE)")
|
||||
debug_button_state(self.frame_type_buttons, "BEFORE_REFRESH")
|
||||
# Throttle button refresh to prevent race conditions
|
||||
import time
|
||||
current_time = time.time()
|
||||
if current_time - self._last_refresh_time < self._refresh_throttle_seconds:
|
||||
debug_log("refresh_frame_types() - THROTTLED, skipping refresh")
|
||||
return # Skip refresh if called too recently
|
||||
self._last_refresh_time = current_time
|
||||
|
||||
# Get all detected frame types with their total packet counts
|
||||
frame_types = self._get_all_frame_types()
|
||||
|
||||
# Calculate flow counts for all frame types
|
||||
frame_type_flow_counts = {}
|
||||
for frame_type in frame_types.keys():
|
||||
flow_count = sum(1 for flow in self.analyzer.flows.values() if frame_type in flow.frame_types)
|
||||
frame_type_flow_counts[frame_type] = flow_count
|
||||
|
||||
# UPDATE PREDEFINED FRAME TYPE BUTTONS (show/hide and update counts only)
|
||||
for frame_type in self.predefined_frame_types:
|
||||
if frame_type in self.frame_type_buttons:
|
||||
btn = self.frame_type_buttons[frame_type]
|
||||
if frame_type in frame_type_flow_counts:
|
||||
flow_count = frame_type_flow_counts[frame_type]
|
||||
# Update button content only
|
||||
hotkey = btn.label.split('.')[0] if '.' in btn.label else '?'
|
||||
short_name = btn._shorten_frame_type(frame_type)
|
||||
btn.label = f"{hotkey}.{short_name}({flow_count})"
|
||||
btn.count = flow_count
|
||||
|
||||
# Show button if it has data or is predefined (always show predefined during loading)
|
||||
should_show = flow_count > 0 or frame_type in self.predefined_frame_types
|
||||
btn.visible = should_show
|
||||
else:
|
||||
# No data for this frame type yet, keep hidden but maintain button
|
||||
btn.visible = False
|
||||
|
||||
# HANDLE NEW FRAME TYPES - assign to placeholder buttons only
|
||||
new_frame_types = set(frame_type_flow_counts.keys()) - set(self.predefined_frame_types)
|
||||
placeholder_keys = [k for k in self.frame_type_buttons.keys() if k.startswith("__placeholder_")]
|
||||
|
||||
# Find available placeholders (not already assigned)
|
||||
assigned_frame_types = set()
|
||||
for frame_type in new_frame_types:
|
||||
if frame_type in self.frame_type_buttons:
|
||||
assigned_frame_types.add(frame_type)
|
||||
|
||||
unassigned_new_types = new_frame_types - assigned_frame_types
|
||||
available_placeholders = []
|
||||
for placeholder_key in placeholder_keys:
|
||||
btn = self.frame_type_buttons[placeholder_key]
|
||||
if not hasattr(btn, 'assigned_frame_type') or not btn.visible:
|
||||
available_placeholders.append(placeholder_key)
|
||||
|
||||
# Assign new frame types to available placeholders
|
||||
for i, frame_type in enumerate(sorted(unassigned_new_types)):
|
||||
if i < len(available_placeholders) and frame_type_flow_counts[frame_type] > 0:
|
||||
placeholder_key = available_placeholders[i]
|
||||
btn = self.frame_type_buttons[placeholder_key]
|
||||
|
||||
# Assign this placeholder to the new frame type
|
||||
flow_count = frame_type_flow_counts[frame_type]
|
||||
hotkey = str(btn.placeholder_index + 2) # hotkeys 2-0
|
||||
short_name = btn._shorten_frame_type(frame_type)
|
||||
btn.label = f"{hotkey}.{short_name}({flow_count})"
|
||||
btn.count = flow_count
|
||||
btn.frame_type = frame_type
|
||||
btn.assigned_frame_type = frame_type
|
||||
btn.visible = True
|
||||
|
||||
# Also add to frame_type_buttons with the frame type as key for easy lookup
|
||||
self.frame_type_buttons[frame_type] = btn
|
||||
|
||||
# Update existing assigned placeholder buttons
|
||||
for frame_type in assigned_frame_types:
|
||||
if frame_type in self.frame_type_buttons:
|
||||
btn = self.frame_type_buttons[frame_type]
|
||||
flow_count = frame_type_flow_counts[frame_type]
|
||||
hotkey = btn.label.split('.')[0] if '.' in btn.label else '?'
|
||||
short_name = btn._shorten_frame_type(frame_type)
|
||||
btn.label = f"{hotkey}.{short_name}({flow_count})"
|
||||
btn.count = flow_count
|
||||
btn.visible = flow_count > 0
|
||||
|
||||
# Update button highlighting
|
||||
self._update_button_highlighting()
|
||||
debug_log("refresh_frame_types() - Button visibility and content updated (NO RECREATION)")
|
||||
debug_button_state(self.frame_type_buttons, "AFTER_VISIBILITY_UPDATE")
|
||||
|
||||
# Track frame types for change detection
|
||||
current_frame_types = set(frame_types.keys())
|
||||
if current_frame_types != self._last_frame_types:
|
||||
self._last_frame_types = current_frame_types
|
||||
|
||||
# CRITICAL: Rebuild table columns when frame types change (for Overview mode)
|
||||
if self.selected_frame_type == "Overview":
|
||||
self._setup_flow_table()
|
||||
# Clear existing data before adding new data with new column structure
|
||||
self.flow_table.clear()
|
||||
|
||||
# _update_button_counts method removed - buttons are now managed by visibility only
|
||||
|
||||
|
||||
def refresh_flow_data(self):
|
||||
"""Refresh the flow table based on selected filter"""
|
||||
self.flow_table.clear()
|
||||
|
||||
if self.selected_frame_type == "Overview":
|
||||
self._show_overview()
|
||||
else:
|
||||
self._show_frame_type_flows(self.selected_frame_type)
|
||||
|
||||
def _show_overview(self):
|
||||
"""Show all flows in overview mode with frame type columns"""
|
||||
flows = list(self.analyzer.flows.values())
|
||||
all_frame_types = self._get_all_frame_types()
|
||||
sorted_frame_types = sorted(all_frame_types.keys(), key=lambda x: all_frame_types[x], reverse=True)
|
||||
|
||||
# Get current table columns to check what frame types are expected
|
||||
try:
|
||||
table_columns = [col.key for col in self.flow_table._columns]
|
||||
except (AttributeError, TypeError):
|
||||
# If columns aren't accessible, fall back to using current frame types
|
||||
table_columns = []
|
||||
|
||||
expected_frame_types = []
|
||||
for col_key in table_columns:
|
||||
if col_key.startswith("ft_"):
|
||||
# Extract frame type from column key
|
||||
expected_frame_types.append(col_key[3:]) # Remove "ft_" prefix
|
||||
|
||||
# If no frame type columns detected, use sorted frame types directly
|
||||
if not expected_frame_types:
|
||||
expected_frame_types = [frame_type.replace('-', '_').replace(':', '_').replace('(', '_').replace(')', '_').replace(' ', '_').replace('.', '_') for frame_type in sorted_frame_types]
|
||||
|
||||
# Collect all row data first
|
||||
all_rows = []
|
||||
|
||||
for i, flow in enumerate(flows):
|
||||
# Status based on enhanced analysis
|
||||
status = "Enhanced" if flow.enhanced_analysis.decoder_type != "Standard" else "Normal"
|
||||
status_style = "green" if status == "Enhanced" else "white"
|
||||
|
||||
# Start with basic flow info
|
||||
row_data = [
|
||||
str(i + 1),
|
||||
f"{flow.src_ip}:{flow.src_port}",
|
||||
f"{flow.dst_ip}:{flow.dst_port}",
|
||||
flow.transport_protocol,
|
||||
str(flow.frame_count)
|
||||
]
|
||||
|
||||
# Add packet count for each frame type column in the order they appear in table
|
||||
for expected_ft_key in expected_frame_types:
|
||||
# Find the actual frame type that matches this column key
|
||||
matching_frame_type = None
|
||||
for frame_type in sorted_frame_types:
|
||||
safe_key = frame_type.replace('-', '_').replace(':', '_').replace('(', '_').replace(')', '_').replace(' ', '_').replace('.', '_')
|
||||
if safe_key == expected_ft_key:
|
||||
matching_frame_type = frame_type
|
||||
break
|
||||
|
||||
if matching_frame_type and matching_frame_type in flow.frame_types:
|
||||
count = flow.frame_types[matching_frame_type].count
|
||||
if count > 0:
|
||||
colored_count = self._color_code_packet_count(count, all_frame_types[matching_frame_type])
|
||||
row_data.append(colored_count)
|
||||
else:
|
||||
row_data.append("-")
|
||||
else:
|
||||
row_data.append("-")
|
||||
|
||||
# Add status
|
||||
row_data.append(Text(status, style=status_style))
|
||||
|
||||
# Store row data with original flow index for key
|
||||
all_rows.append((row_data, i))
|
||||
|
||||
# Sort rows if sorting is enabled
|
||||
if self.sort_column is not None and all_rows:
|
||||
all_rows.sort(key=lambda x: self._get_sort_key(x[0], self.sort_column), reverse=self.sort_reverse)
|
||||
|
||||
# Add sorted rows to table
|
||||
for row_data, original_index in all_rows:
|
||||
# CRITICAL: Validate row data matches column count before adding
|
||||
try:
|
||||
# Get column count for validation
|
||||
column_count = len(self.flow_table.ordered_columns) if hasattr(self.flow_table, 'ordered_columns') else 0
|
||||
if column_count > 0 and len(row_data) != column_count:
|
||||
# Skip this row if data doesn't match columns - table structure is being updated
|
||||
continue
|
||||
|
||||
self.flow_table.add_row(*row_data, key=f"flow-{original_index}")
|
||||
except (ValueError, AttributeError) as e:
|
||||
# Skip this row if there's a column mismatch - table is being rebuilt
|
||||
continue
|
||||
|
||||
def _show_frame_type_flows(self, frame_type: str):
|
||||
"""Show flows filtered by frame type with timing statistics"""
|
||||
flows_with_type = []
|
||||
|
||||
for i, flow in enumerate(self.analyzer.flows.values()):
|
||||
if frame_type in flow.frame_types:
|
||||
flows_with_type.append((i, flow, flow.frame_types[frame_type]))
|
||||
|
||||
# Collect all row data first
|
||||
all_rows = []
|
||||
|
||||
for flow_idx, flow, ft_stats in flows_with_type:
|
||||
# Calculate timing statistics
|
||||
if ft_stats.inter_arrival_times:
|
||||
min_delta = min(ft_stats.inter_arrival_times) * 1000
|
||||
max_delta = max(ft_stats.inter_arrival_times) * 1000
|
||||
else:
|
||||
min_delta = max_delta = 0
|
||||
|
||||
# Quality score
|
||||
quality = self._calculate_quality(ft_stats)
|
||||
quality_text = self._format_quality(quality)
|
||||
|
||||
row_data = [
|
||||
str(flow_idx + 1),
|
||||
f"{flow.src_ip}:{flow.src_port}",
|
||||
f"{flow.dst_ip}:{flow.dst_port}",
|
||||
flow.transport_protocol,
|
||||
str(ft_stats.count),
|
||||
f"{ft_stats.avg_inter_arrival * 1000:.1f}ms" if ft_stats.avg_inter_arrival > 0 else "N/A",
|
||||
f"{ft_stats.std_inter_arrival * 1000:.1f}ms" if ft_stats.std_inter_arrival > 0 else "N/A",
|
||||
f"{min_delta:.1f}ms" if min_delta > 0 else "N/A",
|
||||
f"{max_delta:.1f}ms" if max_delta > 0 else "N/A",
|
||||
str(len(ft_stats.outlier_frames)),
|
||||
quality_text
|
||||
]
|
||||
|
||||
# Store row data with original flow index for key
|
||||
all_rows.append((row_data, flow_idx))
|
||||
|
||||
# Sort rows if sorting is enabled
|
||||
if self.sort_column is not None and all_rows:
|
||||
all_rows.sort(key=lambda x: self._get_sort_key(x[0], self.sort_column), reverse=self.sort_reverse)
|
||||
|
||||
# Add sorted rows to table
|
||||
for row_data, original_index in all_rows:
|
||||
# CRITICAL: Validate row data matches column count before adding
|
||||
try:
|
||||
# Get column count for validation
|
||||
column_count = len(self.flow_table.ordered_columns) if hasattr(self.flow_table, 'ordered_columns') else 0
|
||||
if column_count > 0 and len(row_data) != column_count:
|
||||
# Skip this row if data doesn't match columns - table structure is being updated
|
||||
continue
|
||||
|
||||
self.flow_table.add_row(*row_data, key=f"flow-{original_index}")
|
||||
except (ValueError, AttributeError) as e:
|
||||
# Skip this row if there's a column mismatch - table is being rebuilt
|
||||
continue
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle filter button clicks"""
|
||||
button = event.button
|
||||
|
||||
# Determine frame type from button
|
||||
if button.id == "btn-overview":
|
||||
self.select_frame_type("Overview")
|
||||
else:
|
||||
# Extract frame type from button
|
||||
for frame_type, btn in self.frame_type_buttons.items():
|
||||
if btn == button:
|
||||
self.select_frame_type(frame_type)
|
||||
break
|
||||
|
||||
def select_frame_type(self, frame_type: str):
|
||||
"""Select a frame type filter"""
|
||||
if self.selected_frame_type != frame_type:
|
||||
self.selected_frame_type = frame_type
|
||||
self._setup_flow_table()
|
||||
self.refresh_flow_data()
|
||||
self.post_message(self.FrameTypeSelected(frame_type))
|
||||
|
||||
# Update button highlighting
|
||||
self._update_button_highlighting()
|
||||
debug_log("refresh_frame_types() - Buttons recreated")
|
||||
debug_button_state(self.frame_type_buttons, "AFTER_BUTTON_CREATION")
|
||||
debug_log("on_mount() - Initialization complete")
|
||||
debug_button_state(self.frame_type_buttons, "AFTER_MOUNT_COMPLETE")
|
||||
|
||||
def _update_button_highlighting(self):
|
||||
"""Update which button appears active/highlighted"""
|
||||
for frame_type, btn in self.frame_type_buttons.items():
|
||||
if frame_type == self.selected_frame_type:
|
||||
btn.add_class("-active")
|
||||
else:
|
||||
btn.remove_class("-active")
|
||||
|
||||
def action_select_filter(self, number: str):
|
||||
"""Handle number key press for filter selection"""
|
||||
if number == '1':
|
||||
# Overview
|
||||
self.select_frame_type("Overview")
|
||||
else:
|
||||
# Frame type buttons - find by hotkey
|
||||
hotkeys = ['2', '3', '4', '5', '6', '7', '8', '9', '0']
|
||||
if number in hotkeys:
|
||||
# Find the button with this hotkey
|
||||
for frame_type, btn in self.frame_type_buttons.items():
|
||||
if frame_type != "Overview" and hasattr(btn, 'frame_type'):
|
||||
# Check if this button's label starts with this number
|
||||
if btn.label.plain.startswith(f"{number}."):
|
||||
self.select_frame_type(frame_type)
|
||||
break
|
||||
|
||||
def action_sort_column(self, column_index: int):
|
||||
"""Sort table by specified column index (0-based)"""
|
||||
# Check if we have enough columns
|
||||
if not self.flow_table or not hasattr(self.flow_table, 'ordered_columns'):
|
||||
return
|
||||
|
||||
if column_index >= len(self.flow_table.ordered_columns):
|
||||
return # Column doesn't exist
|
||||
|
||||
# Toggle sort direction if same column, otherwise start with ascending
|
||||
if self.sort_column == column_index:
|
||||
self.sort_reverse = not self.sort_reverse
|
||||
else:
|
||||
self.sort_column = column_index
|
||||
self.sort_reverse = False
|
||||
|
||||
# Refresh data with new sorting
|
||||
self.refresh_flow_data()
|
||||
|
||||
def _get_sort_key(self, row_data: list, column_index: int):
|
||||
"""Get sort key for a row based on column index"""
|
||||
if column_index >= len(row_data):
|
||||
return ""
|
||||
|
||||
value = row_data[column_index]
|
||||
|
||||
# Handle Text objects (extract plain text)
|
||||
if hasattr(value, 'plain'):
|
||||
text_value = value.plain
|
||||
else:
|
||||
text_value = str(value)
|
||||
|
||||
# Try to convert to number for numeric sorting
|
||||
try:
|
||||
# Handle values like "1,105" (remove commas)
|
||||
if ',' in text_value:
|
||||
text_value = text_value.replace(',', '')
|
||||
|
||||
# Handle values with units like "102.2ms" or "1.5MB"
|
||||
if text_value.endswith('ms'):
|
||||
return float(text_value[:-2])
|
||||
elif text_value.endswith('MB'):
|
||||
return float(text_value[:-2]) * 1000000
|
||||
elif text_value.endswith('KB'):
|
||||
return float(text_value[:-2]) * 1000
|
||||
elif text_value.endswith('B'):
|
||||
return float(text_value[:-1])
|
||||
elif text_value.endswith('%'):
|
||||
return float(text_value[:-1])
|
||||
elif text_value == "N/A" or text_value == "-":
|
||||
return -1 # Sort N/A and "-" values to the end
|
||||
else:
|
||||
return float(text_value)
|
||||
except (ValueError, AttributeError):
|
||||
# For string values, use alphabetical sorting
|
||||
return text_value.lower()
|
||||
|
||||
def _format_bytes(self, bytes_val: int) -> str:
|
||||
"""Format bytes to human readable"""
|
||||
if bytes_val < 1024:
|
||||
return f"{bytes_val}B"
|
||||
elif bytes_val < 1024 * 1024:
|
||||
return f"{bytes_val / 1024:.1f}KB"
|
||||
else:
|
||||
return f"{bytes_val / (1024 * 1024):.1f}MB"
|
||||
|
||||
def _calculate_quality(self, ft_stats) -> float:
|
||||
"""Calculate quality score for frame type stats"""
|
||||
if ft_stats.count == 0:
|
||||
return 0.0
|
||||
|
||||
outlier_rate = len(ft_stats.outlier_frames) / ft_stats.count
|
||||
consistency = 1.0 - min(outlier_rate * 2, 1.0)
|
||||
return consistency * 100
|
||||
|
||||
def _format_quality(self, quality: float) -> Text:
|
||||
"""Format quality with color"""
|
||||
if quality >= 90:
|
||||
return Text(f"{quality:.0f}%", style="green")
|
||||
elif quality >= 70:
|
||||
return Text(f"{quality:.0f}%", style="yellow")
|
||||
else:
|
||||
return Text(f"{quality:.0f}%", style="red")
|
||||
|
||||
def _get_all_frame_types(self) -> dict:
|
||||
"""Get all frame types across all flows with their total counts"""
|
||||
frame_types = {}
|
||||
for flow in self.analyzer.flows.values():
|
||||
for frame_type, stats in flow.frame_types.items():
|
||||
if frame_type not in frame_types:
|
||||
frame_types[frame_type] = 0
|
||||
frame_types[frame_type] += stats.count
|
||||
return frame_types
|
||||
|
||||
def _shorten_frame_type_name(self, frame_type: str) -> str:
|
||||
"""Shorten frame type names for better column display"""
|
||||
# Common abbreviations for better column display
|
||||
abbreviations = {
|
||||
'CH10-Data': 'CH10',
|
||||
'CH10-Multi-Source': 'Multi',
|
||||
'CH10-Extended': 'Ext',
|
||||
'CH10-ACTTS': 'ACTTS',
|
||||
'PTP-Signaling': 'PTP-Sig',
|
||||
'PTP-FollowUp': 'PTP-FU',
|
||||
'PTP-Sync': 'PTP-Syn',
|
||||
'PTP-Unknown (0x6)': 'PTP-Unk',
|
||||
'UDP': 'UDP',
|
||||
'TMATS': 'TMATS',
|
||||
'TCP': 'TCP'
|
||||
}
|
||||
return abbreviations.get(frame_type, frame_type[:8])
|
||||
|
||||
def _color_code_packet_count(self, count: int, max_count: int) -> Text:
|
||||
"""Color code packet counts based on relative frequency"""
|
||||
if max_count == 0:
|
||||
return Text(str(count), style="white")
|
||||
|
||||
# Calculate percentage of maximum for this frame type
|
||||
percentage = (count / max_count) * 100
|
||||
|
||||
if percentage >= 80: # High volume (80-100% of max)
|
||||
return Text(str(count), style="red bold")
|
||||
elif percentage >= 50: # Medium-high volume (50-79% of max)
|
||||
return Text(str(count), style="yellow bold")
|
||||
elif percentage >= 20: # Medium volume (20-49% of max)
|
||||
return Text(str(count), style="cyan")
|
||||
elif percentage >= 5: # Low volume (5-19% of max)
|
||||
return Text(str(count), style="blue")
|
||||
else: # Very low volume (0-4% of max)
|
||||
return Text(str(count), style="dim white")
|
||||
692
analyzer/tui/textual/widgets/filtered_flow_view.py.debug_backup
Normal file
692
analyzer/tui/textual/widgets/filtered_flow_view.py.debug_backup
Normal file
@@ -0,0 +1,692 @@
|
||||
"""
|
||||
Filtered Flow View Widget - Grid with frame type filter buttons
|
||||
"""
|
||||
|
||||
from textual.widgets import Button, DataTable, Static
|
||||
from textual.containers import Vertical, Horizontal
|
||||
from textual.reactive import reactive
|
||||
from textual.message import Message
|
||||
from textual.binding import Binding
|
||||
from typing import TYPE_CHECKING, Optional, List, Dict
|
||||
from rich.text import Text
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ....analysis.core import EthernetAnalyzer
|
||||
from ....models import FlowStats
|
||||
|
||||
|
||||
class FrameTypeButton(Button):
|
||||
"""Button for frame type filtering"""
|
||||
|
||||
def __init__(self, frame_type: str, hotkey: str, count: int = 0, **kwargs):
|
||||
self.frame_type = frame_type
|
||||
self.count = count
|
||||
# Shorten frame type names for 1-row buttons
|
||||
short_name = self._shorten_frame_type(frame_type)
|
||||
label = f"{hotkey}.{short_name}({count})" # Remove spaces to be more compact
|
||||
# Create valid ID by removing/replacing invalid characters
|
||||
safe_id = frame_type.replace('-', '_').replace(':', '_').replace('(', '_').replace(')', '_').replace(' ', '_').replace('.', '_')
|
||||
super().__init__(label, id=f"btn-{safe_id}", **kwargs)
|
||||
|
||||
def _shorten_frame_type(self, frame_type: str) -> str:
|
||||
"""Shorten frame type names for compact 1-row buttons"""
|
||||
abbreviations = {
|
||||
'CH10-Data': 'CH10',
|
||||
'CH10-Multi-Source': 'Multi',
|
||||
'CH10-Extended': 'Ext',
|
||||
'CH10-ACTTS': 'ACTTS',
|
||||
'PTP-Signaling': 'PTP-S',
|
||||
'PTP-FollowUp': 'PTP-F',
|
||||
'PTP-Sync': 'PTP',
|
||||
'PTP-Unknown (0x6)': 'PTP-U',
|
||||
'UDP': 'UDP',
|
||||
'TMATS': 'TMATS',
|
||||
'TCP': 'TCP'
|
||||
}
|
||||
return abbreviations.get(frame_type, frame_type[:6]) # Max 6 chars for unknown types
|
||||
|
||||
|
||||
class FilteredFlowView(Vertical):
|
||||
"""Flow grid with frame type filter buttons"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("alt+1", "sort_column(0)", "Sort by column 1", show=False),
|
||||
Binding("alt+2", "sort_column(1)", "Sort by column 2", show=False),
|
||||
Binding("alt+3", "sort_column(2)", "Sort by column 3", show=False),
|
||||
Binding("alt+4", "sort_column(3)", "Sort by column 4", show=False),
|
||||
Binding("alt+5", "sort_column(4)", "Sort by column 5", show=False),
|
||||
Binding("alt+6", "sort_column(5)", "Sort by column 6", show=False),
|
||||
Binding("alt+7", "sort_column(6)", "Sort by column 7", show=False),
|
||||
Binding("alt+8", "sort_column(7)", "Sort by column 8", show=False),
|
||||
Binding("alt+9", "sort_column(8)", "Sort by column 9", show=False),
|
||||
Binding("alt+0", "sort_column(9)", "Sort by column 10", show=False),
|
||||
]
|
||||
|
||||
DEFAULT_CSS = """
|
||||
FilteredFlowView {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
#filter-bar {
|
||||
height: auto;
|
||||
min-height: 1;
|
||||
max-height: 1;
|
||||
background: #262626;
|
||||
padding: 0 1;
|
||||
dock: top;
|
||||
layout: horizontal;
|
||||
}
|
||||
|
||||
#filter-bar Button {
|
||||
margin: 0 1 0 0; /* Consistent right spacing */
|
||||
min-width: 10; /* Reduced for compact labels */
|
||||
height: auto;
|
||||
min-height: 1;
|
||||
padding: 0; /* Remove padding to fit text in 1 row */
|
||||
text-align: center; /* Center text in button */
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
#btn-overview {
|
||||
margin: 0 1 0 0; /* Overview button - same spacing */
|
||||
height: auto;
|
||||
min-height: 1;
|
||||
padding: 0; /* Remove padding to fit text in 1 row */
|
||||
text-align: center; /* Center text in button */
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
#filter-bar Button:hover {
|
||||
background: #0080ff;
|
||||
}
|
||||
|
||||
#filter-bar Button.-active {
|
||||
background: #0080ff;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
#filtered-flow-table {
|
||||
height: 1fr;
|
||||
}
|
||||
"""
|
||||
|
||||
selected_frame_type = reactive("Overview")
|
||||
|
||||
class FrameTypeSelected(Message):
|
||||
"""Message when frame type filter is selected"""
|
||||
def __init__(self, frame_type: str) -> None:
|
||||
self.frame_type = frame_type
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, analyzer: 'EthernetAnalyzer', **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.analyzer = analyzer
|
||||
self.frame_type_buttons = {}
|
||||
self.flow_table = None
|
||||
self._last_frame_types = set() # Track frame types to avoid unnecessary refreshes
|
||||
self._buttons_created = False # Track if buttons have been created to avoid flicker
|
||||
|
||||
# Table sorting state
|
||||
self.sort_column = None # Index of column to sort by (None = no sorting)
|
||||
self.sort_reverse = False # True for descending, False for ascending
|
||||
|
||||
# Button refresh throttling to prevent race conditions
|
||||
self._last_refresh_time = 0
|
||||
self._refresh_throttle_seconds = 1.0 # Only refresh buttons once per second
|
||||
|
||||
# Predefined frame types that will have buttons created at initialization
|
||||
self.predefined_frame_types = [
|
||||
'CH10-Data',
|
||||
'UDP',
|
||||
'PTP-Sync',
|
||||
'PTP-Signaling',
|
||||
'PTP-FollowUp',
|
||||
'TMATS',
|
||||
'TCP',
|
||||
'CH10-Multi-Source',
|
||||
'CH10-Extended'
|
||||
]
|
||||
|
||||
def compose(self):
|
||||
"""Create the filter bar and flow grid"""
|
||||
# Filter button bar at top
|
||||
with Horizontal(id="filter-bar"):
|
||||
# Overview button (hotkey 1) - compact format
|
||||
overview_btn = Button("1.Overview", id="btn-overview", classes="-active")
|
||||
self.frame_type_buttons["Overview"] = overview_btn
|
||||
yield overview_btn
|
||||
|
||||
# Create predefined frame type buttons at initialization
|
||||
# Note: Initial order will be updated by refresh_frame_types() to sort by count
|
||||
hotkeys = ['2', '3', '4', '5', '6', '7', '8', '9', '0']
|
||||
for i, frame_type in enumerate(self.predefined_frame_types):
|
||||
if i < len(hotkeys):
|
||||
# Start with 0 count - will be updated during data refresh
|
||||
btn = FrameTypeButton(frame_type, hotkeys[i], 0)
|
||||
self.frame_type_buttons[frame_type] = btn
|
||||
yield btn
|
||||
|
||||
# Flow data table
|
||||
self.flow_table = DataTable(
|
||||
id="filtered-flow-table",
|
||||
cursor_type="row",
|
||||
zebra_stripes=True,
|
||||
show_header=True,
|
||||
show_row_labels=False
|
||||
)
|
||||
yield self.flow_table
|
||||
|
||||
def on_mount(self):
|
||||
"""Initialize the view"""
|
||||
self._setup_flow_table()
|
||||
# Mark buttons as created since we pre-created them in compose()
|
||||
self._buttons_created = True
|
||||
# Update button counts and data
|
||||
self.refresh_frame_types()
|
||||
self.refresh_flow_data()
|
||||
# Ensure Overview button starts highlighted
|
||||
self._update_button_highlighting()
|
||||
|
||||
def _setup_flow_table(self):
|
||||
"""Setup table columns based on selected frame type"""
|
||||
table = self.flow_table
|
||||
table.clear(columns=True)
|
||||
|
||||
if self.selected_frame_type == "Overview":
|
||||
# Overview columns with individual frame type columns
|
||||
table.add_column("#", width=4, key="num")
|
||||
table.add_column("Source", width=18, key="source")
|
||||
table.add_column("Destination", width=18, key="dest")
|
||||
table.add_column("Protocol", width=8, key="protocol")
|
||||
table.add_column("Total", width=8, key="total_packets")
|
||||
|
||||
# Add columns for each detected frame type
|
||||
all_frame_types = self._get_all_frame_types()
|
||||
for frame_type in sorted(all_frame_types.keys(), key=lambda x: all_frame_types[x], reverse=True):
|
||||
# Shorten column name for better display
|
||||
short_name = self._shorten_frame_type_name(frame_type)
|
||||
# Create safe key for column
|
||||
safe_key = frame_type.replace('-', '_').replace(':', '_').replace('(', '_').replace(')', '_').replace(' ', '_').replace('.', '_')
|
||||
table.add_column(short_name, width=8, key=f"ft_{safe_key}")
|
||||
|
||||
table.add_column("Status", width=10, key="status")
|
||||
else:
|
||||
# Frame type specific columns
|
||||
table.add_column("#", width=4, key="num")
|
||||
table.add_column("Source", width=20, key="source")
|
||||
table.add_column("Destination", width=20, key="dest")
|
||||
table.add_column("Protocol", width=8, key="protocol")
|
||||
table.add_column(f"{self.selected_frame_type} Packets", width=12, key="ft_packets")
|
||||
table.add_column("Avg ΔT", width=10, key="avg_delta")
|
||||
table.add_column("Std ΔT", width=10, key="std_delta")
|
||||
table.add_column("Min ΔT", width=10, key="min_delta")
|
||||
table.add_column("Max ΔT", width=10, key="max_delta")
|
||||
table.add_column("Outliers", width=8, key="outliers")
|
||||
table.add_column("Quality", width=8, key="quality")
|
||||
|
||||
def refresh_frame_types(self):
|
||||
"""Update frame type button counts and reorder by count (highest to left)"""
|
||||
# Throttle button refresh to prevent race conditions
|
||||
import time
|
||||
current_time = time.time()
|
||||
if current_time - self._last_refresh_time < self._refresh_throttle_seconds:
|
||||
return # Skip refresh if called too recently
|
||||
self._last_refresh_time = current_time
|
||||
|
||||
# Get all detected frame types with their total packet counts
|
||||
frame_types = self._get_all_frame_types()
|
||||
|
||||
# If no frame types yet, skip button update
|
||||
if not frame_types:
|
||||
return
|
||||
|
||||
# Calculate flow counts for all frame types (including new ones)
|
||||
frame_type_flow_counts = {}
|
||||
for frame_type in frame_types.keys():
|
||||
flow_count = sum(1 for flow in self.analyzer.flows.values() if frame_type in flow.frame_types)
|
||||
frame_type_flow_counts[frame_type] = flow_count
|
||||
|
||||
# Sort frame types by count (highest first)
|
||||
sorted_frame_types = sorted(frame_type_flow_counts.items(), key=lambda x: x[1], reverse=True)
|
||||
|
||||
# Check if the order has actually changed to avoid unnecessary updates
|
||||
# Include predefined frame types even with 0 count to avoid unnecessary recreation
|
||||
current_order = [ft for ft, _ in sorted_frame_types[:9]
|
||||
if frame_type_flow_counts[ft] > 0 or ft in self.predefined_frame_types]
|
||||
|
||||
# Get the previous order from button tracking
|
||||
previous_order = [ft for ft in self.frame_type_buttons.keys() if ft != "Overview"]
|
||||
|
||||
# Check if we can just update counts instead of recreating buttons
|
||||
# During early loading, be more flexible about order changes for predefined types
|
||||
can_update_counts_only = False
|
||||
|
||||
if len(current_order) == len(previous_order):
|
||||
# Same number of buttons - check if they're the same set (order can be different during loading)
|
||||
current_set = set(current_order)
|
||||
previous_set = set(previous_order)
|
||||
|
||||
if current_set == previous_set:
|
||||
# Same frame types, just update counts without recreating
|
||||
can_update_counts_only = True
|
||||
elif all(ft in self.predefined_frame_types for ft in current_set.symmetric_difference(previous_set)):
|
||||
# Only predefined types differ - still safe to just update counts during loading
|
||||
can_update_counts_only = True
|
||||
|
||||
if can_update_counts_only:
|
||||
# Just update counts in existing buttons
|
||||
self._update_button_counts(frame_type_flow_counts)
|
||||
return
|
||||
|
||||
# Order changed, need to recreate buttons
|
||||
try:
|
||||
filter_bar = self.query_one("#filter-bar", Horizontal)
|
||||
except Exception:
|
||||
# Filter bar not available yet
|
||||
return
|
||||
|
||||
# Remove all buttons except Overview - use a safer approach
|
||||
overview_btn = None
|
||||
buttons_to_remove = []
|
||||
|
||||
for widget in list(filter_bar.children):
|
||||
if widget.id == "btn-overview":
|
||||
overview_btn = widget
|
||||
else:
|
||||
buttons_to_remove.append(widget)
|
||||
|
||||
# Remove non-overview buttons
|
||||
for widget in buttons_to_remove:
|
||||
try:
|
||||
if widget.parent: # Only remove if still has parent
|
||||
widget.remove()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Clear frame type buttons dict and keep overview
|
||||
self.frame_type_buttons.clear()
|
||||
if overview_btn:
|
||||
self.frame_type_buttons["Overview"] = overview_btn
|
||||
|
||||
# Add new buttons in sorted order
|
||||
hotkeys = ['2', '3', '4', '5', '6', '7', '8', '9', '0']
|
||||
for i, (frame_type, flow_count) in enumerate(sorted_frame_types[:9]):
|
||||
# Always show predefined frame types, even with 0 count during early loading
|
||||
# Only skip if count is 0 AND it's not a predefined frame type
|
||||
should_show = (flow_count > 0) or (frame_type in self.predefined_frame_types)
|
||||
|
||||
if i < len(hotkeys) and should_show:
|
||||
btn = FrameTypeButton(frame_type, hotkeys[i], flow_count)
|
||||
self.frame_type_buttons[frame_type] = btn
|
||||
try:
|
||||
filter_bar.mount(btn)
|
||||
except Exception:
|
||||
# If mount fails, skip this button
|
||||
pass
|
||||
|
||||
# Update button highlighting
|
||||
self._update_button_highlighting()
|
||||
|
||||
# Track frame types for change detection
|
||||
current_frame_types = set(frame_types.keys())
|
||||
if current_frame_types != self._last_frame_types:
|
||||
self._last_frame_types = current_frame_types
|
||||
|
||||
# CRITICAL: Rebuild table columns when frame types change (for Overview mode)
|
||||
if self.selected_frame_type == "Overview":
|
||||
self._setup_flow_table()
|
||||
# Clear existing data before adding new data with new column structure
|
||||
self.flow_table.clear()
|
||||
|
||||
def _update_button_counts(self, frame_type_flow_counts: dict):
|
||||
"""Update button counts without recreating buttons"""
|
||||
for frame_type, btn in self.frame_type_buttons.items():
|
||||
if frame_type == "Overview":
|
||||
continue
|
||||
|
||||
if frame_type in frame_type_flow_counts:
|
||||
flow_count = frame_type_flow_counts[frame_type]
|
||||
# Extract hotkey from current label
|
||||
try:
|
||||
hotkey = btn.label.split('.')[0]
|
||||
short_name = btn._shorten_frame_type(frame_type)
|
||||
btn.label = f"{hotkey}.{short_name}({flow_count})"
|
||||
btn.count = flow_count
|
||||
except Exception:
|
||||
# If label update fails, ignore
|
||||
pass
|
||||
|
||||
|
||||
def refresh_flow_data(self):
|
||||
"""Refresh the flow table based on selected filter"""
|
||||
self.flow_table.clear()
|
||||
|
||||
if self.selected_frame_type == "Overview":
|
||||
self._show_overview()
|
||||
else:
|
||||
self._show_frame_type_flows(self.selected_frame_type)
|
||||
|
||||
def _show_overview(self):
|
||||
"""Show all flows in overview mode with frame type columns"""
|
||||
flows = list(self.analyzer.flows.values())
|
||||
all_frame_types = self._get_all_frame_types()
|
||||
sorted_frame_types = sorted(all_frame_types.keys(), key=lambda x: all_frame_types[x], reverse=True)
|
||||
|
||||
# Get current table columns to check what frame types are expected
|
||||
try:
|
||||
table_columns = [col.key for col in self.flow_table._columns]
|
||||
except (AttributeError, TypeError):
|
||||
# If columns aren't accessible, fall back to using current frame types
|
||||
table_columns = []
|
||||
|
||||
expected_frame_types = []
|
||||
for col_key in table_columns:
|
||||
if col_key.startswith("ft_"):
|
||||
# Extract frame type from column key
|
||||
expected_frame_types.append(col_key[3:]) # Remove "ft_" prefix
|
||||
|
||||
# If no frame type columns detected, use sorted frame types directly
|
||||
if not expected_frame_types:
|
||||
expected_frame_types = [frame_type.replace('-', '_').replace(':', '_').replace('(', '_').replace(')', '_').replace(' ', '_').replace('.', '_') for frame_type in sorted_frame_types]
|
||||
|
||||
# Collect all row data first
|
||||
all_rows = []
|
||||
|
||||
for i, flow in enumerate(flows):
|
||||
# Status based on enhanced analysis
|
||||
status = "Enhanced" if flow.enhanced_analysis.decoder_type != "Standard" else "Normal"
|
||||
status_style = "green" if status == "Enhanced" else "white"
|
||||
|
||||
# Start with basic flow info
|
||||
row_data = [
|
||||
str(i + 1),
|
||||
f"{flow.src_ip}:{flow.src_port}",
|
||||
f"{flow.dst_ip}:{flow.dst_port}",
|
||||
flow.transport_protocol,
|
||||
str(flow.frame_count)
|
||||
]
|
||||
|
||||
# Add packet count for each frame type column in the order they appear in table
|
||||
for expected_ft_key in expected_frame_types:
|
||||
# Find the actual frame type that matches this column key
|
||||
matching_frame_type = None
|
||||
for frame_type in sorted_frame_types:
|
||||
safe_key = frame_type.replace('-', '_').replace(':', '_').replace('(', '_').replace(')', '_').replace(' ', '_').replace('.', '_')
|
||||
if safe_key == expected_ft_key:
|
||||
matching_frame_type = frame_type
|
||||
break
|
||||
|
||||
if matching_frame_type and matching_frame_type in flow.frame_types:
|
||||
count = flow.frame_types[matching_frame_type].count
|
||||
if count > 0:
|
||||
colored_count = self._color_code_packet_count(count, all_frame_types[matching_frame_type])
|
||||
row_data.append(colored_count)
|
||||
else:
|
||||
row_data.append("-")
|
||||
else:
|
||||
row_data.append("-")
|
||||
|
||||
# Add status
|
||||
row_data.append(Text(status, style=status_style))
|
||||
|
||||
# Store row data with original flow index for key
|
||||
all_rows.append((row_data, i))
|
||||
|
||||
# Sort rows if sorting is enabled
|
||||
if self.sort_column is not None and all_rows:
|
||||
all_rows.sort(key=lambda x: self._get_sort_key(x[0], self.sort_column), reverse=self.sort_reverse)
|
||||
|
||||
# Add sorted rows to table
|
||||
for row_data, original_index in all_rows:
|
||||
# CRITICAL: Validate row data matches column count before adding
|
||||
try:
|
||||
# Get column count for validation
|
||||
column_count = len(self.flow_table.ordered_columns) if hasattr(self.flow_table, 'ordered_columns') else 0
|
||||
if column_count > 0 and len(row_data) != column_count:
|
||||
# Skip this row if data doesn't match columns - table structure is being updated
|
||||
continue
|
||||
|
||||
self.flow_table.add_row(*row_data, key=f"flow-{original_index}")
|
||||
except (ValueError, AttributeError) as e:
|
||||
# Skip this row if there's a column mismatch - table is being rebuilt
|
||||
continue
|
||||
|
||||
def _show_frame_type_flows(self, frame_type: str):
|
||||
"""Show flows filtered by frame type with timing statistics"""
|
||||
flows_with_type = []
|
||||
|
||||
for i, flow in enumerate(self.analyzer.flows.values()):
|
||||
if frame_type in flow.frame_types:
|
||||
flows_with_type.append((i, flow, flow.frame_types[frame_type]))
|
||||
|
||||
# Collect all row data first
|
||||
all_rows = []
|
||||
|
||||
for flow_idx, flow, ft_stats in flows_with_type:
|
||||
# Calculate timing statistics
|
||||
if ft_stats.inter_arrival_times:
|
||||
min_delta = min(ft_stats.inter_arrival_times) * 1000
|
||||
max_delta = max(ft_stats.inter_arrival_times) * 1000
|
||||
else:
|
||||
min_delta = max_delta = 0
|
||||
|
||||
# Quality score
|
||||
quality = self._calculate_quality(ft_stats)
|
||||
quality_text = self._format_quality(quality)
|
||||
|
||||
row_data = [
|
||||
str(flow_idx + 1),
|
||||
f"{flow.src_ip}:{flow.src_port}",
|
||||
f"{flow.dst_ip}:{flow.dst_port}",
|
||||
flow.transport_protocol,
|
||||
str(ft_stats.count),
|
||||
f"{ft_stats.avg_inter_arrival * 1000:.1f}ms" if ft_stats.avg_inter_arrival > 0 else "N/A",
|
||||
f"{ft_stats.std_inter_arrival * 1000:.1f}ms" if ft_stats.std_inter_arrival > 0 else "N/A",
|
||||
f"{min_delta:.1f}ms" if min_delta > 0 else "N/A",
|
||||
f"{max_delta:.1f}ms" if max_delta > 0 else "N/A",
|
||||
str(len(ft_stats.outlier_frames)),
|
||||
quality_text
|
||||
]
|
||||
|
||||
# Store row data with original flow index for key
|
||||
all_rows.append((row_data, flow_idx))
|
||||
|
||||
# Sort rows if sorting is enabled
|
||||
if self.sort_column is not None and all_rows:
|
||||
all_rows.sort(key=lambda x: self._get_sort_key(x[0], self.sort_column), reverse=self.sort_reverse)
|
||||
|
||||
# Add sorted rows to table
|
||||
for row_data, original_index in all_rows:
|
||||
# CRITICAL: Validate row data matches column count before adding
|
||||
try:
|
||||
# Get column count for validation
|
||||
column_count = len(self.flow_table.ordered_columns) if hasattr(self.flow_table, 'ordered_columns') else 0
|
||||
if column_count > 0 and len(row_data) != column_count:
|
||||
# Skip this row if data doesn't match columns - table structure is being updated
|
||||
continue
|
||||
|
||||
self.flow_table.add_row(*row_data, key=f"flow-{original_index}")
|
||||
except (ValueError, AttributeError) as e:
|
||||
# Skip this row if there's a column mismatch - table is being rebuilt
|
||||
continue
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle filter button clicks"""
|
||||
button = event.button
|
||||
|
||||
# Determine frame type from button
|
||||
if button.id == "btn-overview":
|
||||
self.select_frame_type("Overview")
|
||||
else:
|
||||
# Extract frame type from button
|
||||
for frame_type, btn in self.frame_type_buttons.items():
|
||||
if btn == button:
|
||||
self.select_frame_type(frame_type)
|
||||
break
|
||||
|
||||
def select_frame_type(self, frame_type: str):
|
||||
"""Select a frame type filter"""
|
||||
if self.selected_frame_type != frame_type:
|
||||
self.selected_frame_type = frame_type
|
||||
self._setup_flow_table()
|
||||
self.refresh_flow_data()
|
||||
self.post_message(self.FrameTypeSelected(frame_type))
|
||||
|
||||
# Update button highlighting
|
||||
self._update_button_highlighting()
|
||||
|
||||
def _update_button_highlighting(self):
|
||||
"""Update which button appears active/highlighted"""
|
||||
for frame_type, btn in self.frame_type_buttons.items():
|
||||
if frame_type == self.selected_frame_type:
|
||||
btn.add_class("-active")
|
||||
else:
|
||||
btn.remove_class("-active")
|
||||
|
||||
def action_select_filter(self, number: str):
|
||||
"""Handle number key press for filter selection"""
|
||||
if number == '1':
|
||||
# Overview
|
||||
self.select_frame_type("Overview")
|
||||
else:
|
||||
# Frame type buttons - find by hotkey
|
||||
hotkeys = ['2', '3', '4', '5', '6', '7', '8', '9', '0']
|
||||
if number in hotkeys:
|
||||
# Find the button with this hotkey
|
||||
for frame_type, btn in self.frame_type_buttons.items():
|
||||
if frame_type != "Overview" and hasattr(btn, 'frame_type'):
|
||||
# Check if this button's label starts with this number
|
||||
if btn.label.plain.startswith(f"{number}."):
|
||||
self.select_frame_type(frame_type)
|
||||
break
|
||||
|
||||
def action_sort_column(self, column_index: int):
|
||||
"""Sort table by specified column index (0-based)"""
|
||||
# Check if we have enough columns
|
||||
if not self.flow_table or not hasattr(self.flow_table, 'ordered_columns'):
|
||||
return
|
||||
|
||||
if column_index >= len(self.flow_table.ordered_columns):
|
||||
return # Column doesn't exist
|
||||
|
||||
# Toggle sort direction if same column, otherwise start with ascending
|
||||
if self.sort_column == column_index:
|
||||
self.sort_reverse = not self.sort_reverse
|
||||
else:
|
||||
self.sort_column = column_index
|
||||
self.sort_reverse = False
|
||||
|
||||
# Refresh data with new sorting
|
||||
self.refresh_flow_data()
|
||||
|
||||
def _get_sort_key(self, row_data: list, column_index: int):
|
||||
"""Get sort key for a row based on column index"""
|
||||
if column_index >= len(row_data):
|
||||
return ""
|
||||
|
||||
value = row_data[column_index]
|
||||
|
||||
# Handle Text objects (extract plain text)
|
||||
if hasattr(value, 'plain'):
|
||||
text_value = value.plain
|
||||
else:
|
||||
text_value = str(value)
|
||||
|
||||
# Try to convert to number for numeric sorting
|
||||
try:
|
||||
# Handle values like "1,105" (remove commas)
|
||||
if ',' in text_value:
|
||||
text_value = text_value.replace(',', '')
|
||||
|
||||
# Handle values with units like "102.2ms" or "1.5MB"
|
||||
if text_value.endswith('ms'):
|
||||
return float(text_value[:-2])
|
||||
elif text_value.endswith('MB'):
|
||||
return float(text_value[:-2]) * 1000000
|
||||
elif text_value.endswith('KB'):
|
||||
return float(text_value[:-2]) * 1000
|
||||
elif text_value.endswith('B'):
|
||||
return float(text_value[:-1])
|
||||
elif text_value.endswith('%'):
|
||||
return float(text_value[:-1])
|
||||
elif text_value == "N/A" or text_value == "-":
|
||||
return -1 # Sort N/A and "-" values to the end
|
||||
else:
|
||||
return float(text_value)
|
||||
except (ValueError, AttributeError):
|
||||
# For string values, use alphabetical sorting
|
||||
return text_value.lower()
|
||||
|
||||
def _format_bytes(self, bytes_val: int) -> str:
|
||||
"""Format bytes to human readable"""
|
||||
if bytes_val < 1024:
|
||||
return f"{bytes_val}B"
|
||||
elif bytes_val < 1024 * 1024:
|
||||
return f"{bytes_val / 1024:.1f}KB"
|
||||
else:
|
||||
return f"{bytes_val / (1024 * 1024):.1f}MB"
|
||||
|
||||
def _calculate_quality(self, ft_stats) -> float:
|
||||
"""Calculate quality score for frame type stats"""
|
||||
if ft_stats.count == 0:
|
||||
return 0.0
|
||||
|
||||
outlier_rate = len(ft_stats.outlier_frames) / ft_stats.count
|
||||
consistency = 1.0 - min(outlier_rate * 2, 1.0)
|
||||
return consistency * 100
|
||||
|
||||
def _format_quality(self, quality: float) -> Text:
|
||||
"""Format quality with color"""
|
||||
if quality >= 90:
|
||||
return Text(f"{quality:.0f}%", style="green")
|
||||
elif quality >= 70:
|
||||
return Text(f"{quality:.0f}%", style="yellow")
|
||||
else:
|
||||
return Text(f"{quality:.0f}%", style="red")
|
||||
|
||||
def _get_all_frame_types(self) -> dict:
|
||||
"""Get all frame types across all flows with their total counts"""
|
||||
frame_types = {}
|
||||
for flow in self.analyzer.flows.values():
|
||||
for frame_type, stats in flow.frame_types.items():
|
||||
if frame_type not in frame_types:
|
||||
frame_types[frame_type] = 0
|
||||
frame_types[frame_type] += stats.count
|
||||
return frame_types
|
||||
|
||||
def _shorten_frame_type_name(self, frame_type: str) -> str:
|
||||
"""Shorten frame type names for better column display"""
|
||||
# Common abbreviations for better column display
|
||||
abbreviations = {
|
||||
'CH10-Data': 'CH10',
|
||||
'CH10-Multi-Source': 'Multi',
|
||||
'CH10-Extended': 'Ext',
|
||||
'CH10-ACTTS': 'ACTTS',
|
||||
'PTP-Signaling': 'PTP-Sig',
|
||||
'PTP-FollowUp': 'PTP-FU',
|
||||
'PTP-Sync': 'PTP-Syn',
|
||||
'PTP-Unknown (0x6)': 'PTP-Unk',
|
||||
'UDP': 'UDP',
|
||||
'TMATS': 'TMATS',
|
||||
'TCP': 'TCP'
|
||||
}
|
||||
return abbreviations.get(frame_type, frame_type[:8])
|
||||
|
||||
def _color_code_packet_count(self, count: int, max_count: int) -> Text:
|
||||
"""Color code packet counts based on relative frequency"""
|
||||
if max_count == 0:
|
||||
return Text(str(count), style="white")
|
||||
|
||||
# Calculate percentage of maximum for this frame type
|
||||
percentage = (count / max_count) * 100
|
||||
|
||||
if percentage >= 80: # High volume (80-100% of max)
|
||||
return Text(str(count), style="red bold")
|
||||
elif percentage >= 50: # Medium-high volume (50-79% of max)
|
||||
return Text(str(count), style="yellow bold")
|
||||
elif percentage >= 20: # Medium volume (20-49% of max)
|
||||
return Text(str(count), style="cyan")
|
||||
elif percentage >= 5: # Low volume (5-19% of max)
|
||||
return Text(str(count), style="blue")
|
||||
else: # Very low volume (0-4% of max)
|
||||
return Text(str(count), style="dim white")
|
||||
@@ -43,7 +43,7 @@ class EnhancedFlowTable(Vertical):
|
||||
|
||||
selected_flow_index = reactive(0)
|
||||
sort_key = reactive("flows")
|
||||
simplified_view = reactive(False) # Toggle between detailed and simplified view
|
||||
simplified_view = reactive(True) # Default to simplified view without subflows
|
||||
|
||||
def __init__(self, analyzer: 'EthernetAnalyzer', **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
@@ -96,11 +96,12 @@ class EnhancedFlowTable(Vertical):
|
||||
table.add_column("Destination", width=18, key="dest")
|
||||
table.add_column("Extended", width=8, key="extended")
|
||||
table.add_column("Frame Type", width=10, key="frame_type")
|
||||
table.add_column("Pkts", width=6, key="rate")
|
||||
table.add_column("Pkts", width=6, key="packets")
|
||||
table.add_column("Size", width=8, key="volume")
|
||||
table.add_column("ΔT(ms)", width=8, key="delta_t")
|
||||
table.add_column("σ(ms)", width=8, key="sigma")
|
||||
table.add_column("Out", width=5, key="outliers")
|
||||
table.add_column("Rate", width=6, key="rate")
|
||||
|
||||
def refresh_data(self):
|
||||
"""Refresh flow table with current view mode"""
|
||||
@@ -228,45 +229,30 @@ class EnhancedFlowTable(Vertical):
|
||||
frame_summary = self._get_frame_summary(flow)
|
||||
frame_text = Text(frame_summary, style="blue")
|
||||
|
||||
# Rate with sparkline
|
||||
# Packet count (separate from rate)
|
||||
packets_text = Text(str(flow.frame_count), justify="right")
|
||||
|
||||
# Rate sparkline (separate column)
|
||||
rate_spark = self._create_rate_sparkline(metrics['rate_history'])
|
||||
rate_text = Text(f"{metrics['rate_history'][-1]:.0f} {rate_spark}")
|
||||
rate_text = Text(rate_spark, justify="center")
|
||||
|
||||
# Size with actual value
|
||||
size_value = self._format_bytes(flow.total_bytes)
|
||||
size_text = Text(f"{size_value:>8}")
|
||||
|
||||
# Delta T (average time between packets in ms)
|
||||
if flow.avg_inter_arrival > 0:
|
||||
delta_t_ms = flow.avg_inter_arrival * 1000
|
||||
if delta_t_ms >= 1000:
|
||||
delta_t_str = f"{delta_t_ms/1000:.1f}s"
|
||||
else:
|
||||
delta_t_str = f"{delta_t_ms:.1f}"
|
||||
else:
|
||||
delta_t_str = "N/A"
|
||||
delta_t_text = Text(delta_t_str, justify="right")
|
||||
# Delta T and Sigma - empty for main flows (subflows show the detail)
|
||||
delta_t_text = Text("", justify="right")
|
||||
sigma_text = Text("", justify="right")
|
||||
|
||||
# Sigma (standard deviation in ms)
|
||||
if flow.std_inter_arrival > 0:
|
||||
sigma_ms = flow.std_inter_arrival * 1000
|
||||
if sigma_ms >= 1000:
|
||||
sigma_str = f"{sigma_ms/1000:.1f}s"
|
||||
else:
|
||||
sigma_str = f"{sigma_ms:.1f}"
|
||||
else:
|
||||
sigma_str = "N/A"
|
||||
sigma_text = Text(sigma_str, justify="right")
|
||||
|
||||
# Outlier count (packets outside tolerance)
|
||||
outlier_count = len(flow.outlier_frames)
|
||||
outlier_text = Text(str(outlier_count), justify="right",
|
||||
style="red" if outlier_count > 0 else "green")
|
||||
# Outlier count - sum of frame-type-specific outliers (not flow-level)
|
||||
frame_type_outlier_count = sum(len(ft_stats.outlier_frames) for ft_stats in flow.frame_types.values())
|
||||
outlier_text = Text(str(frame_type_outlier_count), justify="right",
|
||||
style="red" if frame_type_outlier_count > 0 else "green")
|
||||
|
||||
return [
|
||||
num_text, source_text, proto_text, dest_text,
|
||||
extended_text, frame_text, rate_text, size_text,
|
||||
delta_t_text, sigma_text, outlier_text
|
||||
extended_text, frame_text, packets_text, size_text,
|
||||
delta_t_text, sigma_text, outlier_text, rate_text
|
||||
]
|
||||
|
||||
def _create_simplified_row(self, num: int, flow: 'FlowStats') -> List[Text]:
|
||||
@@ -389,20 +375,24 @@ class EnhancedFlowTable(Vertical):
|
||||
if flow.enhanced_analysis.decoder_type != "Standard":
|
||||
return int(flow.enhanced_analysis.avg_frame_quality)
|
||||
else:
|
||||
# Base quality on outlier percentage
|
||||
outlier_pct = len(flow.outlier_frames) / flow.frame_count * 100 if flow.frame_count > 0 else 0
|
||||
# Base quality on frame-type-specific outlier percentage
|
||||
frame_type_outlier_count = sum(len(ft_stats.outlier_frames) for ft_stats in flow.frame_types.values())
|
||||
outlier_pct = frame_type_outlier_count / flow.frame_count * 100 if flow.frame_count > 0 else 0
|
||||
return max(0, int(100 - outlier_pct * 10))
|
||||
|
||||
def _get_flow_status(self, flow: 'FlowStats') -> str:
|
||||
"""Determine flow status"""
|
||||
if flow.enhanced_analysis.decoder_type != "Standard":
|
||||
return "Enhanced"
|
||||
elif len(flow.outlier_frames) > flow.frame_count * 0.1:
|
||||
return "Alert"
|
||||
elif len(flow.outlier_frames) > 0:
|
||||
return "Warning"
|
||||
else:
|
||||
return "Normal"
|
||||
# Use frame-type-specific outliers for status
|
||||
frame_type_outlier_count = sum(len(ft_stats.outlier_frames) for ft_stats in flow.frame_types.values())
|
||||
if frame_type_outlier_count > flow.frame_count * 0.1:
|
||||
return "Alert"
|
||||
elif frame_type_outlier_count > 0:
|
||||
return "Warning"
|
||||
else:
|
||||
return "Normal"
|
||||
|
||||
def _get_flow_style(self, flow: 'FlowStats') -> Optional[str]:
|
||||
"""Get styling for flow row"""
|
||||
@@ -465,12 +455,18 @@ class EnhancedFlowTable(Vertical):
|
||||
return combinations
|
||||
|
||||
def _create_protocol_subrows(self, flow: 'FlowStats') -> List[List[Text]]:
|
||||
"""Create sub-rows for enhanced protocol/frame type breakdown only"""
|
||||
"""Create sub-rows for protocol/frame type breakdown - matches details panel logic"""
|
||||
subrows = []
|
||||
enhanced_frame_types = self._get_enhanced_frame_types(flow)
|
||||
combinations = self._get_enhanced_protocol_frame_combinations(flow, enhanced_frame_types)
|
||||
|
||||
for extended_proto, frame_type, count, percentage in combinations: # Show all enhanced subrows
|
||||
# For enhanced flows, show ALL frame types (same logic as details panel)
|
||||
if flow.enhanced_analysis.decoder_type != "Standard":
|
||||
combinations = self._get_protocol_frame_combinations(flow)
|
||||
else:
|
||||
# For standard flows, only show enhanced frame types
|
||||
enhanced_frame_types = self._get_enhanced_frame_types(flow)
|
||||
combinations = self._get_enhanced_protocol_frame_combinations(flow, enhanced_frame_types)
|
||||
|
||||
for extended_proto, frame_type, count, percentage in combinations:
|
||||
# Calculate timing for this frame type if available
|
||||
frame_delta_t = ""
|
||||
frame_sigma = ""
|
||||
@@ -478,12 +474,30 @@ class EnhancedFlowTable(Vertical):
|
||||
|
||||
if frame_type in flow.frame_types:
|
||||
ft_stats = flow.frame_types[frame_type]
|
||||
|
||||
# Always calculate timing if we have data, even if very small values
|
||||
if ft_stats.avg_inter_arrival > 0:
|
||||
dt_ms = ft_stats.avg_inter_arrival * 1000
|
||||
frame_delta_t = f"{dt_ms:.1f}" if dt_ms < 1000 else f"{dt_ms/1000:.1f}s"
|
||||
elif len(ft_stats.inter_arrival_times) >= 2:
|
||||
# If avg is 0 but we have data, recalculate on the fly
|
||||
import statistics
|
||||
avg_arrival = statistics.mean(ft_stats.inter_arrival_times)
|
||||
if avg_arrival > 0:
|
||||
dt_ms = avg_arrival * 1000
|
||||
frame_delta_t = f"{dt_ms:.1f}" if dt_ms < 1000 else f"{dt_ms/1000:.1f}s"
|
||||
|
||||
if ft_stats.std_inter_arrival > 0:
|
||||
sig_ms = ft_stats.std_inter_arrival * 1000
|
||||
frame_sigma = f"{sig_ms:.1f}" if sig_ms < 1000 else f"{sig_ms/1000:.1f}s"
|
||||
elif len(ft_stats.inter_arrival_times) >= 2:
|
||||
# If std is 0 but we have data, recalculate on the fly
|
||||
import statistics
|
||||
std_arrival = statistics.stdev(ft_stats.inter_arrival_times)
|
||||
if std_arrival > 0:
|
||||
sig_ms = std_arrival * 1000
|
||||
frame_sigma = f"{sig_ms:.1f}" if sig_ms < 1000 else f"{sig_ms/1000:.1f}s"
|
||||
|
||||
frame_outliers = str(len(ft_stats.outlier_frames))
|
||||
|
||||
subrow = [
|
||||
@@ -497,7 +511,8 @@ class EnhancedFlowTable(Vertical):
|
||||
Text(f"{self._format_bytes(count * (flow.total_bytes // flow.frame_count) if flow.frame_count > 0 else 0):>8}", style="dim"),
|
||||
Text(frame_delta_t, style="dim", justify="right"),
|
||||
Text(frame_sigma, style="dim", justify="right"),
|
||||
Text(frame_outliers, style="dim red" if frame_outliers and int(frame_outliers) > 0 else "dim", justify="right")
|
||||
Text(frame_outliers, style="dim red" if frame_outliers and int(frame_outliers) > 0 else "dim", justify="right"),
|
||||
Text("", style="dim") # Empty rate column for subrows
|
||||
]
|
||||
subrows.append(subrow)
|
||||
|
||||
|
||||
@@ -267,20 +267,46 @@ class SubFlowDetailsPanel(Vertical):
|
||||
sections.append(Text("Sub-Flow Timing", style="bold cyan"))
|
||||
sections.append(timing_table)
|
||||
|
||||
# Outlier details if any
|
||||
if subflow.outlier_frames and subflow.outlier_details:
|
||||
# Enhanced outlier details if any
|
||||
if subflow.outlier_frames:
|
||||
outlier_table = Table(show_header=True, box=None)
|
||||
outlier_table.add_column("Frame#", justify="right")
|
||||
outlier_table.add_column("Prev Frame#", justify="right")
|
||||
outlier_table.add_column("ΔT(ms)", justify="right")
|
||||
outlier_table.add_column("σ Dev", justify="right")
|
||||
|
||||
for frame_num, delta_t in subflow.outlier_details[:5]: # Show first 5 outliers
|
||||
# Use enhanced details if available, fallback to legacy details
|
||||
outlier_data = []
|
||||
if hasattr(subflow, 'enhanced_outlier_details') and subflow.enhanced_outlier_details:
|
||||
for frame_num, prev_frame_num, delta_t in subflow.enhanced_outlier_details[:5]:
|
||||
# Calculate sigma deviation
|
||||
sigma_dev = "N/A"
|
||||
if subflow.std_inter_arrival > 0 and subflow.avg_inter_arrival > 0:
|
||||
deviation = (delta_t - subflow.avg_inter_arrival) / subflow.std_inter_arrival
|
||||
sigma_dev = f"{deviation:.1f}σ"
|
||||
|
||||
outlier_data.append((frame_num, prev_frame_num, delta_t, sigma_dev))
|
||||
elif subflow.outlier_details:
|
||||
for frame_num, delta_t in subflow.outlier_details[:5]:
|
||||
# Calculate sigma deviation
|
||||
sigma_dev = "N/A"
|
||||
if subflow.std_inter_arrival > 0 and subflow.avg_inter_arrival > 0:
|
||||
deviation = (delta_t - subflow.avg_inter_arrival) / subflow.std_inter_arrival
|
||||
sigma_dev = f"{deviation:.1f}σ"
|
||||
|
||||
outlier_data.append((frame_num, "N/A", delta_t, sigma_dev))
|
||||
|
||||
for frame_num, prev_frame_num, delta_t, sigma_dev in outlier_data:
|
||||
outlier_table.add_row(
|
||||
str(frame_num),
|
||||
f"{delta_t * 1000:.1f}"
|
||||
str(prev_frame_num) if prev_frame_num != "N/A" else "N/A",
|
||||
f"{delta_t * 1000:.1f}",
|
||||
sigma_dev
|
||||
)
|
||||
|
||||
if len(subflow.outlier_details) > 5:
|
||||
outlier_table.add_row("...", f"+{len(subflow.outlier_details) - 5} more")
|
||||
total_outliers = len(subflow.enhanced_outlier_details) if hasattr(subflow, 'enhanced_outlier_details') else len(subflow.outlier_details)
|
||||
if total_outliers > 5:
|
||||
outlier_table.add_row("...", "...", f"+{total_outliers - 5}", "more")
|
||||
|
||||
sections.append(Text("Outlier Details", style="bold red"))
|
||||
sections.append(outlier_table)
|
||||
@@ -320,16 +346,40 @@ class SubFlowDetailsPanel(Vertical):
|
||||
reverse=True
|
||||
):
|
||||
percentage = (stats.count / total * 100) if total > 0 else 0
|
||||
delta_t = f"{stats.avg_inter_arrival * 1000:.1f}" if stats.avg_inter_arrival > 0 else "N/A"
|
||||
sigma = f"{stats.std_inter_arrival * 1000:.1f}" if stats.std_inter_arrival > 0 else "N/A"
|
||||
|
||||
# Use same logic as grid rows for consistency
|
||||
delta_t = ""
|
||||
if stats.avg_inter_arrival > 0:
|
||||
dt_ms = stats.avg_inter_arrival * 1000
|
||||
delta_t = f"{dt_ms:.1f}" if dt_ms < 1000 else f"{dt_ms/1000:.1f}s"
|
||||
elif len(stats.inter_arrival_times) >= 2:
|
||||
# Fallback calculation if stored avg is zero
|
||||
import statistics
|
||||
avg_arrival = statistics.mean(stats.inter_arrival_times)
|
||||
if avg_arrival > 0:
|
||||
dt_ms = avg_arrival * 1000
|
||||
delta_t = f"{dt_ms:.1f}" if dt_ms < 1000 else f"{dt_ms/1000:.1f}s"
|
||||
|
||||
sigma = ""
|
||||
if stats.std_inter_arrival > 0:
|
||||
sig_ms = stats.std_inter_arrival * 1000
|
||||
sigma = f"{sig_ms:.1f}" if sig_ms < 1000 else f"{sig_ms/1000:.1f}s"
|
||||
elif len(stats.inter_arrival_times) >= 2:
|
||||
# Fallback calculation if stored std is zero
|
||||
import statistics
|
||||
std_arrival = statistics.stdev(stats.inter_arrival_times)
|
||||
if std_arrival > 0:
|
||||
sig_ms = std_arrival * 1000
|
||||
sigma = f"{sig_ms:.1f}" if sig_ms < 1000 else f"{sig_ms/1000:.1f}s"
|
||||
|
||||
outliers = str(len(stats.outlier_frames))
|
||||
|
||||
frame_table.add_row(
|
||||
frame_type[:15],
|
||||
frame_type, # Show full frame type name
|
||||
f"{stats.count:,}",
|
||||
f"{percentage:.1f}%",
|
||||
delta_t,
|
||||
sigma,
|
||||
delta_t if delta_t else "N/A",
|
||||
sigma if sigma else "N/A",
|
||||
outliers
|
||||
)
|
||||
|
||||
|
||||
279
analyzer/tui/textual/widgets/tabbed_flow_view.py
Normal file
279
analyzer/tui/textual/widgets/tabbed_flow_view.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
Tabbed Flow View Widget - Shows Overview + Frame Type specific tabs
|
||||
"""
|
||||
|
||||
from textual.widgets import TabbedContent, TabPane, DataTable, Static
|
||||
from textual.containers import Vertical, Horizontal
|
||||
from textual.reactive import reactive
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Set
|
||||
from rich.text import Text
|
||||
from rich.table import Table
|
||||
from rich.panel import Panel
|
||||
from .flow_table_v2 import EnhancedFlowTable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ....analysis.core import EthernetAnalyzer
|
||||
from ....models import FlowStats
|
||||
|
||||
|
||||
class FrameTypeFlowTable(DataTable):
|
||||
"""Flow table filtered for a specific frame type"""
|
||||
|
||||
def __init__(self, frame_type: str, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.frame_type = frame_type
|
||||
self.cursor_type = "row"
|
||||
self.zebra_stripes = True
|
||||
self.show_header = True
|
||||
self.show_row_labels = False
|
||||
|
||||
def setup_columns(self):
|
||||
"""Setup columns for frame-type specific view"""
|
||||
self.add_column("Flow", width=4, key="flow_id")
|
||||
self.add_column("Source IP", width=16, key="src_ip")
|
||||
self.add_column("Src Port", width=8, key="src_port")
|
||||
self.add_column("Dest IP", width=16, key="dst_ip")
|
||||
self.add_column("Dst Port", width=8, key="dst_port")
|
||||
self.add_column("Protocol", width=8, key="protocol")
|
||||
self.add_column("Packets", width=8, key="packets")
|
||||
self.add_column("Avg ΔT", width=10, key="avg_delta")
|
||||
self.add_column("Std ΔT", width=10, key="std_delta")
|
||||
self.add_column("Outliers", width=8, key="outliers")
|
||||
self.add_column("Quality", width=8, key="quality")
|
||||
|
||||
|
||||
class FrameTypeStatsPanel(Static):
|
||||
"""Statistics panel for a specific frame type"""
|
||||
|
||||
def __init__(self, frame_type: str, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.frame_type = frame_type
|
||||
self._stats_content = f"Statistics for {self.frame_type}\n\nNo data available yet."
|
||||
|
||||
def render(self):
|
||||
"""Render frame type statistics"""
|
||||
return Panel(
|
||||
self._stats_content,
|
||||
title=f"📊 {self.frame_type} Statistics",
|
||||
border_style="blue"
|
||||
)
|
||||
|
||||
def update_content(self, content: str):
|
||||
"""Update the statistics content"""
|
||||
self._stats_content = content
|
||||
self.refresh()
|
||||
|
||||
|
||||
class FrameTypeTabContent(Vertical):
|
||||
"""Content for a specific frame type tab"""
|
||||
|
||||
def __init__(self, frame_type: str, analyzer: 'EthernetAnalyzer', **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.frame_type = frame_type
|
||||
self.analyzer = analyzer
|
||||
|
||||
def compose(self):
|
||||
"""Compose the frame type tab content"""
|
||||
with Horizontal():
|
||||
# Left side - Flow table for this frame type (sanitize ID)
|
||||
table_id = f"table-{self.frame_type.replace('-', '_').replace(':', '_')}"
|
||||
yield FrameTypeFlowTable(self.frame_type, id=table_id)
|
||||
|
||||
# Right side - Frame type statistics (sanitize ID)
|
||||
stats_id = f"stats-{self.frame_type.replace('-', '_').replace(':', '_')}"
|
||||
yield FrameTypeStatsPanel(self.frame_type, id=stats_id)
|
||||
|
||||
def on_mount(self):
|
||||
"""Initialize the frame type tab"""
|
||||
table_id = f"#table-{self.frame_type.replace('-', '_').replace(':', '_')}"
|
||||
table = self.query_one(table_id, FrameTypeFlowTable)
|
||||
table.setup_columns()
|
||||
self.refresh_data()
|
||||
|
||||
def refresh_data(self):
|
||||
"""Refresh data for this frame type"""
|
||||
try:
|
||||
table_id = f"#table-{self.frame_type.replace('-', '_').replace(':', '_')}"
|
||||
table = self.query_one(table_id, FrameTypeFlowTable)
|
||||
|
||||
# Clear existing data
|
||||
table.clear()
|
||||
|
||||
# Get flows that have this frame type
|
||||
flows_with_frametype = []
|
||||
flow_list = list(self.analyzer.flows.values())
|
||||
|
||||
for i, flow in enumerate(flow_list):
|
||||
if self.frame_type in flow.frame_types:
|
||||
ft_stats = flow.frame_types[self.frame_type]
|
||||
flows_with_frametype.append((i, flow, ft_stats))
|
||||
|
||||
# Add rows for flows with this frame type
|
||||
for flow_idx, flow, ft_stats in flows_with_frametype:
|
||||
# Calculate quality score
|
||||
quality_score = self._calculate_quality_score(ft_stats)
|
||||
quality_text = self._format_quality(quality_score)
|
||||
|
||||
# Format timing statistics
|
||||
avg_delta = f"{ft_stats.avg_inter_arrival * 1000:.1f}ms" if ft_stats.avg_inter_arrival > 0 else "N/A"
|
||||
std_delta = f"{ft_stats.std_inter_arrival * 1000:.1f}ms" if ft_stats.std_inter_arrival > 0 else "N/A"
|
||||
|
||||
row_data = [
|
||||
str(flow_idx + 1), # Flow ID
|
||||
flow.src_ip,
|
||||
str(flow.src_port),
|
||||
flow.dst_ip,
|
||||
str(flow.dst_port),
|
||||
flow.transport_protocol,
|
||||
str(ft_stats.count),
|
||||
avg_delta,
|
||||
std_delta,
|
||||
str(len(ft_stats.outlier_frames)),
|
||||
quality_text
|
||||
]
|
||||
|
||||
table.add_row(*row_data, key=f"flow-{flow_idx}")
|
||||
|
||||
# Update statistics panel
|
||||
self._update_stats_panel(flows_with_frametype)
|
||||
|
||||
except Exception as e:
|
||||
# Handle case where widgets aren't ready yet
|
||||
pass
|
||||
|
||||
def _calculate_quality_score(self, ft_stats) -> float:
|
||||
"""Calculate quality score for frame type stats"""
|
||||
if ft_stats.count == 0:
|
||||
return 0.0
|
||||
|
||||
# Base score on outlier rate and timing consistency
|
||||
outlier_rate = len(ft_stats.outlier_frames) / ft_stats.count
|
||||
consistency = 1.0 - min(outlier_rate * 2, 1.0) # Lower outlier rate = higher consistency
|
||||
|
||||
return consistency * 100
|
||||
|
||||
def _format_quality(self, quality_score: float) -> Text:
|
||||
"""Format quality score with color coding"""
|
||||
if quality_score >= 90:
|
||||
return Text(f"{quality_score:.0f}%", style="green")
|
||||
elif quality_score >= 70:
|
||||
return Text(f"{quality_score:.0f}%", style="yellow")
|
||||
else:
|
||||
return Text(f"{quality_score:.0f}%", style="red")
|
||||
|
||||
def _update_stats_panel(self, flows_with_frametype):
|
||||
"""Update the statistics panel with current data"""
|
||||
try:
|
||||
stats_id = f"#stats-{self.frame_type.replace('-', '_').replace(':', '_')}"
|
||||
stats_panel = self.query_one(stats_id, FrameTypeStatsPanel)
|
||||
|
||||
if not flows_with_frametype:
|
||||
stats_content = f"No flows found with {self.frame_type} frames"
|
||||
else:
|
||||
# Calculate aggregate statistics
|
||||
total_flows = len(flows_with_frametype)
|
||||
total_packets = sum(ft_stats.count for _, _, ft_stats in flows_with_frametype)
|
||||
total_outliers = sum(len(ft_stats.outlier_frames) for _, _, ft_stats in flows_with_frametype)
|
||||
|
||||
# Calculate average timing
|
||||
avg_timings = [ft_stats.avg_inter_arrival for _, _, ft_stats in flows_with_frametype if ft_stats.avg_inter_arrival > 0]
|
||||
overall_avg = sum(avg_timings) / len(avg_timings) if avg_timings else 0
|
||||
|
||||
# Format statistics
|
||||
stats_content = f"""Flows: {total_flows}
|
||||
Total Packets: {total_packets:,}
|
||||
Total Outliers: {total_outliers}
|
||||
Outlier Rate: {(total_outliers/total_packets*100):.1f}%
|
||||
Avg Inter-arrival: {overall_avg*1000:.1f}ms"""
|
||||
|
||||
# Update the panel content using the new method
|
||||
stats_panel.update_content(stats_content)
|
||||
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
|
||||
class TabbedFlowView(TabbedContent):
|
||||
"""Tabbed view showing Overview + Frame Type specific tabs"""
|
||||
|
||||
active_frame_types = reactive(set())
|
||||
|
||||
def __init__(self, analyzer: 'EthernetAnalyzer', **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.analyzer = analyzer
|
||||
self.overview_table = None
|
||||
self.frame_type_tabs = {}
|
||||
|
||||
def compose(self):
|
||||
"""Create the tabbed interface"""
|
||||
# Overview tab (always present)
|
||||
with TabPane("Overview", id="tab-overview"):
|
||||
self.overview_table = EnhancedFlowTable(self.analyzer, id="overview-flow-table")
|
||||
yield self.overview_table
|
||||
|
||||
# Create tabs for common frame types (based on detection analysis)
|
||||
common_frame_types = ["CH10-Data", "CH10-ACTTS", "TMATS", "PTP-Sync", "PTP-Signaling", "UDP", "IGMP"]
|
||||
|
||||
for frame_type in common_frame_types:
|
||||
tab_id = f"tab-{frame_type.lower().replace('-', '_').replace(':', '_')}"
|
||||
content_id = f"content-{frame_type.replace('-', '_').replace(':', '_')}"
|
||||
with TabPane(frame_type, id=tab_id):
|
||||
tab_content = FrameTypeTabContent(frame_type, self.analyzer, id=content_id)
|
||||
self.frame_type_tabs[frame_type] = tab_content
|
||||
yield tab_content
|
||||
|
||||
def _create_frame_type_tabs(self):
|
||||
"""Create tabs for detected frame types"""
|
||||
frame_types = self._get_detected_frame_types()
|
||||
|
||||
for frame_type in sorted(frame_types):
|
||||
tab_id = f"tab-{frame_type.lower().replace('-', '_').replace(':', '_')}"
|
||||
with TabPane(frame_type, id=tab_id):
|
||||
tab_content = FrameTypeTabContent(frame_type, self.analyzer, id=f"content-{frame_type}")
|
||||
self.frame_type_tabs[frame_type] = tab_content
|
||||
yield tab_content
|
||||
|
||||
def _get_detected_frame_types(self) -> Set[str]:
|
||||
"""Get all detected frame types from current flows"""
|
||||
frame_types = set()
|
||||
|
||||
for flow in self.analyzer.flows.values():
|
||||
frame_types.update(flow.frame_types.keys())
|
||||
|
||||
return frame_types
|
||||
|
||||
def on_mount(self):
|
||||
"""Initialize tabs"""
|
||||
self.refresh_all_tabs()
|
||||
|
||||
def refresh_all_tabs(self):
|
||||
"""Refresh data in all tabs"""
|
||||
# Refresh overview tab
|
||||
if self.overview_table:
|
||||
self.overview_table.refresh_data()
|
||||
|
||||
# Get detected frame types
|
||||
detected_frame_types = self._get_detected_frame_types()
|
||||
|
||||
# Refresh frame type tabs that have data
|
||||
for frame_type, tab_content in self.frame_type_tabs.items():
|
||||
if frame_type in detected_frame_types:
|
||||
tab_content.refresh_data()
|
||||
# Tab has data, it will show content when selected
|
||||
pass
|
||||
else:
|
||||
# Tab has no data, it will show empty when selected
|
||||
pass
|
||||
|
||||
def update_tabs(self):
|
||||
"""Update tabs based on newly detected frame types"""
|
||||
current_frame_types = self._get_detected_frame_types()
|
||||
|
||||
# Check if we need to add new tabs
|
||||
new_frame_types = current_frame_types - self.active_frame_types
|
||||
if new_frame_types:
|
||||
# This would require rebuilding the widget
|
||||
# For now, just refresh existing tabs
|
||||
self.refresh_all_tabs()
|
||||
|
||||
self.active_frame_types = current_frame_types
|
||||
Reference in New Issue
Block a user