Enhanced Textual TUI with proper API usage and documentation

- Fixed DataTable row selection and event handling
- Added explicit column keys to prevent auto-generated keys
- Implemented row-to-flow mapping for reliable selection tracking
- Converted left metrics panel to horizontal top bar
- Fixed all missing FlowStats/EnhancedAnalysisData attributes
- Created comprehensive Textual API documentation in Documentation/textual/
- Added validation checklist to prevent future API mismatches
- Preserved cursor position during data refreshes
- Fixed RowKey type handling and event names

The TUI now properly handles flow selection, displays metrics in a compact top bar,
and correctly correlates selected rows with the details pane.
This commit is contained in:
2025-07-27 18:37:55 -04:00
parent 5c2cb1a4ed
commit 36a576dc2c
29 changed files with 3751 additions and 51 deletions

View File

@@ -11,6 +11,7 @@ import curses
from .analysis import EthernetAnalyzer
from .tui import TUIInterface
from .tui.modern_interface import ModernTUIInterface
from .tui.textual.app_v2 import StreamLensAppV2
from .utils import PCAPLoader, LiveCapture
@@ -31,6 +32,8 @@ def main():
help='Launch GUI mode (requires PySide6)')
parser.add_argument('--classic', action='store_true',
help='Use classic TUI interface')
parser.add_argument('--textual', action='store_true',
help='Use modern Textual TUI interface (experimental)')
args = parser.parse_args()
@@ -101,8 +104,13 @@ def main():
generate_outlier_report(analyzer, args.outlier_threshold)
return
# TUI mode - choose between classic and modern interface
if args.classic:
# TUI mode - choose between classic, modern curses, and textual interface
if args.textual:
# Use new Textual-based interface (TipTop-inspired)
app = StreamLensAppV2(analyzer)
app.run()
return
elif args.classic:
tui = TUIInterface(analyzer)
else:
tui = ModernTUIInterface(analyzer)

View File

@@ -53,6 +53,10 @@ class EnhancedAnalysisData:
# Decoded Frame Data Storage
sample_decoded_fields: Dict[str, any] = field(default_factory=dict) # Sample of actual decoded fields for display
available_field_names: List[str] = field(default_factory=list) # List of all available field names from decoder
field_count: int = 0 # Total number of fields decoded
frame_types: Set[str] = field(default_factory=set) # Set of unique frame types encountered
timing_accuracy: float = 0.0 # Timing accuracy percentage
signal_quality: float = 0.0 # Signal quality percentage
@dataclass
class FlowStats:
@@ -75,4 +79,8 @@ class FlowStats:
protocols: Set[str] = field(default_factory=set)
detected_protocol_types: Set[str] = field(default_factory=set) # Enhanced protocol detection (CH10, PTP, IENA, etc)
frame_types: Dict[str, FrameTypeStats] = field(default_factory=dict) # Per-frame-type statistics
enhanced_analysis: EnhancedAnalysisData = field(default_factory=EnhancedAnalysisData) # Enhanced decoder analysis
enhanced_analysis: EnhancedAnalysisData = field(default_factory=EnhancedAnalysisData) # Enhanced decoder analysis
first_seen: float = 0.0 # Timestamp of the first frame in this flow
last_seen: float = 0.0 # Timestamp of the last frame in this flow
duration: float = 0.0 # Duration of the flow in seconds
jitter: float = 0.0 # Network jitter measurement

View File

