use crate::app::{AppState, GraveyardMode, LatencyBucket, LatencyConfig};
use crate::net::ConnectionState;
use crate::theme::{
get_overdrive_icon, interpolate_color, BLOOD_RED, BONE_WHITE, NEON_PURPLE, PUMPKIN_ORANGE,
TOXIC_GREEN,
};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
symbols::Marker,
text::{Line, Span},
widgets::{
canvas::{Canvas, Line as CanvasLine},
Block, BorderType, Borders, Paragraph,
},
Frame,
};
use std::collections::HashMap;
use unicode_width::UnicodeWidthStr;
const RING_RADII: [f64; 3] = [25.0, 35.0, 45.0];
const MIN_EDGE_PADDING: f64 = 5.0;
const HOST_CENTER: (f64, f64) = (50.0, 50.0);
const PARTICLE_OFFSETS: [f32; 3] = [0.0, 0.33, 0.66];
const PARTICLE_SYMBOL: &str = "●";
const MAX_VISIBLE_ENDPOINTS: usize = 8;
const PARTICLE_REDUCTION_THRESHOLD: usize = 50;
const REDUCED_PARTICLE_OFFSETS: [f32; 1] = [0.33];
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LayoutConfig {
pub ring_low: f64,
pub ring_medium: f64,
pub ring_high: f64,
pub edge_padding: f64,
pub is_adaptive: bool,
}
impl Default for LayoutConfig {
fn default() -> Self {
Self {
ring_low: RING_RADII[0],
ring_medium: RING_RADII[1],
ring_high: RING_RADII[2],
edge_padding: MIN_EDGE_PADDING,
is_adaptive: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EndpointType {
Localhost,
Private,
Public,
ListenOnly,
}
impl EndpointType {
pub fn icon(&self) -> &'static str {
match self {
Self::Localhost => "⚰️",
Self::Private => "🪦",
Self::Public => "🎃",
Self::ListenOnly => "🕯",
}
}
pub fn color(&self) -> Color {
match self {
Self::Localhost => TOXIC_GREEN,
Self::Private => BONE_WHITE,
Self::Public => PUMPKIN_ORANGE,
Self::ListenOnly => NEON_PURPLE,
}
}
pub fn icon_with_badge(&self, is_heavy_talker: bool) -> String {
let base_icon = self.icon();
if is_heavy_talker {
format!("{}👑", base_icon)
} else {
base_icon.to_string()
}
}
}
pub fn classify_endpoint(ip: &str, is_listen_socket: bool) -> EndpointType {
if is_listen_socket {
return EndpointType::ListenOnly;
}
if ip == "127.0.0.1" || ip == "::1" || ip == "0.0.0.0" {
return EndpointType::Localhost;
}
if let Some(endpoint_type) = classify_ipv4_private(ip) {
return endpoint_type;
}
EndpointType::Public
}
fn classify_ipv4_private(ip: &str) -> Option<EndpointType> {
let parts: Vec<&str> = ip.split('.').collect();
if parts.len() != 4 {
return None; }
let octets: Vec<u8> = parts.iter().filter_map(|p| p.parse::<u8>().ok()).collect();
if octets.len() != 4 {
return None; }
if octets[0] == 10 {
return Some(EndpointType::Private);
}
if octets[0] == 172 && (16..=31).contains(&octets[1]) {
return Some(EndpointType::Private);
}
if octets[0] == 192 && octets[1] == 168 {
return Some(EndpointType::Private);
}
None
}
pub fn is_heavy_talker(conn_count: usize, all_counts: &[usize]) -> bool {
if all_counts.is_empty() {
return false;
}
let mut sorted = all_counts.to_vec();
sorted.sort_by(|a, b| b.cmp(a));
let threshold = if sorted.len() >= 5 {
sorted[4] } else {
*sorted.last().unwrap_or(&0)
};
conn_count >= threshold && conn_count > 0
}
pub fn classify_latency(latency_ms: Option<u64>, config: &LatencyConfig) -> LatencyBucket {
match latency_ms {
None => LatencyBucket::Unknown,
Some(ms) => {
if ms < config.low_threshold_ms {
LatencyBucket::Low
} else if ms <= config.high_threshold_ms {
LatencyBucket::Medium
} else {
LatencyBucket::High
}
}
}
}
pub fn particle_position(
start: (f64, f64),
end: (f64, f64),
pulse_phase: f32,
offset: f32,
) -> (f64, f64) {
let t = ((pulse_phase + offset) % 1.0) as f64;
let x = start.0 + (end.0 - start.0) * t;
let y = start.1 + (end.1 - start.1) * t;
(x, y)
}
const LARGE_COFFIN_TEMPLATE: [&str; 4] = [
" /‾‾‾‾‾‾\\ ", " / HOST__ \\ ", " \\ / ", " \\______/ ", ];
const LARGE_COFFIN_WIDTH: usize = 14;
const LARGE_COFFIN_HEIGHT: usize = 4;
const LARGE_COFFIN_MAX_NAME: usize = 6;
const LARGE_COFFIN_PLACEHOLDER: &str = "HOST__";
const MID_COFFIN_TEMPLATE: [&str; 3] = [
" /‾‾‾‾‾‾\\ ", "/ HOST__ \\ ", " \\______/ ", ];
const MID_COFFIN_WIDTH: usize = 11;
const MID_COFFIN_HEIGHT: usize = 3;
const MID_COFFIN_MAX_NAME: usize = 6;
const MID_COFFIN_PLACEHOLDER: &str = "HOST__";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CoffinVariant {
Large,
Mid,
Label,
}
#[derive(Debug, Clone)]
pub struct CoffinRender {
pub lines: Vec<String>,
pub variant: CoffinVariant,
pub width: usize,
pub height: usize,
}
fn truncate_host_name(host: &str, max_len: usize) -> String {
let char_count = host.chars().count();
if char_count <= max_len {
host.to_string()
} else if max_len <= 3 {
host.chars().take(max_len).collect()
} else {
let truncated: String = host.chars().take(max_len - 2).collect();
format!("{}..", truncated)
}
}
fn center_pad(s: &str, width: usize) -> String {
let s_len = s.chars().count();
if s_len >= width {
return s.to_string();
}
let total_pad = width - s_len;
let left_pad = total_pad / 2;
let right_pad = total_pad - left_pad;
format!("{}{}{}", " ".repeat(left_pad), s, " ".repeat(right_pad))
}
pub fn build_large_coffin(host: &str) -> CoffinRender {
let display_name = truncate_host_name(host, LARGE_COFFIN_MAX_NAME);
let padded_name = center_pad(&display_name, LARGE_COFFIN_MAX_NAME);
let lines: Vec<String> = LARGE_COFFIN_TEMPLATE
.iter()
.map(|line| {
if line.contains(LARGE_COFFIN_PLACEHOLDER) {
line.replace(LARGE_COFFIN_PLACEHOLDER, &padded_name)
} else {
line.to_string()
}
})
.collect();
CoffinRender {
lines,
variant: CoffinVariant::Large,
width: LARGE_COFFIN_WIDTH,
height: LARGE_COFFIN_HEIGHT,
}
}
pub fn build_mid_coffin(host: &str) -> CoffinRender {
let display_name = truncate_host_name(host, MID_COFFIN_MAX_NAME);
let padded_name = center_pad(&display_name, MID_COFFIN_MAX_NAME);
let lines: Vec<String> = MID_COFFIN_TEMPLATE
.iter()
.map(|line| {
if line.contains(MID_COFFIN_PLACEHOLDER) {
line.replace(MID_COFFIN_PLACEHOLDER, &padded_name)
} else {
line.to_string()
}
})
.collect();
CoffinRender {
lines,
variant: CoffinVariant::Mid,
width: MID_COFFIN_WIDTH,
height: MID_COFFIN_HEIGHT,
}
}
pub fn build_label_coffin(host: &str, max_width: usize) -> CoffinRender {
let available = max_width.saturating_sub(4);
let display_name = truncate_host_name(host, available.max(3));
let line = format!("[⚰ {}]", display_name);
let width = line.chars().count();
CoffinRender {
lines: vec![line],
variant: CoffinVariant::Label,
width,
height: 1,
}
}
pub fn choose_coffin_variant(area_width: f64, area_height: f64, host: &str) -> CoffinRender {
let char_width = (area_width / 1.0) as usize;
let char_height = (area_height / 4.0) as usize;
if char_width >= LARGE_COFFIN_WIDTH && char_height >= LARGE_COFFIN_HEIGHT {
return build_large_coffin(host);
}
if char_width >= MID_COFFIN_WIDTH && char_height >= MID_COFFIN_HEIGHT {
return build_mid_coffin(host);
}
build_label_coffin(host, char_width.max(10))
}
pub fn coffin_exclusion_radius(variant: CoffinVariant) -> f64 {
match variant {
CoffinVariant::Large => 20.0, CoffinVariant::Mid => 16.0, CoffinVariant::Label => 10.0, }
}
pub fn draw_coffin_block(
ctx: &mut ratatui::widgets::canvas::Context<'_>,
host_name: &str,
overdrive_enabled: bool,
canvas_height: f64,
center_x: f64,
center_y: f64,
) -> CoffinVariant {
let (cx, cy) = (center_x, center_y);
let coffin_color = if overdrive_enabled {
PUMPKIN_ORANGE
} else {
NEON_PURPLE
};
let coffin = choose_coffin_variant(100.0, canvas_height, host_name);
let variant = coffin.variant;
let style = Style::default()
.fg(coffin_color)
.add_modifier(Modifier::BOLD);
let line_spacing = match coffin.variant {
CoffinVariant::Large => 4.0,
CoffinVariant::Mid => 4.5,
CoffinVariant::Label => 0.0,
};
let total_height = (coffin.height as f64 - 1.0) * line_spacing;
let start_y = cy + total_height / 2.0;
let cell_width = 1.0;
let coffin_width = coffin.width as f64 * cell_width;
for (i, line) in coffin.lines.iter().enumerate() {
let x = cx - coffin_width / 2.0;
let y = start_y - (i as f64 * line_spacing);
ctx.print(x, y, Span::styled(line.clone(), style));
}
variant
}
pub fn get_coffin_variant_for_canvas(canvas_height: f64, host_name: &str) -> CoffinVariant {
choose_coffin_variant(100.0, canvas_height, host_name).variant
}
pub fn draw_latency_rings<F>(
ctx: &mut ratatui::widgets::canvas::Context<'_>,
layout: &LayoutConfig,
draw_point: F,
) where
F: Fn(&mut ratatui::widgets::canvas::Context<'_>, f64, f64, Style),
{
let (cx, cy) = HOST_CENTER;
let ring_radii = [layout.ring_low, layout.ring_medium, layout.ring_high];
for (ring_idx, radius) in ring_radii.iter().enumerate() {
let opacity_factor = 1.0 - (ring_idx as f32 * 0.25);
let r = (169.0 * opacity_factor) as u8;
let g = (177.0 * opacity_factor) as u8;
let b = (214.0 * opacity_factor) as u8;
let ring_color = Color::Rgb(r, g, b);
let ring_style = Style::default().fg(ring_color);
for angle_deg in (0..360).step_by(10) {
let angle_rad = (angle_deg as f64).to_radians();
let x = cx + radius * angle_rad.cos();
let y = cy + radius * angle_rad.sin();
let min_bound = layout.edge_padding;
let max_bound = 100.0 - layout.edge_padding;
if x >= min_bound && x <= max_bound && y >= min_bound && y <= max_bound {
draw_point(ctx, x, y, ring_style);
}
}
}
}
pub fn has_latency_data(endpoints: &[EndpointNode]) -> bool {
endpoints
.iter()
.any(|node| node.latency_bucket != LatencyBucket::Unknown)
}
pub fn calculate_endpoint_position(
endpoint_idx: usize,
total_in_bucket: usize,
latency_bucket: LatencyBucket,
layout: &LayoutConfig,
) -> (f64, f64) {
let (cx, cy) = HOST_CENTER;
let radius = match latency_bucket {
LatencyBucket::Low => layout.ring_low,
LatencyBucket::Medium => layout.ring_medium,
LatencyBucket::High => layout.ring_high,
LatencyBucket::Unknown => layout.ring_medium, };
let total = total_in_bucket.max(1) as f64;
let angle =
(endpoint_idx as f64 / total) * 2.0 * std::f64::consts::PI - std::f64::consts::PI / 2.0;
let jitter = ((endpoint_idx % 3) as f64 - 1.0) * 2.0;
let effective_radius = radius + jitter;
let x = cx + effective_radius * angle.cos();
let y = cy + effective_radius * angle.sin();
let min_bound = layout.edge_padding;
let max_bound = 100.0 - layout.edge_padding;
(x.clamp(min_bound, max_bound), y.clamp(min_bound, max_bound))
}
pub struct EndpointNode {
pub label: String,
pub x: f64,
pub y: f64,
pub state: ConnectionState,
pub conn_count: usize,
pub latency_bucket: LatencyBucket,
pub endpoint_type: EndpointType,
pub is_heavy_talker: bool,
}
pub fn render_network_map(f: &mut Frame, area: Rect, app: &AppState) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(0)])
.split(area);
let filtered_connections: Vec<&crate::net::Connection> = match app.graveyard_mode {
GraveyardMode::Host => app.connections.iter().collect(),
GraveyardMode::Process => {
if let Some(selected_pid) = app.selected_process_pid {
app.connections
.iter()
.filter(|conn| conn.pid == Some(selected_pid))
.collect()
} else {
Vec::new()
}
}
};
let mut endpoints_map: HashMap<String, Vec<&crate::net::Connection>> = HashMap::new();
let mut listen_count = 0;
for conn in &filtered_connections {
if conn.state == ConnectionState::Listen {
listen_count += 1;
} else if conn.remote_addr != "0.0.0.0" {
endpoints_map
.entry(conn.remote_addr.clone())
.or_default()
.push(conn);
}
}
let endpoint_count = endpoints_map.len();
let center_label = match app.graveyard_mode {
GraveyardMode::Host => "HOST".to_string(),
GraveyardMode::Process => {
if let Some(pid) = app.selected_process_pid {
let process_name = filtered_connections
.iter()
.find_map(|conn| {
if conn.pid == Some(pid) {
conn.process_name.clone()
} else {
None
}
})
.unwrap_or_else(|| "unknown".to_string());
let short_name = if process_name.len() > 8 {
format!("{}...", &process_name[..5])
} else {
process_name
};
format!("{} ({})", short_name, pid)
} else {
"HOST".to_string()
}
}
};
let summary = Paragraph::new(Line::from(vec![
Span::styled(" 📊 ", Style::default().fg(NEON_PURPLE)),
Span::styled(
format!(
"Endpoints: {} | Listening: {} | Total: {} ",
endpoint_count,
listen_count,
filtered_connections.len()
),
Style::default().fg(BONE_WHITE),
),
Span::styled("[", Style::default().fg(Color::DarkGray)),
Span::styled("⚰️ ", Style::default().fg(PUMPKIN_ORANGE)),
Span::styled("host ", Style::default().fg(Color::DarkGray)),
Span::styled("🏠 ", Style::default().fg(TOXIC_GREEN)),
Span::styled("local ", Style::default().fg(Color::DarkGray)),
Span::styled("🎃 ", Style::default().fg(PUMPKIN_ORANGE)),
Span::styled("ext ", Style::default().fg(Color::DarkGray)),
Span::styled("👑 ", Style::default().fg(Color::Yellow)),
Span::styled("hot", Style::default().fg(Color::DarkGray)),
Span::styled("]", Style::default().fg(Color::DarkGray)),
]))
.block(
Block::default()
.borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(NEON_PURPLE))
.title(vec![Span::styled(
"━ 🕸️ The Graveyard (Network Topology) ━",
Style::default()
.fg(NEON_PURPLE)
.add_modifier(Modifier::BOLD),
)]),
);
f.render_widget(summary, chunks[0]);
let mut sorted_endpoints: Vec<_> = endpoints_map.iter().collect();
sorted_endpoints.sort_by(|a, b| b.1.len().cmp(&a.1.len()));
let max_nodes = MAX_VISIBLE_ENDPOINTS;
let latency_config = &app.latency_config;
let hidden_endpoint_count = sorted_endpoints.len().saturating_sub(max_nodes);
let endpoint_data: Vec<_> = sorted_endpoints
.iter()
.take(max_nodes)
.map(|(addr, conns)| {
let state = conns
.iter()
.fold(
HashMap::new(),
|mut acc: HashMap<ConnectionState, usize>, c| {
*acc.entry(c.state).or_insert(0) += 1;
acc
},
)
.into_iter()
.max_by_key(|(_, count)| *count)
.map(|(state, _)| state)
.unwrap_or(ConnectionState::Unknown);
let label = if addr.len() > 15 {
format!("{}...", &addr[..12])
} else {
(*addr).to_string()
};
let latency_bucket = classify_latency(None, latency_config);
let is_listen_socket =
*addr == "0.0.0.0" && conns.iter().all(|c| c.state == ConnectionState::Listen);
let endpoint_type = classify_endpoint(addr, is_listen_socket);
(label, state, conns.len(), latency_bucket, endpoint_type)
})
.collect();
let all_conn_counts: Vec<usize> = endpoint_data
.iter()
.map(|(_, _, count, _, _)| *count)
.collect();
let canvas_width_cells = chunks[1].width.saturating_sub(2) as f64;
let canvas_height_cells = chunks[1].height.saturating_sub(1) as f64;
let smaller_dimension = canvas_width_cells.min(canvas_height_cells);
let scale_factor = ((smaller_dimension - 30.0) / 20.0 + 1.0).clamp(1.0, 3.5);
let layout_config = LayoutConfig {
ring_low: RING_RADII[0] * scale_factor,
ring_medium: RING_RADII[1] * scale_factor,
ring_high: RING_RADII[2] * scale_factor,
edge_padding: MIN_EDGE_PADDING,
is_adaptive: scale_factor > 1.0,
};
let mut bucket_counts: HashMap<LatencyBucket, usize> = HashMap::new();
for (_, _, _, bucket, _) in &endpoint_data {
*bucket_counts.entry(*bucket).or_insert(0) += 1;
}
let mut bucket_indices: HashMap<LatencyBucket, usize> = HashMap::new();
let nodes: Vec<EndpointNode> = endpoint_data
.into_iter()
.map(
|(label, state, conn_count, latency_bucket, endpoint_type)| {
let idx_in_bucket = *bucket_indices.entry(latency_bucket).or_insert(0);
let total_in_bucket = *bucket_counts.get(&latency_bucket).unwrap_or(&1);
*bucket_indices.get_mut(&latency_bucket).unwrap() += 1;
let (x, y) = calculate_endpoint_position(
idx_in_bucket,
total_in_bucket,
latency_bucket,
&layout_config,
);
let is_heavy = is_heavy_talker(conn_count, &all_conn_counts);
EndpointNode {
label,
x,
y,
state,
conn_count,
latency_bucket,
endpoint_type,
is_heavy_talker: is_heavy,
}
},
)
.collect();
let pulse_color = interpolate_color((138, 43, 226), (187, 154, 247), app.pulse_phase);
let is_empty = nodes.is_empty() && filtered_connections.is_empty();
let graveyard_mode = app.graveyard_mode;
let should_draw_rings = has_latency_data(&nodes);
let animations_enabled = app.graveyard_settings.animations_enabled;
let pulse_phase = app.pulse_phase;
let edge_count = nodes.len();
let animation_reduced = app.animation_reduced;
let labels_enabled = app.graveyard_settings.labels_enabled;
let overdrive_enabled = app.graveyard_settings.overdrive_enabled;
let canvas_width_cells = chunks[1].width.saturating_sub(2) as f64; let canvas_height_cells = chunks[1].height.saturating_sub(1) as f64;
let canvas_pixel_width = canvas_width_cells * 2.0;
let canvas_pixel_height = canvas_height_cells * 4.0;
let aspect_ratio = canvas_pixel_width / canvas_pixel_height.max(1.0);
let x_range = 100.0 * aspect_ratio;
let x_center = x_range / 2.0;
let nodes: Vec<EndpointNode> = nodes
.into_iter()
.map(|mut node| {
node.x = (node.x - 50.0) + x_center;
node
})
.collect();
let canvas_height = canvas_pixel_height;
let canvas = Canvas::default()
.block(
Block::default()
.borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(NEON_PURPLE)),
)
.marker(Marker::Braille)
.x_bounds([0.0, x_range])
.y_bounds([0.0, 100.0])
.paint(move |ctx| {
let cx = x_center;
let cy = 50.0;
if should_draw_rings {
draw_latency_rings(ctx, &layout_config, |ctx, x, y, style| {
ctx.print(x, y, Span::styled("·", style));
});
}
let coffin_variant = get_coffin_variant_for_canvas(canvas_height, ¢er_label);
let coffin_radius = coffin_exclusion_radius(coffin_variant);
for node in &nodes {
let line_color = match node.state {
ConnectionState::Established => TOXIC_GREEN,
ConnectionState::TimeWait | ConnectionState::CloseWait => PUMPKIN_ORANGE,
ConnectionState::SynSent | ConnectionState::SynRecv => Color::Yellow,
ConnectionState::Close => BLOOD_RED,
_ => pulse_color,
};
let dx = node.x - cx;
let dy = node.y - cy;
let dist = (dx * dx + dy * dy).sqrt();
let (start_x, start_y) = if dist > coffin_radius {
let ratio = coffin_radius / dist;
(cx + dx * ratio, cy + dy * ratio)
} else {
(cx, cy)
};
ctx.draw(&CanvasLine {
x1: start_x,
y1: start_y,
x2: node.x,
y2: node.y,
color: line_color,
});
if animations_enabled {
let is_visible =
node.x >= 0.0 && node.x <= 100.0 && node.y >= 0.0 && node.y <= 100.0;
if !is_visible {
continue;
}
let particle_color = match node.state {
ConnectionState::TimeWait | ConnectionState::CloseWait => PUMPKIN_ORANGE,
ConnectionState::Established => {
if node.latency_bucket == LatencyBucket::High {
PUMPKIN_ORANGE
} else {
TOXIC_GREEN
}
}
_ => NEON_PURPLE,
};
let particle_offsets: &[f32] =
if animation_reduced || edge_count > PARTICLE_REDUCTION_THRESHOLD {
&REDUCED_PARTICLE_OFFSETS
} else {
&PARTICLE_OFFSETS
};
for &offset in particle_offsets {
let (px, py) = particle_position(
(start_x, start_y),
(node.x, node.y),
pulse_phase,
offset,
);
ctx.print(
px,
py,
Span::styled(PARTICLE_SYMBOL, Style::default().fg(particle_color)),
);
}
}
}
draw_coffin_block(ctx, ¢er_label, overdrive_enabled, canvas_height, cx, cy);
for node in &nodes {
let icon = if overdrive_enabled {
let overdrive_icon = get_overdrive_icon(node.state, node.latency_bucket);
if node.is_heavy_talker {
format!("{}👑", overdrive_icon)
} else {
overdrive_icon.to_string()
}
} else {
node.endpoint_type.icon_with_badge(node.is_heavy_talker)
};
let color = match node.state {
ConnectionState::TimeWait | ConnectionState::CloseWait => PUMPKIN_ORANGE,
ConnectionState::Close => BLOOD_RED,
_ => node.endpoint_type.color(),
};
let icon_offset = icon.width() as f64 / 2.0;
ctx.print(
node.x - icon_offset,
node.y,
Span::styled(icon.clone(), Style::default().fg(color)),
);
if labels_enabled {
let label = format!("{} ({})", node.label, node.conn_count);
let label_offset = label.width() as f64 / 2.0;
ctx.print(
node.x - label_offset,
node.y - 4.0,
Span::styled(label, Style::default().fg(color)),
);
}
}
if is_empty {
let empty_message = match graveyard_mode {
GraveyardMode::Process => "(no active connections for this process)",
GraveyardMode::Host => "The graveyard is quiet...",
};
let msg_offset = (empty_message.width() as f64 / 2.0) * 1.2;
ctx.print(
cx - msg_offset,
cy - 5.0,
Span::styled(
empty_message,
Style::default()
.fg(BONE_WHITE)
.add_modifier(Modifier::ITALIC),
),
);
}
if hidden_endpoint_count > 0 {
let more_text = format!("... and {} more", hidden_endpoint_count);
let text_offset = (more_text.width() as f64 / 2.0) * 1.2;
ctx.print(
cx - text_offset,
8.0,
Span::styled(
more_text,
Style::default()
.fg(BONE_WHITE)
.add_modifier(Modifier::ITALIC),
),
);
}
});
f.render_widget(canvas, chunks[1]);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::LatencyConfig;
#[test]
fn test_classify_endpoint_localhost() {
assert_eq!(
classify_endpoint("127.0.0.1", false),
EndpointType::Localhost
);
assert_eq!(classify_endpoint("::1", false), EndpointType::Localhost);
assert_eq!(classify_endpoint("0.0.0.0", false), EndpointType::Localhost);
}
#[test]
fn test_classify_endpoint_rfc1918_class_a() {
assert_eq!(classify_endpoint("10.0.0.1", false), EndpointType::Private);
assert_eq!(
classify_endpoint("10.255.255.255", false),
EndpointType::Private
);
assert_eq!(
classify_endpoint("10.100.50.25", false),
EndpointType::Private
);
}
#[test]
fn test_classify_endpoint_rfc1918_class_b() {
assert_eq!(
classify_endpoint("172.16.0.1", false),
EndpointType::Private
);
assert_eq!(
classify_endpoint("172.31.255.255", false),
EndpointType::Private
);
assert_eq!(
classify_endpoint("172.20.100.50", false),
EndpointType::Private
);
assert_eq!(classify_endpoint("172.15.0.1", false), EndpointType::Public);
assert_eq!(classify_endpoint("172.32.0.1", false), EndpointType::Public);
}
#[test]
fn test_classify_endpoint_rfc1918_class_c() {
assert_eq!(
classify_endpoint("192.168.0.1", false),
EndpointType::Private
);
assert_eq!(
classify_endpoint("192.168.255.255", false),
EndpointType::Private
);
assert_eq!(
classify_endpoint("192.168.1.100", false),
EndpointType::Private
);
assert_eq!(
classify_endpoint("192.169.0.1", false),
EndpointType::Public
);
assert_eq!(
classify_endpoint("192.167.0.1", false),
EndpointType::Public
);
}
#[test]
fn test_classify_endpoint_public() {
assert_eq!(classify_endpoint("8.8.8.8", false), EndpointType::Public);
assert_eq!(classify_endpoint("1.1.1.1", false), EndpointType::Public);
assert_eq!(
classify_endpoint("203.0.113.50", false),
EndpointType::Public
);
assert_eq!(
classify_endpoint("198.51.100.1", false),
EndpointType::Public
);
}
#[test]
fn test_classify_endpoint_listen_only() {
assert_eq!(classify_endpoint("0.0.0.0", true), EndpointType::ListenOnly);
assert_eq!(
classify_endpoint("127.0.0.1", true),
EndpointType::ListenOnly
);
assert_eq!(
classify_endpoint("192.168.1.1", true),
EndpointType::ListenOnly
);
}
#[test]
fn test_endpoint_type_icons() {
assert_eq!(EndpointType::Localhost.icon(), "⚰️");
assert_eq!(EndpointType::Private.icon(), "🪦");
assert_eq!(EndpointType::Public.icon(), "🎃");
assert_eq!(EndpointType::ListenOnly.icon(), "🕯");
}
#[test]
fn test_endpoint_type_colors() {
assert_eq!(EndpointType::Localhost.color(), TOXIC_GREEN);
assert_eq!(EndpointType::Private.color(), BONE_WHITE);
assert_eq!(EndpointType::Public.color(), PUMPKIN_ORANGE);
assert_eq!(EndpointType::ListenOnly.color(), NEON_PURPLE);
}
#[test]
fn test_endpoint_type_icon_with_badge() {
assert_eq!(EndpointType::Public.icon_with_badge(false), "🎃");
assert_eq!(EndpointType::Public.icon_with_badge(true), "🎃👑");
assert_eq!(EndpointType::Private.icon_with_badge(true), "🪦👑");
}
#[test]
fn test_classify_latency_low() {
let config = LatencyConfig::default();
assert_eq!(classify_latency(Some(0), &config), LatencyBucket::Low);
assert_eq!(classify_latency(Some(25), &config), LatencyBucket::Low);
assert_eq!(classify_latency(Some(49), &config), LatencyBucket::Low);
}
#[test]
fn test_classify_latency_medium() {
let config = LatencyConfig::default();
assert_eq!(classify_latency(Some(50), &config), LatencyBucket::Medium);
assert_eq!(classify_latency(Some(100), &config), LatencyBucket::Medium);
assert_eq!(classify_latency(Some(200), &config), LatencyBucket::Medium);
}
#[test]
fn test_classify_latency_high() {
let config = LatencyConfig::default();
assert_eq!(classify_latency(Some(201), &config), LatencyBucket::High);
assert_eq!(classify_latency(Some(500), &config), LatencyBucket::High);
assert_eq!(classify_latency(Some(1000), &config), LatencyBucket::High);
}
#[test]
fn test_classify_latency_unknown() {
let config = LatencyConfig::default();
assert_eq!(classify_latency(None, &config), LatencyBucket::Unknown);
}
#[test]
fn test_classify_latency_custom_thresholds() {
let config = LatencyConfig {
low_threshold_ms: 100,
high_threshold_ms: 500,
};
assert_eq!(classify_latency(Some(50), &config), LatencyBucket::Low);
assert_eq!(classify_latency(Some(99), &config), LatencyBucket::Low);
assert_eq!(classify_latency(Some(100), &config), LatencyBucket::Medium);
assert_eq!(classify_latency(Some(300), &config), LatencyBucket::Medium);
assert_eq!(classify_latency(Some(500), &config), LatencyBucket::Medium);
assert_eq!(classify_latency(Some(501), &config), LatencyBucket::High);
}
#[test]
fn test_is_heavy_talker_top_5() {
let all_counts = vec![100, 80, 60, 40, 20, 10, 5];
assert!(is_heavy_talker(100, &all_counts));
assert!(is_heavy_talker(80, &all_counts));
assert!(is_heavy_talker(60, &all_counts));
assert!(is_heavy_talker(40, &all_counts));
assert!(is_heavy_talker(20, &all_counts));
assert!(!is_heavy_talker(10, &all_counts));
assert!(!is_heavy_talker(5, &all_counts));
}
#[test]
fn test_is_heavy_talker_fewer_than_5() {
let all_counts = vec![50, 30, 10];
assert!(is_heavy_talker(50, &all_counts));
assert!(is_heavy_talker(30, &all_counts));
assert!(is_heavy_talker(10, &all_counts));
}
#[test]
fn test_is_heavy_talker_empty() {
let all_counts: Vec<usize> = vec![];
assert!(!is_heavy_talker(10, &all_counts));
}
#[test]
fn test_is_heavy_talker_zero_count() {
let all_counts = vec![10, 5, 0, 0, 0];
assert!(!is_heavy_talker(0, &all_counts));
assert!(is_heavy_talker(10, &all_counts));
assert!(is_heavy_talker(5, &all_counts));
}
#[test]
fn test_is_heavy_talker_ties() {
let all_counts = vec![100, 50, 50, 50, 50, 10];
assert!(is_heavy_talker(100, &all_counts));
assert!(is_heavy_talker(50, &all_counts));
assert!(!is_heavy_talker(10, &all_counts));
}
#[test]
fn test_particle_position_at_start() {
let start = (50.0, 50.0);
let end = (80.0, 30.0);
let pos = particle_position(start, end, 0.0, 0.0);
assert!((pos.0 - 50.0).abs() < 0.001);
assert!((pos.1 - 50.0).abs() < 0.001);
}
#[test]
fn test_particle_position_at_middle() {
let start = (50.0, 50.0);
let end = (80.0, 30.0);
let pos = particle_position(start, end, 0.5, 0.0);
assert!((pos.0 - 65.0).abs() < 0.001);
assert!((pos.1 - 40.0).abs() < 0.001);
}
#[test]
fn test_particle_position_at_end() {
let start = (50.0, 50.0);
let end = (80.0, 30.0);
let pos = particle_position(start, end, 1.0, 0.0);
assert!((pos.0 - 50.0).abs() < 0.001);
assert!((pos.1 - 50.0).abs() < 0.001);
}
#[test]
fn test_particle_position_with_offset() {
let start = (0.0, 0.0);
let end = (100.0, 100.0);
let pos = particle_position(start, end, 0.0, 0.33);
assert!((pos.0 - 33.0).abs() < 0.001);
assert!((pos.1 - 33.0).abs() < 0.001);
let pos = particle_position(start, end, 0.0, 0.66);
assert!((pos.0 - 66.0).abs() < 0.001);
assert!((pos.1 - 66.0).abs() < 0.001);
}
#[test]
fn test_particle_position_wrapping() {
let start = (0.0, 0.0);
let end = (100.0, 0.0);
let pos = particle_position(start, end, 0.8, 0.33);
let expected_t = (0.8 + 0.33) % 1.0;
assert!((pos.0 - expected_t * 100.0).abs() < 0.001);
}
#[test]
fn test_calculate_endpoint_position_ring_selection() {
let layout = LayoutConfig::default();
let (x_low, y_low) = calculate_endpoint_position(0, 1, LatencyBucket::Low, &layout);
let (x_med, y_med) = calculate_endpoint_position(0, 1, LatencyBucket::Medium, &layout);
let (x_high, y_high) = calculate_endpoint_position(0, 1, LatencyBucket::High, &layout);
let dist_low = ((x_low - 50.0).powi(2) + (y_low - 50.0).powi(2)).sqrt();
let dist_med = ((x_med - 50.0).powi(2) + (y_med - 50.0).powi(2)).sqrt();
let dist_high = ((x_high - 50.0).powi(2) + (y_high - 50.0).powi(2)).sqrt();
assert!(dist_low < dist_med);
assert!(dist_med < dist_high);
}
#[test]
fn test_calculate_endpoint_position_unknown_fallback() {
let layout = LayoutConfig::default();
let (x_unknown, y_unknown) =
calculate_endpoint_position(0, 1, LatencyBucket::Unknown, &layout);
let (x_medium, y_medium) =
calculate_endpoint_position(0, 1, LatencyBucket::Medium, &layout);
let dist_unknown = ((x_unknown - 50.0).powi(2) + (y_unknown - 50.0).powi(2)).sqrt();
let dist_medium = ((x_medium - 50.0).powi(2) + (y_medium - 50.0).powi(2)).sqrt();
assert!((dist_unknown - dist_medium).abs() < 5.0);
}
#[test]
fn test_calculate_endpoint_position_bounds() {
let layout = LayoutConfig::default();
for i in 0..10 {
for bucket in [
LatencyBucket::Low,
LatencyBucket::Medium,
LatencyBucket::High,
] {
let (x, y) = calculate_endpoint_position(i, 10, bucket, &layout);
assert!(
x >= layout.edge_padding && x <= 100.0 - layout.edge_padding,
"x={} out of bounds for padding={}",
x,
layout.edge_padding
);
assert!(
y >= layout.edge_padding && y <= 100.0 - layout.edge_padding,
"y={} out of bounds for padding={}",
y,
layout.edge_padding
);
}
}
}
#[test]
fn test_calculate_endpoint_position_ring_ordering() {
let layout = LayoutConfig::default();
let (x_low, y_low) = calculate_endpoint_position(0, 1, LatencyBucket::Low, &layout);
let (x_high, y_high) = calculate_endpoint_position(0, 1, LatencyBucket::High, &layout);
let dist_low = ((x_low - 50.0).powi(2) + (y_low - 50.0).powi(2)).sqrt();
let dist_high = ((x_high - 50.0).powi(2) + (y_high - 50.0).powi(2)).sqrt();
assert!(
dist_low < dist_high,
"Low ring should be closer than high ring"
);
}
#[test]
fn test_has_latency_data() {
let nodes_with_data = vec![EndpointNode {
label: "test".to_string(),
x: 50.0,
y: 50.0,
state: ConnectionState::Established,
conn_count: 1,
latency_bucket: LatencyBucket::Low,
endpoint_type: EndpointType::Public,
is_heavy_talker: false,
}];
assert!(has_latency_data(&nodes_with_data));
let nodes_without_data = vec![EndpointNode {
label: "test".to_string(),
x: 50.0,
y: 50.0,
state: ConnectionState::Established,
conn_count: 1,
latency_bucket: LatencyBucket::Unknown,
endpoint_type: EndpointType::Public,
is_heavy_talker: false,
}];
assert!(!has_latency_data(&nodes_without_data));
let empty_nodes: Vec<EndpointNode> = vec![];
assert!(!has_latency_data(&empty_nodes));
}
#[test]
fn large_coffin_shape_is_stable() {
let coffin = build_large_coffin("HOST");
assert_eq!(coffin.variant, CoffinVariant::Large);
assert_eq!(coffin.height, 4, "Large coffin must be exactly 4 lines");
assert_eq!(
coffin.width, LARGE_COFFIN_WIDTH,
"Large coffin width must match constant"
);
assert_eq!(coffin.lines.len(), 4);
for (i, line) in coffin.lines.iter().enumerate() {
assert_eq!(
line.chars().count(),
14,
"Line {} must be exactly 14 chars",
i
);
}
assert_eq!(
coffin.lines[0], " /‾‾‾‾‾‾\\ ",
"Line 0 (top) must match exactly"
);
assert!(coffin.lines[1].contains("HOST"), "Line 1 must contain HOST");
assert!(
coffin.lines[1].starts_with(" /"),
"Line 1 must start with ' /'"
);
assert!(
coffin.lines[1].ends_with("\\ "),
"Line 1 must end with '\\ '"
);
assert_eq!(
coffin.lines[2], " \\ / ",
"Line 2 must match exactly"
);
assert_eq!(
coffin.lines[3], " \\______/ ",
"Line 3 (bottom) must match exactly"
);
}
#[test]
fn mid_coffin_shape_is_stable() {
let coffin = build_mid_coffin("HOST");
assert_eq!(coffin.variant, CoffinVariant::Mid);
assert_eq!(coffin.height, 3, "Mid coffin must be exactly 3 lines");
assert_eq!(
coffin.width, MID_COFFIN_WIDTH,
"Mid coffin width must match constant"
);
assert_eq!(coffin.lines.len(), 3);
for (i, line) in coffin.lines.iter().enumerate() {
assert_eq!(
line.chars().count(),
11,
"Line {} must be exactly 11 chars",
i
);
}
assert_eq!(
coffin.lines[0], " /‾‾‾‾‾‾\\ ",
"Line 0 (top) must match exactly"
);
assert!(coffin.lines[1].contains("HOST"), "Line 1 must contain HOST");
assert!(
coffin.lines[1].starts_with("/"),
"Line 1 must start with '/'"
);
assert!(coffin.lines[1].ends_with(" "), "Line 1 must end with space");
assert_eq!(
coffin.lines[2], " \\______/ ",
"Line 2 (bottom) must match exactly"
);
}
#[test]
fn label_coffin_format_is_stable() {
let coffin = build_label_coffin("HOST", 20);
assert_eq!(coffin.variant, CoffinVariant::Label);
assert_eq!(coffin.height, 1, "Label coffin must be exactly 1 line");
assert_eq!(coffin.lines.len(), 1);
assert_eq!(coffin.lines[0], "[⚰ HOST]", "Label format must be [⚰ HOST]");
}
#[test]
fn test_coffin_name_truncation() {
let coffin = build_large_coffin("kafka-broker-1");
let has_truncated = coffin.lines.iter().any(|line| line.contains(".."));
assert!(has_truncated, "Long name should be truncated with ..");
let host_line = &coffin.lines[1];
assert_eq!(
host_line.chars().count(),
LARGE_COFFIN_WIDTH,
"Truncated name line must maintain coffin width"
);
}
#[test]
fn test_coffin_graceful_degradation() {
let large = choose_coffin_variant(100.0, 100.0, "TEST");
assert_eq!(
large.variant,
CoffinVariant::Large,
"Large canvas should use Large coffin"
);
let mid = choose_coffin_variant(13.0, 16.0, "TEST");
assert_eq!(
mid.variant,
CoffinVariant::Mid,
"Medium canvas should use Mid coffin"
);
let label = choose_coffin_variant(10.0, 4.0, "TEST");
assert_eq!(
label.variant,
CoffinVariant::Label,
"Small canvas should use Label coffin"
);
}
#[test]
fn test_coffin_dimensions_are_fixed() {
let large = build_large_coffin("X");
assert_eq!(large.width, LARGE_COFFIN_WIDTH);
assert_eq!(large.height, LARGE_COFFIN_HEIGHT);
assert_eq!(large.width, 14, "Large coffin width constant must be 14");
assert_eq!(large.height, 4, "Large coffin height constant must be 4");
let mid = build_mid_coffin("X");
assert_eq!(mid.width, MID_COFFIN_WIDTH);
assert_eq!(mid.height, MID_COFFIN_HEIGHT);
assert_eq!(mid.width, 11, "Mid coffin width constant must be 11");
assert_eq!(mid.height, 3, "Mid coffin height constant must be 3");
}
#[test]
fn test_coffin_exclusion_radius() {
let large_radius = coffin_exclusion_radius(CoffinVariant::Large);
let mid_radius = coffin_exclusion_radius(CoffinVariant::Mid);
let label_radius = coffin_exclusion_radius(CoffinVariant::Label);
assert!(
large_radius > mid_radius,
"Large coffin needs larger exclusion"
);
assert!(
mid_radius > label_radius,
"Mid coffin needs larger exclusion than Label"
);
assert_eq!(large_radius, 20.0, "Large coffin exclusion radius");
assert_eq!(mid_radius, 16.0, "Mid coffin exclusion radius");
assert_eq!(label_radius, 10.0, "Label coffin exclusion radius");
}
#[test]
fn test_truncate_host_name() {
assert_eq!(truncate_host_name("HOST", 10), "HOST");
assert_eq!(truncate_host_name("kafka-broker-1", 6), "kafk..");
assert_eq!(truncate_host_name("AB", 2), "AB");
assert_eq!(truncate_host_name("ABCD", 3), "ABC"); assert_eq!(truncate_host_name("ABCDEF", 4), "AB..");
}
#[test]
fn test_center_pad() {
assert_eq!(center_pad("X", 5), " X ");
assert_eq!(center_pad("AB", 6), " AB ");
assert_eq!(center_pad("HOST", 6), " HOST ");
assert_eq!(center_pad("TOOLONG", 4), "TOOLONG"); assert_eq!(center_pad("ABC", 4), "ABC "); }
#[test]
fn test_coffin_with_various_hostnames() {
let short = build_large_coffin("DB");
assert!(
short.lines[1].contains("DB"),
"Short name should be visible"
);
let exact = build_large_coffin("KAFKA1");
assert!(
exact.lines[1].contains("KAFKA1"),
"Exact fit name should be visible"
);
let long = build_large_coffin("very-long-hostname");
assert!(
long.lines[1].contains(".."),
"Long name should be truncated"
);
assert!(
!long.lines[1].contains("very-long"),
"Full long name should not appear"
);
}
#[test]
fn test_label_coffin_width_constraint() {
let narrow = build_label_coffin("kafka-broker-1", 10);
assert!(narrow.width <= 10, "Label should respect max_width");
let wide = build_label_coffin("kafka-broker-1", 30);
assert!(
wide.lines[0].len() > narrow.lines[0].len(),
"Wider constraint should show more of the name"
);
}
}