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:
2025-07-27 18:37:55 -04:00
parent 5c2cb1a4ed
commit 36a576dc2c
29 changed files with 3751 additions and 51 deletions

View File

@@ -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"