Modern TUI with Enhanced Protocol Hierarchy Interface

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
This commit is contained in:
2025-07-26 22:46:49 -04:00
parent 0f2fc8f92c
commit 5c2cb1a4ed
22 changed files with 4207 additions and 35 deletions

View File

@@ -64,6 +64,8 @@ class TUIInterface:
break
elif action == 'visualize':
self._handle_visualization()
elif action == 'switch_tab':
self.detail_panel.switch_tab()
def _draw_main_view(self, stdscr):
"""Draw three-panel main view: flows list, details, and timeline"""

View File

@@ -0,0 +1,272 @@
"""
Modern TUI Interface for StreamLens
Focused on Flow Analysis, Packet Decoding, and Statistical Analysis
"""
import curses
from typing import TYPE_CHECKING, List, Optional
from enum import Enum
from .navigation import NavigationHandler
from .modern_views import FlowAnalysisView, PacketDecoderView, StatisticalAnalysisView
from ..utils.signal_visualizer import signal_visualizer
if TYPE_CHECKING:
from ..analysis.core import EthernetAnalyzer
class ViewMode(Enum):
FLOW_ANALYSIS = "flow"
PACKET_DECODER = "decode"
STATISTICAL_ANALYSIS = "stats"
class ModernTUIInterface:
"""
Modern StreamLens TUI Interface
Three primary views accessed via 1/2/3:
- 1: Flow Analysis - Visual flow overview with enhanced protocol detection
- 2: Packet Decoder - Deep protocol inspection and field extraction
- 3: Statistical Analysis - Timing analysis, outliers, and quality metrics
"""
def __init__(self, analyzer: 'EthernetAnalyzer'):
self.analyzer = analyzer
self.navigation = NavigationHandler()
# Current view mode
self.current_view = ViewMode.FLOW_ANALYSIS
# Initialize view controllers
self.flow_view = FlowAnalysisView(analyzer)
self.decoder_view = PacketDecoderView(analyzer)
self.stats_view = StatisticalAnalysisView(analyzer)
# Global state
self.selected_flow_key = None
self.show_help = False
def run(self, stdscr):
"""Main TUI loop for modern interface"""
curses.curs_set(0) # Hide cursor
stdscr.keypad(True)
# Set timeout based on live mode
if self.analyzer.is_live:
stdscr.timeout(500) # 0.5 second for live updates
else:
stdscr.timeout(1000) # 1 second for static analysis
while True:
try:
stdscr.clear()
# Draw header with view indicators
self._draw_header(stdscr)
# Draw main content based on current view
if self.current_view == ViewMode.FLOW_ANALYSIS:
self.flow_view.draw(stdscr, self.selected_flow_key)
elif self.current_view == ViewMode.PACKET_DECODER:
self.decoder_view.draw(stdscr, self.selected_flow_key)
elif self.current_view == ViewMode.STATISTICAL_ANALYSIS:
self.stats_view.draw(stdscr, self.selected_flow_key)
# Draw status bar
self._draw_status_bar(stdscr)
# Overlay help if requested
if self.show_help:
self._draw_help_overlay(stdscr)
stdscr.refresh()
# Handle input
key = stdscr.getch()
# Handle timeout (no key pressed) - refresh for live capture
if key == -1 and self.analyzer.is_live:
continue
action = self._handle_input(key)
if action == 'quit':
if self.analyzer.is_live:
self.analyzer.stop_capture = True
break
except curses.error:
# Handle terminal resize or other curses errors
pass
def _draw_header(self, stdscr):
"""Draw the header with application title and view tabs"""
height, width = stdscr.getmaxyx()
# Title
title = "StreamLens - Ethernet Traffic Analyzer"
stdscr.addstr(0, 2, title, curses.A_BOLD)
# Live indicator
if self.analyzer.is_live:
live_text = "[LIVE]"
stdscr.addstr(0, width - len(live_text) - 2, live_text,
curses.A_BOLD | curses.A_BLINK)
# View tabs
tab_line = 1
tab_x = 2
# 1: Flow Analysis
if self.current_view == ViewMode.FLOW_ANALYSIS:
stdscr.addstr(tab_line, tab_x, "[1: Flow Analysis]", curses.A_REVERSE)
else:
stdscr.addstr(tab_line, tab_x, " 1: Flow Analysis ", curses.A_DIM)
tab_x += 19
# 2: Packet Decoder
if self.current_view == ViewMode.PACKET_DECODER:
stdscr.addstr(tab_line, tab_x, "[2: Packet Decoder]", curses.A_REVERSE)
else:
stdscr.addstr(tab_line, tab_x, " 2: Packet Decoder ", curses.A_DIM)
tab_x += 20
# 3: Statistical Analysis
if self.current_view == ViewMode.STATISTICAL_ANALYSIS:
stdscr.addstr(tab_line, tab_x, "[3: Statistical Analysis]", curses.A_REVERSE)
else:
stdscr.addstr(tab_line, tab_x, " 3: Statistical Analysis ", curses.A_DIM)
# Draw separator line
stdscr.addstr(2, 0, "" * width)
def _draw_status_bar(self, stdscr):
"""Draw status bar with context-sensitive help"""
height, width = stdscr.getmaxyx()
status_y = height - 1
# Base controls
status_text = "[1-3]Views [↑↓]Navigate [Enter]Select [H]Help [Q]Quit"
# Add view-specific controls
if self.current_view == ViewMode.FLOW_ANALYSIS:
status_text += " [V]Visualize [D]Decode"
elif self.current_view == ViewMode.PACKET_DECODER:
status_text += " [E]Export [C]Copy"
elif self.current_view == ViewMode.STATISTICAL_ANALYSIS:
status_text += " [R]Refresh [O]Outliers"
# Add live capture controls
if self.analyzer.is_live:
status_text += " [P]Pause"
stdscr.addstr(status_y, 0, status_text[:width-1], curses.A_REVERSE)
def _draw_help_overlay(self, stdscr):
"""Draw help overlay with comprehensive controls"""
height, width = stdscr.getmaxyx()
# Calculate overlay size
overlay_height = min(20, height - 4)
overlay_width = min(80, width - 4)
start_y = (height - overlay_height) // 2
start_x = (width - overlay_width) // 2
# Create help window
help_lines = [
"StreamLens - Help",
"",
"VIEWS:",
" 1 - Flow Analysis: Visual flow overview and protocol detection",
" 2 - Packet Decoder: Deep packet inspection and field extraction",
" 3 - Statistical Analysis: Timing analysis and quality metrics",
"",
"NAVIGATION:",
" ↑/↓ - Navigate items",
" Enter - Select flow/packet",
" Tab - Switch panels (when available)",
" PgUp/PgDn - Scroll large lists",
"",
"ANALYSIS:",
" V - Visualize signals (Flow Analysis)",
" D - Deep decode selected flow",
" E - Export decoded data",
" R - Refresh statistics",
" O - Show outlier details",
"",
"GENERAL:",
" H - Toggle this help",
" Q - Quit application",
"",
"Press any key to close help..."
]
# Draw background
for y in range(overlay_height):
stdscr.addstr(start_y + y, start_x, " " * overlay_width, curses.A_REVERSE)
# Draw help content
for i, line in enumerate(help_lines[:overlay_height-1]):
if start_y + i < height - 1:
display_line = line[:overlay_width-2]
attr = curses.A_REVERSE | curses.A_BOLD if i == 0 else curses.A_REVERSE
stdscr.addstr(start_y + i, start_x + 1, display_line, attr)
def _handle_input(self, key: int) -> str:
"""Handle keyboard input with view-specific actions"""
# Global controls
if key == ord('q') or key == ord('Q'):
return 'quit'
elif key == ord('h') or key == ord('H'):
self.show_help = not self.show_help
return 'help_toggle'
elif self.show_help:
# Any key closes help
self.show_help = False
return 'help_close'
# View switching
elif key == ord('1'):
self.current_view = ViewMode.FLOW_ANALYSIS
return 'view_change'
elif key == ord('2'):
self.current_view = ViewMode.PACKET_DECODER
return 'view_change'
elif key == ord('3'):
self.current_view = ViewMode.STATISTICAL_ANALYSIS
return 'view_change'
# Delegate to current view
elif self.current_view == ViewMode.FLOW_ANALYSIS:
return self.flow_view.handle_input(key, self._get_flows_list())
elif self.current_view == ViewMode.PACKET_DECODER:
return self.decoder_view.handle_input(key, self._get_flows_list())
elif self.current_view == ViewMode.STATISTICAL_ANALYSIS:
return self.stats_view.handle_input(key, self._get_flows_list())
return 'none'
def _get_flows_list(self):
"""Get prioritized list of flows for analysis"""
flows_list = list(self.analyzer.flows.values())
# Sort by relevance: enhanced flows first, then by packet count
flows_list.sort(key=lambda x: (
x.enhanced_analysis.decoder_type != "Standard", # Enhanced first
self.analyzer.statistics_engine.get_max_sigma_deviation(x), # High outliers
x.frame_count # Packet count
), reverse=True)
return flows_list
def get_selected_flow(self):
"""Get currently selected flow for cross-view communication"""
if self.selected_flow_key:
return self.analyzer.flows.get(self.selected_flow_key)
return None
def set_selected_flow(self, flow_key):
"""Set selected flow for cross-view communication"""
self.selected_flow_key = flow_key

