tabbed frametype filtering

This commit is contained in:
2025-07-30 23:48:32 -04:00
parent 8d883f25c3
commit bb3eeb79d0
92 changed files with 33696 additions and 139 deletions

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

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

View File

@@ -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)

View File

@@ -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
)

View 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