316 lines
12 KiB
Python
316 lines
12 KiB
Python
|
|
"""
|
|||
|
|
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)
|