View File

@@ -0,0 +1,9 @@
"""
Modern TUI Views for StreamLens
"""
from .flow_analysis import FlowAnalysisView
from .packet_decoder import PacketDecoderView
from .statistical_analysis import StatisticalAnalysisView
__all__ = ['FlowAnalysisView', 'PacketDecoderView', 'StatisticalAnalysisView']

View File

@@ -0,0 +1,318 @@
"""
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

View File

@@ -0,0 +1,307 @@
"""
Packet Decoder View - Deep protocol inspection and field extraction
Focuses on understanding packet contents and protocol compliance
"""
import curses
from typing import TYPE_CHECKING, List, Optional, Dict, Any
from ...models import FlowStats
if TYPE_CHECKING:
from ...analysis.core import EthernetAnalyzer
class PacketDecoderView:
"""
Packet Decoder View - F2
Deep packet inspection interface:
- Protocol field extraction and display
- Frame-by-frame analysis
- Enhanced decoder output
- Field value inspection
"""
def __init__(self, analyzer: 'EthernetAnalyzer'):
self.analyzer = analyzer
self.selected_flow = 0
self.selected_frame = 0
self.selected_field = 0
self.scroll_offset = 0
self.field_scroll_offset = 0
self.current_panel = 0 # 0=flows, 1=frames, 2=fields
def draw(self, stdscr, selected_flow_key: Optional[str]):
"""Draw the Packet Decoder view"""
height, width = stdscr.getmaxyx()
start_y = 3
max_height = height - 2
flows_list = self._get_enhanced_flows()
if not flows_list:
stdscr.addstr(start_y + 2, 4, "No enhanced decodable flows detected", curses.A_DIM)
stdscr.addstr(start_y + 3, 4, "Switch to Flow Analysis (F1) to see all flows", curses.A_DIM)
return
# Decoder view header
stdscr.addstr(start_y, 4, "PACKET DECODER - Enhanced Protocol Analysis", curses.A_BOLD)
# Three-panel layout: Flows | Frames | Fields
panel_width = width // 3
# Panel 1: Enhanced Flows (left)
self._draw_flows_panel(stdscr, start_y + 2, 4, panel_width - 2, max_height - start_y - 2, flows_list)
# Separator
for y in range(start_y + 2, max_height):
stdscr.addstr(y, panel_width, "", curses.A_DIM)
# Panel 2: Frame Details (center)
selected_flow = flows_list[self.selected_flow] if flows_list else None
self._draw_frames_panel(stdscr, start_y + 2, panel_width + 2, panel_width - 2, max_height - start_y - 2, selected_flow)
# Separator
for y in range(start_y + 2, max_height):
stdscr.addstr(y, 2 * panel_width, "", curses.A_DIM)
# Panel 3: Field Inspection (right)
self._draw_fields_panel(stdscr, start_y + 2, 2 * panel_width + 2, panel_width - 2, max_height - start_y - 2, selected_flow)
def _draw_flows_panel(self, stdscr, start_y: int, start_x: int, width: int, height: int, flows_list: List[FlowStats]):
"""Draw enhanced flows panel"""
panel_attr = curses.A_BOLD if self.current_panel == 0 else curses.A_NORMAL
# Panel header
header = "Enhanced Flows"
stdscr.addstr(start_y, start_x, header, panel_attr | curses.A_UNDERLINE)
current_y = start_y + 2
available_height = height - 2
# Flow list
for i, flow in enumerate(flows_list[:available_height]):
is_selected = (i == self.selected_flow and self.current_panel == 0)
attr = curses.A_REVERSE if is_selected else curses.A_NORMAL
# Flow summary line
flow_line = self._format_flow_summary(flow, width - 2)
stdscr.addstr(current_y + i, start_x, flow_line, attr)
# Decoder indicator
decoder_indicator = "" if flow.enhanced_analysis.decoder_type != "Standard" else ""
stdscr.addstr(current_y + i, start_x + width - 3, decoder_indicator,
curses.A_BOLD if flow.enhanced_analysis.decoder_type != "Standard" else curses.A_DIM)
def _draw_frames_panel(self, stdscr, start_y: int, start_x: int, width: int, height: int, flow: Optional[FlowStats]):
"""Draw frame details panel"""
panel_attr = curses.A_BOLD if self.current_panel == 1 else curses.A_NORMAL
# Panel header
header = "Frame Analysis"
stdscr.addstr(start_y, start_x, header, panel_attr | curses.A_UNDERLINE)
if not flow:
stdscr.addstr(start_y + 2, start_x, "No flow selected", curses.A_DIM)
return
current_y = start_y + 2
# Flow details
stdscr.addstr(current_y, start_x, f"Flow: {flow.src_ip}{flow.dst_ip}", curses.A_BOLD)
current_y += 1
stdscr.addstr(current_y, start_x, f"Decoder: {flow.enhanced_analysis.decoder_type}")
current_y += 2
# Sample frames
if flow.enhanced_analysis.sample_decoded_fields:
stdscr.addstr(current_y, start_x, "Decoded Frames:", curses.A_UNDERLINE)
current_y += 1
for i, (frame_key, frame_data) in enumerate(flow.enhanced_analysis.sample_decoded_fields.items()):
is_selected = (i == self.selected_frame and self.current_panel == 1)
attr = curses.A_REVERSE if is_selected else curses.A_NORMAL
frame_line = f"{frame_key}: {len(frame_data)} fields"
stdscr.addstr(current_y + i, start_x, frame_line[:width-2], attr)
else:
stdscr.addstr(current_y, start_x, "No decoded frame data available", curses.A_DIM)
current_y += 1
stdscr.addstr(current_y, start_x, "Decoder may not be implemented", curses.A_DIM)
def _draw_fields_panel(self, stdscr, start_y: int, start_x: int, width: int, height: int, flow: Optional[FlowStats]):
"""Draw field inspection panel"""
panel_attr = curses.A_BOLD if self.current_panel == 2 else curses.A_NORMAL
# Panel header
header = "Field Inspector"
stdscr.addstr(start_y, start_x, header, panel_attr | curses.A_UNDERLINE)
if not flow or not flow.enhanced_analysis.sample_decoded_fields:
stdscr.addstr(start_y + 2, start_x, "No field data available", curses.A_DIM)
return
current_y = start_y + 2
# Get selected frame data
frame_items = list(flow.enhanced_analysis.sample_decoded_fields.items())
if self.selected_frame < len(frame_items):
frame_key, frame_data = frame_items[self.selected_frame]
stdscr.addstr(current_y, start_x, f"Frame: {frame_key}", curses.A_BOLD)
current_y += 2
# Field list with values
available_height = height - 4
field_items = list(frame_data.items())
start_field = self.field_scroll_offset
end_field = min(start_field + available_height, len(field_items))
for i in range(start_field, end_field):
field_name, field_value = field_items[i]
display_idx = i - start_field
is_selected = (i == self.selected_field and self.current_panel == 2)
attr = curses.A_REVERSE if is_selected else curses.A_NORMAL
# Format field line
field_line = self._format_field_line(field_name, field_value, width - 2)
stdscr.addstr(current_y + display_idx, start_x, field_line, attr)
# Scroll indicators
if start_field > 0:
stdscr.addstr(current_y, start_x + width - 5, "", curses.A_DIM)
if end_field < len(field_items):
stdscr.addstr(current_y + available_height - 1, start_x + width - 5, "", curses.A_DIM)
def _format_flow_summary(self, flow: FlowStats, max_width: int) -> str:
"""Format flow summary for flows panel"""
# Format as "src:port → dst:port | protocol"
source = f"{flow.src_ip}:{flow.src_port}"
destination = f"{flow.dst_ip}:{flow.dst_port}"
protocol = flow.enhanced_analysis.decoder_type
if protocol == "Standard":
protocol = self._get_primary_protocol(flow)
# Calculate available space for src and dst
protocol_space = len(protocol) + 3 # " | " + protocol
available_space = max_width - protocol_space
src_space = available_space // 2 - 2 # Account for " → "
dst_space = available_space - src_space - 3 # " → "
if len(source) > src_space:
source = f"{flow.src_ip[:src_space-6]}…:{flow.src_port}"
if len(destination) > dst_space:
destination = f"{flow.dst_ip[:dst_space-6]}…:{flow.dst_port}"
return f"{source}{destination} | {protocol}"[:max_width]
def _format_field_line(self, field_name: str, field_value: Any, max_width: int) -> str:
"""Format field name and value for display"""
# Clean up field name
display_name = field_name.replace('_', ' ').title()
# Format value based on type
if isinstance(field_value, bool):
display_value = "Yes" if field_value else "No"
elif isinstance(field_value, float):
if "timestamp" in field_name.lower():
display_value = f"{field_value:.6f}s"
else:
display_value = f"{field_value:.3f}"
elif field_value is None:
display_value = "N/A"
else:
display_value = str(field_value)
# Truncate if needed
available_name_width = max_width // 2
available_value_width = max_width - available_name_width - 3
if len(display_name) > available_name_width:
display_name = display_name[:available_name_width-1] + ""
if len(display_value) > available_value_width:
display_value = display_value[:available_value_width-1] + ""
return f"{display_name:<{available_name_width}} : {display_value}"
def _get_enhanced_flows(self) -> List[FlowStats]:
"""Get flows with enhanced decoders available"""
flows_list = []
for flow in self.analyzer.flows.values():
if (flow.enhanced_analysis.decoder_type != "Standard" or
"CHAPTER10" in flow.detected_protocol_types or
"PTP" in flow.detected_protocol_types or
"IENA" in flow.detected_protocol_types):
flows_list.append(flow)
# Sort by decoder quality and packet count
flows_list.sort(key=lambda x: (
x.enhanced_analysis.decoder_type != "Standard",
len(x.enhanced_analysis.sample_decoded_fields),
x.frame_count
), reverse=True)
return flows_list
def _get_primary_protocol(self, flow: FlowStats) -> str:
"""Get primary protocol for display"""
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]
return list(flow.detected_protocol_types)[0]
return flow.transport_protocol
def handle_input(self, key: int, flows_list: List[FlowStats]) -> str:
"""Handle input for Packet Decoder view"""
enhanced_flows = self._get_enhanced_flows()
if key == ord('\t'): # Tab to switch panels
self.current_panel = (self.current_panel + 1) % 3
return 'panel_switch'
elif key == curses.KEY_UP:
if self.current_panel == 0: # Flows panel
self.selected_flow = max(0, self.selected_flow - 1)
elif self.current_panel == 1: # Frames panel
self.selected_frame = max(0, self.selected_frame - 1)
elif self.current_panel == 2: # Fields panel
self.selected_field = max(0, self.selected_field - 1)
self._adjust_field_scroll()
return 'selection_change'
elif key == curses.KEY_DOWN:
if self.current_panel == 0: # Flows panel
self.selected_flow = min(len(enhanced_flows) - 1, self.selected_flow + 1)
elif self.current_panel == 1: # Frames panel
if enhanced_flows and self.selected_flow < len(enhanced_flows):
flow = enhanced_flows[self.selected_flow]
max_frames = len(flow.enhanced_analysis.sample_decoded_fields) - 1
self.selected_frame = min(max_frames, self.selected_frame + 1)
elif self.current_panel == 2: # Fields panel
if enhanced_flows and self.selected_flow < len(enhanced_flows):
flow = enhanced_flows[self.selected_flow]
if flow.enhanced_analysis.sample_decoded_fields:
frame_items = list(flow.enhanced_analysis.sample_decoded_fields.items())
if self.selected_frame < len(frame_items):
frame_data = frame_items[self.selected_frame][1]
max_fields = len(frame_data) - 1
self.selected_field = min(max_fields, self.selected_field + 1)
self._adjust_field_scroll()
return 'selection_change'
elif key == ord('e') or key == ord('E'):
return 'export_fields'
elif key == ord('c') or key == ord('C'):
return 'copy_field'
return 'none'
def _adjust_field_scroll(self):
"""Adjust field scroll to keep selected field visible"""
# Simple scroll adjustment - could be enhanced
if self.selected_field < self.field_scroll_offset:
self.field_scroll_offset = self.selected_field
elif self.selected_field >= self.field_scroll_offset + 10: # Assume 10 visible fields
self.field_scroll_offset = self.selected_field - 9

View File

@@ -0,0 +1,432 @@
"""
Statistical Analysis View - Timing analysis, outliers, and quality metrics
Focuses on understanding network performance and data quality
"""
import curses
import statistics
from typing import TYPE_CHECKING, List, Optional, Dict, Tuple
from ...models import FlowStats
if TYPE_CHECKING:
from ...analysis.core import EthernetAnalyzer
class StatisticalAnalysisView:
"""
Statistical Analysis View - F3
Performance and quality analysis interface:
- Timing statistics and outlier detection
- Quality metrics and trends
- Performance indicators
- Network health assessment
"""
def __init__(self, analyzer: 'EthernetAnalyzer'):
self.analyzer = analyzer
self.selected_flow = 0
self.analysis_mode = 0 # 0=overview, 1=outliers, 2=quality, 3=timing
self.scroll_offset = 0
def draw(self, stdscr, selected_flow_key: Optional[str]):
"""Draw the Statistical Analysis view"""
height, width = stdscr.getmaxyx()
start_y = 3
max_height = height - 2
flows_list = self._get_flows_list()
if not flows_list:
stdscr.addstr(start_y + 2, 4, "No flows available for statistical analysis", curses.A_DIM)
return
# Statistical analysis header
mode_names = ["Overview", "Outlier Analysis", "Quality Metrics", "Timing Analysis"]
current_mode = mode_names[self.analysis_mode]
stdscr.addstr(start_y, 4, f"STATISTICAL ANALYSIS - {current_mode}", curses.A_BOLD)
# Mode selector
mode_line = start_y + 1
for i, mode_name in enumerate(mode_names):
x_pos = 4 + i * 20
if i == self.analysis_mode:
stdscr.addstr(mode_line, x_pos, f"[{mode_name}]", curses.A_REVERSE)
else:
stdscr.addstr(mode_line, x_pos, f" {mode_name} ", curses.A_DIM)
# Analysis content area
content_y = start_y + 3
content_height = max_height - content_y
if self.analysis_mode == 0:
self._draw_overview(stdscr, content_y, width, content_height, flows_list)
elif self.analysis_mode == 1:
self._draw_outlier_analysis(stdscr, content_y, width, content_height, flows_list)
elif self.analysis_mode == 2:
self._draw_quality_metrics(stdscr, content_y, width, content_height, flows_list)
elif self.analysis_mode == 3:
self._draw_timing_analysis(stdscr, content_y, width, content_height, flows_list)
def _draw_overview(self, stdscr, start_y: int, width: int, height: int, flows_list: List[FlowStats]):
"""Draw statistical overview"""
current_y = start_y
# Overall statistics
total_packets = sum(flow.frame_count for flow in flows_list)
total_outliers = sum(len(flow.outlier_frames) for flow in flows_list)
outlier_percentage = (total_outliers / total_packets * 100) if total_packets > 0 else 0
stdscr.addstr(current_y, 4, "NETWORK PERFORMANCE SUMMARY", curses.A_UNDERLINE)
current_y += 2
# Key metrics
metrics = [
("Total Flows", str(len(flows_list))),
("Total Packets", f"{total_packets:,}"),
("Total Outliers", f"{total_outliers:,} ({outlier_percentage:.2f}%)"),
("Enhanced Flows", str(sum(1 for f in flows_list if f.enhanced_analysis.decoder_type != "Standard"))),
]
for metric_name, metric_value in metrics:
stdscr.addstr(current_y, 4, f"{metric_name:20}: {metric_value}")
current_y += 1
current_y += 1
# Flow performance table
stdscr.addstr(current_y, 4, "FLOW PERFORMANCE RANKING", curses.A_UNDERLINE)
current_y += 2
# Table header
header = f"{'Rank':>4} {'Flow':30} {'Packets':>8} {'Outliers':>9} {'Avg Δt':>10} {'Jitter':>8} {'Score':>6}"
stdscr.addstr(current_y, 4, header, curses.A_BOLD)
current_y += 1
# Rank flows by performance
ranked_flows = self._rank_flows_by_performance(flows_list)
visible_flows = min(height - (current_y - start_y) - 2, len(ranked_flows))
for i in range(visible_flows):
flow, score = ranked_flows[i]
is_selected = (i == self.selected_flow)
attr = curses.A_REVERSE if is_selected else curses.A_NORMAL
# Format flow line
flow_desc = f"{flow.src_ip}:{flow.src_port}{flow.dst_ip}:{flow.dst_port}"
if len(flow_desc) > 28:
flow_desc = f"{flow.src_ip[:8]}…:{flow.src_port}{flow.dst_ip[:8]}…:{flow.dst_port}"
outliers = len(flow.outlier_frames)
outlier_pct = f"{outliers/flow.frame_count*100:.1f}%" if flow.frame_count > 0 else "0%"
avg_timing = f"{flow.avg_inter_arrival*1000:.1f}ms" if flow.avg_inter_arrival > 0 else "N/A"
jitter = f"{flow.std_inter_arrival*1000:.1f}ms" if flow.std_inter_arrival > 0 else "N/A"
line = f"{i+1:>4} {flow_desc:30} {flow.frame_count:>8} {outlier_pct:>9} {avg_timing:>10} {jitter:>8} {score:>6.1f}"
stdscr.addstr(current_y + i, 4, line[:width-8], attr)
def _draw_outlier_analysis(self, stdscr, start_y: int, width: int, height: int, flows_list: List[FlowStats]):
"""Draw detailed outlier analysis"""
current_y = start_y
stdscr.addstr(current_y, 4, "OUTLIER ANALYSIS", curses.A_UNDERLINE)
current_y += 2
# Find flows with outliers
outlier_flows = [(flow, len(flow.outlier_frames)) for flow in flows_list if flow.outlier_frames]
outlier_flows.sort(key=lambda x: x[1], reverse=True)
if not outlier_flows:
stdscr.addstr(current_y, 4, "No outliers detected in any flows", curses.A_DIM)
stdscr.addstr(current_y + 1, 4, "All packet timing appears normal", curses.A_DIM)
return
# Outlier summary
total_outliers = sum(count for _, count in outlier_flows)
stdscr.addstr(current_y, 4, f"Flows with outliers: {len(outlier_flows)}")
current_y += 1
stdscr.addstr(current_y, 4, f"Total outlier packets: {total_outliers}")
current_y += 2
# Detailed outlier breakdown
stdscr.addstr(current_y, 4, "OUTLIER DETAILS", curses.A_BOLD)
current_y += 1
header = f"{'Flow':35} {'Outliers':>9} {'Rate':>8} {'Max Σ':>8} {'Timing':>12}"
stdscr.addstr(current_y, 4, header, curses.A_UNDERLINE)
current_y += 1
visible_flows = min(height - (current_y - start_y) - 2, len(outlier_flows))
for i in range(visible_flows):
flow, outlier_count = outlier_flows[i]
is_selected = (i == self.selected_flow)
attr = curses.A_REVERSE if is_selected else curses.A_NORMAL
flow_desc = f"{flow.src_ip}:{flow.src_port}{flow.dst_ip}:{flow.dst_port}"
if len(flow_desc) > 33:
flow_desc = f"{flow.src_ip[:10]}…:{flow.src_port}{flow.dst_ip[:10]}…:{flow.dst_port}"
outlier_rate = f"{outlier_count/flow.frame_count*100:.1f}%" if flow.frame_count > 0 else "0%"
max_sigma = self.analyzer.statistics_engine.get_max_sigma_deviation(flow)
timing_info = f"{flow.avg_inter_arrival*1000:.1f}±{flow.std_inter_arrival*1000:.1f}ms"
line = f"{flow_desc:35} {outlier_count:>9} {outlier_rate:>8} {max_sigma:>7.1f}σ {timing_info:>12}"
stdscr.addstr(current_y + i, 4, line[:width-8], attr)
# Selected flow outlier details
if outlier_flows and self.selected_flow < len(outlier_flows):
selected_flow, _ = outlier_flows[self.selected_flow]
self._draw_selected_flow_outliers(stdscr, current_y + visible_flows + 1, width,
height - (current_y + visible_flows + 1 - start_y), selected_flow)
def _draw_quality_metrics(self, stdscr, start_y: int, width: int, height: int, flows_list: List[FlowStats]):
"""Draw quality metrics analysis"""
current_y = start_y
stdscr.addstr(current_y, 4, "QUALITY METRICS", curses.A_UNDERLINE)
current_y += 2
# Enhanced flows quality
enhanced_flows = [f for f in flows_list if f.enhanced_analysis.decoder_type != "Standard"]
if enhanced_flows:
stdscr.addstr(current_y, 4, "ENHANCED DECODER QUALITY", curses.A_BOLD)
current_y += 1
header = f"{'Flow':30} {'Decoder':15} {'Quality':>8} {'Drift':>10} {'Errors':>8}"
stdscr.addstr(current_y, 4, header, curses.A_UNDERLINE)
current_y += 1
for i, flow in enumerate(enhanced_flows[:height - (current_y - start_y) - 5]):
is_selected = (i == self.selected_flow)
attr = curses.A_REVERSE if is_selected else curses.A_NORMAL
flow_desc = f"{flow.src_ip}:{flow.src_port}{flow.dst_ip}:{flow.dst_port}"
if len(flow_desc) > 28:
flow_desc = f"{flow.src_ip[:8]}…:{flow.src_port}{flow.dst_ip[:8]}…:{flow.dst_port}"
enhanced = flow.enhanced_analysis
decoder_type = enhanced.decoder_type.replace("_Enhanced", "")
quality = f"{enhanced.avg_frame_quality:.1f}%" if enhanced.avg_frame_quality > 0 else "N/A"
drift = f"{enhanced.avg_clock_drift_ppm:.1f}ppm" if enhanced.avg_clock_drift_ppm != 0 else "N/A"
error_count = (enhanced.rtc_sync_errors + enhanced.format_errors +
enhanced.overflow_errors + enhanced.sequence_gaps)
line = f"{flow_desc:30} {decoder_type:15} {quality:>8} {drift:>10} {error_count:>8}"
stdscr.addstr(current_y + i, 4, line[:width-8], attr)
current_y += len(enhanced_flows) + 2
# General quality indicators
stdscr.addstr(current_y, 4, "GENERAL QUALITY INDICATORS", curses.A_BOLD)
current_y += 1
# Calculate network health metrics
health_metrics = self._calculate_health_metrics(flows_list)
for metric_name, metric_value, status in health_metrics:
status_color = curses.A_BOLD if status == "GOOD" else curses.A_DIM if status == "WARNING" else curses.A_REVERSE
stdscr.addstr(current_y, 4, f"{metric_name:25}: {metric_value:15} [{status}]", status_color)
current_y += 1
def _draw_timing_analysis(self, stdscr, start_y: int, width: int, height: int, flows_list: List[FlowStats]):
"""Draw detailed timing analysis"""
current_y = start_y
stdscr.addstr(current_y, 4, "TIMING ANALYSIS", curses.A_UNDERLINE)
current_y += 2
# Timing distribution summary
all_inter_arrivals = []
for flow in flows_list:
all_inter_arrivals.extend(flow.inter_arrival_times)
if all_inter_arrivals:
mean_timing = statistics.mean(all_inter_arrivals)
median_timing = statistics.median(all_inter_arrivals)
std_timing = statistics.stdev(all_inter_arrivals) if len(all_inter_arrivals) > 1 else 0
stdscr.addstr(current_y, 4, "NETWORK TIMING DISTRIBUTION", curses.A_BOLD)
current_y += 1
timing_stats = [
("Mean Inter-arrival", f"{mean_timing*1000:.3f} ms"),
("Median Inter-arrival", f"{median_timing*1000:.3f} ms"),
("Standard Deviation", f"{std_timing*1000:.3f} ms"),
("Coefficient of Variation", f"{std_timing/mean_timing:.3f}" if mean_timing > 0 else "N/A"),
]
for stat_name, stat_value in timing_stats:
stdscr.addstr(current_y, 4, f"{stat_name:25}: {stat_value}")
current_y += 1
current_y += 1
# Per-flow timing details
stdscr.addstr(current_y, 4, "PER-FLOW TIMING ANALYSIS", curses.A_BOLD)
current_y += 1
header = f"{'Flow':30} {'Mean':>10} {'Std Dev':>10} {'CV':>8} {'Range':>12}"
stdscr.addstr(current_y, 4, header, curses.A_UNDERLINE)
current_y += 1
# Sort flows by timing variability
timing_flows = [(flow, flow.std_inter_arrival / flow.avg_inter_arrival if flow.avg_inter_arrival > 0 else 0)
for flow in flows_list if flow.inter_arrival_times]
timing_flows.sort(key=lambda x: x[1], reverse=True)
visible_flows = min(height - (current_y - start_y) - 2, len(timing_flows))
for i in range(visible_flows):
flow, cv = timing_flows[i]
is_selected = (i == self.selected_flow)
attr = curses.A_REVERSE if is_selected else curses.A_NORMAL
flow_desc = f"{flow.src_ip}:{flow.src_port}{flow.dst_ip}:{flow.dst_port}"
if len(flow_desc) > 28:
flow_desc = f"{flow.src_ip[:8]}…:{flow.src_port}{flow.dst_ip[:8]}…:{flow.dst_port}"
mean_ms = f"{flow.avg_inter_arrival*1000:.1f}ms"
std_ms = f"{flow.std_inter_arrival*1000:.1f}ms"
cv_str = f"{cv:.3f}"
if flow.inter_arrival_times:
range_ms = f"{(max(flow.inter_arrival_times) - min(flow.inter_arrival_times))*1000:.1f}ms"
else:
range_ms = "N/A"
line = f"{flow_desc:30} {mean_ms:>10} {std_ms:>10} {cv_str:>8} {range_ms:>12}"
stdscr.addstr(current_y + i, 4, line[:width-8], attr)
def _rank_flows_by_performance(self, flows_list: List[FlowStats]) -> List[Tuple[FlowStats, float]]:
"""Rank flows by performance score (lower is better)"""
ranked = []
for flow in flows_list:
score = 0.0
# Outlier penalty (higher percentage = higher score)
if flow.frame_count > 0:
outlier_rate = len(flow.outlier_frames) / flow.frame_count
score += outlier_rate * 100 # 0-100 points
# Timing variability penalty
if flow.avg_inter_arrival > 0:
cv = flow.std_inter_arrival / flow.avg_inter_arrival
score += cv * 50 # 0-50+ points
# Enhanced decoder bonus (negative score)
if flow.enhanced_analysis.decoder_type != "Standard":
score -= 10
if flow.enhanced_analysis.avg_frame_quality > 80:
score -= 5 # Good quality bonus
ranked.append((flow, score))
ranked.sort(key=lambda x: x[1]) # Lower scores first (better performance)
return ranked
def _calculate_health_metrics(self, flows_list: List[FlowStats]) -> List[Tuple[str, str, str]]:
"""Calculate network health metrics"""
metrics = []
# Overall outlier rate
total_packets = sum(flow.frame_count for flow in flows_list)
total_outliers = sum(len(flow.outlier_frames) for flow in flows_list)
outlier_rate = (total_outliers / total_packets * 100) if total_packets > 0 else 0
outlier_status = "GOOD" if outlier_rate < 1.0 else "WARNING" if outlier_rate < 5.0 else "CRITICAL"
metrics.append(("Network Outlier Rate", f"{outlier_rate:.2f}%", outlier_status))
# Enhanced decoder coverage
enhanced_count = sum(1 for f in flows_list if f.enhanced_analysis.decoder_type != "Standard")
coverage = (enhanced_count / len(flows_list) * 100) if flows_list else 0
coverage_status = "GOOD" if coverage > 50 else "WARNING" if coverage > 0 else "NONE"
metrics.append(("Enhanced Coverage", f"{coverage:.1f}%", coverage_status))
# Timing consistency
all_cvs = []
for flow in flows_list:
if flow.avg_inter_arrival > 0:
cv = flow.std_inter_arrival / flow.avg_inter_arrival
all_cvs.append(cv)
if all_cvs:
avg_cv = statistics.mean(all_cvs)
timing_status = "GOOD" if avg_cv < 0.1 else "WARNING" if avg_cv < 0.5 else "CRITICAL"
metrics.append(("Timing Consistency", f"CV={avg_cv:.3f}", timing_status))
return metrics
def _draw_selected_flow_outliers(self, stdscr, start_y: int, width: int, height: int, flow: FlowStats):
"""Draw outlier details for selected flow"""
if height < 3:
return
stdscr.addstr(start_y, 4, f"OUTLIER DETAILS: {flow.src_ip}:{flow.src_port}{flow.dst_ip}:{flow.dst_port}", curses.A_BOLD)
current_y = start_y + 1
if flow.outlier_details:
header = f"{'Frame#':>8} {'Inter-arrival':>15} {'Deviation':>12}"
stdscr.addstr(current_y, 4, header, curses.A_UNDERLINE)
current_y += 1
visible_outliers = min(height - 3, len(flow.outlier_details))
for i in range(visible_outliers):
frame_num, timing = flow.outlier_details[i]
# Calculate sigma deviation
if flow.avg_inter_arrival > 0 and flow.std_inter_arrival > 0:
sigma = abs(timing - flow.avg_inter_arrival) / flow.std_inter_arrival
deviation = f"{sigma:.1f}σ"
else:
deviation = "N/A"
outlier_line = f"{frame_num:>8} {timing*1000:>12.3f}ms {deviation:>12}"
stdscr.addstr(current_y + i, 4, outlier_line)
def _get_flows_list(self) -> List[FlowStats]:
"""Get flows sorted for statistical analysis"""
flows_list = list(self.analyzer.flows.values())
# Sort by statistical interest: outliers first, then enhanced, then packet count
flows_list.sort(key=lambda x: (
len(x.outlier_frames),
x.enhanced_analysis.decoder_type != "Standard",
x.frame_count
), reverse=True)
return flows_list
def handle_input(self, key: int, flows_list: List[FlowStats]) -> str:
"""Handle input for Statistical Analysis view"""
if key == curses.KEY_UP:
self.selected_flow = max(0, self.selected_flow - 1)
return 'selection_change'
elif key == curses.KEY_DOWN:
max_flows = len(flows_list) - 1
self.selected_flow = min(max_flows, self.selected_flow + 1)
return 'selection_change'
elif key == curses.KEY_LEFT:
self.analysis_mode = max(0, self.analysis_mode - 1)
self.selected_flow = 0 # Reset selection when changing modes
return 'mode_change'
elif key == curses.KEY_RIGHT:
self.analysis_mode = min(3, self.analysis_mode + 1)
self.selected_flow = 0 # Reset selection when changing modes
return 'mode_change'
elif key >= ord('1') and key <= ord('4'):
self.analysis_mode = key - ord('1')
self.selected_flow = 0
return 'mode_change'
elif key == ord('r') or key == ord('R'):
return 'refresh_stats'
elif key == ord('o') or key == ord('O'):
self.analysis_mode = 1 # Switch to outlier analysis
return 'show_outliers'
return 'none'

View File

@@ -57,6 +57,8 @@ class NavigationHandler:
return 'selection_change'
elif key == ord('v') and self.current_view == 'main': # Visualize Chapter 10 signals
return 'visualize'
elif key == ord('\t') and self.current_view == 'main': # Tab key to switch detail panel tabs
return 'switch_tab'
return 'none'
@@ -72,7 +74,7 @@ class NavigationHandler:
"""Get status bar text based on current view"""
if self.current_view == 'main':
timeline_status = "ON" if self.show_timeline else "OFF"
return f"[↑↓]navigate [PgUp/PgDn]scroll [t]imeline:{timeline_status} [v]isualize CH10 [d]issection [q]uit"
return f"[↑↓]navigate [Tab]switch tabs [PgUp/PgDn]scroll [t]imeline:{timeline_status} [v]isualize CH10 [d]issection [q]uit"
elif self.current_view == 'dissection':
return "[m]ain view [q]uit"
else:

View File

@@ -2,14 +2,18 @@
Right panel - Flow details with frame type table
"""
from typing import List, Optional, Tuple
from typing import List, Optional, Tuple, Dict
import curses
from ...models import FlowStats, FrameTypeStats
class DetailPanel:
"""Right panel showing detailed flow information"""
"""Right panel showing detailed flow information with tabs"""
def __init__(self):
self.active_tab = 0 # 0 = Info, 1 = Decode
self.tabs = ["Info", "Decode"]
def draw(self, stdscr, x_offset: int, y_offset: int, width: int,
flows_list: List[FlowStats], selected_flow: int, max_height: Optional[int] = None):
@@ -31,14 +35,58 @@ class DetailPanel:
else:
max_lines = y_offset + max_height
try:
# Draw tab bar
tab_y = y_offset
self._draw_tab_bar(stdscr, x_offset, tab_y, width)
y_offset += 2 # Space for tab bar
max_lines -= 2
# Draw content based on active tab
if self.active_tab == 0:
self._draw_info_tab(stdscr, x_offset, y_offset, width, max_lines, flow, selected_frame_type)
elif self.active_tab == 1:
self._draw_decode_tab(stdscr, x_offset, y_offset, width, max_lines, flow, selected_frame_type)
except curses.error:
# Ignore curses errors from writing outside screen bounds
pass
def _draw_tab_bar(self, stdscr, x_offset: int, y_offset: int, width: int):
"""Draw the tab bar at the top of the panel"""
tab_line = ""
for i, tab_name in enumerate(self.tabs):
if i == self.active_tab:
tab_line += f"[{tab_name}]"
else:
tab_line += f" {tab_name} "
if i < len(self.tabs) - 1:
tab_line += " "
# Add tab navigation hint
tab_line += f" {' ' * (width - len(tab_line) - 15)}[Tab] to switch"
stdscr.addstr(y_offset, x_offset, tab_line[:width-1], curses.A_BOLD)
stdscr.addstr(y_offset + 1, x_offset, "" * min(width-1, 50))
def _draw_info_tab(self, stdscr, x_offset: int, y_offset: int, width: int, max_lines: int,
flow: FlowStats, selected_frame_type: Optional[str]):
"""Draw the Info tab content (original detail panel content)"""
try:
# ALWAYS show flow details first
stdscr.addstr(y_offset, x_offset, f"FLOW DETAILS: {flow.src_ip} -> {flow.dst_ip}", curses.A_BOLD)
y_offset += 2
stdscr.addstr(y_offset, x_offset, f"Packets: {flow.frame_count} | Bytes: {flow.total_bytes:,}")
y_offset += 1
# Enhanced analysis information
if flow.enhanced_analysis.decoder_type != "Standard":
y_offset += 1
self._draw_enhanced_analysis(stdscr, x_offset, y_offset, width, flow)
y_offset += self._get_enhanced_analysis_lines(flow)
# Frame types table
if flow.frame_types and y_offset < max_lines:
y_offset += 1
@@ -174,4 +222,259 @@ class DetailPanel:
current_item += 1
# Fallback to first flow if selection is out of bounds
return flows_list[0] if flows_list else None, None
return flows_list[0] if flows_list else None, None
def _draw_enhanced_analysis(self, stdscr, x_offset: int, y_offset: int, width: int, flow: FlowStats):
"""Draw enhanced analysis information"""
enhanced = flow.enhanced_analysis
try:
# Enhanced analysis header
stdscr.addstr(y_offset, x_offset, f"Enhanced Analysis ({enhanced.decoder_type}):", curses.A_BOLD)
y_offset += 1
# Timing analysis for CH10
if enhanced.has_internal_timing:
stdscr.addstr(y_offset, x_offset + 2, f"Clock Drift: {enhanced.avg_clock_drift_ppm:.2f} PPM (max: {enhanced.max_clock_drift_ppm:.2f})")
y_offset += 1
stdscr.addstr(y_offset, x_offset + 2, f"Timing Quality: {enhanced.timing_quality.title()} | Stability: {enhanced.timing_stability.title()}")
y_offset += 1
if enhanced.anomaly_rate > 0:
stdscr.addstr(y_offset, x_offset + 2, f"Anomaly Rate: {enhanced.anomaly_rate:.1%} | Confidence: {enhanced.avg_confidence_score:.2f}")
y_offset += 1
# Frame quality metrics
if enhanced.avg_frame_quality > 0:
stdscr.addstr(y_offset, x_offset + 2, f"Frame Quality: {enhanced.avg_frame_quality:.1f}%")
y_offset += 1
# Error counts
errors = []
if enhanced.sequence_gaps > 0:
errors.append(f"Seq: {enhanced.sequence_gaps}")
if enhanced.rtc_sync_errors > 0:
errors.append(f"RTC: {enhanced.rtc_sync_errors}")
if enhanced.format_errors > 0:
errors.append(f"Fmt: {enhanced.format_errors}")
if enhanced.overflow_errors > 0:
errors.append(f"Ovf: {enhanced.overflow_errors}")
if errors:
stdscr.addstr(y_offset, x_offset + 2, f"Errors: {' | '.join(errors)}")
y_offset += 1
# Data analysis
if enhanced.channel_count > 0:
channel_info = f"Channels: {enhanced.channel_count}"
if enhanced.analog_channels > 0:
channel_info += f" (Analog: {enhanced.analog_channels})"
if enhanced.pcm_channels > 0:
channel_info += f" (PCM: {enhanced.pcm_channels})"
if enhanced.tmats_frames > 0:
channel_info += f" (TMATS: {enhanced.tmats_frames})"
stdscr.addstr(y_offset, x_offset + 2, channel_info)
y_offset += 1
# Primary data type
if enhanced.primary_data_type != "Unknown":
stdscr.addstr(y_offset, x_offset + 2, f"Primary Type: {enhanced.primary_data_type}")
y_offset += 1
except curses.error:
pass
def _get_enhanced_analysis_lines(self, flow: FlowStats) -> int:
"""Calculate how many lines the enhanced analysis will take"""
enhanced = flow.enhanced_analysis
lines = 1 # Header line
if enhanced.has_internal_timing:
lines += 2 # Clock drift + timing quality
if enhanced.anomaly_rate > 0:
lines += 1 # Anomaly rate
if enhanced.avg_frame_quality > 0:
lines += 1 # Frame quality
# Check if we have errors to display
if any([enhanced.sequence_gaps, enhanced.rtc_sync_errors,
enhanced.format_errors, enhanced.overflow_errors]):
lines += 1
if enhanced.channel_count > 0:
lines += 1 # Channel info
if enhanced.primary_data_type != "Unknown":
lines += 1 # Primary data type
return lines
def _draw_decode_tab(self, stdscr, x_offset: int, y_offset: int, width: int, max_lines: int,
flow: FlowStats, selected_frame_type: Optional[str]):
"""Draw the Decode tab content showing decoded frame information"""
if flow.enhanced_analysis.decoder_type == "Standard":
stdscr.addstr(y_offset, x_offset, "No enhanced decoder available for this flow", curses.A_DIM)
return
# Header for decode information
stdscr.addstr(y_offset, x_offset, f"DECODED DATA ({flow.enhanced_analysis.decoder_type}):", curses.A_BOLD)
y_offset += 2
# Tree view of decoded information
if flow.enhanced_analysis.decoder_type == "Chapter10_Enhanced":
self._draw_ch10_decode_tree(stdscr, x_offset, y_offset, width, max_lines, flow)
else:
stdscr.addstr(y_offset, x_offset, f"Decoder type '{flow.enhanced_analysis.decoder_type}' display not implemented")
def _draw_ch10_decode_tree(self, stdscr, x_offset: int, y_offset: int, width: int, max_lines: int, flow: FlowStats):
"""Draw Chapter 10 decoded information in tree format"""
enhanced = flow.enhanced_analysis
tree_items = [
("📋 Header Information", None, 0),
(" Decoder Type", enhanced.decoder_type, 1),
(" Primary Data Type", enhanced.primary_data_type, 1),
(" Channel Count", str(enhanced.channel_count) if enhanced.channel_count > 0 else "Unknown", 1),
("", None, 0), # Spacer
("⏱️ Timing Analysis", None, 0),
(" Has Internal Timing", "Yes" if enhanced.has_internal_timing else "No", 1),
(" Timing Quality", enhanced.timing_quality.title(), 1),
(" Clock Drift (avg)", f"{enhanced.avg_clock_drift_ppm:.2f} PPM" if enhanced.avg_clock_drift_ppm != 0 else "N/A", 1),
(" Clock Drift (max)", f"{enhanced.max_clock_drift_ppm:.2f} PPM" if enhanced.max_clock_drift_ppm != 0 else "N/A", 1),
(" Timing Stability", enhanced.timing_stability.title(), 1),
(" Anomaly Rate", f"{enhanced.anomaly_rate:.1%}" if enhanced.anomaly_rate > 0 else "0%", 1),
(" Confidence Score", f"{enhanced.avg_confidence_score:.2f}" if enhanced.avg_confidence_score > 0 else "N/A", 1),
("", None, 0), # Spacer
("📊 Quality Metrics", None, 0),
(" Frame Quality (avg)", f"{enhanced.avg_frame_quality:.1f}%" if enhanced.avg_frame_quality > 0 else "N/A", 1),
(" Sequence Gaps", str(enhanced.sequence_gaps), 1),
(" RTC Sync Errors", str(enhanced.rtc_sync_errors), 1),
(" Format Errors", str(enhanced.format_errors), 1),
(" Overflow Errors", str(enhanced.overflow_errors), 1),
("", None, 0), # Spacer
("📡 Channel Information", None, 0),
(" Total Channels", str(enhanced.channel_count), 1),
(" Analog Channels", str(enhanced.analog_channels), 1),
(" PCM Channels", str(enhanced.pcm_channels), 1),
(" TMATS Frames", str(enhanced.tmats_frames), 1),
]
# Add decoded frame samples if available
if enhanced.sample_decoded_fields:
tree_items.extend([
("", None, 0), # Spacer
("🔍 Decoded Frame Samples", None, 0),
])
for frame_key, frame_data in enhanced.sample_decoded_fields.items():
tree_items.append((f" {frame_key.replace('_', ' ').title()}", None, 1))
# Show important fields first
priority_fields = ['packet_timestamp', 'internal_timestamp', 'data_type_name', 'channel_id',
'sequence_number', 'frame_quality_score', 'rtc_sync_error']
# Add priority fields
for field_name in priority_fields:
if field_name in frame_data:
display_value = self._format_field_value(field_name, frame_data[field_name])
tree_items.append((f" {field_name.replace('_', ' ').title()}", display_value, 2))
# Add other fields (up to 5 more)
other_fields = [k for k in frame_data.keys() if k not in priority_fields]
for i, field_name in enumerate(other_fields[:5]):
display_value = self._format_field_value(field_name, frame_data[field_name])
tree_items.append((f" {field_name.replace('_', ' ').title()}", display_value, 2))
if len(other_fields) > 5:
tree_items.append((f" ... and {len(other_fields) - 5} more fields", "", 2))
# Add available fields summary
if enhanced.available_field_names:
tree_items.extend([
("", None, 0), # Spacer
("📝 Available Decoder Fields", None, 0),
(" Total Fields Available", str(len(enhanced.available_field_names)), 1),
(" Field Categories", self._categorize_fields(enhanced.available_field_names), 1),
])
# Render tree items
current_y = y_offset
for label, value, indent_level in tree_items:
if current_y >= max_lines:
break
if not label: # Spacer line
current_y += 1
continue
indent = " " * indent_level
if value is None: # Category header
line = f"{indent}{label}"
stdscr.addstr(current_y, x_offset, line[:width-1], curses.A_BOLD)
else: # Key-value pair
line = f"{indent}{label}: {value}"
stdscr.addstr(current_y, x_offset, line[:width-1])
current_y += 1
# Add scrolling hint if content is cut off
if current_y >= max_lines and len(tree_items) > (max_lines - y_offset):
if max_lines > 0:
stdscr.addstr(max_lines - 1, x_offset, "... (content truncated)", curses.A_DIM)
def switch_tab(self):
"""Switch to the next tab"""
self.active_tab = (self.active_tab + 1) % len(self.tabs)
def _format_field_value(self, field_name: str, value) -> str:
"""Format field value for display"""
if value is None:
return "N/A"
# Special formatting for different field types
if "timestamp" in field_name.lower():
try:
return f"{float(value):.6f}s"
except:
return str(value)
elif "error" in field_name.lower() or field_name.endswith("_error"):
return "Yes" if value else "No"
elif "quality" in field_name.lower() and isinstance(value, (int, float)):
return f"{value:.1f}%"
elif isinstance(value, float):
return f"{value:.3f}"
elif isinstance(value, bool):
return "Yes" if value else "No"
else:
return str(value)
def _categorize_fields(self, field_names: List[str]) -> str:
"""Categorize available fields into groups"""
categories = {
"Timing": 0,
"Quality": 0,
"Data": 0,
"Header": 0,
"Other": 0
}
for field_name in field_names:
lower_name = field_name.lower()
if any(keyword in lower_name for keyword in ["time", "timestamp", "rtc", "drift"]):
categories["Timing"] += 1
elif any(keyword in lower_name for keyword in ["quality", "error", "sync", "confidence"]):
categories["Quality"] += 1
elif any(keyword in lower_name for keyword in ["data", "analog", "pcm", "channel", "sample"]):
categories["Data"] += 1
elif any(keyword in lower_name for keyword in ["header", "type", "id", "sequence", "length"]):
categories["Header"] += 1
else:
categories["Other"] += 1
# Format as compact string
active_categories = [f"{k}:{v}" for k, v in categories.items() if v > 0]
return ", ".join(active_categories)