@@ -27,6 +27,7 @@ class FlowAnalysisView:
self.selected_flow = 0
self.scroll_offset = 0
self.show_frame_types = True
self.column_widths = {}
def draw(self, stdscr, selected_flow_key: Optional[str]):
"""Draw the Flow Analysis view"""
@@ -64,19 +65,11 @@ class FlowAnalysisView:
stdscr.addstr(start_y, 4, "FLOW ANALYSIS", curses.A_BOLD | curses.A_UNDERLINE)
current_y = start_y + 2
# Column headers with visual indicators
headers = (
f"{'#':>2} "
f"{'Source':20} "
f"{'Proto':6} "
f"{'Destination':20} "
f"{'Extended':10} "
f"{'Frame Type':12} "
f"{'Pkts':>6} "
f"{'Volume':>8} "
f"{'Timing':>8} "
f"{'Quality':>8}"
)
# Calculate dynamic column widths based on available space
self.column_widths = self._calculate_column_widths(width)
# Column headers with dynamic widths
headers = self._format_headers()
stdscr.addstr(current_y, 4, headers, curses.A_UNDERLINE)
current_y += 1
@@ -85,28 +78,41 @@ class FlowAnalysisView:
start_idx = self.scroll_offset
end_idx = min(start_idx + visible_flows, len(flows_list))
# Draw flows
# Draw flows with sub-rows for each extended protocol/frame type variation
display_row = 0
for i in range(start_idx, end_idx):
flow = flows_list[i]
display_idx = i - start_idx
# Flow selection
is_selected = (i == self.selected_flow)
attr = curses.A_REVERSE if is_selected else curses.A_NORMAL
# Flow line
flow_line = self._format_flow_line(i + 1, flow)
stdscr.addstr(current_y + display_idx, 4, flow_line[:width-8], attr)
# Get distinct extended protocol/frame type combinations
protocol_frame_combinations = self._get_protocol_frame_combinations(flow)
# Main flow line (summary)
attr = curses.A_REVERSE if is_selected else curses.A_BOLD
flow_line = self._format_flow_summary_line(i + 1, flow)
stdscr.addstr(current_y + display_row, 4, flow_line, attr)
# Enhanced indicator
if flow.enhanced_analysis.decoder_type != "Standard":
stdscr.addstr(current_y + display_idx, 2, "", curses.A_BOLD | curses.color_pair(1))
stdscr.addstr(current_y + display_row, 2, "", curses.A_BOLD | curses.color_pair(1))
# Frame types sub-display (if selected and enabled)
if is_selected and self.show_frame_types and flow.frame_types:
sub_y = current_y + display_idx + 1
if sub_y < current_y + visible_flows:
self._draw_frame_types_compact(stdscr, sub_y, width, flow)
display_row += 1
# Sub-rows for each protocol/frame type combination
for j, (extended_proto, frame_type, count, percentage) in enumerate(protocol_frame_combinations):
if current_y + display_row >= current_y + visible_flows:
break
sub_attr = curses.A_REVERSE if (is_selected and j == 0) else curses.A_DIM
sub_line = self._format_protocol_frame_line(flow, extended_proto, frame_type, count, percentage)
stdscr.addstr(current_y + display_row, 4, sub_line, sub_attr)
display_row += 1
# Stop if we've filled the visible area
if current_y + display_row >= current_y + visible_flows:
break
# Scroll indicators
if start_idx > 0:
@@ -114,21 +120,244 @@ class FlowAnalysisView:
if end_idx < len(flows_list):
stdscr.addstr(current_y + visible_flows - 1, width - 10, "↓ More", curses.A_DIM)
def _calculate_column_widths(self, terminal_width: int) -> dict:
"""Calculate dynamic column widths based on available terminal width"""
# Reserve space for margins and prevent line wrapping
# 4 chars left margin + 4 chars right margin + 8 safety margin to prevent wrapping
available_width = terminal_width - 16
# Fixed minimum widths for critical columns
min_widths = {
'flow_num': 3, # " #"
'source': 15, # Compact IP:port
'proto': 4, # "UDP", "TCP"
'destination': 15, # Compact IP:port
'extended': 6, # "CH10", "PTP"
'frame_type': 8, # Compact frame type
'pkts': 6, # Right-aligned numbers
'volume': 8, # Right-aligned with units
'timing': 8, # Right-aligned with units
'quality': 8 # Right-aligned percentages
}
# Calculate total minimum width needed
min_total = sum(min_widths.values())
# If we have extra space, distribute it proportionally
if available_width > min_total:
extra_space = available_width - min_total
# Distribute extra space to text columns (source, destination, extended, frame_type)
expandable_columns = ['source', 'destination', 'extended', 'frame_type']
extra_per_column = extra_space // len(expandable_columns)
widths = min_widths.copy()
for col in expandable_columns:
widths[col] += extra_per_column
# Give any remaining space to source and destination
remaining = extra_space % len(expandable_columns)
if remaining > 0:
widths['source'] += remaining // 2
widths['destination'] += remaining // 2
if remaining % 2:
widths['source'] += 1
else:
# Use minimum widths if terminal is too narrow
widths = min_widths
return widths
def _format_headers(self) -> str:
"""Format column headers using dynamic widths"""
cw = self.column_widths
return (
f"{'#':>{cw['flow_num']-1}} "
f"{'Source':<{cw['source']}} "
f"{'Proto':<{cw['proto']}} "
f"{'Destination':<{cw['destination']}} "
f"{'Extended':<{cw['extended']}} "
f"{'Frame Type':<{cw['frame_type']}} "
f"{'Pkts':>{cw['pkts']}} "
f"{'Volume':>{cw['volume']}} "
f"{'Timing':>{cw['timing']}} "
f"{'Quality':>{cw['quality']}}"
)
def _get_protocol_frame_combinations(self, flow: FlowStats) -> List[Tuple[str, str, int, float]]:
"""Get distinct extended protocol/frame type combinations for a flow"""
combinations = []
total_packets = flow.frame_count
# Group frame types by extended protocol
protocol_frames = {}
if flow.frame_types:
for frame_type, ft_stats in flow.frame_types.items():
# Determine extended protocol for this frame type
extended_proto = self._get_extended_protocol_for_frame(flow, frame_type)
if extended_proto not in protocol_frames:
protocol_frames[extended_proto] = []
protocol_frames[extended_proto].append((frame_type, ft_stats.count))
else:
# No frame types, just show the flow-level extended protocol
extended_proto = self._get_extended_protocol(flow)
protocol_frames[extended_proto] = [("General", total_packets)]
# Convert to list of tuples with percentages
for extended_proto, frame_list in protocol_frames.items():
for frame_type, count in frame_list:
percentage = (count / total_packets * 100) if total_packets > 0 else 0
combinations.append((extended_proto, frame_type, count, percentage))
# Sort by count (descending)
combinations.sort(key=lambda x: x[2], reverse=True)
return combinations
def _get_extended_protocol_for_frame(self, flow: FlowStats, frame_type: str) -> str:
"""Get extended protocol for a specific frame type"""
if frame_type.startswith('CH10') or frame_type == 'TMATS':
return 'CH10'
elif frame_type.startswith('PTP'):
return 'PTP'
elif frame_type == 'IENA':
return 'IENA'
elif frame_type == 'NTP':
return 'NTP'
else:
# Fallback to flow-level extended protocol
return self._get_extended_protocol(flow)
def _format_flow_summary_line(self, flow_num: int, flow: FlowStats) -> str:
"""Format the main flow summary line"""
# Source with port (left-aligned)
source = f"{flow.src_ip}:{flow.src_port}"
max_source_len = self.column_widths.get('source', 24) - 2
if len(source) > max_source_len:
ip_space = max_source_len - len(f":{flow.src_port}") - 1 # -1 for ellipsis
source = f"{flow.src_ip[:ip_space]}…:{flow.src_port}"
# Transport protocol
protocol = flow.transport_protocol
# Destination with port (left-aligned)
destination = f"{flow.dst_ip}:{flow.dst_port}"
max_dest_len = self.column_widths.get('destination', 24) - 2
if len(destination) > max_dest_len:
ip_space = max_dest_len - len(f":{flow.dst_port}") - 1 # -1 for ellipsis
destination = f"{flow.dst_ip[:ip_space]}…:{flow.dst_port}"
# Summary info instead of specific extended/frame
extended_summary = f"{len(self._get_protocol_frame_combinations(flow))} types"
frame_summary = "Mixed" if len(flow.frame_types) > 1 else "Single"
# Packet count
pkt_count = f"{flow.frame_count}"
# Volume with units
volume = self._format_bytes(flow.total_bytes)
# Timing quality
if flow.avg_inter_arrival > 0:
timing_ms = flow.avg_inter_arrival * 1000
if timing_ms >= 1000:
timing = f"{timing_ms/1000:.1f}s"
else:
timing = f"{timing_ms:.1f}ms"
else:
timing = "N/A"
# Quality score
if flow.enhanced_analysis.decoder_type != "Standard":
if flow.enhanced_analysis.avg_frame_quality > 0:
quality = f"{flow.enhanced_analysis.avg_frame_quality:.0f}%"
else:
quality = "Enhanced"
else:
# Check for outliers
outlier_pct = len(flow.outlier_frames) / flow.frame_count * 100 if flow.frame_count > 0 else 0
if outlier_pct > 5:
quality = f"{outlier_pct:.0f}% Out"
else:
quality = "Normal"
cw = self.column_widths
return (f"{flow_num:>{cw['flow_num']-1}} "
f"{source:<{cw['source']}} "
f"{protocol:<{cw['proto']}} "
f"{destination:<{cw['destination']}} "
f"{extended_summary:<{cw['extended']}} "
f"{frame_summary:<{cw['frame_type']}} "
f"{pkt_count:>{cw['pkts']}} "
f"{volume:>{cw['volume']}} "
f"{timing:>{cw['timing']}} "
f"{quality:>{cw['quality']}}")
def _format_protocol_frame_line(self, flow: FlowStats, extended_proto: str, frame_type: str, count: int, percentage: float) -> str:
"""Format a sub-row line for a specific protocol/frame type combination"""
# Empty source/protocol/destination for sub-rows
source = ""
protocol = ""
destination = ""
# Extended protocol and frame type
extended = extended_proto if extended_proto != '-' else ""
frame = frame_type
# Packet count for this combination
pkt_count = f"{count}"
# Volume calculation (approximate based on percentage)
volume_bytes = int(flow.total_bytes * (percentage / 100))
volume = self._format_bytes(volume_bytes)
# Timing for this frame type if available
if frame_type in flow.frame_types and flow.frame_types[frame_type].avg_inter_arrival > 0:
timing_ms = flow.frame_types[frame_type].avg_inter_arrival * 1000
if timing_ms >= 1000:
timing = f"{timing_ms/1000:.1f}s" # Convert to seconds for large values
else:
timing = f"{timing_ms:.1f}ms"
else:
timing = "-"
# Percentage as quality indicator
quality = f"{percentage:.1f}%"
cw = self.column_widths
indent = " " * cw['flow_num'] # Match flow_num space allocation
return (f"{indent}"
f"{source:<{cw['source']}} "
f"{protocol:<{cw['proto']}} "
f"{destination:<{cw['destination']}} "
f"{extended:<{cw['extended']}} "
f"{frame:<{cw['frame_type']}} "
f"{pkt_count:>{cw['pkts']}} "
f"{volume:>{cw['volume']}} "
f"{timing:>{cw['timing']}} "
f"{quality:>{cw['quality']}}")
def _format_flow_line(self, flow_num: int, flow: FlowStats) -> str:
"""Format a single flow line with comprehensive information"""
# Source with port (left-aligned)
source = f"{flow.src_ip}:{flow.src_port}"
if len(source) > 18:
source = f"{flow.src_ip[:10]}…:{flow.src_port}"
max_source_len = self.column_widths.get('source', 24) - 2
if len(source) > max_source_len:
ip_space = max_source_len - len(f":{flow.src_port}") - 1 # -1 for ellipsis
source = f"{flow.src_ip[:ip_space]}…:{flow.src_port}"
# Transport protocol (TCP, UDP, ICMP, IGMP, etc.)
protocol = flow.transport_protocol
# Destination with port (left-aligned)
destination = f"{flow.dst_ip}:{flow.dst_port}"
if len(destination) > 18:
destination = f"{flow.dst_ip[:10]}…:{flow.dst_port}"
max_dest_len = self.column_widths.get('destination', 24) - 2
if len(destination) > max_dest_len:
ip_space = max_dest_len - len(f":{flow.dst_port}") - 1 # -1 for ellipsis
destination = f"{flow.dst_ip[:ip_space]}…:{flow.dst_port}"
# Extended protocol (Chapter 10, PTP, IENA, etc.)
extended_protocol = self._get_extended_protocol(flow)
@@ -144,7 +373,11 @@ class FlowAnalysisView:
# Timing quality
if flow.avg_inter_arrival > 0:
timing = f"{flow.avg_inter_arrival*1000:.1f}ms"
timing_ms = flow.avg_inter_arrival * 1000
if timing_ms >= 1000:
timing = f"{timing_ms/1000:.1f}s"
else:
timing = f"{timing_ms:.1f}ms"
else:
timing = "N/A"

