Enhanced TUI with split flow details and timing analysis

- Added ΔT (deltaT), σ (sigma), and outlier count columns to flow table
- Split right panel into top (main flow) and bottom (sub-flow) sections
- Removed inner panel borders for clean 3-panel layout
- Added sub-flow selection logic with detailed timing statistics
- Implemented per-frame-type timing analysis in sub-flow details
- Color-coded outliers and timing data for quick visual assessment
This commit is contained in:
2025-07-28 09:50:59 -04:00
parent 4dd632012f
commit a3c50fd845
4 changed files with 430 additions and 42 deletions

View File

@@ -19,7 +19,7 @@ import time
from .widgets.sparkline import SparklineWidget
from .widgets.metric_card import MetricCard
from .widgets.flow_table_v2 import EnhancedFlowTable
from .widgets.flow_details import FlowDetailsPanel
from .widgets.split_flow_details import FlowMainDetailsPanel, SubFlowDetailsPanel
if TYPE_CHECKING:
from ...analysis.core import EthernetAnalyzer
@@ -90,20 +90,19 @@ class StreamLensAppV2(App):
yield MetricCard("Enhanced", f"{self.enhanced_flows}", color="success", id="enhanced-metric")
yield MetricCard("Outliers", f"{self.outlier_count}", color="warning" if self.outlier_count > 0 else "normal", id="outliers-metric")
# Main content area with horizontal split
# Main content area with 3 clean panels
with Horizontal(id="content-area"):
# Left - Enhanced flow table (wider)
with Vertical(id="left-panel", classes="panel-wide"):
# Left - Enhanced flow table
yield EnhancedFlowTable(
self.analyzer,
id="flow-table"
id="flow-table",
classes="panel-wide"
)
# Right - Selected flow details
with Vertical(id="right-panel", classes="panel"):
yield FlowDetailsPanel(
id="flow-details"
)
# Right top - Main flow details
with Vertical(id="right-panels"):
yield FlowMainDetailsPanel(id="main-flow-details")
yield SubFlowDetailsPanel(id="sub-flow-details")
yield Footer()
@@ -241,8 +240,13 @@ class StreamLensAppV2(App):
def on_enhanced_flow_table_flow_selected(self, event: EnhancedFlowTable.FlowSelected) -> None:
"""Handle flow selection events"""
if event.flow:
details_panel = self.query_one("#flow-details", FlowDetailsPanel)
details_panel.update_flow(event.flow)
# Update main flow details panel
main_panel = self.query_one("#main-flow-details", FlowMainDetailsPanel)
main_panel.update_flow(event.flow)
# Update sub-flow details panel
sub_panel = self.query_one("#sub-flow-details", SubFlowDetailsPanel)
sub_panel.update_flow(event.flow, event.subflow_type)
def _format_bytes_per_sec(self, bps: float) -> str:

View File

