Files
StreamLens/analyzer/tui/textual/widgets/flow_table_v2.py
2025-07-28 11:06:10 -04:00

494 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""
Enhanced Flow Table Widget - TipTop-inspired with inline visualizations
"""
from textual.widgets import DataTable
from textual.containers import Vertical
from textual.reactive import reactive
from textual.message import Message
from typing import TYPE_CHECKING, List, Optional
from rich.text import Text
from rich.box import ROUNDED
if TYPE_CHECKING:
from ....analysis.core import EthernetAnalyzer
from ....models import FlowStats
class EnhancedFlowTable(Vertical):
"""
Enhanced flow table with TipTop-style inline visualizations
Features:
- Inline sparklines for packet rate
- Bar charts for volume and quality
- Color-coded rows based on status
- Hierarchical sub-rows for protocol breakdown
"""
DEFAULT_CSS = """
EnhancedFlowTable {
height: 1fr;
padding: 0;
margin: 0;
}
EnhancedFlowTable DataTable {
height: 1fr;
scrollbar-gutter: stable;
padding: 0;
margin: 0;
}
"""
selected_flow_index = reactive(0)
sort_key = reactive("flows")
def __init__(self, analyzer: 'EthernetAnalyzer', **kwargs):
super().__init__(**kwargs)
self.analyzer = analyzer
self.flows_list = []
self.row_to_flow_map = {} # Map row keys to flow indices
self.flow_metrics = {} # Store per-flow metrics history
def compose(self):
"""Create the enhanced flow table"""
# Table title with sort indicators
yield DataTable(
id="flows-data-table",
cursor_type="row",
zebra_stripes=True,
show_header=True,
show_row_labels=False
)
def on_mount(self):
"""Initialize the table"""
table = self.query_one("#flows-data-table", DataTable)
# Compact columns optimized for data density
table.add_column("#", width=2, key="num")
table.add_column("Source", width=18, key="source")
table.add_column("Proto", width=4, key="proto")
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("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")
self.refresh_data()
def refresh_data(self):
"""Refresh flow table with enhanced visualizations"""
table = self.query_one("#flows-data-table", DataTable)
# Preserve cursor and scroll positions
cursor_row = table.cursor_row
cursor_column = table.cursor_column
scroll_x = table.scroll_x
scroll_y = table.scroll_y
selected_row_key = None
if table.rows and cursor_row < len(table.rows):
selected_row_key = list(table.rows.keys())[cursor_row]
table.clear()
# Clear row mapping
self.row_to_flow_map.clear()
self.row_to_subflow_map = {} # Map row keys to (flow_index, subflow_type)
# Get and sort flows
self.flows_list = self._get_sorted_flows()
# Add flows with enhanced display
for i, flow in enumerate(self.flows_list):
# Track metrics for this flow
flow_key = f"{flow.src_ip}:{flow.src_port}-{flow.dst_ip}:{flow.dst_port}"
if flow_key not in self.flow_metrics:
self.flow_metrics[flow_key] = {
'rate_history': [],
'last_packet_count': flow.frame_count,
'last_update': flow.last_seen
}
# Calculate current rate
metrics = self.flow_metrics[flow_key]
time_delta = flow.last_seen - metrics['last_update'] if metrics['last_update'] else 1
packet_delta = flow.frame_count - metrics['last_packet_count']
current_rate = packet_delta / max(time_delta, 0.1)
# Update metrics
metrics['rate_history'].append(current_rate)
if len(metrics['rate_history']) > 10:
metrics['rate_history'].pop(0)
metrics['last_packet_count'] = flow.frame_count
metrics['last_update'] = flow.last_seen
# Create row with visualizations
row_data = self._create_enhanced_row(i + 1, flow, metrics)
row_key = table.add_row(*row_data, key=f"flow_{i}")
# Map row key to flow index
self.row_to_flow_map[row_key] = i
# Apply row styling based on status
style = self._get_flow_style(flow)
if style:
# Note: DataTable doesn't have set_row_style, using CSS classes instead
pass
# Add sub-rows for protocol breakdown
if self._should_show_subrows(flow):
sub_rows = self._create_protocol_subrows(flow)
combinations = self._get_protocol_frame_combinations(flow)
for j, sub_row in enumerate(sub_rows):
sub_key = table.add_row(*sub_row, key=f"flow_{i}_sub_{j}")
# Map sub-row to parent flow and subflow type
self.row_to_flow_map[sub_key] = i
if j < len(combinations):
_, frame_type, _, _ = combinations[j]
self.row_to_subflow_map[sub_key] = (i, frame_type)
# Restore cursor position
if selected_row_key and selected_row_key in table.rows:
row_index = list(table.rows.keys()).index(selected_row_key)
table.move_cursor(row=row_index, column=cursor_column, animate=False)
elif table.row_count > 0:
# If original selection not found, try to maintain row position
new_row = min(cursor_row, table.row_count - 1)
table.move_cursor(row=new_row, column=cursor_column, animate=False)
# Restore scroll position
table.scroll_to(x=scroll_x, y=scroll_y, animate=False)
def _create_enhanced_row(self, num: int, flow: 'FlowStats', metrics: dict) -> List[Text]:
"""Create enhanced row with inline visualizations"""
# Flow number
num_text = Text(str(num), justify="right")
# Source (truncated if needed)
source = f"{flow.src_ip}:{flow.src_port}"
source_text = Text(source[:20] + "..." if len(source) > 22 else source)
# Protocol with color
proto_text = Text(flow.transport_protocol, style="bold cyan")
# Destination
dest = f"{flow.dst_ip}:{flow.dst_port}"
dest_text = Text(dest[:20] + "..." if len(dest) > 22 else dest)
# Extended protocol
extended = self._get_extended_protocol(flow)
extended_text = Text(extended, style="yellow" if extended != "-" else "dim")
# Frame type summary
frame_summary = self._get_frame_summary(flow)
frame_text = Text(frame_summary, style="blue")
# Rate with sparkline
rate_spark = self._create_rate_sparkline(metrics['rate_history'])
rate_text = Text(f"{metrics['rate_history'][-1]:.0f} {rate_spark}")
# 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")
# 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")
return [
num_text, source_text, proto_text, dest_text,
extended_text, frame_text, rate_text, size_text,
delta_t_text, sigma_text, outlier_text
]
def _create_rate_sparkline(self, history: List[float]) -> str:
"""Create mini sparkline for rate"""
if not history:
return "" * 4
spark_chars = " ▁▂▃▄▅▆▇█"
data_min = min(history) if history else 0
data_max = max(history) if history else 1
if data_max == data_min:
return "" * 4
result = []
for value in history[-4:]: # Last 4 values
normalized = (value - data_min) / (data_max - data_min)
char_index = int(normalized * 8)
result.append(spark_chars[char_index])
return "".join(result)
def _create_volume_bar(self, bytes_count: int) -> str:
"""Create bar chart for volume"""
# Scale to GB for comparison
gb = bytes_count / 1_000_000_000
# Create bar (max 5 chars)
if gb >= 10:
return "█████"
elif gb >= 1:
filled = int(gb / 2)
return "" * filled + "" * (5 - filled)
else:
# For smaller volumes, show at least one bar
mb = bytes_count / 1_000_000
if mb >= 100:
return "█░░░░"
else:
return "▌░░░░"
def _create_quality_bar(self, flow: 'FlowStats') -> tuple[str, str]:
"""Create quality bar chart with color"""
quality = self._get_quality_score(flow)
# Create bar (5 chars)
filled = int(quality / 20) # 0-100 -> 0-5
bar = "" * filled + "" * (5 - filled)
# Determine color
if quality >= 90:
color = "green"
elif quality >= 70:
color = "yellow"
else:
color = "red"
return bar, color
def _get_quality_score(self, flow: 'FlowStats') -> int:
"""Calculate quality score for flow"""
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
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"
def _get_flow_style(self, flow: 'FlowStats') -> Optional[str]:
"""Get styling for flow row"""
status = self._get_flow_status(flow)
if status == "Enhanced":
return "bold"
elif status == "Alert":
return "bold red"
elif status == "Warning":
return "yellow"
return None
def _should_show_subrows(self, flow: 'FlowStats') -> bool:
"""Determine if flow should show protocol breakdown"""
# Show subrows for flows with multiple frame types or enhanced analysis
return (len(flow.frame_types) > 1 or
flow.enhanced_analysis.decoder_type != "Standard")
def _create_protocol_subrows(self, flow: 'FlowStats') -> List[List[Text]]:
"""Create sub-rows for protocol/frame type breakdown"""
subrows = []
combinations = self._get_protocol_frame_combinations(flow)
for extended_proto, frame_type, count, percentage in combinations: # Show all subrows
# Calculate timing for this frame type if available
frame_delta_t = ""
frame_sigma = ""
frame_outliers = ""
if frame_type in flow.frame_types:
ft_stats = flow.frame_types[frame_type]
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"
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"
frame_outliers = str(len(ft_stats.outlier_frames))
subrow = [
Text(""), # Empty flow number
Text(""), # Empty source
Text(""), # Empty protocol
Text(""), # Empty destination
Text(f" {extended_proto}", style="dim yellow"),
Text(frame_type, style="dim blue"),
Text(f"{count}", style="dim", justify="right"),
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")
]
subrows.append(subrow)
return subrows
def _get_sorted_flows(self) -> List['FlowStats']:
"""Get flows sorted by current sort key"""
flows = list(self.analyzer.flows.values())
if self.sort_key == "packets":
flows.sort(key=lambda x: x.frame_count, reverse=True)
elif self.sort_key == "volume":
flows.sort(key=lambda x: x.total_bytes, reverse=True)
elif self.sort_key == "quality":
flows.sort(key=lambda x: self._get_quality_score(x), reverse=True)
else: # Default: sort by importance
flows.sort(key=lambda x: (
x.enhanced_analysis.decoder_type != "Standard",
len(x.outlier_frames),
x.frame_count
), reverse=True)
return flows
def sort_by(self, key: str):
"""Change sort order"""
self.sort_key = key
self.refresh_data()
class FlowSelected(Message):
"""Message sent when a flow is selected"""
def __init__(self, flow: Optional['FlowStats'], subflow_type: Optional[str] = None) -> None:
self.flow = flow
self.subflow_type = subflow_type
super().__init__()
def get_selected_flow(self) -> Optional['FlowStats']:
"""Get currently selected flow"""
table = self.query_one("#flows-data-table", DataTable)
if table.cursor_row is None or not table.rows:
return None
# Get the row key at cursor position
row_keys = list(table.rows.keys())
if table.cursor_row >= len(row_keys):
return None
row_key = row_keys[table.cursor_row]
# Look up flow index from our mapping
flow_idx = self.row_to_flow_map.get(row_key)
if flow_idx is not None and 0 <= flow_idx < len(self.flows_list):
return self.flows_list[flow_idx]
return None
def get_selected_subflow_type(self) -> Optional[str]:
"""Get currently selected sub-flow type if applicable"""
table = self.query_one("#flows-data-table", DataTable)
if table.cursor_row is None or not table.rows:
return None
# Get the row key at cursor position
row_keys = list(table.rows.keys())
if table.cursor_row >= len(row_keys):
return None
row_key = row_keys[table.cursor_row]
# Check if this is a sub-row
if row_key in self.row_to_subflow_map:
_, subflow_type = self.row_to_subflow_map[row_key]
return subflow_type
return None
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
"""Handle row highlight to update selection"""
selected_flow = self.get_selected_flow()
subflow_type = self.get_selected_subflow_type()
# Debug through app's debug panel
flow_info = f"{selected_flow.src_ip}:{selected_flow.src_port}" if selected_flow else "None"
table = self.query_one("#flows-data-table", DataTable)
current_row = table.cursor_row if table.cursor_row is not None else -1
try:
debug_panel = self.app.query_one("#debug-panel")
debug_panel.add_debug_message(f"TABLE: Row {current_row} - {flow_info}, subflow:{subflow_type}")
except:
pass # Debug panel might not be available yet
self.post_message(self.FlowSelected(selected_flow, subflow_type))
# Helper methods from original implementation
def _get_extended_protocol(self, flow: 'FlowStats') -> str:
"""Get extended protocol"""
if flow.detected_protocol_types:
enhanced_protocols = {'CHAPTER10', 'CH10', 'PTP', 'IENA'}
found = flow.detected_protocol_types & enhanced_protocols
if found:
protocol = list(found)[0]
return 'CH10' if protocol in ['CHAPTER10', 'CH10'] else protocol
return '-'
def _get_frame_summary(self, flow: 'FlowStats') -> str:
"""Get frame type summary"""
if not flow.frame_types:
return "General"
elif len(flow.frame_types) == 1:
return list(flow.frame_types.keys())[0][:11]
else:
return f"{len(flow.frame_types)} types"
def _get_protocol_frame_combinations(self, flow: 'FlowStats'):
"""Get protocol/frame combinations"""
combinations = []
total = flow.frame_count
for frame_type, stats in flow.frame_types.items():
extended = self._get_extended_protocol(flow)
percentage = (stats.count / total * 100) if total > 0 else 0
combinations.append((extended, frame_type, stats.count, percentage))
return sorted(combinations, key=lambda x: x[2], reverse=True)
def _format_bytes(self, bytes_count: int) -> str:
"""Format byte count"""
if bytes_count >= 1_000_000_000:
return f"{bytes_count / 1_000_000_000:.1f}G"
elif bytes_count >= 1_000_000:
return f"{bytes_count / 1_000_000:.1f}M"
elif bytes_count >= 1_000:
return f"{bytes_count / 1_000:.1f}K"
else:
return f"{bytes_count}B"