View File

@@ -0,0 +1,8 @@
"""
Textual-based TUI implementation for StreamLens
Provides modern, reactive interface with widget-based architecture
"""
from .app import StreamLensApp
__all__ = ['StreamLensApp']

128
analyzer/tui/textual/app.py Normal file
View File

@@ -0,0 +1,128 @@
"""
Main StreamLens Textual Application
Modern TUI interface using Textual framework
"""
from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Header, Footer, TabbedContent, TabPane, Static, DataTable
from textual.reactive import reactive
from typing import TYPE_CHECKING
from .widgets.flow_table import FlowAnalysisWidget
from .widgets.packet_viewer import PacketDecoderWidget
from .widgets.metrics_dashboard import StatisticalAnalysisWidget
if TYPE_CHECKING:
from ...analysis.core import EthernetAnalyzer
class StreamLensApp(App):
"""
StreamLens Textual TUI Application
Modern interface with three main tabs:
- Flow Analysis: Interactive flow table with hierarchical protocol breakdown
- Packet Decoder: 3-panel packet inspection interface
- Statistical Analysis: Real-time metrics and performance analysis
"""
CSS_PATH = "styles/streamlens.tcss"
BINDINGS = [
("1", "show_tab('flows')", "Flow Analysis"),
("2", "show_tab('decoder')", "Packet Decoder"),
("3", "show_tab('stats')", "Statistics"),
("q", "quit", "Quit"),
("ctrl+c", "quit", "Quit"),
("?", "toggle_help", "Help"),
]
# Reactive attributes for live data updates
total_flows = reactive(0)
total_packets = reactive(0)
live_status = reactive("Stopped")
def __init__(self, analyzer: 'EthernetAnalyzer'):
super().__init__()
self.analyzer = analyzer
self.title = "StreamLens - Ethernet Traffic Analyzer"
self.sub_title = "Modern Network Flow Analysis"
def compose(self) -> ComposeResult:
"""Create the application layout"""
yield Header()
with Container(id="main-container"):
# Status summary bar
yield Static(
f"Flows: {self.total_flows} | Packets: {self.total_packets} | Status: {self.live_status}",
id="status-summary"
)
# Main tabbed interface
with TabbedContent(initial="flows"):
# Flow Analysis Tab
with TabPane("Flow Analysis", id="flows"):
yield FlowAnalysisWidget(self.analyzer, id="flow-analysis")
# Packet Decoder Tab
with TabPane("Packet Decoder", id="decoder"):
yield PacketDecoderWidget(self.analyzer, id="packet-decoder")
# Statistical Analysis Tab
with TabPane("Statistics", id="stats"):
yield StatisticalAnalysisWidget(self.analyzer, id="statistics")
yield Footer()
def on_mount(self) -> None:
"""Initialize the application"""
self.update_status()
# Set up periodic updates for live analysis
if self.analyzer.is_live:
self.set_interval(1.0, self.update_status)
def update_status(self) -> None:
"""Update reactive status attributes"""
summary = self.analyzer.get_summary()
self.total_flows = summary.get('unique_flows', 0)
self.total_packets = summary.get('total_packets', 0)
self.live_status = "Live" if self.analyzer.is_live else "Offline"
# Update status summary display
status_widget = self.query_one("#status-summary", Static)
status_widget.update(
f"Flows: {self.total_flows} | Packets: {self.total_packets} | Status: {self.live_status}"
)
def action_show_tab(self, tab_id: str) -> None:
"""Switch to specified tab"""
tabs = self.query_one(TabbedContent)
tabs.active = tab_id
def action_toggle_help(self) -> None:
"""Toggle help information"""
# TODO: Implement help modal
pass
def watch_total_flows(self, new_value: int) -> None:
"""React to flow count changes"""
# Trigger updates in child widgets
flow_widget = self.query_one("#flow-analysis", FlowAnalysisWidget)
flow_widget.refresh_data()
def watch_total_packets(self, new_value: int) -> None:
"""React to packet count changes"""
# Trigger updates in decoder widget
decoder_widget = self.query_one("#packet-decoder", PacketDecoderWidget)
decoder_widget.refresh_data()
def get_selected_flow(self):
"""Get currently selected flow from flow analysis widget"""
flow_widget = self.query_one("#flow-analysis", FlowAnalysisWidget)
return flow_widget.get_selected_flow()
def action_quit(self) -> None:
"""Clean exit"""
self.exit()

View File

