Enhanced Textual TUI with proper API usage and documentation
- Fixed DataTable row selection and event handling - Added explicit column keys to prevent auto-generated keys - Implemented row-to-flow mapping for reliable selection tracking - Converted left metrics panel to horizontal top bar - Fixed all missing FlowStats/EnhancedAnalysisData attributes - Created comprehensive Textual API documentation in Documentation/textual/ - Added validation checklist to prevent future API mismatches - Preserved cursor position during data refreshes - Fixed RowKey type handling and event names The TUI now properly handles flow selection, displays metrics in a compact top bar, and correctly correlates selected rows with the details pane.
This commit is contained in:
@@ -27,6 +27,7 @@ class FlowAnalysisView:
|
||||
self.selected_flow = 0
|
||||
self.scroll_offset = 0
|
||||
self.show_frame_types = True
|
||||
self.column_widths = {}
|
||||
|
||||
def draw(self, stdscr, selected_flow_key: Optional[str]):
|
||||
"""Draw the Flow Analysis view"""
|
||||
@@ -64,19 +65,11 @@ class FlowAnalysisView:
|
||||
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}"
|
||||
)
|
||||
# Calculate dynamic column widths based on available space
|
||||
self.column_widths = self._calculate_column_widths(width)
|
||||
|
||||
# Column headers with dynamic widths
|
||||
headers = self._format_headers()
|
||||
stdscr.addstr(current_y, 4, headers, curses.A_UNDERLINE)
|
||||
current_y += 1
|
||||
|
||||
@@ -85,28 +78,41 @@ class FlowAnalysisView:
|
||||
start_idx = self.scroll_offset
|
||||
end_idx = min(start_idx + visible_flows, len(flows_list))
|
||||
|
||||
# Draw flows
|
||||
# Draw flows with sub-rows for each extended protocol/frame type variation
|
||||
display_row = 0
|
||||
for i in range(start_idx, end_idx):
|
||||
flow = flows_list[i]
|
||||
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)
|
||||
# Get distinct extended protocol/frame type combinations
|
||||
protocol_frame_combinations = self._get_protocol_frame_combinations(flow)
|
||||
|
||||
# Main flow line (summary)
|
||||
attr = curses.A_REVERSE if is_selected else curses.A_BOLD
|
||||
flow_line = self._format_flow_summary_line(i + 1, flow)
|
||||
stdscr.addstr(current_y + display_row, 4, flow_line, attr)
|
||||
|
||||
# Enhanced indicator
|
||||
if flow.enhanced_analysis.decoder_type != "Standard":
|
||||
stdscr.addstr(current_y + display_idx, 2, "●", curses.A_BOLD | curses.color_pair(1))
|
||||
stdscr.addstr(current_y + display_row, 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)
|
||||
display_row += 1
|
||||
|
||||
# Sub-rows for each protocol/frame type combination
|
||||
for j, (extended_proto, frame_type, count, percentage) in enumerate(protocol_frame_combinations):
|
||||
if current_y + display_row >= current_y + visible_flows:
|
||||
break
|
||||
|
||||
sub_attr = curses.A_REVERSE if (is_selected and j == 0) else curses.A_DIM
|
||||
sub_line = self._format_protocol_frame_line(flow, extended_proto, frame_type, count, percentage)
|
||||
stdscr.addstr(current_y + display_row, 4, sub_line, sub_attr)
|
||||
display_row += 1
|
||||
|
||||
# Stop if we've filled the visible area
|
||||
if current_y + display_row >= current_y + visible_flows:
|
||||
break
|
||||
|
||||
# Scroll indicators
|
||||
if start_idx > 0:
|
||||
@@ -114,21 +120,244 @@ class FlowAnalysisView:
|
||||
if end_idx < len(flows_list):
|
||||
stdscr.addstr(current_y + visible_flows - 1, width - 10, "↓ More", curses.A_DIM)
|
||||
|
||||
def _calculate_column_widths(self, terminal_width: int) -> dict:
|
||||
"""Calculate dynamic column widths based on available terminal width"""
|
||||
# Reserve space for margins and prevent line wrapping
|
||||
# 4 chars left margin + 4 chars right margin + 8 safety margin to prevent wrapping
|
||||
available_width = terminal_width - 16
|
||||
|
||||
# Fixed minimum widths for critical columns
|
||||
min_widths = {
|
||||
'flow_num': 3, # " #"
|
||||
'source': 15, # Compact IP:port
|
||||
'proto': 4, # "UDP", "TCP"
|
||||
'destination': 15, # Compact IP:port
|
||||
'extended': 6, # "CH10", "PTP"
|
||||
'frame_type': 8, # Compact frame type
|
||||
'pkts': 6, # Right-aligned numbers
|
||||
'volume': 8, # Right-aligned with units
|
||||
'timing': 8, # Right-aligned with units
|
||||
'quality': 8 # Right-aligned percentages
|
||||
}
|
||||
|
||||
# Calculate total minimum width needed
|
||||
min_total = sum(min_widths.values())
|
||||
|
||||
# If we have extra space, distribute it proportionally
|
||||
if available_width > min_total:
|
||||
extra_space = available_width - min_total
|
||||
|
||||
# Distribute extra space to text columns (source, destination, extended, frame_type)
|
||||
expandable_columns = ['source', 'destination', 'extended', 'frame_type']
|
||||
extra_per_column = extra_space // len(expandable_columns)
|
||||
|
||||
widths = min_widths.copy()
|
||||
for col in expandable_columns:
|
||||
widths[col] += extra_per_column
|
||||
|
||||
# Give any remaining space to source and destination
|
||||
remaining = extra_space % len(expandable_columns)
|
||||
if remaining > 0:
|
||||
widths['source'] += remaining // 2
|
||||
widths['destination'] += remaining // 2
|
||||
if remaining % 2:
|
||||
widths['source'] += 1
|
||||
else:
|
||||
# Use minimum widths if terminal is too narrow
|
||||
widths = min_widths
|
||||
|
||||
return widths
|
||||
|
||||
def _format_headers(self) -> str:
|
||||
"""Format column headers using dynamic widths"""
|
||||
cw = self.column_widths
|
||||
return (
|
||||
f"{'#':>{cw['flow_num']-1}} "
|
||||
f"{'Source':<{cw['source']}} "
|
||||
f"{'Proto':<{cw['proto']}} "
|
||||
f"{'Destination':<{cw['destination']}} "
|
||||
f"{'Extended':<{cw['extended']}} "
|
||||
f"{'Frame Type':<{cw['frame_type']}} "
|
||||
f"{'Pkts':>{cw['pkts']}} "
|
||||
f"{'Volume':>{cw['volume']}} "
|
||||
f"{'Timing':>{cw['timing']}} "
|
||||
f"{'Quality':>{cw['quality']}}"
|
||||
)
|
||||
|
||||
def _get_protocol_frame_combinations(self, flow: FlowStats) -> List[Tuple[str, str, int, float]]:
|
||||
"""Get distinct extended protocol/frame type combinations for a flow"""
|
||||
combinations = []
|
||||
total_packets = flow.frame_count
|
||||
|
||||
# Group frame types by extended protocol
|
||||
protocol_frames = {}
|
||||
|
||||
if flow.frame_types:
|
||||
for frame_type, ft_stats in flow.frame_types.items():
|
||||
# Determine extended protocol for this frame type
|
||||
extended_proto = self._get_extended_protocol_for_frame(flow, frame_type)
|
||||
|
||||
if extended_proto not in protocol_frames:
|
||||
protocol_frames[extended_proto] = []
|
||||
|
||||
protocol_frames[extended_proto].append((frame_type, ft_stats.count))
|
||||
else:
|
||||
# No frame types, just show the flow-level extended protocol
|
||||
extended_proto = self._get_extended_protocol(flow)
|
||||
protocol_frames[extended_proto] = [("General", total_packets)]
|
||||
|
||||
# Convert to list of tuples with percentages
|
||||
for extended_proto, frame_list in protocol_frames.items():
|
||||
for frame_type, count in frame_list:
|
||||
percentage = (count / total_packets * 100) if total_packets > 0 else 0
|
||||
combinations.append((extended_proto, frame_type, count, percentage))
|
||||
|
||||
# Sort by count (descending)
|
||||
combinations.sort(key=lambda x: x[2], reverse=True)
|
||||
return combinations
|
||||
|
||||
def _get_extended_protocol_for_frame(self, flow: FlowStats, frame_type: str) -> str:
|
||||
"""Get extended protocol for a specific frame type"""
|
||||
if frame_type.startswith('CH10') or frame_type == 'TMATS':
|
||||
return 'CH10'
|
||||
elif frame_type.startswith('PTP'):
|
||||
return 'PTP'
|
||||
elif frame_type == 'IENA':
|
||||
return 'IENA'
|
||||
elif frame_type == 'NTP':
|
||||
return 'NTP'
|
||||
else:
|
||||
# Fallback to flow-level extended protocol
|
||||
return self._get_extended_protocol(flow)
|
||||
|
||||
def _format_flow_summary_line(self, flow_num: int, flow: FlowStats) -> str:
|
||||
"""Format the main flow summary line"""
|
||||
# Source with port (left-aligned)
|
||||
source = f"{flow.src_ip}:{flow.src_port}"
|
||||
max_source_len = self.column_widths.get('source', 24) - 2
|
||||
if len(source) > max_source_len:
|
||||
ip_space = max_source_len - len(f":{flow.src_port}") - 1 # -1 for ellipsis
|
||||
source = f"{flow.src_ip[:ip_space]}…:{flow.src_port}"
|
||||
|
||||
# Transport protocol
|
||||
protocol = flow.transport_protocol
|
||||
|
||||
# Destination with port (left-aligned)
|
||||
destination = f"{flow.dst_ip}:{flow.dst_port}"
|
||||
max_dest_len = self.column_widths.get('destination', 24) - 2
|
||||
if len(destination) > max_dest_len:
|
||||
ip_space = max_dest_len - len(f":{flow.dst_port}") - 1 # -1 for ellipsis
|
||||
destination = f"{flow.dst_ip[:ip_space]}…:{flow.dst_port}"
|
||||
|
||||
# Summary info instead of specific extended/frame
|
||||
extended_summary = f"{len(self._get_protocol_frame_combinations(flow))} types"
|
||||
frame_summary = "Mixed" if len(flow.frame_types) > 1 else "Single"
|
||||
|
||||
# Packet count
|
||||
pkt_count = f"{flow.frame_count}"
|
||||
|
||||
# Volume with units
|
||||
volume = self._format_bytes(flow.total_bytes)
|
||||
|
||||
# Timing quality
|
||||
if flow.avg_inter_arrival > 0:
|
||||
timing_ms = flow.avg_inter_arrival * 1000
|
||||
if timing_ms >= 1000:
|
||||
timing = f"{timing_ms/1000:.1f}s"
|
||||
else:
|
||||
timing = f"{timing_ms:.1f}ms"
|
||||
else:
|
||||
timing = "N/A"
|
||||
|
||||
# Quality score
|
||||
if flow.enhanced_analysis.decoder_type != "Standard":
|
||||
if flow.enhanced_analysis.avg_frame_quality > 0:
|
||||
quality = f"{flow.enhanced_analysis.avg_frame_quality:.0f}%"
|
||||
else:
|
||||
quality = "Enhanced"
|
||||
else:
|
||||
# Check for outliers
|
||||
outlier_pct = len(flow.outlier_frames) / flow.frame_count * 100 if flow.frame_count > 0 else 0
|
||||
if outlier_pct > 5:
|
||||
quality = f"{outlier_pct:.0f}% Out"
|
||||
else:
|
||||
quality = "Normal"
|
||||
|
||||
cw = self.column_widths
|
||||
return (f"{flow_num:>{cw['flow_num']-1}} "
|
||||
f"{source:<{cw['source']}} "
|
||||
f"{protocol:<{cw['proto']}} "
|
||||
f"{destination:<{cw['destination']}} "
|
||||
f"{extended_summary:<{cw['extended']}} "
|
||||
f"{frame_summary:<{cw['frame_type']}} "
|
||||
f"{pkt_count:>{cw['pkts']}} "
|
||||
f"{volume:>{cw['volume']}} "
|
||||
f"{timing:>{cw['timing']}} "
|
||||
f"{quality:>{cw['quality']}}")
|
||||
|
||||
def _format_protocol_frame_line(self, flow: FlowStats, extended_proto: str, frame_type: str, count: int, percentage: float) -> str:
|
||||
"""Format a sub-row line for a specific protocol/frame type combination"""
|
||||
# Empty source/protocol/destination for sub-rows
|
||||
source = ""
|
||||
protocol = ""
|
||||
destination = ""
|
||||
|
||||
# Extended protocol and frame type
|
||||
extended = extended_proto if extended_proto != '-' else ""
|
||||
frame = frame_type
|
||||
|
||||
# Packet count for this combination
|
||||
pkt_count = f"{count}"
|
||||
|
||||
# Volume calculation (approximate based on percentage)
|
||||
volume_bytes = int(flow.total_bytes * (percentage / 100))
|
||||
volume = self._format_bytes(volume_bytes)
|
||||
|
||||
# Timing for this frame type if available
|
||||
if frame_type in flow.frame_types and flow.frame_types[frame_type].avg_inter_arrival > 0:
|
||||
timing_ms = flow.frame_types[frame_type].avg_inter_arrival * 1000
|
||||
if timing_ms >= 1000:
|
||||
timing = f"{timing_ms/1000:.1f}s" # Convert to seconds for large values
|
||||
else:
|
||||
timing = f"{timing_ms:.1f}ms"
|
||||
else:
|
||||
timing = "-"
|
||||
|
||||
# Percentage as quality indicator
|
||||
quality = f"{percentage:.1f}%"
|
||||
|
||||
cw = self.column_widths
|
||||
indent = " " * cw['flow_num'] # Match flow_num space allocation
|
||||
return (f"{indent}"
|
||||
f"{source:<{cw['source']}} "
|
||||
f"{protocol:<{cw['proto']}} "
|
||||
f"{destination:<{cw['destination']}} "
|
||||
f"{extended:<{cw['extended']}} "
|
||||
f"{frame:<{cw['frame_type']}} "
|
||||
f"{pkt_count:>{cw['pkts']}} "
|
||||
f"{volume:>{cw['volume']}} "
|
||||
f"{timing:>{cw['timing']}} "
|
||||
f"{quality:>{cw['quality']}}")
|
||||
|
||||
def _format_flow_line(self, flow_num: int, flow: FlowStats) -> str:
|
||||
"""Format a single flow line with comprehensive information"""
|
||||
|
||||
# Source with port (left-aligned)
|
||||
source = f"{flow.src_ip}:{flow.src_port}"
|
||||
if len(source) > 18:
|
||||
source = f"{flow.src_ip[:10]}…:{flow.src_port}"
|
||||
max_source_len = self.column_widths.get('source', 24) - 2
|
||||
if len(source) > max_source_len:
|
||||
ip_space = max_source_len - len(f":{flow.src_port}") - 1 # -1 for ellipsis
|
||||
source = f"{flow.src_ip[:ip_space]}…:{flow.src_port}"
|
||||
|
||||
# Transport protocol (TCP, UDP, ICMP, IGMP, etc.)
|
||||
protocol = flow.transport_protocol
|
||||
|
||||
# Destination with port (left-aligned)
|
||||
destination = f"{flow.dst_ip}:{flow.dst_port}"
|
||||
if len(destination) > 18:
|
||||
destination = f"{flow.dst_ip[:10]}…:{flow.dst_port}"
|
||||
max_dest_len = self.column_widths.get('destination', 24) - 2
|
||||
if len(destination) > max_dest_len:
|
||||
ip_space = max_dest_len - len(f":{flow.dst_port}") - 1 # -1 for ellipsis
|
||||
destination = f"{flow.dst_ip[:ip_space]}…:{flow.dst_port}"
|
||||
|
||||
# Extended protocol (Chapter 10, PTP, IENA, etc.)
|
||||
extended_protocol = self._get_extended_protocol(flow)
|
||||
@@ -144,7 +373,11 @@ class FlowAnalysisView:
|
||||
|
||||
# Timing quality
|
||||
if flow.avg_inter_arrival > 0:
|
||||
timing = f"{flow.avg_inter_arrival*1000:.1f}ms"
|
||||
timing_ms = flow.avg_inter_arrival * 1000
|
||||
if timing_ms >= 1000:
|
||||
timing = f"{timing_ms/1000:.1f}s"
|
||||
else:
|
||||
timing = f"{timing_ms:.1f}ms"
|
||||
else:
|
||||
timing = "N/A"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user