Files
StreamLens/analyzer/tui/textual/widgets/flow_table_v2.py
2025-07-28 08:14:15 -04:00

421 lines
16 KiB
Python

"""
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")
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()
# 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)
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
self.row_to_flow_map[sub_key] = i
# 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}")
# Quality with bar chart and color
quality_bar, quality_color = self._create_quality_bar(flow)
quality_value = self._get_quality_score(flow)
quality_text = Text(f"{quality_value:>3}% {quality_bar}", style=quality_color)
# Status indicator
status = self._get_flow_status(flow)
status_color = {
"Normal": "green",
"Enhanced": "bold green",
"Warning": "yellow",
"Alert": "red"
}.get(status, "white")
status_text = Text(status, style=status_color)
return [
num_text, source_text, proto_text, dest_text,
extended_text, frame_text, rate_text, size_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[:3]: # Max 3 subrows
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")
]
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']) -> None:
self.flow = flow
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 on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
"""Handle row highlight to update selection"""
selected_flow = self.get_selected_flow()
self.post_message(self.FlowSelected(selected_flow))
# 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"