@@ -35,15 +35,13 @@ MetricCard {
padding: 0;
}
/* Panel Styling - Minimal borders */
/* Panel Styling - No borders for clean look */
.panel {
border: solid #99ccff;
padding: 0;
margin: 0;
}
.panel-wide {
border: solid #99ccff;
padding: 0;
margin: 0;
}
@@ -62,13 +60,25 @@ MetricCard {
padding: 0;
}
/* Right Panel - Details (compact) */
#right-panel {
/* Right Panels - Details (compact) */
#right-panels {
width: 25%;
background: #1a1a1a;
padding: 0;
}
FlowMainDetailsPanel {
height: 3fr;
background: #1a1a1a;
border: solid #ff8800;
}
SubFlowDetailsPanel {
height: 2fr;
background: #1a1a1a;
border: solid #ff8800;
}
/* Sparkline Charts */
SparklineWidget {
height: 5;
@@ -188,9 +198,6 @@ DataTable:focus {
border: solid #00ffcc;
}
/* Panel Borders */
Static {
border: round #0080ff;
}
/* Panel Borders - Removed for clean look */
/* End of styles */

View File

@@ -75,6 +75,9 @@ class EnhancedFlowTable(Vertical):
table.add_column("Frame Type", width=10, key="frame_type")
table.add_column("Pkts", width=6, key="rate")
table.add_column("Size", width=8, key="volume")
table.add_column("ΔT(ms)", width=8, key="delta_t")
table.add_column("σ(ms)", width=8, key="sigma")
table.add_column("Out", width=5, key="outliers")
self.refresh_data()
@@ -95,6 +98,7 @@ class EnhancedFlowTable(Vertical):
# Clear row mapping
self.row_to_flow_map.clear()
self.row_to_subflow_map = {} # Map row keys to (flow_index, subflow_type)
# Get and sort flows
self.flows_list = self._get_sorted_flows()
@@ -139,10 +143,14 @@ class EnhancedFlowTable(Vertical):
# Add sub-rows for protocol breakdown
if self._should_show_subrows(flow):
sub_rows = self._create_protocol_subrows(flow)
combinations = self._get_protocol_frame_combinations(flow)
for j, sub_row in enumerate(sub_rows):
sub_key = table.add_row(*sub_row, key=f"flow_{i}_sub_{j}")
# Map sub-row to parent flow
# Map sub-row to parent flow and subflow type
self.row_to_flow_map[sub_key] = i
if j < len(combinations):
_, frame_type, _, _ = combinations[j]
self.row_to_subflow_map[sub_key] = (i, frame_type)
# Restore cursor position
if selected_row_key and selected_row_key in table.rows:
@@ -188,24 +196,37 @@ class EnhancedFlowTable(Vertical):
size_value = self._format_bytes(flow.total_bytes)
size_text = Text(f"{size_value:>8}")
# Quality with bar chart and color
quality_bar, quality_color = self._create_quality_bar(flow)
quality_value = self._get_quality_score(flow)
quality_text = Text(f"{quality_value:>3}% {quality_bar}", style=quality_color)
# Delta T (average time between packets in ms)
if flow.avg_inter_arrival > 0:
delta_t_ms = flow.avg_inter_arrival * 1000
if delta_t_ms >= 1000:
delta_t_str = f"{delta_t_ms/1000:.1f}s"
else:
delta_t_str = f"{delta_t_ms:.1f}"
else:
delta_t_str = "N/A"
delta_t_text = Text(delta_t_str, justify="right")
# Status indicator
status = self._get_flow_status(flow)
status_color = {
"Normal": "green",
"Enhanced": "bold green",
"Warning": "yellow",
"Alert": "red"
}.get(status, "white")
status_text = Text(status, style=status_color)
# Sigma (standard deviation in ms)
if flow.std_inter_arrival > 0:
sigma_ms = flow.std_inter_arrival * 1000
if sigma_ms >= 1000:
sigma_str = f"{sigma_ms/1000:.1f}s"
else:
sigma_str = f"{sigma_ms:.1f}"
else:
sigma_str = "N/A"
sigma_text = Text(sigma_str, justify="right")
# Outlier count (packets outside tolerance)
outlier_count = len(flow.outlier_frames)
outlier_text = Text(str(outlier_count), justify="right",
style="red" if outlier_count > 0 else "green")
return [
num_text, source_text, proto_text, dest_text,
extended_text, frame_text, rate_text, size_text
extended_text, frame_text, rate_text, size_text,
delta_t_text, sigma_text, outlier_text
]
def _create_rate_sparkline(self, history: List[float]) -> str:
@@ -308,6 +329,21 @@ class EnhancedFlowTable(Vertical):
combinations = self._get_protocol_frame_combinations(flow)
for extended_proto, frame_type, count, percentage in combinations[:3]: # Max 3 subrows
# Calculate timing for this frame type if available
frame_delta_t = ""
frame_sigma = ""
frame_outliers = ""
if frame_type in flow.frame_types:
ft_stats = flow.frame_types[frame_type]
if ft_stats.avg_inter_arrival > 0:
dt_ms = ft_stats.avg_inter_arrival * 1000
frame_delta_t = f"{dt_ms:.1f}" if dt_ms < 1000 else f"{dt_ms/1000:.1f}s"
if ft_stats.std_inter_arrival > 0:
sig_ms = ft_stats.std_inter_arrival * 1000
frame_sigma = f"{sig_ms:.1f}" if sig_ms < 1000 else f"{sig_ms/1000:.1f}s"
frame_outliers = str(len(ft_stats.outlier_frames))
subrow = [
Text(""), # Empty flow number
Text(""), # Empty source
@@ -316,7 +352,10 @@ class EnhancedFlowTable(Vertical):
Text(f" {extended_proto}", style="dim yellow"),
Text(frame_type, style="dim blue"),
Text(f"{count}", style="dim", justify="right"),
Text(f"{self._format_bytes(count * (flow.total_bytes // flow.frame_count) if flow.frame_count > 0 else 0):>8}", style="dim")
Text(f"{self._format_bytes(count * (flow.total_bytes // flow.frame_count) if flow.frame_count > 0 else 0):>8}", style="dim"),
Text(frame_delta_t, style="dim", justify="right"),
Text(frame_sigma, style="dim", justify="right"),
Text(frame_outliers, style="dim red" if frame_outliers and int(frame_outliers) > 0 else "dim", justify="right")
]
subrows.append(subrow)
@@ -348,8 +387,9 @@ class EnhancedFlowTable(Vertical):
class FlowSelected(Message):
"""Message sent when a flow is selected"""
def __init__(self, flow: Optional['FlowStats']) -> None:
def __init__(self, flow: Optional['FlowStats'], subflow_type: Optional[str] = None) -> None:
self.flow = flow
self.subflow_type = subflow_type
super().__init__()
def get_selected_flow(self) -> Optional['FlowStats']:
@@ -372,10 +412,31 @@ class EnhancedFlowTable(Vertical):
return None
def get_selected_subflow_type(self) -> Optional[str]:
"""Get currently selected sub-flow type if applicable"""
table = self.query_one("#flows-data-table", DataTable)
if table.cursor_row is None or not table.rows:
return None
# Get the row key at cursor position
row_keys = list(table.rows.keys())
if table.cursor_row >= len(row_keys):
return None
row_key = row_keys[table.cursor_row]
# Check if this is a sub-row
if row_key in self.row_to_subflow_map:
_, subflow_type = self.row_to_subflow_map[row_key]
return subflow_type
return None
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
"""Handle row highlight to update selection"""
selected_flow = self.get_selected_flow()
self.post_message(self.FlowSelected(selected_flow))
subflow_type = self.get_selected_subflow_type()
self.post_message(self.FlowSelected(selected_flow, subflow_type))
# Helper methods from original implementation
def _get_extended_protocol(self, flow: 'FlowStats') -> str:

View File

@@ -0,0 +1,316 @@
"""
Split Flow Details Panel - Top/Bottom layout for flow and sub-flow details
"""
from textual.widget import Widget
from textual.containers import Vertical
from textual.widgets import Static
from textual.reactive import reactive
from rich.text import Text
from rich.panel import Panel
from rich.console import RenderableType, Group
from rich.table import Table
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from ....models import FlowStats, FrameTypeStats
class FlowMainDetailsPanel(Vertical):
"""Top panel showing main flow details"""
DEFAULT_CSS = """
FlowMainDetailsPanel {
height: 1fr;
padding: 1;
}
FlowMainDetailsPanel Static {
margin-bottom: 0;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.current_flow = None
def compose(self):
"""Create the main flow details layout"""
yield Static("Main Flow Details", classes="panel-header")
yield Static(
"Select a flow to view details",
id="main-details-content"
)
def update_flow(self, flow: Optional['FlowStats']) -> None:
"""Update panel with main flow details"""
self.current_flow = flow
content_widget = self.query_one("#main-details-content", Static)
if not flow:
content_widget.update("Select a flow to view details")
return
details = self._create_main_flow_details(flow)
content_widget.update(details)
def _create_main_flow_details(self, flow: 'FlowStats') -> RenderableType:
"""Create main flow details display"""
sections = []
# Flow identification
id_table = Table(show_header=False, box=None, padding=0)
id_table.add_column(style="dim", width=12)
id_table.add_column()
id_table.add_row("Source:", f"{flow.src_ip}:{flow.src_port}")
id_table.add_row("Destination:", f"{flow.dst_ip}:{flow.dst_port}")
id_table.add_row("Protocol:", flow.transport_protocol)
id_table.add_row("Packets:", f"{flow.frame_count:,}")
id_table.add_row("Volume:", self._format_bytes(flow.total_bytes))
sections.append(Text("Flow Information", style="bold blue"))
sections.append(id_table)
# Enhanced analysis
if flow.enhanced_analysis.decoder_type != "Standard":
enhanced_table = Table(show_header=False, box=None, padding=0)
enhanced_table.add_column(style="dim", width=12)
enhanced_table.add_column()
enhanced_table.add_row("Decoder:", flow.enhanced_analysis.decoder_type)
enhanced_table.add_row("Quality:", f"{flow.enhanced_analysis.avg_frame_quality:.1f}%")
enhanced_table.add_row("Fields:", str(flow.enhanced_analysis.field_count))
if flow.enhanced_analysis.frame_types:
types_str = ", ".join(list(flow.enhanced_analysis.frame_types)[:3])
if len(flow.enhanced_analysis.frame_types) > 3:
types_str += f" +{len(flow.enhanced_analysis.frame_types) - 3}"
enhanced_table.add_row("Types:", types_str)
sections.append(Text("Enhanced Analysis", style="bold green"))
sections.append(enhanced_table)
# Timing analysis with new columns
timing_table = Table(show_header=False, box=None, padding=0)
timing_table.add_column(style="dim", width=12)
timing_table.add_column()
timing_table.add_row("Duration:", f"{flow.duration:.2f}s")
timing_table.add_row("Avg ΔT:", f"{flow.avg_inter_arrival * 1000:.1f}ms")
timing_table.add_row("Std σ:", f"{flow.std_inter_arrival * 1000:.1f}ms")
timing_table.add_row("Outliers:", f"{len(flow.outlier_frames)}")
timing_table.add_row("Jitter:", f"{flow.jitter * 1000:.2f}ms")
timing_table.add_row("First Seen:", self._format_timestamp(flow.first_seen))
timing_table.add_row("Last Seen:", self._format_timestamp(flow.last_seen))
sections.append(Text("Timing Analysis", style="bold cyan"))
sections.append(timing_table)
return Group(*sections)
def _format_bytes(self, bytes_count: int) -> str:
"""Format byte count with units"""
if bytes_count >= 1_000_000_000:
return f"{bytes_count / 1_000_000_000:.2f} GB"
elif bytes_count >= 1_000_000:
return f"{bytes_count / 1_000_000:.2f} MB"
elif bytes_count >= 1_000:
return f"{bytes_count / 1_000:.2f} KB"
else:
return f"{bytes_count} B"
def _format_timestamp(self, timestamp: float) -> str:
"""Format timestamp for display"""
import datetime
dt = datetime.datetime.fromtimestamp(timestamp)
return dt.strftime("%H:%M:%S.%f")[:-3]
class SubFlowDetailsPanel(Vertical):
"""Bottom panel showing sub-flow details or summary"""
DEFAULT_CSS = """
SubFlowDetailsPanel {
height: 1fr;
padding: 1;
}
SubFlowDetailsPanel Static {
margin-bottom: 0;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.current_flow = None
self.selected_subflow = None
def compose(self):
"""Create the sub-flow details layout"""
yield Static("Sub-Flow Details", classes="panel-header")
yield Static(
"No sub-flow selected",
id="sub-details-content"
)
def update_flow(self, flow: Optional['FlowStats'], subflow_type: Optional[str] = None) -> None:
"""Update panel with sub-flow details or summary"""
self.current_flow = flow
self.selected_subflow = subflow_type
content_widget = self.query_one("#sub-details-content", Static)
if not flow:
content_widget.update("No sub-flow selected")
return
if subflow_type and subflow_type in flow.frame_types:
# Show specific sub-flow details
details = self._create_subflow_details(flow, flow.frame_types[subflow_type])
else:
# Show summary of all sub-flows
details = self._create_subflow_summary(flow)
content_widget.update(details)
def _create_subflow_details(self, flow: 'FlowStats', subflow: 'FrameTypeStats') -> RenderableType:
"""Create specific sub-flow details"""
sections = []
# Sub-flow identification
id_table = Table(show_header=False, box=None, padding=0)
id_table.add_column(style="dim", width=12)
id_table.add_column()
id_table.add_row("Frame Type:", subflow.frame_type)
id_table.add_row("Packets:", f"{subflow.count:,}")
id_table.add_row("Volume:", self._format_bytes(subflow.total_bytes))
percentage = (subflow.count / flow.frame_count * 100) if flow.frame_count > 0 else 0
id_table.add_row("% of Flow:", f"{percentage:.1f}%")
sections.append(Text("Sub-Flow Information", style="bold yellow"))
sections.append(id_table)
# Sub-flow timing
timing_table = Table(show_header=False, box=None, padding=0)
timing_table.add_column(style="dim", width=12)
timing_table.add_column()
timing_table.add_row("Avg ΔT:", f"{subflow.avg_inter_arrival * 1000:.1f}ms" if subflow.avg_inter_arrival > 0 else "N/A")
timing_table.add_row("Std σ:", f"{subflow.std_inter_arrival * 1000:.1f}ms" if subflow.std_inter_arrival > 0 else "N/A")
timing_table.add_row("Outliers:", f"{len(subflow.outlier_frames)}")
sections.append(Text("Sub-Flow Timing", style="bold cyan"))
sections.append(timing_table)
# Outlier details if any
if subflow.outlier_frames and subflow.outlier_details:
outlier_table = Table(show_header=True, box=None)
outlier_table.add_column("Frame#", justify="right")
outlier_table.add_column("ΔT(ms)", justify="right")
for frame_num, delta_t in subflow.outlier_details[:5]: # Show first 5 outliers
outlier_table.add_row(
str(frame_num),
f"{delta_t * 1000:.1f}"
)
if len(subflow.outlier_details) > 5:
outlier_table.add_row("...", f"+{len(subflow.outlier_details) - 5} more")
sections.append(Text("Outlier Details", style="bold red"))
sections.append(outlier_table)
return Group(*sections)
def _create_subflow_summary(self, flow: 'FlowStats') -> RenderableType:
"""Create summary of all sub-flows"""
if not flow.frame_types or len(flow.frame_types) <= 1:
return Text("No sub-flows available", style="dim")
sections = []
sections.append(Text("Sub-Flow Summary", style="bold yellow"))
# Frame type breakdown table
frame_table = Table(show_header=True, box=None)
frame_table.add_column("Frame Type", style="blue")
frame_table.add_column("Count", justify="right")
frame_table.add_column("%", justify="right", style="dim")
frame_table.add_column("ΔT(ms)", justify="right", style="cyan")
frame_table.add_column("σ(ms)", justify="right", style="cyan")
frame_table.add_column("Out", justify="right", style="red")
total = flow.frame_count
for frame_type, stats in sorted(
flow.frame_types.items(),
key=lambda x: x[1].count,
reverse=True
):
percentage = (stats.count / total * 100) if total > 0 else 0
delta_t = f"{stats.avg_inter_arrival * 1000:.1f}" if stats.avg_inter_arrival > 0 else "N/A"
sigma = f"{stats.std_inter_arrival * 1000:.1f}" if stats.std_inter_arrival > 0 else "N/A"
outliers = str(len(stats.outlier_frames))
frame_table.add_row(
frame_type[:15],
f"{stats.count:,}",
f"{percentage:.1f}%",
delta_t,
sigma,
outliers
)
sections.append(frame_table)
return Group(*sections)
def _format_bytes(self, bytes_count: int) -> str:
"""Format byte count with units"""
if bytes_count >= 1_000_000_000:
return f"{bytes_count / 1_000_000_000:.2f} GB"
elif bytes_count >= 1_000_000:
return f"{bytes_count / 1_000_000:.2f} MB"
elif bytes_count >= 1_000:
return f"{bytes_count / 1_000:.2f} KB"
else:
return f"{bytes_count} B"
class SplitFlowDetailsPanel(Vertical):
"""Combined panel with top/bottom split for flow and sub-flow details"""
DEFAULT_CSS = """
SplitFlowDetailsPanel {
height: 1fr;
padding: 0;
}
SplitFlowDetailsPanel > FlowMainDetailsPanel {
height: 3fr;
}
SplitFlowDetailsPanel > SubFlowDetailsPanel {
height: 2fr;
}
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.current_flow = None
def compose(self):
"""Create the split layout"""
yield FlowMainDetailsPanel(id="main-flow-details")
yield SubFlowDetailsPanel(id="sub-flow-details")
def update_flow(self, flow: Optional['FlowStats'], subflow_type: Optional[str] = None) -> None:
"""Update both panels with flow data"""
self.current_flow = flow
# Update main flow details
main_panel = self.query_one("#main-flow-details", FlowMainDetailsPanel)
main_panel.update_flow(flow)
# Update sub-flow details
sub_panel = self.query_one("#sub-flow-details", SubFlowDetailsPanel)
sub_panel.update_flow(flow, subflow_type)