""" 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 textual.events import MouseDown, MouseMove 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 import signal import sys from .widgets.sparkline import SparklineWidget from .widgets.metric_card import MetricCard from .widgets.flow_table_v2 import EnhancedFlowTable from .widgets.split_flow_details import FlowMainDetailsPanel, SubFlowDetailsPanel from .widgets.debug_panel import DebugPanel from ...analysis.background_analyzer import BackgroundAnalyzer 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" ENABLE_COMMAND_PALETTE = False AUTO_FOCUS = None 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"), ("v", "toggle_view_mode", "Toggle View"), ("?", "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) debug_visible = reactive(False) # Hide debug panel for now # 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 # Background parsing support self.background_analyzer = BackgroundAnalyzer( analyzer=analyzer, num_threads=4, batch_size=1000, progress_callback=None, flow_update_callback=self._on_flow_update ) self.pcap_file = None # 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"): # Ultra-compact metrics bar with Horizontal(id="metrics-bar"): yield MetricCard("Flows", f"{self.total_flows}", id="flows-metric") yield MetricCard("Pkts/s", f"{self.packets_per_sec:.0f}", id="packets-metric") yield MetricCard("Vol/s", self._format_bytes_per_sec(self.bytes_per_sec), 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 conditional debug panel with Horizontal(id="content-area"): # Left - Enhanced flow table yield EnhancedFlowTable( self.analyzer, id="flow-table", classes="panel-wide" ) # Middle - Flow details with Vertical(id="flow-panels"): yield FlowMainDetailsPanel(id="main-flow-details") yield SubFlowDetailsPanel(id="sub-flow-details") # Right - Debug panel (conditionally visible) if self.debug_visible: yield DebugPanel(id="debug-panel") yield Footer() def on_mount(self) -> None: """Initialize the application with TipTop-style updates""" try: debug_panel = self.query_one("#debug-panel", DebugPanel) debug_panel.add_debug_message("APP: Application mounted, checking panels...") try: main_panel = self.query_one("#main-flow-details", FlowMainDetailsPanel) sub_panel = self.query_one("#sub-flow-details", SubFlowDetailsPanel) debug_panel.add_debug_message("APP: Both panels found successfully") except Exception as e: debug_panel.add_debug_message(f"APP: Panel query failed: {e}") except: pass # Debug panel not visible # Set initial subtitle with view mode try: flow_table = self.query_one("#flow-table", EnhancedFlowTable) view_mode = flow_table.get_current_view_mode() status = "PAUSED" if self.paused else "LIVE" self.sub_title = f"Network Flow Analysis - {status} - {view_mode} VIEW" except: pass self.update_metrics() # Set up update intervals like TipTop (reduced frequency since we have real-time updates) self.metric_timer = self.set_interval(2.0, self.update_metrics) # 0.5Hz for background updates self.flow_timer = self.set_interval(5.0, self.update_flows) # 0.2Hz for fallback flow updates # Initialize sparkline history self._initialize_history() # Set initial focus to the flow table for immediate keyboard navigation self.call_after_refresh(self._set_initial_focus) def _set_initial_focus(self): """Set initial focus to the flow table after widgets are ready""" try: flow_table = self.query_one("#flow-table", EnhancedFlowTable) data_table = flow_table.query_one("#flows-data-table", DataTable) data_table.focus() except Exception: # If table isn't ready yet, try again after a short delay self.set_timer(0.1, self._set_initial_focus) 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 (thread-safe access) enhanced = 0 outliers = 0 try: # Use background analyzer's thread-safe flow access flows = self.background_analyzer.get_current_flows() for flow in flows.values(): if flow.enhanced_analysis.decoder_type != "Standard": enhanced += 1 outliers += len(flow.outlier_frames) except Exception: # Fallback to direct access if background analyzer not available 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_flow_update(self): """Handle flow data updates from background parser""" try: # Use call_from_thread to safely update UI from background thread self.call_from_thread(self._update_flow_ui) except Exception: # Ignore errors during shutdown pass def _update_flow_ui(self): """Update flow UI (called from main thread)""" try: # Update flow table flow_table = self.query_one("#flow-table", EnhancedFlowTable) flow_table.refresh_data() # Also update metrics in real-time self.update_metrics() except Exception: # Flow table widget may not be available yet pass def start_background_parsing(self, pcap_file: str): """Start parsing PCAP file in background""" self.pcap_file = pcap_file # Start background parsing self.background_analyzer.start_parsing(pcap_file) def stop_background_parsing(self): """Stop background parsing""" self.background_analyzer.stop_parsing() def cleanup(self): """Cleanup resources when app shuts down""" try: self.background_analyzer.cleanup() # Cancel any pending timers if self.metric_timer: self.metric_timer.stop() if self.flow_timer: self.flow_timer.stop() except Exception as e: # Don't let cleanup errors prevent shutdown pass def on_enhanced_flow_table_flow_selected(self, event: EnhancedFlowTable.FlowSelected) -> None: """Handle flow selection events""" try: debug_panel = self.query_one("#debug-panel", DebugPanel) flow_info = f"{event.flow.src_ip}:{event.flow.src_port}" if event.flow else "None" debug_panel.add_debug_message(f"APP: Flow selected - {flow_info}, subflow={event.subflow_type}") except: pass # Debug panel not visible if event.flow: # Update main flow details panel main_panel = self.query_one("#main-flow-details", FlowMainDetailsPanel) main_panel.update_flow(event.flow) # Update sub-flow details panel sub_panel = self.query_one("#sub-flow-details", SubFlowDetailsPanel) sub_panel.update_flow(event.flow, event.subflow_type) 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" # Get current view mode to maintain it in subtitle try: flow_table = self.query_one("#flow-table", EnhancedFlowTable) view_mode = flow_table.get_current_view_mode() self.sub_title = f"Network Flow Analysis - {status} - {view_mode} VIEW" except: 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 def action_toggle_view_mode(self) -> None: """Toggle between simplified and detailed view modes""" flow_table = self.query_one("#flow-table", EnhancedFlowTable) flow_table.toggle_view_mode() # Update subtitle to show current view mode view_mode = flow_table.get_current_view_mode() status = "PAUSED" if self.paused else "LIVE" self.sub_title = f"Network Flow Analysis - {status} - {view_mode} VIEW" def on_mouse_down(self, event: MouseDown) -> None: """Prevent default mouse down behavior to disable mouse interaction.""" event.prevent_default() def on_mouse_move(self, event: MouseMove) -> None: """Prevent default mouse move behavior to disable mouse interaction.""" event.prevent_default() def action_quit(self) -> None: """Quit the application with proper cleanup""" self.cleanup() self.exit() def on_unmount(self) -> None: """Called when app is being unmounted - ensure cleanup""" self.cleanup()