@@ -0,0 +1,284 @@
"""
StreamLens Textual Application V2 - TipTop-Inspired Design
Modern TUI with real-time metrics, sparklines, and professional monitoring aesthetic
"""
from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
from textual.widgets import Header, Footer, Static, DataTable, Label
from textual.reactive import reactive
from textual.timer import Timer
from typing import TYPE_CHECKING
from rich.text import Text
from rich.console import Group
from rich.panel import Panel
from rich.table import Table
import time
from .widgets.sparkline import SparklineWidget
from .widgets.metric_card import MetricCard
from .widgets.flow_table_v2 import EnhancedFlowTable
from .widgets.flow_details import FlowDetailsPanel
if TYPE_CHECKING:
from ...analysis.core import EthernetAnalyzer
class StreamLensAppV2(App):
"""
StreamLens TipTop-Inspired Interface
Features:
- Real-time metrics with sparklines
- Color-coded quality indicators
- Compact information display
- Multi-column layout
- Smooth live updates
"""
CSS_PATH = "styles/streamlens_v2.tcss"
BINDINGS = [
("q", "quit", "Quit"),
("1", "sort('flows')", "Sort Flows"),
("2", "sort('packets')", "Sort Packets"),
("3", "sort('volume')", "Sort Volume"),
("4", "sort('quality')", "Sort Quality"),
("p", "toggle_pause", "Pause"),
("d", "show_details", "Details"),
("?", "toggle_help", "Help"),
]
# Reactive attributes
total_flows = reactive(0)
total_packets = reactive(0)
packets_per_sec = reactive(0.0)
bytes_per_sec = reactive(0.0)
enhanced_flows = reactive(0)
outlier_count = reactive(0)
# Update timers
metric_timer: Timer = None
flow_timer: Timer = None
def __init__(self, analyzer: 'EthernetAnalyzer'):
super().__init__()
self.analyzer = analyzer
self.title = "StreamLens"
self.sub_title = "Network Flow Analysis"
self.paused = False
# Metrics history for sparklines
self.packets_history = []
self.bytes_history = []
self.flows_history = []
self.max_history = 60 # 60 seconds of history
def compose(self) -> ComposeResult:
"""Create TipTop-inspired layout"""
yield Header()
with Container(id="main-container"):
# Top metrics bar - compact like TipTop
with Horizontal(id="metrics-bar"):
yield MetricCard(
"Flows",
f"{self.total_flows}",
trend="stable",
id="flows-metric"
)
yield MetricCard(
"Packets/s",
f"{self.packets_per_sec:.1f}",
trend="up",
sparkline=True,
id="packets-metric"
)
yield MetricCard(
"Volume/s",
self._format_bytes_per_sec(self.bytes_per_sec),
trend="stable",
sparkline=True,
id="volume-metric"
)
yield MetricCard(
"Enhanced",
f"{self.enhanced_flows}",
color="success",
id="enhanced-metric"
)
yield MetricCard(
"Outliers",
f"{self.outlier_count}",
color="warning" if self.outlier_count > 0 else "normal",
id="outliers-metric"
)
# Main content area with horizontal split
with Horizontal(id="content-area"):
# Left - Enhanced flow table (wider)
with Vertical(id="left-panel", classes="panel-wide"):
yield EnhancedFlowTable(
self.analyzer,
id="flow-table"
)
# Right - Selected flow details
with Vertical(id="right-panel", classes="panel"):
yield FlowDetailsPanel(
id="flow-details"
)
yield Footer()
def on_mount(self) -> None:
"""Initialize the application with TipTop-style updates"""
self.update_metrics()
# Set up update intervals like TipTop
self.metric_timer = self.set_interval(0.5, self.update_metrics) # 2Hz for smooth graphs
self.flow_timer = self.set_interval(1.0, self.update_flows) # 1Hz for flow data
# Initialize sparkline history
self._initialize_history()
def _initialize_history(self):
"""Initialize metrics history arrays"""
current_time = time.time()
for _ in range(self.max_history):
self.packets_history.append(0)
self.bytes_history.append(0)
self.flows_history.append(0)
def update_metrics(self) -> None:
"""Update real-time metrics and sparklines"""
if self.paused:
return
# Get current metrics
summary = self.analyzer.get_summary()
self.total_flows = summary.get('unique_flows', 0)
self.total_packets = summary.get('total_packets', 0)
# Calculate rates (simplified for now)
# In real implementation, track deltas over time
current_time = time.time()
if not hasattr(self, '_start_time'):
self._start_time = current_time
elapsed = max(1, current_time - self._start_time)
self.packets_per_sec = self.total_packets / elapsed
self.bytes_per_sec = summary.get('total_bytes', 0) / elapsed
# Count enhanced and outliers
enhanced = 0
outliers = 0
for flow in self.analyzer.flows.values():
if flow.enhanced_analysis.decoder_type != "Standard":
enhanced += 1
outliers += len(flow.outlier_frames)
self.enhanced_flows = enhanced
self.outlier_count = outliers
# Update metric cards
self._update_metric_cards()
# Update sparklines (removed - no longer in left panel)
# self._update_sparklines()
def _update_metric_cards(self):
"""Update the metric card displays"""
# Update flows metric
flows_card = self.query_one("#flows-metric", MetricCard)
flows_card.update_value(f"{self.total_flows}")
# Update packets/s with color coding
packets_card = self.query_one("#packets-metric", MetricCard)
packets_card.update_value(f"{self.packets_per_sec:.1f}")
if self.packets_per_sec > 10000:
packets_card.color = "warning"
elif self.packets_per_sec > 50000:
packets_card.color = "error"
else:
packets_card.color = "success"
# Update volume/s
volume_card = self.query_one("#volume-metric", MetricCard)
volume_card.update_value(self._format_bytes_per_sec(self.bytes_per_sec))
# Update enhanced flows
enhanced_card = self.query_one("#enhanced-metric", MetricCard)
enhanced_card.update_value(f"{self.enhanced_flows}")
# Update outliers with color
outliers_card = self.query_one("#outliers-metric", MetricCard)
outliers_card.update_value(f"{self.outlier_count}")
if self.outlier_count > 100:
outliers_card.color = "error"
elif self.outlier_count > 10:
outliers_card.color = "warning"
else:
outliers_card.color = "normal"
def _update_sparklines(self):
"""Update sparkline charts with latest data"""
# Add new data points
self.packets_history.append(self.packets_per_sec)
self.bytes_history.append(self.bytes_per_sec)
self.flows_history.append(self.total_flows)
# Keep only recent history
if len(self.packets_history) > self.max_history:
self.packets_history.pop(0)
self.bytes_history.pop(0)
self.flows_history.pop(0)
# Update sparkline widgets
flow_spark = self.query_one("#flow-rate-spark", SparklineWidget)
flow_spark.update_data(self.flows_history)
packet_spark = self.query_one("#packet-rate-spark", SparklineWidget)
packet_spark.update_data(self.packets_history)
def update_flows(self) -> None:
"""Update flow table data"""
if self.paused:
return
# Update flow table
flow_table = self.query_one("#flow-table", EnhancedFlowTable)
flow_table.refresh_data()
def on_enhanced_flow_table_flow_selected(self, event: EnhancedFlowTable.FlowSelected) -> None:
"""Handle flow selection events"""
if event.flow:
details_panel = self.query_one("#flow-details", FlowDetailsPanel)
details_panel.update_flow(event.flow)
def _format_bytes_per_sec(self, bps: float) -> str:
"""Format bytes per second with appropriate units"""
if bps >= 1_000_000_000:
return f"{bps / 1_000_000_000:.1f} GB/s"
elif bps >= 1_000_000:
return f"{bps / 1_000_000:.1f} MB/s"
elif bps >= 1_000:
return f"{bps / 1_000:.1f} KB/s"
else:
return f"{bps:.0f} B/s"
def action_toggle_pause(self) -> None:
"""Toggle pause state"""
self.paused = not self.paused
status = "PAUSED" if self.paused else "LIVE"
self.sub_title = f"Network Flow Analysis - {status}"
def action_sort(self, key: str) -> None:
"""Sort flow table by specified key"""
flow_table = self.query_one("#flow-table", EnhancedFlowTable)
flow_table.sort_by(key)
def action_show_details(self) -> None:
"""Show detailed view for selected flow"""
# TODO: Implement detailed flow modal
pass

View File

@@ -0,0 +1,148 @@
/* StreamLens Textual CSS Styling */
/* Main application layout */
#main-container {
height: 1fr;
padding: 1;
}
#status-summary {
height: 1;
background: $primary;
color: $text;
text-align: center;
margin-bottom: 1;
}
/* Flow Analysis Tab Styling */
#flow-title {
height: 1;
text-align: center;
text-style: bold;
color: $accent;
margin-bottom: 1;
}
#flows-table {
height: 1fr;
border: solid $primary;
}
#flows-table:focus {
border: solid $accent;
}
/* Enhanced flow styling */
.enhanced-flow {
background: $success 50%;
color: $text;
}
/* Packet Decoder 3-panel layout */
#flow-summary-panel {
width: 25%;
border: solid $primary;
margin-right: 1;
}
#packet-list-panel {
width: 1fr;
border: solid $primary;
margin-right: 1;
}
#field-details-panel {
width: 25%;
border: solid $primary;
}
#flow-summary-title, #packet-list-title, #field-details-title {
height: 1;
text-align: center;
text-style: bold;
color: $accent;
background: $surface;
}
#flow-tree, #packet-table, #field-tree {
height: 1fr;
}
/* Statistics Dashboard */
#stats-title {
height: 1;
text-align: center;
text-style: bold;
color: $accent;
margin-bottom: 1;
}
#metrics-summary {
height: 3;
margin-bottom: 1;
}
#metrics-summary Static {
width: 1fr;
border: solid $primary;
margin-right: 1;
text-align: center;
padding: 1;
}
#metrics-summary Static:last-child {
margin-right: 0;
}
/* Table styling */
DataTable {
height: 1fr;
scrollbar-gutter: stable;
}
DataTable .even {
background: $surface;
}
DataTable .odd {
background: $background;
}
DataTable .cursor {
background: $accent;
color: $text;
}
/* Tree styling */
Tree {
height: 1fr;
scrollbar-gutter: stable;
}
Tree > .tree--label {
color: $text;
}
Tree > .tree--label:hover {
background: $primary 30%;
}
/* General widget styling */
TabbedContent {
height: 1fr;
}
TabPane {
height: 1fr;
padding: 1;
}
/* Color scheme */
$primary: blue;
$accent: cyan;
$success: green;
$warning: yellow;
$error: red;
$surface: grey 20%;
$background: black;
$text: white;