View File

@@ -19,9 +19,9 @@ class FlowListPanel:
flows_list: List[FlowStats], selected_flow: int):
"""Draw the flow list panel"""
# Draw flows table header with adjusted column widths for better alignment
stdscr.addstr(y_offset, x_offset, "FLOWS:", curses.A_BOLD)
headers = f"{'Src:Port':22} {'Dst:Port':22} {'Proto':6} {'Cast':5} {'#Frames':>7} {'Bytes':>7} {'Encoding':12} {'ΔT Avg':>9}"
# Draw flows table header with enhanced analysis columns
stdscr.addstr(y_offset, x_offset, "FLOWS (Enhanced Analysis):", curses.A_BOLD)
headers = f"{'Src:Port':22} {'Dst:Port':22} {'Proto':6} {'Cast':5} {'#Frames':>7} {'Bytes':>7} {'Encoding':12} {'Quality':>7} {'Drift':>8} {'ΔT Avg':>9}"
stdscr.addstr(y_offset + 1, x_offset, headers[:width-1], curses.A_UNDERLINE)
# Calculate scrolling parameters
@@ -56,7 +56,11 @@ class FlowListPanel:
# Abbreviate traffic classification
cast_abbrev = flow.traffic_classification[:4] if flow.traffic_classification != "Unknown" else "Unk"
line = f"{src_endpoint:22} {dst_endpoint:22} {flow.transport_protocol:6} {cast_abbrev:5} {flow.frame_count:>7} {bytes_str:>7} {encoding_str:12} {avg_time:>9}"
# Enhanced analysis data
quality_str = self._format_quality_score(flow)
drift_str = self._format_drift_info(flow)
line = f"{src_endpoint:22} {dst_endpoint:22} {flow.transport_protocol:6} {cast_abbrev:5} {flow.frame_count:>7} {bytes_str:>7} {encoding_str:12} {quality_str:>7} {drift_str:>8} {avg_time:>9}"
if display_item == selected_flow:
stdscr.addstr(current_row, x_offset, line[:width-1], curses.A_REVERSE)
@@ -78,9 +82,14 @@ class FlowListPanel:
ft_avg = f"{ft_stats.avg_inter_arrival:.3f}s" if ft_stats.avg_inter_arrival > 0 else "N/A"
outlier_count = len(ft_stats.outlier_details) if ft_stats.outlier_details else 0
# Create frame type line aligned with new column layout
# Create frame type line aligned with enhanced column layout
bytes_str_ft = self._format_bytes(ft_stats.total_bytes)
ft_line = f" └─{frame_type:18} {'':22} {'':6} {'':5} {ft_stats.count:>7} {bytes_str_ft:>7} {'':12} {ft_avg:>9}"
# Enhanced analysis for frame types (inherit from parent flow)
quality_str_ft = self._format_quality_score(flow) if frame_type.startswith('CH10') or frame_type == 'TMATS' else ""
drift_str_ft = self._format_drift_info(flow) if frame_type.startswith('CH10') or frame_type == 'TMATS' else ""
ft_line = f" └─{frame_type:18} {'':22} {'':6} {'':5} {ft_stats.count:>7} {bytes_str_ft:>7} {'':12} {quality_str_ft:>7} {drift_str_ft:>8} {ft_avg:>9}"
if display_item == selected_flow:
stdscr.addstr(current_row, x_offset, ft_line[:width-1], curses.A_REVERSE)
@@ -179,4 +188,38 @@ class FlowListPanel:
if found_app:
return list(found_app)[0]
return "Unknown"
return "Unknown"
def _format_quality_score(self, flow: FlowStats) -> str:
"""Format quality score for display"""
enhanced = flow.enhanced_analysis
if enhanced.decoder_type == "Standard" or enhanced.avg_frame_quality == 0:
return "N/A"
# Format quality as percentage
quality = enhanced.avg_frame_quality
if quality >= 90:
return f"{quality:.0f}%"
elif quality >= 70:
return f"{quality:.0f}%"
else:
return f"{quality:.0f}%"
def _format_drift_info(self, flow: FlowStats) -> str:
"""Format clock drift information for display"""
enhanced = flow.enhanced_analysis
if not enhanced.has_internal_timing or enhanced.avg_clock_drift_ppm == 0:
return "N/A"
# Format drift in PPM
drift_ppm = abs(enhanced.avg_clock_drift_ppm)
if drift_ppm >= 1000:
return f"{drift_ppm/1000:.1f}K" # Show in thousands
elif drift_ppm >= 100:
return f"{drift_ppm:.0f}ppm"
elif drift_ppm >= 10:
return f"{drift_ppm:.1f}ppm"
else:
return f"{drift_ppm:.2f}ppm"