working to analyze timing issues
This commit is contained in:
9
analyzer/tui/__init__.py
Normal file
9
analyzer/tui/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Text User Interface components for the Ethernet Traffic Analyzer
|
||||
"""
|
||||
|
||||
from .interface import TUIInterface
|
||||
from .navigation import NavigationHandler
|
||||
from .panels import FlowListPanel, DetailPanel, TimelinePanel
|
||||
|
||||
__all__ = ['TUIInterface', 'NavigationHandler', 'FlowListPanel', 'DetailPanel', 'TimelinePanel']
|
||||
182
analyzer/tui/interface.py
Normal file
182
analyzer/tui/interface.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
Main TUI interface controller
|
||||
"""
|
||||
|
||||
import curses
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .navigation import NavigationHandler
|
||||
from .panels import FlowListPanel, DetailPanel, TimelinePanel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..analysis.core import EthernetAnalyzer
|
||||
|
||||
|
||||
class TUIInterface:
|
||||
"""Text User Interface for the analyzer"""
|
||||
|
||||
def __init__(self, analyzer: 'EthernetAnalyzer'):
|
||||
self.analyzer = analyzer
|
||||
self.navigation = NavigationHandler()
|
||||
|
||||
# Initialize panels
|
||||
self.flow_list_panel = FlowListPanel()
|
||||
self.detail_panel = DetailPanel()
|
||||
self.timeline_panel = TimelinePanel()
|
||||
|
||||
def run(self, stdscr):
|
||||
"""Main TUI loop"""
|
||||
curses.curs_set(0) # Hide cursor
|
||||
stdscr.keypad(True)
|
||||
|
||||
# Set timeout based on whether we're in live mode
|
||||
if self.analyzer.is_live:
|
||||
stdscr.timeout(500) # 0.5 second timeout for live updates
|
||||
else:
|
||||
stdscr.timeout(1000) # 1 second timeout for static analysis
|
||||
|
||||
while True:
|
||||
stdscr.clear()
|
||||
|
||||
if self.navigation.current_view == 'main':
|
||||
self._draw_main_view(stdscr)
|
||||
elif self.navigation.current_view == 'dissection':
|
||||
self._draw_dissection(stdscr)
|
||||
|
||||
# Draw status bar
|
||||
self._draw_status_bar(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 # Just refresh the display
|
||||
|
||||
action = self.navigation.handle_input(key, self._get_flows_list())
|
||||
|
||||
if action == 'quit':
|
||||
if self.analyzer.is_live:
|
||||
self.analyzer.stop_capture = True
|
||||
break
|
||||
|
||||
def _draw_main_view(self, stdscr):
|
||||
"""Draw three-panel main view: flows list, details, and timeline"""
|
||||
height, width = stdscr.getmaxyx()
|
||||
|
||||
# Calculate panel dimensions based on timeline visibility
|
||||
if self.navigation.show_timeline:
|
||||
# Top section: 70% of height, split into left 60% / right 40%
|
||||
# Bottom section: 30% of height, full width
|
||||
top_height = int(height * 0.7)
|
||||
bottom_height = height - top_height - 2 # -2 for separators and status bar
|
||||
else:
|
||||
# Use full height for top section when timeline is hidden
|
||||
top_height = height - 2 # -2 for status bar
|
||||
bottom_height = 0
|
||||
|
||||
left_width = int(width * 0.6)
|
||||
right_width = width - left_width - 1 # -1 for separator
|
||||
|
||||
# Draw title
|
||||
stdscr.addstr(0, 0, "=== ETHERNET TRAFFIC ANALYZER ===", curses.A_BOLD)
|
||||
|
||||
# Draw summary info
|
||||
summary = self.analyzer.get_summary()
|
||||
info_line = f"Packets: {summary['total_packets']} | " \
|
||||
f"Flows: {summary['unique_flows']} | " \
|
||||
f"IPs: {summary['unique_ips']}"
|
||||
|
||||
# Add real-time statistics if enabled
|
||||
if self.analyzer.is_live and self.analyzer.statistics_engine.enable_realtime:
|
||||
rt_summary = self.analyzer.statistics_engine.get_realtime_summary()
|
||||
info_line += f" | Outliers: {rt_summary.get('total_outliers', 0)}"
|
||||
|
||||
stdscr.addstr(1, 0, info_line)
|
||||
|
||||
if self.analyzer.is_live:
|
||||
status_text = "LIVE CAPTURE" if not self.analyzer.statistics_engine.enable_realtime else "LIVE+STATS"
|
||||
stdscr.addstr(1, left_width - len(status_text) - 2, status_text, curses.A_BLINK)
|
||||
|
||||
flows_list = self._get_flows_list()
|
||||
|
||||
# Draw left panel (flows list)
|
||||
self.flow_list_panel.draw(stdscr, 0, 3, left_width, top_height - 3,
|
||||
flows_list, self.navigation.selected_flow)
|
||||
|
||||
# Draw vertical separator for top section
|
||||
for y in range(1, top_height):
|
||||
stdscr.addstr(y, left_width, "│")
|
||||
|
||||
# Draw right panel (details)
|
||||
self.detail_panel.draw(stdscr, left_width + 2, 1, right_width - 2,
|
||||
flows_list, self.navigation.selected_flow, top_height - 2)
|
||||
|
||||
# Draw timeline panel if enabled
|
||||
if self.navigation.show_timeline and bottom_height > 0:
|
||||
# Draw horizontal separator
|
||||
separator_line = "─" * width
|
||||
stdscr.addstr(top_height, 0, separator_line)
|
||||
|
||||
# Draw bottom panel (timeline)
|
||||
timeline_start_y = top_height + 1
|
||||
self.timeline_panel.draw(stdscr, 0, timeline_start_y, width, bottom_height,
|
||||
flows_list, self.navigation.selected_flow)
|
||||
|
||||
def _draw_dissection(self, stdscr):
|
||||
"""Draw frame dissection view"""
|
||||
stdscr.addstr(0, 0, "=== FRAME DISSECTION ===", curses.A_BOLD)
|
||||
|
||||
if not self.analyzer.all_packets:
|
||||
stdscr.addstr(2, 0, "No packets available")
|
||||
return
|
||||
|
||||
# Show dissection of first few packets
|
||||
for i, packet in enumerate(self.analyzer.all_packets[:5]):
|
||||
if i * 6 + 2 >= curses.LINES - 3:
|
||||
break
|
||||
|
||||
dissection = self.analyzer.dissector.dissect_frame(packet, i + 1)
|
||||
|
||||
y_start = i * 6 + 2
|
||||
stdscr.addstr(y_start, 0, f"Frame {dissection['frame_number']}:", curses.A_BOLD)
|
||||
stdscr.addstr(y_start + 1, 2, f"Timestamp: {dissection['timestamp']:.6f}")
|
||||
stdscr.addstr(y_start + 2, 2, f"Size: {dissection['size']} bytes")
|
||||
|
||||
# Show detected protocols
|
||||
protocols = dissection.get('protocols', [])
|
||||
if protocols:
|
||||
proto_str = ", ".join(protocols)
|
||||
stdscr.addstr(y_start + 3, 2, f"Protocols: {proto_str}")
|
||||
|
||||
layers_str = ", ".join([k for k in dissection['layers'].keys() if not dissection['layers'][k].get('error')])
|
||||
stdscr.addstr(y_start + 4, 2, f"Layers: {layers_str}")
|
||||
|
||||
# Show specialized protocol info
|
||||
if 'chapter10' in dissection['layers'] and 'data_type_name' in dissection['layers']['chapter10']:
|
||||
ch10_info = dissection['layers']['chapter10']
|
||||
stdscr.addstr(y_start + 5, 2, f"CH10: {ch10_info['data_type_name']}")
|
||||
elif 'ptp' in dissection['layers'] and 'message_type_name' in dissection['layers']['ptp']:
|
||||
ptp_info = dissection['layers']['ptp']
|
||||
stdscr.addstr(y_start + 5, 2, f"PTP: {ptp_info['message_type_name']}")
|
||||
elif 'iena' in dissection['layers'] and 'packet_type_name' in dissection['layers']['iena']:
|
||||
iena_info = dissection['layers']['iena']
|
||||
stdscr.addstr(y_start + 5, 2, f"IENA: {iena_info['packet_type_name']}")
|
||||
elif 'ip' in dissection['layers']:
|
||||
ip_info = dissection['layers']['ip']
|
||||
stdscr.addstr(y_start + 5, 2, f"IP: {ip_info['src']} -> {ip_info['dst']}")
|
||||
|
||||
def _draw_status_bar(self, stdscr):
|
||||
"""Draw status bar at bottom"""
|
||||
height, width = stdscr.getmaxyx()
|
||||
status_y = height - 1
|
||||
status = self.navigation.get_status_bar_text()
|
||||
stdscr.addstr(status_y, 0, status[:width-1], curses.A_REVERSE)
|
||||
|
||||
def _get_flows_list(self):
|
||||
"""Get sorted list of flows"""
|
||||
flows_list = list(self.analyzer.flows.values())
|
||||
flows_list.sort(key=lambda x: x.frame_count, reverse=True)
|
||||
return flows_list
|
||||
77
analyzer/tui/navigation.py
Normal file
77
analyzer/tui/navigation.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Navigation and input handling for the TUI
|
||||
"""
|
||||
|
||||
import curses
|
||||
from typing import List
|
||||
from ..models import FlowStats
|
||||
|
||||
|
||||
class NavigationHandler:
|
||||
"""Handles navigation and input for the TUI"""
|
||||
|
||||
def __init__(self):
|
||||
self.current_view = 'main' # main, dissection
|
||||
self.selected_flow = 0
|
||||
self.scroll_offset = 0
|
||||
self.show_timeline = True # Toggle for bottom timeline plot
|
||||
|
||||
def handle_input(self, key: int, flows_list: List[FlowStats]) -> str:
|
||||
"""
|
||||
Handle keyboard input and return action
|
||||
|
||||
Returns:
|
||||
Action string: 'quit', 'view_change', 'selection_change', 'none'
|
||||
"""
|
||||
if key == ord('q'):
|
||||
return 'quit'
|
||||
elif key == ord('d'):
|
||||
self.current_view = 'dissection'
|
||||
return 'view_change'
|
||||
elif key == ord('m') or key == 27: # 'm' or ESC to return to main
|
||||
self.current_view = 'main'
|
||||
return 'view_change'
|
||||
elif key == curses.KEY_UP and self.current_view == 'main':
|
||||
self.selected_flow = max(0, self.selected_flow - 1)
|
||||
return 'selection_change'
|
||||
elif key == curses.KEY_DOWN and self.current_view == 'main':
|
||||
max_items = self._get_total_display_items(flows_list)
|
||||
self.selected_flow = min(max_items - 1, self.selected_flow + 1)
|
||||
return 'selection_change'
|
||||
elif key == ord('t'): # Toggle timeline plot
|
||||
self.show_timeline = not self.show_timeline
|
||||
return 'view_change'
|
||||
elif key == curses.KEY_PPAGE and self.current_view == 'main': # Page Up
|
||||
self.selected_flow = max(0, self.selected_flow - 10)
|
||||
return 'selection_change'
|
||||
elif key == curses.KEY_NPAGE and self.current_view == 'main': # Page Down
|
||||
max_items = self._get_total_display_items(flows_list)
|
||||
self.selected_flow = min(max_items - 1, self.selected_flow + 10)
|
||||
return 'selection_change'
|
||||
elif key == curses.KEY_HOME and self.current_view == 'main': # Home
|
||||
self.selected_flow = 0
|
||||
return 'selection_change'
|
||||
elif key == curses.KEY_END and self.current_view == 'main': # End
|
||||
max_items = self._get_total_display_items(flows_list)
|
||||
self.selected_flow = max_items - 1
|
||||
return 'selection_change'
|
||||
|
||||
return 'none'
|
||||
|
||||
def _get_total_display_items(self, flows_list: List[FlowStats]) -> int:
|
||||
"""Calculate total number of selectable items (flows + frame types)"""
|
||||
total = 0
|
||||
for flow in flows_list:
|
||||
total += 1 # Flow itself
|
||||
total += len(flow.frame_types) # Frame types under this flow
|
||||
return total
|
||||
|
||||
def get_status_bar_text(self) -> str:
|
||||
"""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} [d]issection [q]uit"
|
||||
elif self.current_view == 'dissection':
|
||||
return "[m]ain view [q]uit"
|
||||
else:
|
||||
return "[m]ain [d]issection [q]uit"
|
||||
9
analyzer/tui/panels/__init__.py
Normal file
9
analyzer/tui/panels/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
TUI Panel components
|
||||
"""
|
||||
|
||||
from .flow_list import FlowListPanel
|
||||
from .detail_panel import DetailPanel
|
||||
from .timeline import TimelinePanel
|
||||
|
||||
__all__ = ['FlowListPanel', 'DetailPanel', 'TimelinePanel']
|
||||
177
analyzer/tui/panels/detail_panel.py
Normal file
177
analyzer/tui/panels/detail_panel.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
Right panel - Flow details with frame type table
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Tuple
|
||||
import curses
|
||||
|
||||
from ...models import FlowStats, FrameTypeStats
|
||||
|
||||
|
||||
class DetailPanel:
|
||||
"""Right panel showing detailed flow information"""
|
||||
|
||||
def draw(self, stdscr, x_offset: int, y_offset: int, width: int,
|
||||
flows_list: List[FlowStats], selected_flow: int, max_height: Optional[int] = None):
|
||||
"""Draw detailed information panel for selected flow or frame type"""
|
||||
|
||||
if not flows_list:
|
||||
stdscr.addstr(y_offset, x_offset, "No flows available")
|
||||
return
|
||||
|
||||
# Get the selected flow and frame type
|
||||
flow, selected_frame_type = self._get_selected_flow_and_frame_type(flows_list, selected_flow)
|
||||
if not flow:
|
||||
stdscr.addstr(y_offset, x_offset, "No flow selected")
|
||||
return
|
||||
|
||||
if max_height is None:
|
||||
height, _ = stdscr.getmaxyx()
|
||||
max_lines = height - y_offset - 2
|
||||
else:
|
||||
max_lines = y_offset + max_height
|
||||
|
||||
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
|
||||
|
||||
# Frame types table
|
||||
if flow.frame_types and y_offset < max_lines:
|
||||
y_offset += 1
|
||||
stdscr.addstr(y_offset, x_offset, "Frame Types:", curses.A_BOLD)
|
||||
y_offset += 1
|
||||
|
||||
# Table header
|
||||
header = f"{'Type':<12} {'#Pkts':<6} {'Bytes':<8} {'Avg ΔT':<8} {'2σ Out':<6}"
|
||||
stdscr.addstr(y_offset, x_offset, header, curses.A_UNDERLINE)
|
||||
y_offset += 1
|
||||
|
||||
sorted_frame_types = sorted(flow.frame_types.items(), key=lambda x: x[1].count, reverse=True)
|
||||
for frame_type, ft_stats in sorted_frame_types:
|
||||
if y_offset >= max_lines:
|
||||
break
|
||||
|
||||
avg_str = f"{ft_stats.avg_inter_arrival:.3f}s" if ft_stats.avg_inter_arrival > 0 else "N/A"
|
||||
bytes_str = f"{ft_stats.total_bytes:,}" if ft_stats.total_bytes < 10000 else f"{ft_stats.total_bytes/1000:.1f}K"
|
||||
outliers_count = len(ft_stats.outlier_details) if ft_stats.outlier_details else 0
|
||||
|
||||
# Truncate frame type name if too long
|
||||
type_name = frame_type[:11] if len(frame_type) > 11 else frame_type
|
||||
|
||||
ft_line = f"{type_name:<12} {ft_stats.count:<6} {bytes_str:<8} {avg_str:<8} {outliers_count:<6}"
|
||||
stdscr.addstr(y_offset, x_offset, ft_line)
|
||||
y_offset += 1
|
||||
|
||||
# Timing statistics
|
||||
if y_offset < max_lines:
|
||||
y_offset += 1
|
||||
stdscr.addstr(y_offset, x_offset, "Timing:", curses.A_BOLD)
|
||||
y_offset += 1
|
||||
|
||||
if flow.avg_inter_arrival > 0:
|
||||
stdscr.addstr(y_offset, x_offset + 2, f"Avg: {flow.avg_inter_arrival:.6f}s")
|
||||
y_offset += 1
|
||||
if y_offset < max_lines:
|
||||
stdscr.addstr(y_offset, x_offset + 2, f"Std: {flow.std_inter_arrival:.6f}s")
|
||||
y_offset += 1
|
||||
else:
|
||||
stdscr.addstr(y_offset, x_offset + 2, "No timing data")
|
||||
y_offset += 1
|
||||
|
||||
# Display outlier frame details for each frame type
|
||||
if flow.frame_types and y_offset < max_lines:
|
||||
outlier_frame_types = [(frame_type, ft_stats) for frame_type, ft_stats in flow.frame_types.items()
|
||||
if ft_stats.outlier_details]
|
||||
|
||||
if outlier_frame_types:
|
||||
y_offset += 1
|
||||
if y_offset < max_lines:
|
||||
stdscr.addstr(y_offset, x_offset, "Outlier Frames:", curses.A_BOLD)
|
||||
y_offset += 1
|
||||
|
||||
for frame_type, ft_stats in outlier_frame_types:
|
||||
if y_offset >= max_lines:
|
||||
break
|
||||
|
||||
# Display frame type header
|
||||
if y_offset < max_lines:
|
||||
stdscr.addstr(y_offset, x_offset + 2, f"{frame_type}:", curses.A_UNDERLINE)
|
||||
y_offset += 1
|
||||
|
||||
# Display outlier details as individual table rows in format "frame# | deltaT"
|
||||
for frame_num, frame_inter_arrival_time in ft_stats.outlier_details:
|
||||
if y_offset >= max_lines:
|
||||
break
|
||||
outlier_line = f"{frame_num} | {frame_inter_arrival_time:.3f}s"
|
||||
stdscr.addstr(y_offset, x_offset + 4, outlier_line)
|
||||
y_offset += 1
|
||||
|
||||
# If a frame type is selected, show additional frame type specific details
|
||||
if selected_frame_type and selected_frame_type in flow.frame_types and y_offset < max_lines:
|
||||
ft_stats = flow.frame_types[selected_frame_type]
|
||||
|
||||
# Add separator
|
||||
y_offset += 2
|
||||
if y_offset < max_lines:
|
||||
stdscr.addstr(y_offset, x_offset, "─" * min(width-2, 40))
|
||||
y_offset += 1
|
||||
|
||||
# Frame type specific header
|
||||
if y_offset < max_lines:
|
||||
stdscr.addstr(y_offset, x_offset, f"FRAME TYPE: {selected_frame_type}", curses.A_BOLD)
|
||||
y_offset += 2
|
||||
|
||||
# Frame type specific info
|
||||
if y_offset < max_lines:
|
||||
stdscr.addstr(y_offset, x_offset, f"Count: {ft_stats.count}")
|
||||
y_offset += 1
|
||||
if y_offset < max_lines:
|
||||
stdscr.addstr(y_offset, x_offset, f"Bytes: {ft_stats.total_bytes:,}")
|
||||
y_offset += 1
|
||||
|
||||
# Frame type timing
|
||||
if y_offset < max_lines:
|
||||
y_offset += 1
|
||||
stdscr.addstr(y_offset, x_offset, "Timing:", curses.A_BOLD)
|
||||
y_offset += 1
|
||||
|
||||
if ft_stats.avg_inter_arrival > 0:
|
||||
if y_offset < max_lines:
|
||||
stdscr.addstr(y_offset, x_offset + 2, f"Avg: {ft_stats.avg_inter_arrival:.6f}s")
|
||||
y_offset += 1
|
||||
if y_offset < max_lines:
|
||||
stdscr.addstr(y_offset, x_offset + 2, f"Std: {ft_stats.std_inter_arrival:.6f}s")
|
||||
y_offset += 1
|
||||
else:
|
||||
if y_offset < max_lines:
|
||||
stdscr.addstr(y_offset, x_offset + 2, "No timing data")
|
||||
y_offset += 1
|
||||
|
||||
except curses.error:
|
||||
# Ignore curses errors from writing outside screen bounds
|
||||
pass
|
||||
|
||||
def _get_selected_flow_and_frame_type(self, flows_list: List[FlowStats],
|
||||
selected_flow: int) -> Tuple[Optional[FlowStats], Optional[str]]:
|
||||
"""Get the currently selected flow and frame type based on selection index"""
|
||||
current_item = 0
|
||||
|
||||
for flow in flows_list:
|
||||
if current_item == selected_flow:
|
||||
return flow, None # Selected the main flow
|
||||
current_item += 1
|
||||
|
||||
# Check frame types for this flow
|
||||
if flow.frame_types:
|
||||
sorted_frame_types = sorted(flow.frame_types.items(), key=lambda x: x[1].count, reverse=True)
|
||||
for frame_type, ft_stats in sorted_frame_types:
|
||||
if current_item == selected_flow:
|
||||
return flow, frame_type # Selected a frame type
|
||||
current_item += 1
|
||||
|
||||
# Fallback to first flow if selection is out of bounds
|
||||
return flows_list[0] if flows_list else None, None
|
||||
127
analyzer/tui/panels/flow_list.py
Normal file
127
analyzer/tui/panels/flow_list.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
Left panel - Flow list with frame type breakdowns
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
import curses
|
||||
|
||||
from ...models import FlowStats
|
||||
|
||||
|
||||
class FlowListPanel:
|
||||
"""Left panel showing flows and frame type breakdowns"""
|
||||
|
||||
def __init__(self):
|
||||
self.selected_item = 0
|
||||
self.scroll_offset = 0
|
||||
|
||||
def draw(self, stdscr, x_offset: int, y_offset: int, width: int, height: int,
|
||||
flows_list: List[FlowStats], selected_flow: int):
|
||||
"""Draw the flow list panel"""
|
||||
|
||||
# Draw flows table header
|
||||
stdscr.addstr(y_offset, x_offset, "FLOWS:", curses.A_BOLD)
|
||||
headers = f"{'Source IP':15} {'Dest IP':15} {'Pkts':5} {'Protocol':18} {'ΔT Avg':10} {'Out':4}"
|
||||
stdscr.addstr(y_offset + 1, x_offset, headers[:width-1], curses.A_UNDERLINE)
|
||||
|
||||
# Calculate scrolling parameters
|
||||
start_row = y_offset + 2
|
||||
max_rows = height - 3 # Account for header and title
|
||||
total_items = self._get_total_display_items(flows_list)
|
||||
|
||||
# Calculate scroll offset to keep selected item visible
|
||||
scroll_offset = self._calculate_scroll_offset(selected_flow, max_rows, total_items)
|
||||
|
||||
# Draw flows list with frame type breakdowns
|
||||
current_row = start_row
|
||||
display_item = 0 # Track selectable items (flows + frame types)
|
||||
visible_items = 0 # Track items actually drawn
|
||||
|
||||
for flow_idx, flow in enumerate(flows_list):
|
||||
# Check if main flow line should be displayed
|
||||
if display_item >= scroll_offset and visible_items < max_rows:
|
||||
# Draw main flow line
|
||||
protocol_str = self._get_protocol_display(flow)
|
||||
avg_time = f"{flow.avg_inter_arrival:.3f}s" if flow.avg_inter_arrival > 0 else "N/A"
|
||||
|
||||
line = f"{flow.src_ip:15} {flow.dst_ip:15} {flow.frame_count:5} {protocol_str:18} {avg_time:10} {'':4}"
|
||||
|
||||
if display_item == selected_flow:
|
||||
stdscr.addstr(current_row, x_offset, line[:width-1], curses.A_REVERSE)
|
||||
else:
|
||||
stdscr.addstr(current_row, x_offset, line[:width-1], curses.A_BOLD)
|
||||
|
||||
current_row += 1
|
||||
visible_items += 1
|
||||
|
||||
display_item += 1
|
||||
|
||||
# Draw frame type breakdowns for this flow
|
||||
if flow.frame_types:
|
||||
sorted_frame_types = sorted(flow.frame_types.items(), key=lambda x: x[1].count, reverse=True)
|
||||
|
||||
for frame_type, ft_stats in sorted_frame_types:
|
||||
if display_item >= scroll_offset and visible_items < max_rows:
|
||||
# Calculate frame type timing display
|
||||
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 columns
|
||||
ft_line = f"{'':15} {'':15} {ft_stats.count:5} {frame_type:18} {ft_avg:10} {outlier_count:4}"
|
||||
|
||||
if display_item == selected_flow:
|
||||
stdscr.addstr(current_row, x_offset, ft_line[:width-1], curses.A_REVERSE)
|
||||
else:
|
||||
stdscr.addstr(current_row, x_offset, ft_line[:width-1])
|
||||
|
||||
current_row += 1
|
||||
visible_items += 1
|
||||
|
||||
display_item += 1
|
||||
|
||||
def _get_protocol_display(self, flow: FlowStats) -> str:
|
||||
"""Get display string for flow protocols"""
|
||||
if flow.detected_protocol_types:
|
||||
# Prioritize specialized protocols
|
||||
specialized = {'CHAPTER10', 'PTP', 'IENA'}
|
||||
found_specialized = flow.detected_protocol_types & specialized
|
||||
if found_specialized:
|
||||
return list(found_specialized)[0]
|
||||
|
||||
# Use first detected protocol type
|
||||
return list(flow.detected_protocol_types)[0]
|
||||
|
||||
# Fallback to basic protocols
|
||||
if flow.protocols:
|
||||
return list(flow.protocols)[0]
|
||||
|
||||
return "Unknown"
|
||||
|
||||
def _get_total_display_items(self, flows_list: List[FlowStats]) -> int:
|
||||
"""Calculate total number of selectable items (flows + frame types)"""
|
||||
total = 0
|
||||
for flow in flows_list:
|
||||
total += 1 # Flow itself
|
||||
total += len(flow.frame_types) # Frame types under this flow
|
||||
return total
|
||||
|
||||
def _calculate_scroll_offset(self, selected_item: int, max_visible: int, total_items: int) -> int:
|
||||
"""Calculate scroll offset to keep selected item visible"""
|
||||
if total_items <= max_visible:
|
||||
return 0 # No scrolling needed
|
||||
|
||||
# Keep selected item in the middle third of visible area when possible
|
||||
middle_position = max_visible // 3
|
||||
|
||||
# Calculate ideal scroll offset
|
||||
scroll_offset = max(0, selected_item - middle_position)
|
||||
|
||||
# Ensure we don't scroll past the end
|
||||
max_scroll = max(0, total_items - max_visible)
|
||||
scroll_offset = min(scroll_offset, max_scroll)
|
||||
|
||||
return scroll_offset
|
||||
|
||||
def get_total_display_items(self, flows_list: List[FlowStats]) -> int:
|
||||
"""Public method to get total display items"""
|
||||
return self._get_total_display_items(flows_list)
|
||||
269
analyzer/tui/panels/timeline.py
Normal file
269
analyzer/tui/panels/timeline.py
Normal file
@@ -0,0 +1,269 @@
|
||||
"""
|
||||
Bottom panel - Timeline visualization
|
||||
"""
|
||||
|
||||
from typing import List, Tuple, Optional
|
||||
import curses
|
||||
|
||||
from ...models import FlowStats, FrameTypeStats
|
||||
|
||||
|
||||
class TimelinePanel:
|
||||
"""Bottom panel for timeline visualization"""
|
||||
|
||||
def draw(self, stdscr, x_offset: int, y_offset: int, width: int, height: int,
|
||||
flows_list: List[FlowStats], selected_flow: int):
|
||||
"""Draw timeline visualization panel for selected flow or frame type"""
|
||||
|
||||
if not flows_list or height < 5:
|
||||
return
|
||||
|
||||
# Get the selected flow and frame type
|
||||
flow, selected_frame_type = self._get_selected_flow_and_frame_type(flows_list, selected_flow)
|
||||
if not flow:
|
||||
return
|
||||
|
||||
try:
|
||||
# Panel header
|
||||
stdscr.addstr(y_offset, x_offset, "TIMING VISUALIZATION", curses.A_BOLD)
|
||||
if selected_frame_type:
|
||||
stdscr.addstr(y_offset + 1, x_offset, f"Flow: {flow.src_ip} -> {flow.dst_ip} | Frame Type: {selected_frame_type}")
|
||||
else:
|
||||
stdscr.addstr(y_offset + 1, x_offset, f"Flow: {flow.src_ip} -> {flow.dst_ip} | All Frames")
|
||||
|
||||
# Get the appropriate data for timeline
|
||||
if selected_frame_type and selected_frame_type in flow.frame_types:
|
||||
# Use frame type specific data
|
||||
ft_stats = flow.frame_types[selected_frame_type]
|
||||
if len(ft_stats.inter_arrival_times) < 2:
|
||||
stdscr.addstr(y_offset + 2, x_offset, f"Insufficient data for {selected_frame_type} timeline")
|
||||
return
|
||||
deviations = self._calculate_frame_type_deviations(ft_stats)
|
||||
timeline_flow = ft_stats # Use frame type stats for timeline
|
||||
else:
|
||||
# Use overall flow data
|
||||
if len(flow.inter_arrival_times) < 2:
|
||||
stdscr.addstr(y_offset + 2, x_offset, "Insufficient data for timeline")
|
||||
return
|
||||
deviations = self._calculate_frame_deviations(flow)
|
||||
timeline_flow = flow # Use overall flow stats for timeline
|
||||
|
||||
if not deviations:
|
||||
stdscr.addstr(y_offset + 2, x_offset, "No timing data available")
|
||||
return
|
||||
|
||||
# Timeline dimensions
|
||||
timeline_width = width - 10 # Leave space for labels
|
||||
timeline_height = height - 6 # Leave space for header, labels, and time scale
|
||||
timeline_y = y_offset + 3
|
||||
timeline_x = x_offset + 5
|
||||
|
||||
# Draw timeline
|
||||
self._draw_ascii_timeline(stdscr, timeline_x, timeline_y, timeline_width,
|
||||
timeline_height, deviations, timeline_flow)
|
||||
|
||||
except curses.error:
|
||||
# Ignore curses errors from writing outside screen bounds
|
||||
pass
|
||||
|
||||
def _get_selected_flow_and_frame_type(self, flows_list: List[FlowStats],
|
||||
selected_flow: int) -> Tuple[Optional[FlowStats], Optional[str]]:
|
||||
"""Get the currently selected flow and frame type based on selection index"""
|
||||
current_item = 0
|
||||
|
||||
for flow in flows_list:
|
||||
if current_item == selected_flow:
|
||||
return flow, None # Selected the main flow
|
||||
current_item += 1
|
||||
|
||||
# Check frame types for this flow
|
||||
if flow.frame_types:
|
||||
sorted_frame_types = sorted(flow.frame_types.items(), key=lambda x: x[1].count, reverse=True)
|
||||
for frame_type, ft_stats in sorted_frame_types:
|
||||
if current_item == selected_flow:
|
||||
return flow, frame_type # Selected a frame type
|
||||
current_item += 1
|
||||
|
||||
# Fallback to first flow if selection is out of bounds
|
||||
return flows_list[0] if flows_list else None, None
|
||||
|
||||
def _calculate_frame_deviations(self, flow: FlowStats) -> List[Tuple[int, float]]:
|
||||
"""Calculate frame deviations from average inter-arrival time"""
|
||||
if len(flow.inter_arrival_times) < 1 or flow.avg_inter_arrival == 0:
|
||||
return []
|
||||
|
||||
deviations = []
|
||||
|
||||
# Each inter_arrival_time[i] is between frame[i] and frame[i+1]
|
||||
for i, inter_time in enumerate(flow.inter_arrival_times):
|
||||
if i + 1 < len(flow.frame_numbers):
|
||||
frame_num = flow.frame_numbers[i + 1] # The frame that this inter-arrival time leads to
|
||||
deviation = inter_time - flow.avg_inter_arrival
|
||||
deviations.append((frame_num, deviation))
|
||||
|
||||
return deviations
|
||||
|
||||
def _calculate_frame_type_deviations(self, ft_stats: FrameTypeStats) -> List[Tuple[int, float]]:
|
||||
"""Calculate frame deviations for a specific frame type"""
|
||||
if len(ft_stats.inter_arrival_times) < 1 or ft_stats.avg_inter_arrival == 0:
|
||||
return []
|
||||
|
||||
deviations = []
|
||||
|
||||
# Each inter_arrival_time[i] is between frame[i] and frame[i+1]
|
||||
for i, inter_time in enumerate(ft_stats.inter_arrival_times):
|
||||
if i + 1 < len(ft_stats.frame_numbers):
|
||||
frame_num = ft_stats.frame_numbers[i + 1] # The frame that this inter-arrival time leads to
|
||||
deviation = inter_time - ft_stats.avg_inter_arrival
|
||||
deviations.append((frame_num, deviation))
|
||||
|
||||
return deviations
|
||||
|
||||
def _draw_ascii_timeline(self, stdscr, x_offset: int, y_offset: int, width: int, height: int,
|
||||
deviations: List[Tuple[int, float]], flow):
|
||||
"""Draw ASCII timeline chart"""
|
||||
if not deviations or width < 10 or height < 3:
|
||||
return
|
||||
|
||||
# Find min/max deviations for scaling
|
||||
deviation_values = [dev for _, dev in deviations]
|
||||
max_deviation = max(abs(min(deviation_values)), max(deviation_values))
|
||||
|
||||
if max_deviation == 0:
|
||||
max_deviation = 0.001 # Avoid division by zero
|
||||
|
||||
# Calculate center line
|
||||
center_y = y_offset + height // 2
|
||||
|
||||
# Draw center line (represents average timing)
|
||||
center_line = "─" * width
|
||||
stdscr.addstr(center_y, x_offset, center_line)
|
||||
|
||||
# Add center line label
|
||||
if x_offset > 4:
|
||||
stdscr.addstr(center_y, x_offset - 4, "AVG")
|
||||
|
||||
# Scale factor for vertical positioning
|
||||
scale_factor = (height // 2) / max_deviation
|
||||
|
||||
# Always scale to use the entire width
|
||||
# Calculate the time span of the data
|
||||
if len(flow.timestamps) < 2:
|
||||
return
|
||||
|
||||
start_time = flow.timestamps[0]
|
||||
end_time = flow.timestamps[-1]
|
||||
time_span = end_time - start_time
|
||||
|
||||
if time_span <= 0:
|
||||
return
|
||||
|
||||
# Create a mapping from deviation frame numbers to actual timestamps
|
||||
frame_to_timestamp = {}
|
||||
for i, (frame_num, deviation) in enumerate(deviations):
|
||||
if i < len(flow.timestamps):
|
||||
frame_to_timestamp[frame_num] = flow.timestamps[i]
|
||||
|
||||
# Plot points across entire width
|
||||
for x in range(width):
|
||||
# Calculate which timestamp this x position represents
|
||||
time_ratio = x / (width - 1) if width > 1 else 0
|
||||
target_time = start_time + (time_ratio * time_span)
|
||||
|
||||
# Find the closest deviation to this time
|
||||
closest_deviation = None
|
||||
min_time_diff = float('inf')
|
||||
|
||||
for frame_num, deviation in deviations:
|
||||
# Use the correct timestamp mapping
|
||||
if frame_num in frame_to_timestamp:
|
||||
frame_time = frame_to_timestamp[frame_num]
|
||||
time_diff = abs(frame_time - target_time)
|
||||
|
||||
if time_diff < min_time_diff:
|
||||
min_time_diff = time_diff
|
||||
closest_deviation = deviation
|
||||
|
||||
if closest_deviation is not None:
|
||||
# Calculate vertical position
|
||||
y_pos = center_y - int(closest_deviation * scale_factor)
|
||||
y_pos = max(y_offset, min(y_offset + height - 1, y_pos))
|
||||
|
||||
# Choose character based on deviation magnitude
|
||||
char = self._get_timeline_char(closest_deviation, flow.avg_inter_arrival)
|
||||
|
||||
# Draw the point
|
||||
try:
|
||||
stdscr.addstr(y_pos, x_offset + x, char)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# Draw scale labels and timeline info
|
||||
self._draw_timeline_labels(stdscr, x_offset, y_offset, width, height,
|
||||
max_deviation, deviations, flow, time_span)
|
||||
|
||||
def _get_timeline_char(self, deviation: float, avg_time: float) -> str:
|
||||
"""Get character representation for timeline point based on deviation"""
|
||||
if abs(deviation) < avg_time * 0.1: # Within 10% of average
|
||||
return "·"
|
||||
elif abs(deviation) < avg_time * 0.5: # Within 50% of average
|
||||
return "•" if deviation > 0 else "○"
|
||||
else: # Significant deviation (outlier)
|
||||
return "█" if deviation > 0 else "▄"
|
||||
|
||||
def _draw_timeline_labels(self, stdscr, x_offset: int, y_offset: int, width: int, height: int,
|
||||
max_deviation: float, deviations: List[Tuple[int, float]],
|
||||
flow, time_span: float):
|
||||
"""Draw timeline labels and summary information"""
|
||||
# Draw scale labels
|
||||
if height >= 5:
|
||||
# Top label (positive deviation)
|
||||
top_dev = max_deviation
|
||||
if x_offset > 4:
|
||||
stdscr.addstr(y_offset, x_offset - 4, f"+{top_dev:.2f}s")
|
||||
|
||||
# Bottom label (negative deviation)
|
||||
bottom_dev = -max_deviation
|
||||
if x_offset > 4:
|
||||
stdscr.addstr(y_offset + height - 1, x_offset - 4, f"{bottom_dev:.2f}s")
|
||||
|
||||
# Timeline info with time scale above summary
|
||||
info_y = y_offset + height + 1
|
||||
if info_y < y_offset + height + 3: # Make sure we have space for two lines
|
||||
total_frames = len(deviations)
|
||||
|
||||
# First line: Time scale
|
||||
relative_start = 0.0
|
||||
relative_end = time_span
|
||||
relative_middle = time_span / 2
|
||||
|
||||
# Format time scale labels
|
||||
start_label = f"{relative_start:.1f}s"
|
||||
middle_label = f"{relative_middle:.1f}s"
|
||||
end_label = f"{relative_end:.1f}s"
|
||||
|
||||
# Draw time scale labels at left, middle, right
|
||||
stdscr.addstr(info_y, x_offset, start_label)
|
||||
|
||||
# Middle label
|
||||
middle_x = x_offset + width // 2 - len(middle_label) // 2
|
||||
if middle_x > x_offset + len(start_label) + 1 and middle_x + len(middle_label) < x_offset + width - len(end_label) - 1:
|
||||
stdscr.addstr(info_y, middle_x, middle_label)
|
||||
|
||||
# Right label
|
||||
end_x = x_offset + width - len(end_label)
|
||||
if end_x > x_offset + len(start_label) + 1:
|
||||
stdscr.addstr(info_y, end_x, end_label)
|
||||
|
||||
# Second line: Frame count and deviation range
|
||||
summary_y = info_y + 1
|
||||
if summary_y < y_offset + height + 3:
|
||||
left_info = f"Frames: {total_frames} | Range: ±{max_deviation:.3f}s"
|
||||
stdscr.addstr(summary_y, x_offset, left_info)
|
||||
|
||||
# Right side outliers count with 2σ threshold
|
||||
threshold_2sigma = flow.avg_inter_arrival + (2 * flow.std_inter_arrival)
|
||||
outliers_info = f"Outliers: {len(flow.outlier_frames)} (>2σ: {threshold_2sigma:.4f}s)"
|
||||
outliers_x = x_offset + width - len(outliers_info)
|
||||
if outliers_x > x_offset + len(left_info) + 2: # Make sure there's space
|
||||
stdscr.addstr(summary_y, outliers_x, outliers_info)
|
||||
Reference in New Issue
Block a user