View File

@@ -0,0 +1,208 @@
/* StreamLens V2 - TipTop-Inspired Styling */
/* Color Scheme - Dark theme with vibrant accents */
$primary: #0080ff;
$primary-lighten-1: #3399ff;
$primary-lighten-2: #66b3ff;
$primary-lighten-3: #99ccff;
$accent: #00ffcc;
$success: #00ff88;
$warning: #ffcc00;
$error: #ff3366;
$surface: #1a1a1a;
$surface-lighten-1: #262626;
$surface-lighten-2: #333333;
$background: #0d0d0d;
$text: #ffffff;
$text-muted: #999999;
/* Main Application Layout */
Screen {
background: $background;
}
#main-container {
height: 1fr;
background: $background;
}
/* Metrics Bar - Horizontal compact display at top */
#metrics-bar {
height: 7;
padding: 1;
background: $surface;
border-bottom: thick $primary;
align: center middle;
}
MetricCard {
width: 1fr;
height: 5;
margin: 0 1;
max-width: 20;
border: tall $primary-lighten-2;
padding: 0 1;
align: center middle;
}
/* Content Area - Three column layout */
#content-area {
height: 1fr;
padding: 1;
}
/* Panel Styling */
.panel {
border: solid $primary-lighten-3;
padding: 1;
margin: 0 1;
}
.panel-wide {
border: solid $primary-lighten-3;
padding: 1;
margin: 0 1;
}
.panel-header {
text-align: center;
text-style: bold;
color: $accent;
margin-bottom: 1;
}
/* Left Panel - Main Flow Table (expanded) */
#left-panel {
width: 70%;
background: $surface;
}
/* Right Panel - Details */
#right-panel {
width: 30%;
background: $surface;
}
/* Sparkline Charts */
SparklineWidget {
height: 5;
margin-bottom: 1;
padding: 0;
}
/* Enhanced Flow Table */
#flows-data-table {
height: 1fr;
scrollbar-background: $surface-lighten-1;
scrollbar-color: $primary;
scrollbar-size: 1 1;
}
#flows-data-table > .datatable--header {
background: $surface-lighten-2;
color: $accent;
text-style: bold;
}
#flows-data-table > .datatable--cursor {
background: $primary 30%;
color: $text;
}
#flows-data-table > .datatable--hover {
background: $primary 20%;
}
#flows-data-table > .datatable--odd-row {
background: $surface;
}
#flows-data-table > .datatable--even-row {
background: $surface-lighten-1;
}
/* Flow Details Panel */
FlowDetailsPanel {
padding: 1;
}
FlowDetailsPanel Panel {
margin-bottom: 1;
}
/* Status Colors */
.status-normal {
color: $success;
}
.status-warning {
color: $warning;
}
.status-error {
color: $error;
}
.status-enhanced {
color: $accent;
text-style: bold;
}
/* Quality Indicators */
.quality-high {
color: $success;
}
.quality-medium {
color: $warning;
}
.quality-low {
color: $error;
}
/* Animations and Transitions */
.updating {
background: $primary 10%;
transition: background 200ms;
}
/* Header and Footer */
Header {
background: $surface;
color: $text;
border-bottom: solid $primary;
}
Footer {
background: $surface;
color: $text-muted;
border-top: solid $primary;
}
/* Scrollbars */
Vertical {
scrollbar-size: 1 1;
scrollbar-background: $surface-lighten-1;
scrollbar-color: $primary;
}
Horizontal {
scrollbar-size: 1 1;
scrollbar-background: $surface-lighten-1;
scrollbar-color: $primary;
}
/* Focus States */
DataTable:focus {
border: solid $accent;
}
/* Panel Borders */
Static {
border: round $primary;
}
/* End of styles */

View File

@@ -0,0 +1,3 @@
"""
Textual widgets for StreamLens TUI
"""

View File

