diff --git a/analyzer/tui/textual/app_v2.py b/analyzer/tui/textual/app_v2.py index 3239342..e85eae5 100644 --- a/analyzer/tui/textual/app_v2.py +++ b/analyzer/tui/textual/app_v2.py @@ -19,7 +19,7 @@ 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 +from .widgets.split_flow_details import FlowMainDetailsPanel, SubFlowDetailsPanel if TYPE_CHECKING: from ...analysis.core import EthernetAnalyzer @@ -90,20 +90,19 @@ class StreamLensAppV2(App): 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 + # Main content area with 3 clean panels 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" - ) + # Left - Enhanced flow table + yield EnhancedFlowTable( + self.analyzer, + id="flow-table", + classes="panel-wide" + ) - # Right - Selected flow details - with Vertical(id="right-panel", classes="panel"): - yield FlowDetailsPanel( - id="flow-details" - ) + # Right top - Main flow details + with Vertical(id="right-panels"): + yield FlowMainDetailsPanel(id="main-flow-details") + yield SubFlowDetailsPanel(id="sub-flow-details") yield Footer() @@ -241,8 +240,13 @@ class StreamLensAppV2(App): 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) + # 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: diff --git a/analyzer/tui/textual/styles/streamlens_v2.tcss b/analyzer/tui/textual/styles/streamlens_v2.tcss index 74ae106..e1be6be 100644 --- a/analyzer/tui/textual/styles/streamlens_v2.tcss +++ b/analyzer/tui/textual/styles/streamlens_v2.tcss @@ -35,15 +35,13 @@ MetricCard { padding: 0; } -/* Panel Styling - Minimal borders */ +/* Panel Styling - No borders for clean look */ .panel { - border: solid #99ccff; padding: 0; margin: 0; } .panel-wide { - border: solid #99ccff; padding: 0; margin: 0; } @@ -62,13 +60,25 @@ MetricCard { padding: 0; } -/* Right Panel - Details (compact) */ -#right-panel { +/* Right Panels - Details (compact) */ +#right-panels { width: 25%; background: #1a1a1a; padding: 0; } +FlowMainDetailsPanel { + height: 3fr; + background: #1a1a1a; + border: solid #ff8800; +} + +SubFlowDetailsPanel { + height: 2fr; + background: #1a1a1a; + border: solid #ff8800; +} + /* Sparkline Charts */ SparklineWidget { height: 5; @@ -188,9 +198,6 @@ DataTable:focus { border: solid #00ffcc; } -/* Panel Borders */ -Static { - border: round #0080ff; -} +/* Panel Borders - Removed for clean look */ /* End of styles */ \ No newline at end of file diff --git a/analyzer/tui/textual/widgets/flow_table_v2.py b/analyzer/tui/textual/widgets/flow_table_v2.py index d38efdb..bd52085 100644 --- a/analyzer/tui/textual/widgets/flow_table_v2.py +++ b/analyzer/tui/textual/widgets/flow_table_v2.py @@ -75,6 +75,9 @@ class EnhancedFlowTable(Vertical): table.add_column("Frame Type", width=10, key="frame_type") table.add_column("Pkts", width=6, key="rate") table.add_column("Size", width=8, key="volume") + table.add_column("ΔT(ms)", width=8, key="delta_t") + table.add_column("σ(ms)", width=8, key="sigma") + table.add_column("Out", width=5, key="outliers") self.refresh_data() @@ -95,6 +98,7 @@ class EnhancedFlowTable(Vertical): # Clear row mapping self.row_to_flow_map.clear() + self.row_to_subflow_map = {} # Map row keys to (flow_index, subflow_type) # Get and sort flows self.flows_list = self._get_sorted_flows() @@ -139,10 +143,14 @@ class EnhancedFlowTable(Vertical): # Add sub-rows for protocol breakdown if self._should_show_subrows(flow): sub_rows = self._create_protocol_subrows(flow) + combinations = self._get_protocol_frame_combinations(flow) for j, sub_row in enumerate(sub_rows): sub_key = table.add_row(*sub_row, key=f"flow_{i}_sub_{j}") - # Map sub-row to parent flow + # Map sub-row to parent flow and subflow type self.row_to_flow_map[sub_key] = i + if j < len(combinations): + _, frame_type, _, _ = combinations[j] + self.row_to_subflow_map[sub_key] = (i, frame_type) # Restore cursor position if selected_row_key and selected_row_key in table.rows: @@ -188,24 +196,37 @@ class EnhancedFlowTable(Vertical): 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) + # Delta T (average time between packets in ms) + if flow.avg_inter_arrival > 0: + delta_t_ms = flow.avg_inter_arrival * 1000 + if delta_t_ms >= 1000: + delta_t_str = f"{delta_t_ms/1000:.1f}s" + else: + delta_t_str = f"{delta_t_ms:.1f}" + else: + delta_t_str = "N/A" + delta_t_text = Text(delta_t_str, justify="right") - # 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) + # Sigma (standard deviation in ms) + if flow.std_inter_arrival > 0: + sigma_ms = flow.std_inter_arrival * 1000 + if sigma_ms >= 1000: + sigma_str = f"{sigma_ms/1000:.1f}s" + else: + sigma_str = f"{sigma_ms:.1f}" + else: + sigma_str = "N/A" + sigma_text = Text(sigma_str, justify="right") + + # Outlier count (packets outside tolerance) + outlier_count = len(flow.outlier_frames) + outlier_text = Text(str(outlier_count), justify="right", + style="red" if outlier_count > 0 else "green") return [ num_text, source_text, proto_text, dest_text, - extended_text, frame_text, rate_text, size_text + extended_text, frame_text, rate_text, size_text, + delta_t_text, sigma_text, outlier_text ] def _create_rate_sparkline(self, history: List[float]) -> str: @@ -308,6 +329,21 @@ class EnhancedFlowTable(Vertical): combinations = self._get_protocol_frame_combinations(flow) for extended_proto, frame_type, count, percentage in combinations[:3]: # Max 3 subrows + # Calculate timing for this frame type if available + frame_delta_t = "" + frame_sigma = "" + frame_outliers = "" + + if frame_type in flow.frame_types: + ft_stats = flow.frame_types[frame_type] + if ft_stats.avg_inter_arrival > 0: + dt_ms = ft_stats.avg_inter_arrival * 1000 + frame_delta_t = f"{dt_ms:.1f}" if dt_ms < 1000 else f"{dt_ms/1000:.1f}s" + if ft_stats.std_inter_arrival > 0: + sig_ms = ft_stats.std_inter_arrival * 1000 + frame_sigma = f"{sig_ms:.1f}" if sig_ms < 1000 else f"{sig_ms/1000:.1f}s" + frame_outliers = str(len(ft_stats.outlier_frames)) + subrow = [ Text(""), # Empty flow number Text(""), # Empty source @@ -316,7 +352,10 @@ class EnhancedFlowTable(Vertical): Text(f" {extended_proto}", style="dim yellow"), Text(frame_type, style="dim blue"), Text(f"{count}", style="dim", justify="right"), - Text(f"{self._format_bytes(count * (flow.total_bytes // flow.frame_count) if flow.frame_count > 0 else 0):>8}", style="dim") + Text(f"{self._format_bytes(count * (flow.total_bytes // flow.frame_count) if flow.frame_count > 0 else 0):>8}", style="dim"), + Text(frame_delta_t, style="dim", justify="right"), + Text(frame_sigma, style="dim", justify="right"), + Text(frame_outliers, style="dim red" if frame_outliers and int(frame_outliers) > 0 else "dim", justify="right") ] subrows.append(subrow) @@ -348,8 +387,9 @@ class EnhancedFlowTable(Vertical): class FlowSelected(Message): """Message sent when a flow is selected""" - def __init__(self, flow: Optional['FlowStats']) -> None: + def __init__(self, flow: Optional['FlowStats'], subflow_type: Optional[str] = None) -> None: self.flow = flow + self.subflow_type = subflow_type super().__init__() def get_selected_flow(self) -> Optional['FlowStats']: @@ -372,10 +412,31 @@ class EnhancedFlowTable(Vertical): return None + def get_selected_subflow_type(self) -> Optional[str]: + """Get currently selected sub-flow type if applicable""" + table = self.query_one("#flows-data-table", DataTable) + if table.cursor_row is None or not table.rows: + return None + + # Get the row key at cursor position + row_keys = list(table.rows.keys()) + if table.cursor_row >= len(row_keys): + return None + + row_key = row_keys[table.cursor_row] + + # Check if this is a sub-row + if row_key in self.row_to_subflow_map: + _, subflow_type = self.row_to_subflow_map[row_key] + return subflow_type + + return None + def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: """Handle row highlight to update selection""" selected_flow = self.get_selected_flow() - self.post_message(self.FlowSelected(selected_flow)) + subflow_type = self.get_selected_subflow_type() + self.post_message(self.FlowSelected(selected_flow, subflow_type)) # Helper methods from original implementation def _get_extended_protocol(self, flow: 'FlowStats') -> str: diff --git a/analyzer/tui/textual/widgets/split_flow_details.py b/analyzer/tui/textual/widgets/split_flow_details.py new file mode 100644 index 0000000..01b9714 --- /dev/null +++ b/analyzer/tui/textual/widgets/split_flow_details.py @@ -0,0 +1,316 @@ +""" +Split Flow Details Panel - Top/Bottom layout for flow and sub-flow details +""" + +from textual.widget import Widget +from textual.containers import Vertical +from textual.widgets import Static +from textual.reactive import reactive +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, FrameTypeStats + + +class FlowMainDetailsPanel(Vertical): + """Top panel showing main flow details""" + + DEFAULT_CSS = """ + FlowMainDetailsPanel { + height: 1fr; + padding: 1; + } + + FlowMainDetailsPanel Static { + margin-bottom: 0; + } + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.current_flow = None + + def compose(self): + """Create the main flow details layout""" + yield Static("Main Flow Details", classes="panel-header") + yield Static( + "Select a flow to view details", + id="main-details-content" + ) + + def update_flow(self, flow: Optional['FlowStats']) -> None: + """Update panel with main flow details""" + self.current_flow = flow + content_widget = self.query_one("#main-details-content", Static) + + if not flow: + content_widget.update("Select a flow to view details") + return + + details = self._create_main_flow_details(flow) + content_widget.update(details) + + def _create_main_flow_details(self, flow: 'FlowStats') -> RenderableType: + """Create main 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(Text("Flow Information", style="bold blue")) + sections.append(id_table) + + # 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(Text("Enhanced Analysis", style="bold green")) + sections.append(enhanced_table) + + # Timing analysis with new columns + 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 ΔT:", f"{flow.avg_inter_arrival * 1000:.1f}ms") + timing_table.add_row("Std σ:", f"{flow.std_inter_arrival * 1000:.1f}ms") + timing_table.add_row("Outliers:", f"{len(flow.outlier_frames)}") + 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(Text("Timing Analysis", style="bold cyan")) + sections.append(timing_table) + + 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] + + +class SubFlowDetailsPanel(Vertical): + """Bottom panel showing sub-flow details or summary""" + + DEFAULT_CSS = """ + SubFlowDetailsPanel { + height: 1fr; + padding: 1; + } + + SubFlowDetailsPanel Static { + margin-bottom: 0; + } + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.current_flow = None + self.selected_subflow = None + + def compose(self): + """Create the sub-flow details layout""" + yield Static("Sub-Flow Details", classes="panel-header") + yield Static( + "No sub-flow selected", + id="sub-details-content" + ) + + def update_flow(self, flow: Optional['FlowStats'], subflow_type: Optional[str] = None) -> None: + """Update panel with sub-flow details or summary""" + self.current_flow = flow + self.selected_subflow = subflow_type + content_widget = self.query_one("#sub-details-content", Static) + + if not flow: + content_widget.update("No sub-flow selected") + return + + if subflow_type and subflow_type in flow.frame_types: + # Show specific sub-flow details + details = self._create_subflow_details(flow, flow.frame_types[subflow_type]) + else: + # Show summary of all sub-flows + details = self._create_subflow_summary(flow) + + content_widget.update(details) + + def _create_subflow_details(self, flow: 'FlowStats', subflow: 'FrameTypeStats') -> RenderableType: + """Create specific sub-flow details""" + sections = [] + + # Sub-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("Frame Type:", subflow.frame_type) + id_table.add_row("Packets:", f"{subflow.count:,}") + id_table.add_row("Volume:", self._format_bytes(subflow.total_bytes)) + + percentage = (subflow.count / flow.frame_count * 100) if flow.frame_count > 0 else 0 + id_table.add_row("% of Flow:", f"{percentage:.1f}%") + + sections.append(Text("Sub-Flow Information", style="bold yellow")) + sections.append(id_table) + + # Sub-flow timing + 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("Avg ΔT:", f"{subflow.avg_inter_arrival * 1000:.1f}ms" if subflow.avg_inter_arrival > 0 else "N/A") + timing_table.add_row("Std σ:", f"{subflow.std_inter_arrival * 1000:.1f}ms" if subflow.std_inter_arrival > 0 else "N/A") + timing_table.add_row("Outliers:", f"{len(subflow.outlier_frames)}") + + sections.append(Text("Sub-Flow Timing", style="bold cyan")) + sections.append(timing_table) + + # Outlier details if any + if subflow.outlier_frames and subflow.outlier_details: + outlier_table = Table(show_header=True, box=None) + outlier_table.add_column("Frame#", justify="right") + outlier_table.add_column("ΔT(ms)", justify="right") + + for frame_num, delta_t in subflow.outlier_details[:5]: # Show first 5 outliers + outlier_table.add_row( + str(frame_num), + f"{delta_t * 1000:.1f}" + ) + + if len(subflow.outlier_details) > 5: + outlier_table.add_row("...", f"+{len(subflow.outlier_details) - 5} more") + + sections.append(Text("Outlier Details", style="bold red")) + sections.append(outlier_table) + + return Group(*sections) + + def _create_subflow_summary(self, flow: 'FlowStats') -> RenderableType: + """Create summary of all sub-flows""" + if not flow.frame_types or len(flow.frame_types) <= 1: + return Text("No sub-flows available", style="dim") + + sections = [] + sections.append(Text("Sub-Flow Summary", style="bold yellow")) + + # Frame type breakdown table + frame_table = Table(show_header=True, box=None) + frame_table.add_column("Frame Type", style="blue") + frame_table.add_column("Count", justify="right") + frame_table.add_column("%", justify="right", style="dim") + frame_table.add_column("ΔT(ms)", justify="right", style="cyan") + frame_table.add_column("σ(ms)", justify="right", style="cyan") + frame_table.add_column("Out", justify="right", style="red") + + total = flow.frame_count + for frame_type, stats in sorted( + flow.frame_types.items(), + key=lambda x: x[1].count, + reverse=True + ): + percentage = (stats.count / total * 100) if total > 0 else 0 + delta_t = f"{stats.avg_inter_arrival * 1000:.1f}" if stats.avg_inter_arrival > 0 else "N/A" + sigma = f"{stats.std_inter_arrival * 1000:.1f}" if stats.std_inter_arrival > 0 else "N/A" + outliers = str(len(stats.outlier_frames)) + + frame_table.add_row( + frame_type[:15], + f"{stats.count:,}", + f"{percentage:.1f}%", + delta_t, + sigma, + outliers + ) + + sections.append(frame_table) + 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" + + +class SplitFlowDetailsPanel(Vertical): + """Combined panel with top/bottom split for flow and sub-flow details""" + + DEFAULT_CSS = """ + SplitFlowDetailsPanel { + height: 1fr; + padding: 0; + } + + SplitFlowDetailsPanel > FlowMainDetailsPanel { + height: 3fr; + } + + SplitFlowDetailsPanel > SubFlowDetailsPanel { + height: 2fr; + } + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.current_flow = None + + def compose(self): + """Create the split layout""" + yield FlowMainDetailsPanel(id="main-flow-details") + yield SubFlowDetailsPanel(id="sub-flow-details") + + def update_flow(self, flow: Optional['FlowStats'], subflow_type: Optional[str] = None) -> None: + """Update both panels with flow data""" + self.current_flow = flow + + # Update main flow details + main_panel = self.query_one("#main-flow-details", FlowMainDetailsPanel) + main_panel.update_flow(flow) + + # Update sub-flow details + sub_panel = self.query_one("#sub-flow-details", SubFlowDetailsPanel) + sub_panel.update_flow(flow, subflow_type) \ No newline at end of file