""" Flow Analysis View - Visual flow overview with protocol detection Focuses on understanding communication patterns and flow characteristics """ import curses from typing import TYPE_CHECKING, List, Optional, Tuple from ...models import FlowStats if TYPE_CHECKING: from ...analysis.core import EthernetAnalyzer class FlowAnalysisView: """ Flow Analysis View - F1 Primary view for understanding network flows: - Flow overview with visual indicators - Protocol detection and classification - Traffic patterns and volume analysis - Enhanced decoder availability """ def __init__(self, analyzer: 'EthernetAnalyzer'): self.analyzer = analyzer 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""" height, width = stdscr.getmaxyx() start_y = 3 # After header max_height = height - 2 # Reserve for status bar flows_list = self._get_flows_list() if not flows_list: stdscr.addstr(start_y + 2, 4, "No network flows detected", curses.A_DIM) stdscr.addstr(start_y + 3, 4, "Waiting for packets...", curses.A_DIM) return # Flow summary header summary = self.analyzer.get_summary() summary_text = (f"Flows: {summary['unique_flows']} | " f"Packets: {summary['total_packets']} | " f"Endpoints: {summary['unique_ips']}") if self.analyzer.is_live: rt_summary = self.analyzer.statistics_engine.get_realtime_summary() summary_text += f" | Outliers: {rt_summary.get('total_outliers', 0)}" stdscr.addstr(start_y, 4, summary_text, curses.A_BOLD) # Flow analysis area flow_start_y = start_y + 2 self._draw_flow_overview(stdscr, flow_start_y, width, max_height - flow_start_y, flows_list) def _draw_flow_overview(self, stdscr, start_y: int, width: int, max_height: int, flows_list: List[FlowStats]): """Draw comprehensive flow overview""" # Header stdscr.addstr(start_y, 4, "FLOW ANALYSIS", curses.A_BOLD | curses.A_UNDERLINE) current_y = start_y + 2 # 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 # Calculate visible range visible_flows = max_height - (current_y - start_y) - 2 start_idx = self.scroll_offset end_idx = min(start_idx + visible_flows, len(flows_list)) # 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] # Flow selection is_selected = (i == self.selected_flow) # 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_row, 2, "●", curses.A_BOLD | curses.color_pair(1)) 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: stdscr.addstr(current_y, width - 10, "↑ More", curses.A_DIM) 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}" 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}" 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) # Frame type (most common frame type in this flow) frame_type = self._get_primary_frame_type(flow) # 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" return (f"{flow_num:>2} " f"{source:20} " f"{protocol:6} " f"{destination:20} " f"{extended_protocol:10} " f"{frame_type:12} " f"{pkt_count:>6} " f"{volume:>8} " f"{timing:>8} " f"{quality:>8}") def _draw_frame_types_compact(self, stdscr, y: int, width: int, flow: FlowStats): """Draw compact frame type breakdown for selected flow""" frame_types = sorted(flow.frame_types.items(), key=lambda x: x[1].count, reverse=True) # Compact frame type display type_summary = [] for frame_type, ft_stats in frame_types[:4]: # Show top 4 type_summary.append(f"{frame_type}({ft_stats.count})") if len(frame_types) > 4: type_summary.append(f"+{len(frame_types)-4} more") frame_line = f" └─ Frame Types: {', '.join(type_summary)}" stdscr.addstr(y, 4, frame_line[:width-8], curses.A_DIM) def _get_primary_protocol(self, flow: FlowStats) -> str: """Get the most relevant protocol for display""" # Prioritize enhanced protocols if flow.detected_protocol_types: enhanced_protocols = {'CHAPTER10', 'PTP', 'IENA', 'CH10'} found_enhanced = flow.detected_protocol_types & enhanced_protocols if found_enhanced: return list(found_enhanced)[0] # Use first detected protocol return list(flow.detected_protocol_types)[0] # Fallback to transport protocol return flow.transport_protocol 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 _get_primary_frame_type(self, flow: FlowStats) -> str: """Get the most common frame type in this flow""" if not flow.frame_types: return '-' # Find the frame type with the most packets most_common = max(flow.frame_types.items(), key=lambda x: x[1].count) frame_type = most_common[0] # Simplify frame type names for display if frame_type == 'CH10-Data': return 'CH10-Data' elif frame_type == 'TMATS': return 'TMATS' elif frame_type.startswith('PTP-'): return frame_type.replace('PTP-', 'PTP ')[:11] # PTP Sync, PTP Signal elif frame_type == 'UDP': return 'UDP' elif frame_type == 'IGMP': return 'IGMP' else: return frame_type[:11] # Truncate to fit column 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 handle_input(self, key: int, flows_list: List[FlowStats]) -> str: """Handle input for Flow Analysis view""" if key == curses.KEY_UP: self.selected_flow = max(0, self.selected_flow - 1) self._adjust_scroll() return 'selection_change' elif key == curses.KEY_DOWN: self.selected_flow = min(len(flows_list) - 1, self.selected_flow + 1) self._adjust_scroll() return 'selection_change' elif key == curses.KEY_PPAGE: # Page Up self.selected_flow = max(0, self.selected_flow - 10) self._adjust_scroll() return 'selection_change' elif key == curses.KEY_NPAGE: # Page Down self.selected_flow = min(len(flows_list) - 1, self.selected_flow + 10) self._adjust_scroll() return 'selection_change' elif key == ord('\n') or key == curses.KEY_ENTER: # Select flow for detailed analysis if flows_list and self.selected_flow < len(flows_list): selected_flow = flows_list[self.selected_flow] # Signal flow selection for other views return 'flow_selected' elif key == ord('v') or key == ord('V'): # Visualize selected flow return 'visualize' elif key == ord('d') or key == ord('D'): # Switch to decoder view for selected flow return 'decode_flow' elif key == ord('t') or key == ord('T'): # Toggle frame types display self.show_frame_types = not self.show_frame_types return 'toggle_frame_types' return 'none' def _adjust_scroll(self): """Adjust scroll position to keep selected item visible""" # This would be implemented based on visible area calculations pass def get_selected_flow(self, flows_list: List[FlowStats]) -> Optional[FlowStats]: """Get currently selected flow""" if flows_list and 0 <= self.selected_flow < len(flows_list): return flows_list[self.selected_flow] return None