Major Features: - Complete modern TUI interface with three focused views - Enhanced multi-column layout: Source | Proto | Destination | Extended | Frame Type | Metrics - Simplified navigation with 1/2/3 hotkeys instead of F1/F2/F3 - Protocol hierarchy: Transport (TCP/UDP) → Extended (CH10/PTP) → Frame Types - Classic TUI preserved with --classic flag Views Implemented: 1. Flow Analysis View: Enhanced multi-column flow overview with protocol detection 2. Packet Decoder View: Three-panel deep inspection (Flows | Frames | Fields) 3. Statistical Analysis View: Four analysis modes with timing and quality metrics Technical Improvements: - Left-aligned text columns with IP:port precision - Transport protocol separation from extended protocols - Frame type identification (CH10-Data, TMATS, PTP Sync) - Cross-view communication with persistent flow selection - Context-sensitive help and status bars - Comprehensive error handling with console fallback
318 lines
12 KiB
Python
318 lines
12 KiB
Python
"""
|
|
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
|
|
|
|
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
|
|
|
|
# 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}"
|
|
)
|
|
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
|
|
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)
|
|
|
|
# Enhanced indicator
|
|
if flow.enhanced_analysis.decoder_type != "Standard":
|
|
stdscr.addstr(current_y + display_idx, 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)
|
|
|
|
# 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 _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}"
|
|
|
|
# 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}"
|
|
|
|
# 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 = f"{flow.avg_inter_arrival*1000:.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 |