Add reverse DNS hostnames to Details tab and filter PTR traffic (#104)

* feat: add reverse DNS resolution for IP addresses

- Add --resolve-dns flag to enable background DNS resolution
- Add --show-ptr-lookups flag to show/hide PTR lookup connections
- Create dns.rs module with async resolver and LRU cache
- Display hostnames in UI with 'd' key toggle
- Include hostname fields in JSON log output when resolved

Closes #97
This commit is contained in:
Marco Cadetg
2025-12-21 14:29:12 +01:00
committed by GitHub
parent 37486111c4
commit 844f82ce60
9 changed files with 549 additions and 10 deletions

View File

@@ -15,12 +15,13 @@ use crate::filter::ConnectionFilter;
use crate::network::{
capture::{CaptureConfig, PacketReader, setup_packet_capture},
dns::DnsResolver,
interface_stats::{InterfaceRates, InterfaceStats, InterfaceStatsProvider},
merge::{create_connection_from_packet, merge_packet_into_connection},
parser::{PacketParser, ParsedPacket, ParserConfig},
platform::create_process_lookup,
services::ServiceLookup,
types::{ApplicationProtocol, Connection, ConnectionKey, Protocol, RttTracker, TrafficHistory},
types::{ApplicationProtocol, Connection, ConnectionKey, DnsQueryType, Protocol, RttTracker, TrafficHistory},
};
// Platform-specific interface stats provider
@@ -63,6 +64,7 @@ fn log_connection_event(
event_type: &str,
conn: &Connection,
duration_secs: Option<u64>,
dns_resolver: Option<&DnsResolver>,
) {
// Build JSON object based on event type
let mut event = json!({
@@ -75,6 +77,16 @@ fn log_connection_event(
"destination_port": conn.remote_addr.port(),
});
// Add hostname fields if DNS resolution is enabled and hostnames are resolved
if let Some(resolver) = dns_resolver {
if let Some(hostname) = resolver.get_hostname(&conn.remote_addr.ip()) {
event["destination_hostname"] = json!(hostname);
}
if let Some(hostname) = resolver.get_hostname(&conn.local_addr.ip()) {
event["source_hostname"] = json!(hostname);
}
}
// Add process information if available
if let Some(pid) = conn.pid {
event["pid"] = json!(pid);
@@ -162,6 +174,10 @@ pub struct Config {
pub bpf_filter: Option<String>,
/// JSON log file path for connection events
pub json_log_file: Option<String>,
/// Enable reverse DNS resolution for IP addresses
pub resolve_dns: bool,
/// Show PTR lookup connections in UI (when DNS resolution is enabled)
pub show_ptr_lookups: bool,
}
impl Default for Config {
@@ -173,6 +189,8 @@ impl Default for Config {
enable_dpi: true,
bpf_filter: None, // No filter by default to see all packets
json_log_file: None,
resolve_dns: false,
show_ptr_lookups: false,
}
}
}
@@ -248,6 +266,9 @@ pub struct App {
/// RTT tracker for latency measurement
rtt_tracker: Arc<Mutex<RttTracker>>,
/// DNS resolver for reverse DNS lookups
dns_resolver: Option<Arc<DnsResolver>>,
/// Sandbox status (Linux Landlock)
#[cfg(target_os = "linux")]
sandbox_info: Arc<RwLock<SandboxInfo>>,
@@ -262,6 +283,14 @@ impl App {
ServiceLookup::with_defaults()
});
// Initialize DNS resolver if enabled
let dns_resolver = if config.resolve_dns {
info!("DNS resolution enabled - starting background resolver");
Some(Arc::new(DnsResolver::with_defaults()))
} else {
None
};
Ok(Self {
config,
should_stop: Arc::new(AtomicBool::new(false)),
@@ -277,6 +306,7 @@ impl App {
interface_rates: Arc::new(DashMap::new()),
traffic_history: Arc::new(RwLock::new(TrafficHistory::new(60))), // 60 seconds of history
rtt_tracker: Arc::new(Mutex::new(RttTracker::new())),
dns_resolver,
#[cfg(target_os = "linux")]
sandbox_info: Arc::new(RwLock::new(SandboxInfo::default())),
})
@@ -481,6 +511,7 @@ impl App {
let linktype_storage = Arc::clone(&self.linktype);
let json_log_path = self.config.json_log_file.clone();
let rtt_tracker = Arc::clone(&self.rtt_tracker);
let dns_resolver = self.dns_resolver.clone();
let parser_config = ParserConfig {
enable_dpi: self.config.enable_dpi,
..Default::default()
@@ -527,6 +558,7 @@ impl App {
&stats,
&json_log_path,
&rtt_tracker,
dns_resolver.as_deref(),
);
parsed_count += 1;
}
@@ -978,6 +1010,7 @@ impl App {
fn start_cleanup_thread(&self, connections: Arc<DashMap<String, Connection>>) -> Result<()> {
let should_stop = Arc::clone(&self.should_stop);
let json_log_path = self.config.json_log_file.clone();
let dns_resolver = self.dns_resolver.clone();
thread::spawn(move || {
info!("Cleanup thread started");
@@ -1011,7 +1044,13 @@ impl App {
// Log connection_closed event if JSON logging is enabled
if let Some(log_path) = &json_log_path {
log_connection_event(log_path, "connection_closed", conn, duration_secs);
log_connection_event(
log_path,
"connection_closed",
conn,
duration_secs,
dns_resolver.as_deref(),
);
}
// Log cleanup reason for debugging
@@ -1057,13 +1096,34 @@ impl App {
/// Get current connections for UI display
pub fn get_connections(&self) -> Vec<Connection> {
self.connections_snapshot.read().unwrap().clone()
self.get_filtered_connections("")
}
/// Get filtered connections for UI display
pub fn get_filtered_connections(&self, filter_query: &str) -> Vec<Connection> {
let connections = self.connections_snapshot.read().unwrap().clone();
// Filter out DNS PTR queries/responses when reverse DNS is enabled
let hide_ptr_lookups = self.dns_resolver.is_some() && !self.config.show_ptr_lookups;
let connections: Vec<Connection> = if hide_ptr_lookups {
connections
.into_iter()
.filter(|conn| {
// Hide DNS PTR queries/responses (used for reverse DNS lookups)
if let Some(ref dpi) = conn.dpi_info
&& let ApplicationProtocol::Dns(ref dns_info) = dpi.application
&& dns_info.query_type == Some(DnsQueryType::PTR)
{
return false;
}
true
})
.collect()
} else {
connections
};
if filter_query.trim().is_empty() {
return connections;
}
@@ -1181,6 +1241,16 @@ impl App {
(String::from("Unknown"), false)
}
/// Get the DNS resolver if enabled
pub fn get_dns_resolver(&self) -> Option<Arc<DnsResolver>> {
self.dns_resolver.clone()
}
/// Check if DNS resolution is enabled
pub fn is_dns_resolution_enabled(&self) -> bool {
self.dns_resolver.is_some()
}
/// Stop all threads gracefully
pub fn stop(&self) {
info!("Stopping application");
@@ -1195,6 +1265,7 @@ fn update_connection(
_stats: &AppStats,
json_log_path: &Option<String>,
rtt_tracker: &Arc<Mutex<RttTracker>>,
dns_resolver: Option<&DnsResolver>,
) {
let mut key = parsed.connection_key.clone();
let now = SystemTime::now();
@@ -1286,7 +1357,7 @@ fn update_connection(
// Log new connection event if JSON logging is enabled
if let Some(log_path) = json_log_path {
log_connection_event(log_path, "new_connection", &conn, None);
log_connection_event(log_path, "new_connection", &conn, None, dns_resolver);
}
conn

View File

@@ -76,6 +76,18 @@ pub fn build_cli() -> Command {
.value_name("FILTER")
.help(BPF_HELP)
.required(false),
)
.arg(
Arg::new("resolve-dns")
.long("resolve-dns")
.help("Enable reverse DNS resolution for IP addresses (shows hostnames instead of IPs)")
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new("show-ptr-lookups")
.long("show-ptr-lookups")
.help("Show PTR lookup connections in UI (hidden by default when --resolve-dns is enabled)")
.action(clap::ArgAction::SetTrue),
);
#[cfg(target_os = "linux")]

View File

@@ -75,6 +75,16 @@ fn main() -> Result<()> {
}
}
if matches.get_flag("resolve-dns") {
config.resolve_dns = true;
info!("Reverse DNS resolution enabled");
}
if matches.get_flag("show-ptr-lookups") {
config.show_ptr_lookups = true;
info!("PTR lookup connections will be shown in UI");
}
// Set up terminal
let backend = CrosstermBackend::new(io::stdout());
let mut terminal = ui::setup_terminal(backend)?;
@@ -556,6 +566,22 @@ fn run_ui_loop<B: ratatui::prelude::Backend>(
);
}
// Toggle hostname display (when DNS resolution is enabled)
(KeyCode::Char('d'), _) => {
if app.is_dns_resolution_enabled() {
ui_state.quit_confirmation = false;
ui_state.show_hostnames = !ui_state.show_hostnames;
info!(
"Toggled hostname display: {}",
if ui_state.show_hostnames {
"showing hostnames"
} else {
"showing IP addresses"
}
);
}
}
// Cycle sort column with 's'
(KeyCode::Char('s'), KeyModifiers::NONE) => {
ui_state.quit_confirmation = false;

352
src/network/dns.rs Normal file
View File

@@ -0,0 +1,352 @@
//! DNS resolver with background async resolution and caching.
//!
//! Provides non-blocking reverse DNS lookups with an LRU cache to avoid
//! repeated lookups for the same IP address.
use crossbeam::channel::{self, Receiver, Sender};
use dashmap::DashMap;
use dns_lookup::lookup_addr;
use log::debug;
use std::net::IpAddr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
/// Resolution state for a cached entry
#[derive(Debug, Clone, PartialEq)]
pub enum ResolutionState {
/// Resolution is in progress
Pending,
/// Resolution succeeded
Resolved,
/// Resolution failed
Failed,
}
/// Cached hostname entry
#[derive(Debug, Clone)]
pub struct CachedHostname {
/// The resolved hostname, if successful
pub hostname: Option<String>,
/// When this entry was resolved
pub resolved_at: Instant,
/// Current resolution state
pub state: ResolutionState,
}
impl CachedHostname {
fn pending() -> Self {
Self {
hostname: None,
resolved_at: Instant::now(),
state: ResolutionState::Pending,
}
}
fn resolved(hostname: String) -> Self {
Self {
hostname: Some(hostname),
resolved_at: Instant::now(),
state: ResolutionState::Resolved,
}
}
fn failed() -> Self {
Self {
hostname: None,
resolved_at: Instant::now(),
state: ResolutionState::Failed,
}
}
}
/// Configuration for DNS resolver
#[derive(Debug, Clone)]
pub struct DnsResolverConfig {
/// Cache TTL for resolved hostnames (default: 5 minutes)
pub cache_ttl: Duration,
/// Cache TTL for failed lookups (default: 1 minute)
pub negative_cache_ttl: Duration,
/// Maximum cache size (default: 10000 entries)
pub max_cache_size: usize,
/// Number of resolver threads (default: 4)
pub resolver_threads: usize,
}
impl Default for DnsResolverConfig {
fn default() -> Self {
Self {
cache_ttl: Duration::from_secs(300), // 5 minutes
negative_cache_ttl: Duration::from_secs(60), // 1 minute
max_cache_size: 10000,
resolver_threads: 4,
}
}
}
/// Background DNS resolver with caching
pub struct DnsResolver {
/// Hostname cache: IP -> CachedHostname
cache: Arc<DashMap<IpAddr, CachedHostname>>,
/// Channel to send IPs for resolution
request_tx: Sender<IpAddr>,
/// Control flag for shutdown
should_stop: Arc<AtomicBool>,
/// Configuration
config: DnsResolverConfig,
}
impl DnsResolver {
/// Create a new DNS resolver with the given configuration
pub fn new(config: DnsResolverConfig) -> Self {
let cache = Arc::new(DashMap::new());
let (request_tx, request_rx) = channel::unbounded();
let should_stop = Arc::new(AtomicBool::new(false));
let resolver = Self {
cache: Arc::clone(&cache),
request_tx,
should_stop: Arc::clone(&should_stop),
config: config.clone(),
};
// Start resolver threads
resolver.start_resolver_threads(request_rx, &config);
// Start cache cleanup thread
resolver.start_cache_cleanup_thread();
resolver
}
/// Create a new DNS resolver with default configuration
pub fn with_defaults() -> Self {
Self::new(DnsResolverConfig::default())
}
/// Start background resolver threads
fn start_resolver_threads(&self, request_rx: Receiver<IpAddr>, config: &DnsResolverConfig) {
let num_threads = config.resolver_threads;
for i in 0..num_threads {
let rx = request_rx.clone();
let cache = Arc::clone(&self.cache);
let should_stop = Arc::clone(&self.should_stop);
let cache_ttl = config.cache_ttl;
let negative_cache_ttl = config.negative_cache_ttl;
thread::Builder::new()
.name(format!("dns-resolver-{}", i))
.spawn(move || {
debug!("DNS resolver thread {} started", i);
while !should_stop.load(Ordering::Relaxed) {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(ip) => {
// Skip if already resolved or pending
if let Some(entry) = cache.get(&ip) {
let age = entry.resolved_at.elapsed();
match entry.state {
ResolutionState::Pending => continue,
ResolutionState::Resolved if age < cache_ttl => continue,
ResolutionState::Failed if age < negative_cache_ttl => {
continue
}
_ => {} // Expired, re-resolve
}
}
// Mark as pending
cache.insert(ip, CachedHostname::pending());
// Perform DNS lookup
match lookup_addr(&ip) {
Ok(hostname) => {
debug!("Resolved {} -> {}", ip, hostname);
cache.insert(ip, CachedHostname::resolved(hostname));
}
Err(e) => {
debug!("Failed to resolve {}: {}", ip, e);
cache.insert(ip, CachedHostname::failed());
}
}
}
Err(crossbeam::channel::RecvTimeoutError::Timeout) => continue,
Err(crossbeam::channel::RecvTimeoutError::Disconnected) => break,
}
}
debug!("DNS resolver thread {} stopping", i);
})
.expect("Failed to spawn DNS resolver thread");
}
}
/// Start cache cleanup thread to evict expired entries
fn start_cache_cleanup_thread(&self) {
let cache = Arc::clone(&self.cache);
let should_stop = Arc::clone(&self.should_stop);
let cache_ttl = self.config.cache_ttl;
let negative_cache_ttl = self.config.negative_cache_ttl;
let max_cache_size = self.config.max_cache_size;
thread::Builder::new()
.name("dns-cache-cleanup".to_string())
.spawn(move || {
debug!("DNS cache cleanup thread started");
while !should_stop.load(Ordering::Relaxed) {
thread::sleep(Duration::from_secs(30)); // Cleanup every 30 seconds
if should_stop.load(Ordering::Relaxed) {
break;
}
// Remove expired entries
cache.retain(|_, entry| {
let age = entry.resolved_at.elapsed();
match entry.state {
ResolutionState::Resolved => age < cache_ttl,
ResolutionState::Failed => age < negative_cache_ttl,
ResolutionState::Pending => age < Duration::from_secs(30), // Timeout pending
}
});
// If cache is too large, remove oldest entries
if cache.len() > max_cache_size {
let mut entries: Vec<_> = cache
.iter()
.map(|e| (*e.key(), e.resolved_at))
.collect();
entries.sort_by_key(|(_, time)| *time);
let to_remove = cache.len() - max_cache_size;
for (ip, _) in entries.into_iter().take(to_remove) {
cache.remove(&ip);
}
}
debug!("DNS cache size: {}", cache.len());
}
debug!("DNS cache cleanup thread stopping");
})
.expect("Failed to spawn DNS cache cleanup thread");
}
/// Request resolution for an IP address (non-blocking)
pub fn request_resolution(&self, ip: IpAddr) {
// Don't resolve localhost or link-local
if ip.is_loopback() || is_link_local(&ip) {
return;
}
// Check if already in cache and not expired
if let Some(entry) = self.cache.get(&ip) {
let age = entry.resolved_at.elapsed();
match entry.state {
ResolutionState::Pending => return,
ResolutionState::Resolved if age < self.config.cache_ttl => return,
ResolutionState::Failed if age < self.config.negative_cache_ttl => return,
_ => {} // Expired
}
}
// Queue for resolution (ignore send errors - channel is unbounded)
let _ = self.request_tx.send(ip);
}
/// Get hostname for IP if resolved, otherwise return None
pub fn get_hostname(&self, ip: &IpAddr) -> Option<String> {
// Request resolution if not in cache
self.request_resolution(*ip);
// Return cached hostname if available
self.cache.get(ip).and_then(|entry| {
if entry.state == ResolutionState::Resolved {
entry.hostname.clone()
} else {
None
}
})
}
/// Stop the resolver
pub fn stop(&self) {
self.should_stop.store(true, Ordering::Relaxed);
}
}
impl Drop for DnsResolver {
fn drop(&mut self) {
self.stop();
}
}
/// Check if IP is link-local
fn is_link_local(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => v4.is_link_local(),
IpAddr::V6(v6) => {
// fe80::/10
let segments = v6.segments();
(segments[0] & 0xffc0) == 0xfe80
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cached_hostname_states() {
let pending = CachedHostname::pending();
assert_eq!(pending.state, ResolutionState::Pending);
assert!(pending.hostname.is_none());
let resolved = CachedHostname::resolved("example.com".to_string());
assert_eq!(resolved.state, ResolutionState::Resolved);
assert_eq!(resolved.hostname, Some("example.com".to_string()));
let failed = CachedHostname::failed();
assert_eq!(failed.state, ResolutionState::Failed);
assert!(failed.hostname.is_none());
}
#[test]
fn test_link_local_detection() {
// IPv4 link-local
assert!(is_link_local(&"169.254.1.1".parse().unwrap()));
assert!(!is_link_local(&"192.168.1.1".parse().unwrap()));
// IPv6 link-local
assert!(is_link_local(&"fe80::1".parse().unwrap()));
assert!(!is_link_local(&"2001:db8::1".parse().unwrap()));
}
#[test]
fn test_loopback_skip() {
let config = DnsResolverConfig {
resolver_threads: 1,
..Default::default()
};
let resolver = DnsResolver::new(config);
// Loopback should not be queued
resolver.request_resolution("127.0.0.1".parse().unwrap());
assert!(resolver.get_hostname(&"127.0.0.1".parse().unwrap()).is_none());
resolver.stop();
}
#[test]
fn test_default_config() {
let config = DnsResolverConfig::default();
assert_eq!(config.cache_ttl, Duration::from_secs(300));
assert_eq!(config.negative_cache_ttl, Duration::from_secs(60));
assert_eq!(config.max_cache_size, 10000);
assert_eq!(config.resolver_threads, 4);
}
}

View File

@@ -1,4 +1,5 @@
pub mod capture;
pub mod dns;
pub mod dpi;
pub mod interface_stats;
pub mod link_layer;

View File

@@ -12,6 +12,7 @@ use ratatui::{
};
use crate::app::{App, AppStats};
use crate::network::dns::DnsResolver;
use crate::network::types::{
AppProtocolDistribution, Connection, Protocol, ProtocolState, TcpState, TrafficHistory,
};
@@ -122,6 +123,8 @@ pub struct UIState {
pub show_port_numbers: bool,
pub sort_column: SortColumn,
pub sort_ascending: bool,
/// Show hostnames instead of IP addresses (when DNS resolution is enabled)
pub show_hostnames: bool,
}
impl Default for UIState {
@@ -138,6 +141,7 @@ impl Default for UIState {
show_port_numbers: false,
sort_column: SortColumn::default(),
sort_ascending: true, // Default to ascending
show_hostnames: true, // Show hostnames by default when DNS resolution is enabled
}
}
}
@@ -410,7 +414,10 @@ pub fn draw(
match ui_state.selected_tab {
0 => draw_overview(f, ui_state, connections, stats, app, content_area)?,
1 => draw_connection_details(f, ui_state, connections, content_area)?,
1 => {
let dns_resolver = app.get_dns_resolver();
draw_connection_details(f, ui_state, connections, content_area, dns_resolver.as_deref())?
}
2 => draw_interface_stats(f, app, content_area)?,
3 => draw_graph_tab(f, app, connections, content_area)?,
4 => draw_help(f, content_area)?,
@@ -467,7 +474,9 @@ fn draw_overview(
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
.split(area);
draw_connections_list(f, ui_state, connections, chunks[0]);
// Get DNS resolver from app if enabled
let dns_resolver = app.get_dns_resolver();
draw_connections_list(f, ui_state, connections, chunks[0], dns_resolver.as_deref());
draw_stats_panel(f, connections, stats, app, chunks[1])?;
Ok(())
@@ -479,11 +488,15 @@ fn draw_connections_list(
ui_state: &UIState,
connections: &[Connection],
area: Rect,
dns_resolver: Option<&DnsResolver>,
) {
// When DNS resolution is enabled, we need more space for hostnames
let remote_addr_width = if dns_resolver.is_some() && ui_state.show_hostnames { 30 } else { 21 };
let widths = [
Constraint::Length(6), // Protocol (TCP/UDP + arrow = "Pro ↑" = 5 chars, give 6 for padding)
Constraint::Length(17), // Local Address (13 + arrow = 15, fits in 17)
Constraint::Length(21), // Remote Address (14 + arrow = 16, fits in 21)
Constraint::Length(remote_addr_width), // Remote Address - wider when showing hostnames
Constraint::Length(16), // State (5 + arrow = 7, fits in 16)
Constraint::Length(10), // Service (7 + arrow = 9, need at least 10 for padding)
Constraint::Length(24), // DPI/Application (18 + arrow = 20, fits in 24)
@@ -659,10 +672,33 @@ fn draw_connections_list(
Style::default()
};
// Format addresses - use hostnames when DNS resolution is enabled and show_hostnames is true
let local_addr_display = conn.local_addr.to_string();
let remote_addr_display = if ui_state.show_hostnames {
if let Some(resolver) = dns_resolver {
if let Some(hostname) = resolver.get_hostname(&conn.remote_addr.ip()) {
// Truncate hostname if too long, but always show port
let port = conn.remote_addr.port();
let max_hostname_len = (remote_addr_width as usize).saturating_sub(7); // Leave room for :port
if hostname.len() > max_hostname_len {
format!("{}...:{}", &hostname[..max_hostname_len.saturating_sub(3)], port)
} else {
format!("{}:{}", hostname, port)
}
} else {
conn.remote_addr.to_string()
}
} else {
conn.remote_addr.to_string()
}
} else {
conn.remote_addr.to_string()
};
let cells = [
Cell::from(conn.protocol.to_string()),
Cell::from(conn.local_addr.to_string()),
Cell::from(conn.remote_addr.to_string()),
Cell::from(local_addr_display),
Cell::from(remote_addr_display),
Cell::from(conn.state()),
Cell::from(service_display),
Cell::from(dpi_display),
@@ -1664,6 +1700,7 @@ fn draw_connection_details(
ui_state: &UIState,
connections: &[Connection],
area: Rect,
dns_resolver: Option<&DnsResolver>,
) -> Result<()> {
if connections.is_empty() {
let text = Paragraph::new("No connections available")
@@ -1722,6 +1759,24 @@ fn draw_connection_details(
]),
];
// Add reverse DNS hostnames if available
if let Some(resolver) = dns_resolver {
let local_hostname = resolver.get_hostname(&conn.local_addr.ip());
let remote_hostname = resolver.get_hostname(&conn.remote_addr.ip());
if local_hostname.is_some() || remote_hostname.is_some() {
details_text.push(Line::from("")); // Empty line separator
details_text.push(Line::from(vec![
Span::styled("Local Hostname: ", Style::default().fg(Color::Yellow)),
Span::raw(local_hostname.unwrap_or_else(|| "-".to_string())),
]));
details_text.push(Line::from(vec![
Span::styled("Remote Hostname: ", Style::default().fg(Color::Yellow)),
Span::raw(remote_hostname.unwrap_or_else(|| "-".to_string())),
]));
}
}
// Add DPI information
match &conn.dpi_info {
Some(dpi) => {
@@ -2022,6 +2077,10 @@ fn draw_help(f: &mut Frame, area: Rect) -> Result<()> {
Span::styled("p ", Style::default().fg(Color::Yellow)),
Span::raw("Toggle between service names and port numbers"),
]),
Line::from(vec![
Span::styled("d ", Style::default().fg(Color::Yellow)),
Span::raw("Toggle between hostnames and IP addresses (when --resolve-dns)"),
]),
Line::from(vec![
Span::styled("s ", Style::default().fg(Color::Yellow)),
Span::raw("Cycle through sort columns (Bandwidth, Process, etc.)"),