@@ -0,0 +1,173 @@
"""
Flow Details Panel - Detailed information for selected flow
"""
from textual.widget import Widget
from textual.containers import Vertical
from textual.widgets import Static
from rich.text import Text
from rich.panel import Panel
from rich.console import RenderableType, Group
from rich.table import Table
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from ....models import FlowStats
class FlowDetailsPanel(Vertical):
"""
Detailed flow information panel
Shows:
- Flow identification
- Enhanced decoder status
- Timing analysis
- Frame type breakdown
- Quality metrics
"""
DEFAULT_CSS = """
FlowDetailsPanel {
height: 1fr;
padding: 1;
}
FlowDetailsPanel Static {
margin-bottom: 1;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.current_flow = None
def compose(self):
"""Create the details panel layout"""
yield Static("Flow Details", classes="panel-header")
yield Static(
Panel("Select a flow to view details", border_style="dim"),
id="details-content"
)
def update_flow(self, flow: Optional['FlowStats']) -> None:
"""Update panel with flow details"""
self.current_flow = flow
content_widget = self.query_one("#details-content", Static)
if not flow:
content_widget.update(
Panel("Select a flow to view details", border_style="dim")
)
return
# Create detailed content
details = self._create_flow_details(flow)
content_widget.update(details)
def _create_flow_details(self, flow: 'FlowStats') -> RenderableType:
"""Create comprehensive flow details display"""
sections = []
# Flow identification
id_table = Table(show_header=False, box=None, padding=0)
id_table.add_column(style="dim", width=12)
id_table.add_column()
id_table.add_row("Source:", f"{flow.src_ip}:{flow.src_port}")
id_table.add_row("Destination:", f"{flow.dst_ip}:{flow.dst_port}")
id_table.add_row("Protocol:", flow.transport_protocol)
id_table.add_row("Packets:", f"{flow.frame_count:,}")
id_table.add_row("Volume:", self._format_bytes(flow.total_bytes))
sections.append(Panel(id_table, title="Flow Information", border_style="blue"))
# Enhanced analysis
if flow.enhanced_analysis.decoder_type != "Standard":
enhanced_table = Table(show_header=False, box=None, padding=0)
enhanced_table.add_column(style="dim", width=12)
enhanced_table.add_column()
enhanced_table.add_row("Decoder:", flow.enhanced_analysis.decoder_type)
enhanced_table.add_row("Quality:", f"{flow.enhanced_analysis.avg_frame_quality:.1f}%")
enhanced_table.add_row("Fields:", str(flow.enhanced_analysis.field_count))
if flow.enhanced_analysis.frame_types:
types_str = ", ".join(list(flow.enhanced_analysis.frame_types)[:3])
if len(flow.enhanced_analysis.frame_types) > 3:
types_str += f" +{len(flow.enhanced_analysis.frame_types) - 3}"
enhanced_table.add_row("Types:", types_str)
sections.append(Panel(enhanced_table, title="Enhanced Analysis", border_style="green"))
# Timing analysis
timing_table = Table(show_header=False, box=None, padding=0)
timing_table.add_column(style="dim", width=12)
timing_table.add_column()
timing_table.add_row("Duration:", f"{flow.duration:.2f}s")
timing_table.add_row("Avg Interval:", f"{flow.avg_inter_arrival * 1000:.1f}ms")
timing_table.add_row("Jitter:", f"{flow.jitter * 1000:.2f}ms")
timing_table.add_row("First Seen:", self._format_timestamp(flow.first_seen))
timing_table.add_row("Last Seen:", self._format_timestamp(flow.last_seen))
sections.append(Panel(timing_table, title="Timing Analysis", border_style="cyan"))
# Frame type breakdown (if multiple types)
if len(flow.frame_types) > 1:
frame_table = Table(show_header=True, box=None)
frame_table.add_column("Type", style="blue")
frame_table.add_column("Count", justify="right")
frame_table.add_column("%", justify="right")
total = flow.frame_count
for frame_type, stats in sorted(
flow.frame_types.items(),
key=lambda x: x[1].count,
reverse=True
)[:5]:
percentage = (stats.count / total * 100) if total > 0 else 0
frame_table.add_row(
frame_type[:15],
f"{stats.count:,}",
f"{percentage:.1f}%"
)
sections.append(Panel(frame_table, title="Frame Types", border_style="yellow"))
# Quality metrics
if flow.outlier_frames or flow.enhanced_analysis.decoder_type != "Standard":
quality_lines = []
if flow.outlier_frames:
outlier_pct = len(flow.outlier_frames) / flow.frame_count * 100
quality_lines.append(f"Outliers: {len(flow.outlier_frames)} ({outlier_pct:.1f}%)")
if flow.enhanced_analysis.timing_accuracy:
quality_lines.append(f"Timing: {flow.enhanced_analysis.timing_accuracy}")
if flow.enhanced_analysis.signal_quality:
quality_lines.append(f"Signal: {flow.enhanced_analysis.signal_quality:.1f}%")
if quality_lines:
quality_text = "\n".join(quality_lines)
sections.append(Panel(quality_text, title="Quality Metrics", border_style="magenta"))
return Group(*sections)
def _format_bytes(self, bytes_count: int) -> str:
"""Format byte count with units"""
if bytes_count >= 1_000_000_000:
return f"{bytes_count / 1_000_000_000:.2f} GB"
elif bytes_count >= 1_000_000:
return f"{bytes_count / 1_000_000:.2f} MB"
elif bytes_count >= 1_000:
return f"{bytes_count / 1_000:.2f} KB"
else:
return f"{bytes_count} B"
def _format_timestamp(self, timestamp: float) -> str:
"""Format timestamp for display"""
import datetime
dt = datetime.datetime.fromtimestamp(timestamp)
return dt.strftime("%H:%M:%S.%f")[:-3] # Show milliseconds

View File

@@ -0,0 +1,339 @@
"""
Flow Analysis Widget using Textual DataTable
Hierarchical flow display with automatic formatting and responsive layout
"""
from textual.widgets import DataTable, Static
from textual.containers import Vertical
from textual.reactive import reactive
from typing import TYPE_CHECKING, List, Optional, Tuple
from rich.text import Text
if TYPE_CHECKING:
from ....analysis.core import EthernetAnalyzer
from ....models import FlowStats
class FlowAnalysisWidget(Vertical):
"""
Enhanced Flow Analysis using Textual DataTable
Features:
- Automatic column sizing and alignment
- Hierarchical sub-rows for protocol breakdown
- Rich text formatting with colors
- Mouse and keyboard navigation
- Real-time data updates
"""
selected_flow_index = reactive(0)
def __init__(self, analyzer: 'EthernetAnalyzer', **kwargs):
super().__init__(**kwargs)
self.analyzer = analyzer
self.flow_table = None
self.flows_list = []
def compose(self):
"""Create the widget layout"""
yield Static("FLOW ANALYSIS", id="flow-title")
# Main flow data table
flow_table = DataTable(id="flows-table")
flow_table.cursor_type = "row"
flow_table.zebra_stripes = True
# Add columns with proper alignment
flow_table.add_columns(
"#", # Flow number (right-aligned)
"Source", # IP:port (left-aligned)
"Proto", # Transport protocol (left-aligned)
"Destination", # IP:port (left-aligned)
"Extended", # Extended protocol (left-aligned)
"Frame Type", # Frame type (left-aligned)
"Pkts", # Packet count (right-aligned)
"Volume", # Data volume (right-aligned)
"Timing", # Inter-arrival timing (right-aligned)
"Quality" # Quality metric (right-aligned)
)
self.flow_table = flow_table
yield flow_table
def on_mount(self) -> None:
"""Initialize the widget when mounted"""
self.refresh_data()
def refresh_data(self) -> None:
"""Refresh the flow data in the table"""
if not self.flow_table:
return
# Preserve cursor position
cursor_row = self.flow_table.cursor_row
cursor_column = self.flow_table.cursor_column
selected_row_key = None
if self.flow_table.rows and cursor_row < len(self.flow_table.rows):
selected_row_key = list(self.flow_table.rows.keys())[cursor_row]
# Clear existing data
self.flow_table.clear()
# Get updated flows list
self.flows_list = self._get_flows_list()
# Populate table with hierarchical data
for i, flow in enumerate(self.flows_list):
# Add main flow row
main_row = self._create_flow_row(i + 1, flow)
row_key = self.flow_table.add_row(*main_row, key=f"flow_{i}")
# Mark enhanced flows with special styling
if flow.enhanced_analysis.decoder_type != "Standard":
# Note: DataTable doesn't have set_row_style, using CSS classes instead
pass
# Add sub-rows for protocol/frame type breakdown
protocol_combinations = self._get_protocol_frame_combinations(flow)
for j, (extended_proto, frame_type, count, percentage) in enumerate(protocol_combinations):
sub_row = self._create_sub_row(flow, extended_proto, frame_type, count, percentage)
sub_key = self.flow_table.add_row(*sub_row, key=f"flow_{i}_sub_{j}")
# Note: DataTable doesn't have set_row_style, using CSS classes instead
# Restore cursor position
if selected_row_key and selected_row_key in self.flow_table.rows:
row_index = list(self.flow_table.rows.keys()).index(selected_row_key)
self.flow_table.move_cursor(row=row_index, column=cursor_column, animate=False)
elif self.flow_table.row_count > 0:
# If original selection not found, try to maintain row position
new_row = min(cursor_row, self.flow_table.row_count - 1)
self.flow_table.move_cursor(row=new_row, column=cursor_column, animate=False)
def _create_flow_row(self, flow_num: int, flow: 'FlowStats') -> List[Text]:
"""Create main flow row with rich text formatting"""
# Format source with potential truncation
source = f"{flow.src_ip}:{flow.src_port}"
source_text = Text(source[:22] + "..." if len(source) > 25 else source)
# Transport protocol
protocol_text = Text(flow.transport_protocol, style="bold cyan")
# Format destination
destination = f"{flow.dst_ip}:{flow.dst_port}"
dest_text = Text(destination[:22] + "..." if len(destination) > 25 else destination)
# Extended protocol summary
combinations = self._get_protocol_frame_combinations(flow)
extended_text = Text(f"{len(combinations)} types", style="yellow")
# Frame type summary
frame_text = Text("Mixed" if len(flow.frame_types) > 1 else "Single", style="blue")
# Packet count (right-aligned)
packets_text = Text(str(flow.frame_count), justify="right", style="white")
# Volume with units (right-aligned)
volume = self._format_bytes(flow.total_bytes)
volume_text = Text(volume, justify="right", style="magenta")
# Timing (right-aligned)
if flow.avg_inter_arrival > 0:
timing_ms = flow.avg_inter_arrival * 1000
if timing_ms >= 1000:
timing = f"{timing_ms/1000:.1f}s"
else:
timing = f"{timing_ms:.1f}ms"
else:
timing = "N/A"
timing_text = Text(timing, justify="right", style="cyan")
# Quality indicator (right-aligned)
if flow.enhanced_analysis.decoder_type != "Standard":
if flow.enhanced_analysis.avg_frame_quality > 0:
quality = f"{flow.enhanced_analysis.avg_frame_quality:.0f}%"
quality_style = "bold green"
else:
quality = "Enhanced"
quality_style = "green"
else:
outlier_pct = len(flow.outlier_frames) / flow.frame_count * 100 if flow.frame_count > 0 else 0
if outlier_pct > 5:
quality = f"{outlier_pct:.0f}% Out"
quality_style = "red"
else:
quality = "Normal"
quality_style = "green"
quality_text = Text(quality, justify="right", style=quality_style)
return [
Text(str(flow_num), justify="right"),
source_text,
protocol_text,
dest_text,
extended_text,
frame_text,
packets_text,
volume_text,
timing_text,
quality_text
]
def _create_sub_row(self, flow: 'FlowStats', extended_proto: str, frame_type: str, count: int, percentage: float) -> List[Text]:
"""Create sub-row for protocol/frame type combination"""
# Empty columns for inheritance from parent flow
empty = Text("")
# Extended protocol
extended_text = Text(extended_proto if extended_proto != '-' else "", style="dim yellow")
# Frame type
frame_text = Text(frame_type, style="dim blue")
# Packet count for this combination
count_text = Text(str(count), justify="right", style="dim white")
# Volume estimation
volume_bytes = int(flow.total_bytes * (percentage / 100))
volume = self._format_bytes(volume_bytes)
volume_text = Text(volume, justify="right", style="dim magenta")
# Timing for this frame type
if frame_type in flow.frame_types and flow.frame_types[frame_type].avg_inter_arrival > 0:
timing_ms = flow.frame_types[frame_type].avg_inter_arrival * 1000
if timing_ms >= 1000:
timing = f"{timing_ms/1000:.1f}s"
else:
timing = f"{timing_ms:.1f}ms"
else:
timing = "-"
timing_text = Text(timing, justify="right", style="dim cyan")
# Percentage as quality
quality_text = Text(f"{percentage:.1f}%", justify="right", style="dim")
return [
empty, # Flow number
empty, # Source
empty, # Protocol
empty, # Destination
extended_text, # Extended protocol
frame_text, # Frame type
count_text, # Packet count
volume_text, # Volume
timing_text, # Timing
quality_text # Percentage
]
def _get_protocol_frame_combinations(self, flow: 'FlowStats') -> List[Tuple[str, str, int, float]]:
"""Get distinct extended protocol/frame type combinations for a flow"""
combinations = []
total_packets = flow.frame_count
# Group frame types by extended protocol
protocol_frames = {}
if flow.frame_types:
for frame_type, ft_stats in flow.frame_types.items():
# Determine extended protocol for this frame type
extended_proto = self._get_extended_protocol_for_frame(flow, frame_type)
if extended_proto not in protocol_frames:
protocol_frames[extended_proto] = []
protocol_frames[extended_proto].append((frame_type, ft_stats.count))
else:
# No frame types, just show the flow-level extended protocol
extended_proto = self._get_extended_protocol(flow)
protocol_frames[extended_proto] = [("General", total_packets)]
# Convert to list of tuples with percentages
for extended_proto, frame_list in protocol_frames.items():
for frame_type, count in frame_list:
percentage = (count / total_packets * 100) if total_packets > 0 else 0
combinations.append((extended_proto, frame_type, count, percentage))
# Sort by count (descending)
combinations.sort(key=lambda x: x[2], reverse=True)
return combinations
def _get_extended_protocol_for_frame(self, flow: 'FlowStats', frame_type: str) -> str:
"""Get extended protocol for a specific frame type"""
if frame_type.startswith('CH10') or frame_type == 'TMATS':
return 'CH10'
elif frame_type.startswith('PTP'):
return 'PTP'
elif frame_type == 'IENA':
return 'IENA'
elif frame_type == 'NTP':
return 'NTP'
else:
return self._get_extended_protocol(flow)
def _get_extended_protocol(self, flow: 'FlowStats') -> str:
"""Get extended protocol (Chapter 10, PTP, IENA, etc.)"""
if flow.detected_protocol_types:
# Look for specialized protocols
enhanced_protocols = {'CHAPTER10', 'CH10', 'PTP', 'IENA'}
found_enhanced = flow.detected_protocol_types & enhanced_protocols
if found_enhanced:
protocol = list(found_enhanced)[0]
# Simplify display names
if protocol in ['CHAPTER10', 'CH10']:
return 'CH10'
return protocol
# Check for other common protocols
if flow.detected_protocol_types and 'NTP' in flow.detected_protocol_types:
return 'NTP'
return '-'
def _format_bytes(self, bytes_count: int) -> str:
"""Format byte count with appropriate units"""
if bytes_count >= 1_000_000_000:
return f"{bytes_count / 1_000_000_000:.1f}GB"
elif bytes_count >= 1_000_000:
return f"{bytes_count / 1_000_000:.1f}MB"
elif bytes_count >= 1_000:
return f"{bytes_count / 1_000:.1f}KB"
else:
return f"{bytes_count}B"
def _get_flows_list(self) -> List['FlowStats']:
"""Get flows sorted by importance for flow analysis"""
flows_list = list(self.analyzer.flows.values())
# Sort by: Enhanced protocols first, then outliers, then packet count
flows_list.sort(key=lambda x: (
x.enhanced_analysis.decoder_type != "Standard",
len(x.outlier_frames),
x.frame_count
), reverse=True)
return flows_list
def get_selected_flow(self) -> Optional['FlowStats']:
"""Get currently selected flow"""
if not self.flow_table or not self.flows_list:
return None
cursor_row = self.flow_table.cursor_row
if 0 <= cursor_row < len(self.flows_list):
return self.flows_list[cursor_row]
return None
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
"""Handle row selection in the data table"""
# Extract flow index from row key
if event.row_key and event.row_key.startswith("flow_"):
try:
# Parse "flow_N" or "flow_N_sub_M" to get flow index
parts = event.row_key.split("_")
flow_index = int(parts[1])
self.selected_flow_index = flow_index
except (IndexError, ValueError):
pass

View File

@@ -0,0 +1,418 @@
"""
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;
}
EnhancedFlowTable DataTable {
height: 1fr;
scrollbar-gutter: stable;
}
"""
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)
# Add columns with explicit keys to avoid auto-generated keys
table.add_column("#", width=3, key="num")
table.add_column("Source", width=22, key="source")
table.add_column("Proto", width=6, key="proto")
table.add_column("Destination", width=22, key="dest")
table.add_column("Extended", width=10, key="extended")
table.add_column("Frame Type", width=12, key="frame_type")
table.add_column("Rate", width=12, key="rate")
table.add_column("Volume", width=12, key="volume")
table.add_column("Quality", width=12, key="quality")
table.add_column("Status", width=8, key="status")
self.refresh_data()
def refresh_data(self):
"""Refresh flow table with enhanced visualizations"""
table = self.query_one("#flows-data-table", DataTable)
# Preserve cursor position
cursor_row = table.cursor_row
cursor_column = table.cursor_column
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)
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}")
# Volume with bar chart
volume_bar = self._create_volume_bar(flow.total_bytes)
volume_value = self._format_bytes(flow.total_bytes)
volume_text = Text(f"{volume_value:>6} {volume_bar}")
# 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, volume_text,
quality_text, status_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"{percentage:.0f}%", style="dim"),
Text(""), # Empty quality
Text("") # Empty status
]
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"

View File

@@ -0,0 +1,140 @@
"""
Metric Card Widget - Compact metric display inspired by TipTop
"""
from textual.widget import Widget
from textual.reactive import reactive
from rich.text import Text
from rich.console import RenderableType
from rich.panel import Panel
from typing import Optional, Literal
ColorType = Literal["normal", "success", "warning", "error"]
TrendType = Literal["up", "down", "stable"]
class MetricCard(Widget):
"""
Compact metric display card with optional sparkline
Features:
- Title and value display
- Color coding for status
- Optional trend indicator
- Optional inline sparkline
"""
DEFAULT_CSS = """
MetricCard {
width: 1fr;
height: 3;
margin: 0 1;
}
MetricCard.success {
border: solid $success;
}
MetricCard.warning {
border: solid $warning;
}
MetricCard.error {
border: solid $error;
}
"""
value = reactive("0")
color = reactive("normal")
def __init__(
self,
title: str,
value: str = "0",
color: ColorType = "normal",
trend: Optional[TrendType] = None,
sparkline: bool = False,
**kwargs
):
super().__init__(**kwargs)
self.title = title
self.value = value
self.color = color
self.trend = trend
self.sparkline = sparkline
self.spark_data = []
def update_value(self, new_value: str) -> None:
"""Update the metric value"""
self.value = new_value
def update_color(self, new_color: ColorType) -> None:
"""Update the color status"""
self.color = new_color
self.add_class(new_color)
def add_spark_data(self, value: float) -> None:
"""Add data point for sparkline"""
self.spark_data.append(value)
if len(self.spark_data) > 10: # Keep last 10 points
self.spark_data.pop(0)
def render(self) -> RenderableType:
"""Render the metric card"""
# Determine color style
color_map = {
"normal": "white",
"success": "green",
"warning": "yellow",
"error": "red"
}
style = color_map.get(self.color, "white")
# Create trend indicator
trend_icon = ""
if self.trend:
trend_map = {
"up": "",
"down": "",
"stable": ""
}
trend_icon = f" {trend_map.get(self.trend, '')}"
# Create sparkline if enabled
spark_str = ""
if self.sparkline and self.spark_data:
spark_str = " " + self._create_mini_spark()
# Format content
content = Text()
content.append(f"{self.title}\n", style="dim")
content.append(f"{self.value}", style=f"bold {style}")
content.append(trend_icon, style=style)
content.append(spark_str, style="dim cyan")
return Panel(
content,
height=3,
border_style=style if self.color != "normal" else "dim"
)
def _create_mini_spark(self) -> str:
"""Create mini sparkline for inline display"""
if not self.spark_data:
return ""
spark_chars = " ▁▂▃▄▅▆▇█"
data_min = min(self.spark_data)
data_max = max(self.spark_data)
if data_max == data_min:
return "" * len(self.spark_data)
result = []
for value in self.spark_data:
normalized = (value - data_min) / (data_max - data_min)
char_index = int(normalized * 8)
result.append(spark_chars[char_index])
return "".join(result)

View File

@@ -0,0 +1,69 @@
"""
Statistical Analysis Widget - Metrics dashboard with real-time updates
"""
from textual.widgets import Static, TabbedContent, TabPane, DataTable
from textual.containers import Vertical, Horizontal
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ....analysis.core import EthernetAnalyzer
class StatisticalAnalysisWidget(Vertical):
"""
Statistical Analysis Dashboard
Features:
- Real-time metrics display
- Performance analysis charts
- Outlier detection
- Export capabilities
"""
def __init__(self, analyzer: 'EthernetAnalyzer', **kwargs):
super().__init__(**kwargs)
self.analyzer = analyzer
def compose(self):
"""Create the statistics dashboard"""
yield Static("STATISTICAL ANALYSIS", id="stats-title")
# Metrics summary
with Horizontal(id="metrics-summary"):
yield Static("Total Flows: 0", id="total-flows-metric")
yield Static("Total Packets: 0", id="total-packets-metric")
yield Static("Outliers: 0", id="outliers-metric")
yield Static("Quality: 0%", id="quality-metric")
# Analysis modes
with TabbedContent():
with TabPane("Performance", id="performance-tab"):
perf_table = DataTable(id="performance-table")
perf_table.add_columns("Metric", "Value", "Threshold", "Status")
yield perf_table
with TabPane("Protocol Distribution", id="protocol-tab"):
proto_table = DataTable(id="protocol-table")
proto_table.add_columns("Protocol", "Flows", "Packets", "Percentage")
yield proto_table
with TabPane("Timing Analysis", id="timing-tab"):
timing_table = DataTable(id="timing-table")
timing_table.add_columns("Flow", "Min", "Max", "Avg", "Jitter")
yield timing_table
with TabPane("Quality Metrics", id="quality-tab"):
quality_table = DataTable(id="quality-table")
quality_table.add_columns("Flow", "Enhanced", "Quality", "Outliers")
yield quality_table
def on_mount(self) -> None:
"""Initialize the widget"""
self.refresh_data()
def refresh_data(self) -> None:
"""Refresh statistical analysis data"""
# TODO: Implement statistics data refresh
pass

View File

@@ -0,0 +1,56 @@
"""
Packet Decoder Widget - 3-panel packet inspection interface
"""
from textual.widgets import Static, DataTable, Tree
from textual.containers import Horizontal, Vertical
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ....analysis.core import EthernetAnalyzer
class PacketDecoderWidget(Horizontal):
"""
3-Panel Packet Decoder Interface
Layout:
- Left: Flow summary tree
- Center: Packet list table
- Right: Field details tree
"""
def __init__(self, analyzer: 'EthernetAnalyzer', **kwargs):
super().__init__(**kwargs)
self.analyzer = analyzer
def compose(self):
"""Create the 3-panel layout"""
# Left panel: Flow summary
with Vertical(id="flow-summary-panel"):
yield Static("Flow Summary", id="flow-summary-title")
flow_tree = Tree("Flows", id="flow-tree")
yield flow_tree
# Center panel: Packet list
with Vertical(id="packet-list-panel"):
yield Static("Packet Details", id="packet-list-title")
packet_table = DataTable(id="packet-table")
packet_table.add_columns("Time", "Src", "Dst", "Protocol", "Info")
yield packet_table
# Right panel: Field details
with Vertical(id="field-details-panel"):
yield Static("Field Analysis", id="field-details-title")
field_tree = Tree("Fields", id="field-tree")
yield field_tree
def on_mount(self) -> None:
"""Initialize the widget"""
self.refresh_data()
def refresh_data(self) -> None:
"""Refresh packet decoder data"""
# TODO: Implement packet data refresh
pass

View File

@@ -0,0 +1,124 @@
"""
Sparkline Widget - TipTop-style mini charts for real-time metrics
"""
from textual.widget import Widget
from textual.reactive import reactive
from typing import List, Optional
from rich.text import Text
from rich.console import RenderableType
from rich.panel import Panel
class SparklineWidget(Widget):
"""
ASCII sparkline chart widget inspired by TipTop
Shows trend visualization using Unicode block characters:
▁▂▃▄▅▆▇█
"""
DEFAULT_CSS = """
SparklineWidget {
height: 4;
padding: 0 1;
}
"""
data = reactive([], always_update=True)
def __init__(
self,
title: str,
data: List[float] = None,
height: int = 4,
color: str = "cyan",
**kwargs
):
super().__init__(**kwargs)
self.title = title
self.data = data or []
self.height = height
self.color = color
self.spark_chars = " ▁▂▃▄▅▆▇█"
def update_data(self, new_data: List[float]) -> None:
"""Update sparkline data"""
self.data = new_data
def render(self) -> RenderableType:
"""Render the sparkline chart"""
if not self.data:
return Panel(
f"{self.title}: No data",
height=self.height,
border_style="dim"
)
# Calculate sparkline
sparkline = self._create_sparkline()
# Get current value and trend
current = self.data[-1] if self.data else 0
trend = self._calculate_trend()
# Format current value
if self.title == "Flow Rate":
current_str = f"{current:.0f} flows"
elif self.title == "Packet Rate":
current_str = f"{current:.1f} pps"
else:
current_str = f"{current:.1f}"
# Create content
lines = [
f"{self.title}: {current_str} {trend}",
"",
sparkline
]
return Panel(
"\n".join(lines),
height=self.height,
border_style=self.color
)
def _create_sparkline(self) -> str:
"""Create sparkline visualization"""
if len(self.data) < 2:
return "" * 40
# Normalize data
data_min = min(self.data)
data_max = max(self.data)
data_range = data_max - data_min
if data_range == 0:
# All values are the same
return "" * min(len(self.data), 40)
# Create sparkline
sparkline_chars = []
for value in self.data[-40:]: # Last 40 values
# Normalize to 0-8 range (9 spark characters)
normalized = (value - data_min) / data_range
char_index = int(normalized * 8)
sparkline_chars.append(self.spark_chars[char_index])
return "".join(sparkline_chars)
def _calculate_trend(self) -> str:
"""Calculate trend indicator"""
if len(self.data) < 2:
return ""
# Compare last value to average of previous 5
current = self.data[-1]
prev_avg = sum(self.data[-6:-1]) / min(5, len(self.data) - 1)
if current > prev_avg * 1.1:
return ""
elif current < prev_avg * 0.9:
return ""
else:
return ""