From 83ec5c6fc1348177a857a390c99d5b641ac58f4a Mon Sep 17 00:00:00 2001 From: perfectra1n Date: Sun, 18 Jan 2026 12:21:59 -0800 Subject: [PATCH 1/4] feat: provide group-level hotkey features and overview --- crates/talos-pilot-tui/src/action.rs | 12 + crates/talos-pilot-tui/src/app.rs | 225 ++++++++ .../talos-pilot-tui/src/components/cluster.rs | 457 ++++++++++++++- .../src/components/diagnostics/mod.rs | 480 +++++++++++++++- .../src/components/multi_logs.rs | 514 +++++++++++++++-- .../talos-pilot-tui/src/components/network.rs | 501 ++++++++++++++++- .../src/components/processes.rs | 414 ++++++++++++-- .../talos-pilot-tui/src/components/storage.rs | 531 +++++++++++++++++- 8 files changed, 3004 insertions(+), 130 deletions(-) diff --git a/crates/talos-pilot-tui/src/action.rs b/crates/talos-pilot-tui/src/action.rs index 26ef1f3..4765dc8 100644 --- a/crates/talos-pilot-tui/src/action.rs +++ b/crates/talos-pilot-tui/src/action.rs @@ -27,6 +27,8 @@ pub enum Action { ShowNodeDetails(String, String), // cluster, node /// Show multi-service logs: (node_ip, node_role, active_service_ids, all_service_ids) ShowMultiLogs(String, String, Vec, Vec), + /// Show multi-node logs: (group_name, node_role, Vec<(hostname, ip)>, services) + ShowGroupLogs(String, String, Vec<(String, String)>, Vec), /// Show etcd cluster status ShowEtcd, /// Show processes for a node: (hostname, address) @@ -46,6 +48,16 @@ pub enum Action { ShowWorkloads, /// Show storage/disks view for a node: (hostname, address) ShowStorage(String, String), + + /// Show group processes: (group_name, Vec<(hostname, ip)>) + ShowGroupProcesses(String, Vec<(String, String)>), + /// Show group network: (group_name, Vec<(hostname, ip)>) + ShowGroupNetwork(String, Vec<(String, String)>), + /// Show group storage: (group_name, Vec<(hostname, ip)>) + ShowGroupStorage(String, Vec<(String, String)>), + /// Show group diagnostics: (group_name, node_role, Vec<(hostname, ip)>, cp_endpoint) + ShowGroupDiagnostics(String, String, Vec<(String, String)>, Option), + /// Show node operations overlay: (hostname, address, is_controlplane) ShowNodeOperations(String, String, bool), /// Show rolling operations overlay with node list: Vec<(hostname, address, is_controlplane)> diff --git a/crates/talos-pilot-tui/src/app.rs b/crates/talos-pilot-tui/src/app.rs index 9bbe67c..169317d 100644 --- a/crates/talos-pilot-tui/src/app.rs +++ b/crates/talos-pilot-tui/src/app.rs @@ -953,6 +953,72 @@ impl App { self.multi_logs = Some(multi_logs); self.view = View::MultiLogs; } + Action::ShowGroupLogs(group_name, node_role, nodes, services) => { + // Switch to group logs view (multiple nodes) + tracing::info!( + "Viewing group logs for {} ({} nodes)", + group_name, + nodes.len() + ); + + // Create multi-logs component in group mode + let mut multi_logs = MultiLogsComponent::new_group( + group_name.clone(), + node_role, + nodes.clone(), + services.clone(), + ); + + // Set up clients for each node and fetch initial logs + if let Some(client) = self.cluster.client() { + // Build per-node clients + let mut node_clients = std::collections::HashMap::new(); + for (hostname, ip) in &nodes { + let node_client = client.with_node(ip); + node_clients.insert(hostname.clone(), node_client); + } + + // Set the clients for streaming capability + multi_logs.set_multi_node_clients(node_clients.clone(), self.tail_lines); + + // Fetch initial logs from all nodes + let mut all_logs: Vec<(String, String, String)> = Vec::new(); + let service_refs: Vec<&str> = services.iter().map(|s| s.as_str()).collect(); + + for (hostname, node_client) in &node_clients { + match node_client.logs_multi(&service_refs, self.tail_lines).await { + Ok(logs) => { + // logs is Vec<(service_id, content)> + for (service_id, content) in logs { + all_logs.push((hostname.clone(), service_id, content)); + } + } + Err(e) => { + tracing::warn!( + "Failed to fetch logs from {}: {}", + hostname, + e + ); + } + } + } + + if !all_logs.is_empty() { + multi_logs.set_group_logs(all_logs); + // Auto-start streaming for live updates + multi_logs.start_streaming(); + } else { + multi_logs.set_error(format!( + "Failed to fetch logs from {} nodes in {}", + nodes.len(), + group_name + )); + } + } + + self.multi_logs = Some(multi_logs); + self.view = View::MultiLogs; + } Action::ShowNodeDetails(_, _) => { // Legacy - no longer used, we use ShowMultiLogs now } @@ -1194,6 +1260,165 @@ impl App { self.node_operations = Some(node_ops); self.view = View::NodeOperations; } + Action::ShowGroupProcesses(group_name, nodes) => { + // Switch to group processes view (multiple nodes) + tracing::info!( + "Viewing group processes for {} ({} nodes)", + group_name, + nodes.len() + ); + + // Create processes component in group mode + let mut processes = ProcessesComponent::new_group(group_name.clone(), nodes.clone()); + + // Fetch processes from all nodes + if let Some(client) = self.cluster.client() { + for (hostname, ip) in &nodes { + let node_client = client.with_node(ip); + match node_client.processes().await { + Ok(node_processes_list) => { + // The API returns Vec, extract processes from first (should only be one) + for np in node_processes_list { + processes.add_node_processes(hostname.clone(), np.processes); + } + } + Err(e) => { + tracing::warn!("Failed to fetch processes from {}: {}", hostname, e); + } + } + } + } + + self.processes = Some(processes); + self.view = View::Processes; + } + Action::ShowGroupNetwork(group_name, nodes) => { + // Switch to group network view (multiple nodes) + tracing::info!( + "Viewing group network for {} ({} nodes)", + group_name, + nodes.len() + ); + + // Create network component in group mode + let mut network = NetworkStatsComponent::new_group(group_name.clone(), nodes.clone()); + + // Fetch network stats from all nodes + if let Some(client) = self.cluster.client() { + for (hostname, ip) in &nodes { + let node_client = client.with_node(ip); + match node_client.network_device_stats().await { + Ok(node_stats_list) => { + // The API returns Vec, extract devices from each + for ns in node_stats_list { + network.add_node_network(hostname.clone(), ns.devices); + } + } + Err(e) => { + tracing::warn!("Failed to fetch network stats from {}: {}", hostname, e); + } + } + } + } + + self.network = Some(network); + self.view = View::Network; + } + Action::ShowGroupStorage(group_name, nodes) => { + // Switch to group storage view (multiple nodes) + tracing::info!( + "Viewing group storage for {} ({} nodes)", + group_name, + nodes.len() + ); + + // Create storage component in group mode + let mut storage = StorageComponent::new_group(group_name.clone(), nodes.clone()); + + // Get context and config from cluster component + let context = self.cluster.current_context_name().map(|s| s.to_string()); + let config_path = self.cluster.config_path().map(|s| s.to_string()); + + // Fetch storage info from all nodes using talosctl + for (hostname, ip) in &nodes { + // Extract IP without port + let node_ip = ip.split(':').next().unwrap_or(ip); + if let Some(ctx) = &context { + // Fetch disks + match talos_rs::get_disks_for_node(ctx, node_ip, config_path.as_deref()).await { + Ok(disks) => { + // Fetch volumes + match talos_rs::get_volume_status_for_node(ctx, node_ip, config_path.as_deref()).await { + Ok(volumes) => { + storage.add_node_storage(hostname.clone(), disks, volumes); + } + Err(e) => { + tracing::warn!("Failed to fetch volumes from {}: {}", hostname, e); + storage.add_node_storage(hostname.clone(), disks, Vec::new()); + } + } + } + Err(e) => { + tracing::warn!("Failed to fetch disks from {}: {}", hostname, e); + } + } + } + } + + self.storage = Some(storage); + self.view = View::Storage; + } + Action::ShowGroupDiagnostics(group_name, node_role, nodes, cp_endpoint) => { + // Switch to group diagnostics view (multiple nodes) + tracing::info!( + "Viewing group diagnostics for {} ({} nodes, role={})", + group_name, + nodes.len(), + node_role + ); + + // Create diagnostics component in group mode + let mut diagnostics = DiagnosticsComponent::new_group( + group_name.clone(), + node_role.clone(), + nodes.clone(), + cp_endpoint.clone(), + self.config_path.clone(), + ); + + // Fetch diagnostics from all nodes + if let Some(client) = self.cluster.client() { + for (hostname, ip) in &nodes { + let node_client = client.with_node(ip); + + // Create a temporary single-node diagnostics component to run checks + let mut temp_diag = DiagnosticsComponent::new( + hostname.clone(), + ip.clone(), + node_role.clone(), + self.config_path.clone(), + ); + temp_diag.set_client(node_client); + temp_diag.set_controlplane_endpoint(cp_endpoint.clone()); + + // Run the diagnostics checks + match temp_diag.refresh().await { + Ok(_) => { + // Extract the data and add it to the group component + if let Some(data) = temp_diag.take_data() { + diagnostics.add_node_diagnostics(hostname.clone(), data); + } + } + Err(e) => { + tracing::warn!("Failed to fetch diagnostics from {}: {}", hostname, e); + } + } + } + } + + self.diagnostics = Some(diagnostics); + self.view = View::Diagnostics; + } Action::ShowRollingOperations(nodes) => { // Show rolling operations overlay tracing::info!("Viewing rolling operations for {} nodes", nodes.len()); diff --git a/crates/talos-pilot-tui/src/components/cluster.rs b/crates/talos-pilot-tui/src/components/cluster.rs index 17db137..ec1393f 100644 --- a/crates/talos-pilot-tui/src/components/cluster.rs +++ b/crates/talos-pilot-tui/src/components/cluster.rs @@ -818,6 +818,84 @@ impl ClusterComponent { } } + /// If the current selection is a group header (ControlPlaneHeader or WorkersHeader), + /// returns (group_name, role, nodes) where nodes is Vec<(hostname, ip)>. + /// Returns None if the selection is not a group header. + #[allow(clippy::type_complexity)] + fn current_group_nodes(&self) -> Option<(String, String, Vec<(String, String)>)> { + match &self.selected_item { + NodeListItem::ControlPlaneHeader(cluster_idx) => { + let cluster = self.clusters.get(*cluster_idx)?; + let cp_nodes = self.controlplane_nodes_for(*cluster_idx); + let nodes: Vec<(String, String)> = cp_nodes + .iter() + .filter_map(|(_, v)| { + let hostname = v.node.clone(); + let ip = cluster.node_ips.get(&hostname).cloned()?; + Some((hostname, ip)) + }) + .collect(); + if nodes.is_empty() { + None + } else { + Some(("Control Plane".to_string(), "controlplane".to_string(), nodes)) + } + } + NodeListItem::WorkersHeader(cluster_idx) => { + let cluster = self.clusters.get(*cluster_idx)?; + let worker_nodes = self.worker_nodes_for(*cluster_idx); + let nodes: Vec<(String, String)> = worker_nodes + .iter() + .filter_map(|(_, v)| { + let hostname = v.node.clone(); + let ip = cluster.node_ips.get(&hostname).cloned()?; + Some((hostname, ip)) + }) + .collect(); + if nodes.is_empty() { + None + } else { + Some(("Workers".to_string(), "worker".to_string(), nodes)) + } + } + _ => None, + } + } + + /// Find services that are common to all nodes in a group. + /// Returns services present on every node. + fn common_services_for_group(&self, nodes: &[(String, String)]) -> Vec { + if nodes.is_empty() { + return Vec::new(); + } + + // Get service sets for each node + let mut service_sets: Vec> = Vec::new(); + + for (hostname, _) in nodes { + if let Some(services) = self.get_node_services(hostname) { + let set: std::collections::HashSet = + services.iter().map(|s| s.id.clone()).collect(); + service_sets.push(set); + } + } + + if service_sets.is_empty() { + return Vec::new(); + } + + // Find intersection of all service sets + let mut common = service_sets[0].clone(); + for set in service_sets.iter().skip(1) { + common = common.intersection(set).cloned().collect(); + } + + // Convert to sorted Vec for consistent ordering + let mut result: Vec = common.into_iter().collect(); + result.sort(); + result + } + /// Navigate to the currently selected menu item (1-based index, 0 = on node) fn navigate_to_selected_menu(&self) -> Result> { if self.selected_menu_item == 0 || self.selected_menu_item > NavMenuItem::ALL.len() { @@ -1066,9 +1144,18 @@ impl Component for ClusterComponent { } } - // 'l' / 'L' - show logs (all services for node) + // 'l' / 'L' - show logs (all services for node or group) KeyCode::Char('l') | KeyCode::Char('L') => { - if let Some(node_name) = self.current_node_name() { + // First check if we're on a group header + if let Some((group_name, role, nodes)) = self.current_group_nodes() { + let services = self.common_services_for_group(&nodes); + if !services.is_empty() { + Ok(Some(Action::ShowGroupLogs(group_name, role, nodes, services))) + } else { + Ok(None) + } + } else if let Some(node_name) = self.current_node_name() { + // Fall back to single node behavior let service_ids = self.current_service_ids(); if !service_ids.is_empty() { let node_role = self.current_node_role(); @@ -1094,7 +1181,10 @@ impl Component for ClusterComponent { // Direct hotkeys for screens (always work) KeyCode::Char('e') => Ok(Some(Action::ShowEtcd)), KeyCode::Char('p') => { - if let Some(node_name) = self.current_node_name() { + // Check if on a group header first + if let Some((group_name, _role, nodes)) = self.current_group_nodes() { + Ok(Some(Action::ShowGroupProcesses(group_name, nodes))) + } else if let Some(node_name) = self.current_node_name() { let node_ip = self .node_ips() .get(&node_name) @@ -1106,7 +1196,10 @@ impl Component for ClusterComponent { } } KeyCode::Char('n') => { - if let Some(node_name) = self.current_node_name() { + // Check if on a group header first + if let Some((group_name, _role, nodes)) = self.current_group_nodes() { + Ok(Some(Action::ShowGroupNetwork(group_name, nodes))) + } else if let Some(node_name) = self.current_node_name() { let node_ip = self .node_ips() .get(&node_name) @@ -1118,7 +1211,10 @@ impl Component for ClusterComponent { } } KeyCode::Char('s') => { - if let Some(node_name) = self.current_node_name() { + // Check if on a group header first + if let Some((group_name, _role, nodes)) = self.current_group_nodes() { + Ok(Some(Action::ShowGroupStorage(group_name, nodes))) + } else if let Some(node_name) = self.current_node_name() { let node_ip = self .node_ips() .get(&node_name) @@ -1130,7 +1226,21 @@ impl Component for ClusterComponent { } } KeyCode::Char('d') => { - if let Some(node_name) = self.current_node_name() { + // Check if on a group header first + if let Some((group_name, role, nodes)) = self.current_group_nodes() { + // For worker groups, provide a control plane endpoint to fetch kubeconfig from + let cp_endpoint = if role == "worker" { + self.get_controlplane_endpoint() + } else { + None + }; + Ok(Some(Action::ShowGroupDiagnostics( + group_name, + role, + nodes, + cp_endpoint, + ))) + } else if let Some(node_name) = self.current_node_name() { let node_ip = self .node_ips() .get(&node_name) @@ -1294,6 +1404,341 @@ impl Component for ClusterComponent { } } +#[cfg(test)] +mod tests { + use super::*; + + /// Create a test ServiceInfo with the given ID + fn make_service(id: &str) -> ServiceInfo { + ServiceInfo { + id: id.to_string(), + state: "Running".to_string(), + health: None, + } + } + + /// Create a test VersionInfo for a node + fn make_version_info(node: &str) -> VersionInfo { + VersionInfo { + node: node.to_string(), + version: "v1.8.0".to_string(), + sha: "abc123".to_string(), + built: "2024-01-01".to_string(), + go_version: "1.22".to_string(), + os: "linux".to_string(), + arch: "amd64".to_string(), + platform: "metal".to_string(), + } + } + + /// Create a test NodeServices for a node with given service IDs + fn make_node_services(node: &str, service_ids: &[&str]) -> NodeServices { + NodeServices { + node: node.to_string(), + services: service_ids.iter().map(|id| make_service(id)).collect(), + } + } + + /// Create a ClusterComponent with test data + fn create_test_component() -> ClusterComponent { + let mut component = ClusterComponent::new(None, None); + + // Create a cluster with controlplane and worker nodes + let cluster = ClusterData { + name: "test-cluster".to_string(), + client: None, + connected: true, + error: None, + versions: vec![ + make_version_info("cp-node-1"), + make_version_info("cp-node-2"), + make_version_info("worker-1"), + make_version_info("worker-2"), + ], + services: vec![ + // Control plane nodes have etcd + make_node_services("cp-node-1", &["etcd", "kubelet", "apid", "containerd"]), + make_node_services("cp-node-2", &["etcd", "kubelet", "apid", "containerd"]), + // Worker nodes do not have etcd + make_node_services("worker-1", &["kubelet", "apid", "containerd"]), + make_node_services("worker-2", &["kubelet", "apid", "containerd"]), + ], + memory: Vec::new(), + load_avg: Vec::new(), + cpu_info: Vec::new(), + etcd_members: Vec::new(), + discovery_members: Vec::new(), + etcd_summary: None, + node_ips: HashMap::from([ + ("cp-node-1".to_string(), "10.0.0.1".to_string()), + ("cp-node-2".to_string(), "10.0.0.2".to_string()), + ("worker-1".to_string(), "10.0.0.3".to_string()), + ("worker-2".to_string(), "10.0.0.4".to_string()), + ]), + expanded: true, + controlplane_expanded: true, + workers_expanded: true, + }; + + component.clusters.push(cluster); + component.active_cluster = 0; + component + } + + // ========================================================================== + // Tests for current_group_nodes() + // ========================================================================== + + #[test] + fn test_current_group_nodes_returns_none_for_cluster_header() { + let mut component = create_test_component(); + component.selected_item = NodeListItem::ClusterHeader(0); + + let result = component.current_group_nodes(); + assert!(result.is_none(), "ClusterHeader should not return group nodes"); + } + + #[test] + fn test_current_group_nodes_returns_controlplane_when_on_controlplane_header() { + let mut component = create_test_component(); + component.selected_item = NodeListItem::ControlPlaneHeader(0); + + let result = component.current_group_nodes(); + assert!(result.is_some(), "ControlPlaneHeader should return group nodes"); + + let (group_name, role, nodes) = result.unwrap(); + assert_eq!(group_name, "Control Plane"); + assert_eq!(role, "controlplane"); + assert_eq!(nodes.len(), 2, "Should have 2 control plane nodes"); + + // Verify node hostnames and IPs + assert!(nodes.iter().any(|(h, ip)| h == "cp-node-1" && ip == "10.0.0.1")); + assert!(nodes.iter().any(|(h, ip)| h == "cp-node-2" && ip == "10.0.0.2")); + } + + #[test] + fn test_current_group_nodes_returns_workers_when_on_workers_header() { + let mut component = create_test_component(); + component.selected_item = NodeListItem::WorkersHeader(0); + + let result = component.current_group_nodes(); + assert!(result.is_some(), "WorkersHeader should return group nodes"); + + let (group_name, role, nodes) = result.unwrap(); + assert_eq!(group_name, "Workers"); + assert_eq!(role, "worker"); + assert_eq!(nodes.len(), 2, "Should have 2 worker nodes"); + + // Verify node hostnames and IPs + assert!(nodes.iter().any(|(h, ip)| h == "worker-1" && ip == "10.0.0.3")); + assert!(nodes.iter().any(|(h, ip)| h == "worker-2" && ip == "10.0.0.4")); + } + + #[test] + fn test_current_group_nodes_returns_none_when_no_controlplane_nodes() { + let mut component = ClusterComponent::new(None, None); + + // Create a cluster with only workers (no etcd services) + let cluster = ClusterData { + name: "workers-only".to_string(), + client: None, + connected: true, + error: None, + versions: vec![make_version_info("worker-1")], + services: vec![make_node_services("worker-1", &["kubelet", "containerd"])], + memory: Vec::new(), + load_avg: Vec::new(), + cpu_info: Vec::new(), + etcd_members: Vec::new(), + discovery_members: Vec::new(), + etcd_summary: None, + node_ips: HashMap::from([("worker-1".to_string(), "10.0.0.1".to_string())]), + expanded: true, + controlplane_expanded: true, + workers_expanded: true, + }; + + component.clusters.push(cluster); + component.selected_item = NodeListItem::ControlPlaneHeader(0); + + let result = component.current_group_nodes(); + assert!( + result.is_none(), + "ControlPlaneHeader with no controlplane nodes should return None" + ); + } + + #[test] + fn test_current_group_nodes_returns_none_when_no_worker_nodes() { + let mut component = ClusterComponent::new(None, None); + + // Create a cluster with only control plane nodes + let cluster = ClusterData { + name: "cp-only".to_string(), + client: None, + connected: true, + error: None, + versions: vec![make_version_info("cp-node-1")], + services: vec![make_node_services("cp-node-1", &["etcd", "kubelet"])], + memory: Vec::new(), + load_avg: Vec::new(), + cpu_info: Vec::new(), + etcd_members: Vec::new(), + discovery_members: Vec::new(), + etcd_summary: None, + node_ips: HashMap::from([("cp-node-1".to_string(), "10.0.0.1".to_string())]), + expanded: true, + controlplane_expanded: true, + workers_expanded: true, + }; + + component.clusters.push(cluster); + component.selected_item = NodeListItem::WorkersHeader(0); + + let result = component.current_group_nodes(); + assert!( + result.is_none(), + "WorkersHeader with no worker nodes should return None" + ); + } + + #[test] + fn test_current_group_nodes_returns_none_for_individual_nodes() { + let mut component = create_test_component(); + + // Test ControlPlaneNode + component.selected_item = NodeListItem::ControlPlaneNode(0, 0); + assert!( + component.current_group_nodes().is_none(), + "ControlPlaneNode should return None" + ); + + // Test WorkerNode + component.selected_item = NodeListItem::WorkerNode(0, 0); + assert!( + component.current_group_nodes().is_none(), + "WorkerNode should return None" + ); + } + + // ========================================================================== + // Tests for common_services_for_group() + // ========================================================================== + + #[test] + fn test_common_services_for_group_returns_empty_for_empty_nodes() { + let component = create_test_component(); + + let result = component.common_services_for_group(&[]); + assert!(result.is_empty(), "Empty nodes should return empty services"); + } + + #[test] + fn test_common_services_for_group_returns_all_services_for_single_node() { + let component = create_test_component(); + + // Single control plane node + let nodes = vec![("cp-node-1".to_string(), "10.0.0.1".to_string())]; + + let result = component.common_services_for_group(&nodes); + assert_eq!(result.len(), 4, "Single node should return all its services"); + assert!(result.contains(&"etcd".to_string())); + assert!(result.contains(&"kubelet".to_string())); + assert!(result.contains(&"apid".to_string())); + assert!(result.contains(&"containerd".to_string())); + } + + #[test] + fn test_common_services_for_group_returns_intersection_for_multiple_nodes() { + let component = create_test_component(); + + // Control plane and worker nodes have different services + let nodes = vec![ + ("cp-node-1".to_string(), "10.0.0.1".to_string()), + ("worker-1".to_string(), "10.0.0.3".to_string()), + ]; + + let result = component.common_services_for_group(&nodes); + + // Common services: kubelet, apid, containerd (not etcd which is only on cp) + assert_eq!( + result.len(), + 3, + "Should return only common services (intersection)" + ); + assert!(result.contains(&"kubelet".to_string())); + assert!(result.contains(&"apid".to_string())); + assert!(result.contains(&"containerd".to_string())); + assert!( + !result.contains(&"etcd".to_string()), + "etcd should not be in common services" + ); + } + + #[test] + fn test_common_services_for_group_returns_empty_when_no_common_services() { + let mut component = ClusterComponent::new(None, None); + + // Create nodes with completely different services + let cluster = ClusterData { + name: "test".to_string(), + client: None, + connected: true, + error: None, + versions: vec![make_version_info("node-a"), make_version_info("node-b")], + services: vec![ + make_node_services("node-a", &["service-a", "service-b"]), + make_node_services("node-b", &["service-c", "service-d"]), + ], + memory: Vec::new(), + load_avg: Vec::new(), + cpu_info: Vec::new(), + etcd_members: Vec::new(), + discovery_members: Vec::new(), + etcd_summary: None, + node_ips: HashMap::from([ + ("node-a".to_string(), "10.0.0.1".to_string()), + ("node-b".to_string(), "10.0.0.2".to_string()), + ]), + expanded: true, + controlplane_expanded: true, + workers_expanded: true, + }; + + component.clusters.push(cluster); + component.active_cluster = 0; + + let nodes = vec![ + ("node-a".to_string(), "10.0.0.1".to_string()), + ("node-b".to_string(), "10.0.0.2".to_string()), + ]; + + let result = component.common_services_for_group(&nodes); + assert!( + result.is_empty(), + "Nodes with no common services should return empty" + ); + } + + #[test] + fn test_common_services_for_group_sorts_results() { + let component = create_test_component(); + + // Use two control plane nodes that have the same services + let nodes = vec![ + ("cp-node-1".to_string(), "10.0.0.1".to_string()), + ("cp-node-2".to_string(), "10.0.0.2".to_string()), + ]; + + let result = component.common_services_for_group(&nodes); + + // Verify the result is sorted + let mut sorted = result.clone(); + sorted.sort(); + assert_eq!(result, sorted, "Results should be sorted alphabetically"); + } +} + impl ClusterComponent { /// Draw compact header with status indicators fn draw_header(&self, frame: &mut Frame, area: Rect) { diff --git a/crates/talos-pilot-tui/src/components/diagnostics/mod.rs b/crates/talos-pilot-tui/src/components/diagnostics/mod.rs index e322af0..1779b52 100644 --- a/crates/talos-pilot-tui/src/components/diagnostics/mod.rs +++ b/crates/talos-pilot-tui/src/components/diagnostics/mod.rs @@ -37,6 +37,25 @@ pub use types::*; /// Default auto-refresh interval in seconds const AUTO_REFRESH_INTERVAL_SECS: u64 = 10; +/// View mode for group diagnostics +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum GroupViewMode { + /// Interleaved diagnostics from all nodes (default) + #[default] + Interleaved, + /// Diagnostics organized by node (tabbed view) + ByNode, +} + +/// Per-node diagnostics data for group view +#[derive(Debug, Clone, Default)] +pub struct NodeDiagnosticsData { + /// Node hostname + pub hostname: String, + /// Diagnostics data for this node + pub data: DiagnosticsData, +} + /// Data loaded asynchronously for the diagnostics component #[derive(Debug, Clone, Default)] pub struct DiagnosticsData { @@ -104,6 +123,23 @@ pub struct DiagnosticsComponent { controlplane_endpoint: Option, /// Custom config file path (from --config flag) config_path: Option, + + // Group view fields + /// Whether this is a group view (multiple nodes) + is_group_view: bool, + /// Group name (e.g., "Control Plane", "Workers") + group_name: String, + /// Nodes in the group: Vec<(hostname, ip)> + nodes: Vec<(String, String)>, + /// Current view mode for group diagnostics + group_view_mode: GroupViewMode, + /// Per-node diagnostics data + node_data: std::collections::HashMap, + /// Selected node tab index (for ByNode view mode) + selected_node_tab: usize, + /// Node role (for creating proper diagnostic context in group view) + #[allow(dead_code)] + node_role: String, } impl Default for DiagnosticsComponent { @@ -152,7 +188,136 @@ impl DiagnosticsComponent { client: None, controlplane_endpoint: None, config_path, + // Group view fields (not used in single node mode) + is_group_view: false, + group_name: String::new(), + nodes: Vec::new(), + group_view_mode: GroupViewMode::default(), + node_data: std::collections::HashMap::new(), + selected_node_tab: 0, + node_role: node_role, + } + } + + /// Create a new diagnostics component for group view (multiple nodes) + /// - group_name: Name of the group (e.g., "Control Plane", "Workers") + /// - node_role: Role of nodes in the group (e.g., "controlplane", "worker") + /// - nodes: Vec of (hostname, ip) for each node + /// - cp_endpoint: Control plane endpoint for worker nodes + pub fn new_group( + group_name: String, + node_role: String, + nodes: Vec<(String, String)>, + cp_endpoint: Option, + config_path: Option, + ) -> Self { + let mut table_state = TableState::default(); + table_state.select(Some(0)); + + let mut context = DiagnosticContext::new(); + context.node_role = node_role.clone(); + context.hostname = group_name.clone(); + + let initial_data = DiagnosticsData { + hostname: group_name.clone(), + address: String::new(), + context, + ..Default::default() + }; + + Self { + state: AsyncState::with_data(initial_data), + selected_category: 0, + selected_check: 0, + table_state, + pending_action: None, + show_confirmation: false, + confirmation_selection: 1, + copy_feedback_until: None, + show_details: false, + details_title: String::new(), + details_content: String::new(), + applying_fix: false, + apply_result: None, + auto_refresh: true, + client: None, + controlplane_endpoint: cp_endpoint, + config_path, + // Group view fields + is_group_view: true, + group_name, + nodes, + group_view_mode: GroupViewMode::default(), + node_data: std::collections::HashMap::new(), + selected_node_tab: 0, + node_role, + } + } + + /// Add diagnostics data from a node (for group view) + pub fn add_node_diagnostics(&mut self, hostname: String, data: DiagnosticsData) { + if !self.is_group_view { + return; + } + + // Store node data + let node_diag = NodeDiagnosticsData { + hostname: hostname.clone(), + data, + }; + self.node_data.insert(hostname, node_diag); + + // Rebuild merged view + self.rebuild_group_data(); + } + + /// Rebuild merged diagnostics data for group view + fn rebuild_group_data(&mut self) { + if !self.is_group_view { + return; + } + + // Get or create data + let mut data = self.state.take_data().unwrap_or_default(); + + // Clear existing checks + data.system_checks.clear(); + data.kubernetes_checks.clear(); + data.cni_checks.clear(); + data.service_checks.clear(); + data.addon_checks.clear(); + + // Merge checks from all nodes (prefix check names with hostname) + for node_data in self.node_data.values() { + let prefix = format!("[{}] ", node_data.hostname); + for check in &node_data.data.system_checks { + let mut prefixed_check = check.clone(); + prefixed_check.name = format!("{}{}", prefix, check.name); + data.system_checks.push(prefixed_check); + } + for check in &node_data.data.kubernetes_checks { + let mut prefixed_check = check.clone(); + prefixed_check.name = format!("{}{}", prefix, check.name); + data.kubernetes_checks.push(prefixed_check); + } + for check in &node_data.data.cni_checks { + let mut prefixed_check = check.clone(); + prefixed_check.name = format!("{}{}", prefix, check.name); + data.cni_checks.push(prefixed_check); + } + for check in &node_data.data.service_checks { + let mut prefixed_check = check.clone(); + prefixed_check.name = format!("{}{}", prefix, check.name); + data.service_checks.push(prefixed_check); + } + for check in &node_data.data.addon_checks { + let mut prefixed_check = check.clone(); + prefixed_check.name = format!("{}{}", prefix, check.name); + data.addon_checks.push(prefixed_check); + } } + + self.state.set_data(data); } /// Access loaded data immutably @@ -165,6 +330,11 @@ impl DiagnosticsComponent { self.state.data_mut() } + /// Take the loaded data out of the component (for transferring to group view) + pub fn take_data(&mut self) -> Option { + self.state.take_data() + } + /// Set the client for making API calls pub fn set_client(&mut self, client: TalosClient) { self.client = Some(client); @@ -180,9 +350,22 @@ impl DiagnosticsComponent { self.state.set_error(error); } + /// Get the DiagnosticsData to display based on view mode (ByNode or Interleaved) + fn get_display_data(&self) -> Option<&DiagnosticsData> { + if self.is_group_view && self.group_view_mode == GroupViewMode::ByNode { + // ByNode mode: show data from selected node only + let (hostname, _) = self.nodes.get(self.selected_node_tab)?; + let node_data = self.node_data.get(hostname)?; + Some(&node_data.data) + } else { + // Interleaved mode: use merged data + self.data() + } + } + /// Get all checks in the current category fn current_checks(&self) -> &[DiagnosticCheck] { - let Some(data) = self.data() else { + let Some(data) = self.get_display_data() else { return &[]; }; match self.selected_category { @@ -970,6 +1153,40 @@ impl Component for DiagnosticsComponent { KeyCode::Enter => { self.initiate_fix(); } + KeyCode::Char('v') => { + // Toggle view mode (only in group view) + if self.is_group_view { + self.group_view_mode = match self.group_view_mode { + GroupViewMode::Interleaved => GroupViewMode::ByNode, + GroupViewMode::ByNode => GroupViewMode::Interleaved, + }; + // Reset selection when changing view mode + self.selected_check = 0; + self.table_state.select(Some(0)); + } + } + KeyCode::Char('[') => { + // Previous node tab (only in group view with ByNode mode) + if self.is_group_view && self.group_view_mode == GroupViewMode::ByNode { + if self.selected_node_tab > 0 { + self.selected_node_tab -= 1; + // Reset selection when changing tabs + self.selected_check = 0; + self.table_state.select(Some(0)); + } + } + } + KeyCode::Char(']') => { + // Next node tab (only in group view with ByNode mode) + if self.is_group_view && self.group_view_mode == GroupViewMode::ByNode { + if self.selected_node_tab + 1 < self.nodes.len() { + self.selected_node_tab += 1; + // Reset selection when changing tabs + self.selected_check = 0; + self.table_state.select(Some(0)); + } + } + } _ => {} } @@ -1006,10 +1223,59 @@ impl Component for DiagnosticsComponent { }) .unwrap_or_else(|| (String::new(), String::new(), "Unknown")); + // Build header spans based on single node vs group view + let header_spans = if self.is_group_view { + let mut spans = vec![ + Span::styled(" Diagnostics: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled(&self.group_name, Style::default().fg(Color::Cyan)), + Span::styled( + format!(" ({} nodes)", self.nodes.len()), + Style::default().fg(Color::DarkGray), + ), + ]; + + // View mode indicator + let view_mode_label = match self.group_view_mode { + GroupViewMode::Interleaved => "[MERGED]", + GroupViewMode::ByNode => "[BY NODE]", + }; + spans.push(Span::raw(" ")); + spans.push(Span::styled( + view_mode_label, + Style::default().fg(Color::Green), + )); + + // Node tabs for ByNode mode + if self.group_view_mode == GroupViewMode::ByNode && !self.nodes.is_empty() { + spans.push(Span::raw(" ")); + for (i, (node_hostname, _)) in self.nodes.iter().enumerate() { + if i == self.selected_node_tab { + spans.push(Span::styled( + format!("[{}]", node_hostname), + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + )); + } else { + spans.push(Span::styled( + format!(" {} ", node_hostname), + Style::default().fg(Color::DarkGray), + )); + } + } + } + + spans + } else { + // Single node header + vec![ + Span::styled( + format!(" Diagnostics: {} ({}) [{}] ", hostname, address, cni_label), + Style::default().add_modifier(Modifier::BOLD), + ), + ] + }; + // Header - let header_text = format!(" Diagnostics: {} ({}) [{}] ", hostname, address, cni_label); - let header = Paragraph::new(header_text) - .style(Style::default().add_modifier(Modifier::BOLD)) + let header = Paragraph::new(Line::from(header_spans)) .block(Block::default().borders(Borders::BOTTOM)); frame.render_widget(header, chunks[0]); @@ -1017,7 +1283,7 @@ impl Component for DiagnosticsComponent { let error_msg = Paragraph::new(format!("Error: {}", error)).style(Style::default().fg(Color::Red)); frame.render_widget(error_msg, chunks[1]); - } else if let Some(data) = self.data() { + } else if let Some(data) = self.get_display_data() { // Dynamically size Addons section based on whether addons are detected let addons_height = if data.detected_addons.any_detected() { Constraint::Length(5) @@ -1084,7 +1350,23 @@ impl Component for DiagnosticsComponent { } // Footer - let footer = Paragraph::new(Line::from(vec![ + let mut footer_spans = vec![]; + + // Add view mode toggle hint if in group view + if self.is_group_view { + footer_spans.push(Span::styled("[v]", Style::default().fg(Color::Cyan))); + footer_spans.push(Span::raw(" View ")); + + // Add tab navigation hint if in ByNode mode + if self.group_view_mode == GroupViewMode::ByNode { + footer_spans.push(Span::styled("[", Style::default().fg(Color::Cyan))); + footer_spans.push(Span::styled("/", Style::default().fg(Color::DarkGray))); + footer_spans.push(Span::styled("]", Style::default().fg(Color::Cyan))); + footer_spans.push(Span::raw(" Tabs ")); + } + } + + footer_spans.extend(vec![ Span::styled("[j/k]", Style::default().fg(Color::Cyan)), Span::raw(" Navigate "), Span::styled("[Tab]", Style::default().fg(Color::Cyan)), @@ -1095,7 +1377,9 @@ impl Component for DiagnosticsComponent { Span::raw(" Refresh "), Span::styled("[q]", Style::default().fg(Color::Cyan)), Span::raw(" Back"), - ])); + ]); + + let footer = Paragraph::new(Line::from(footer_spans)); frame.render_widget(footer, chunks[2]); if self.show_confirmation { @@ -1109,3 +1393,185 @@ impl Component for DiagnosticsComponent { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Create a test DiagnosticCheck + fn make_check(name: &str) -> DiagnosticCheck { + DiagnosticCheck::pass(name, name, "OK") + } + + /// Create a test DiagnosticsData with some checks + fn make_diagnostics_data(hostname: &str) -> DiagnosticsData { + DiagnosticsData { + hostname: hostname.to_string(), + address: format!("10.0.0.{}", hostname.chars().last().unwrap_or('1')), + context: DiagnosticContext::new(), + system_checks: vec![make_check("cpu"), make_check("memory")], + kubernetes_checks: vec![make_check("etcd")], + cni_checks: vec![make_check("cni_health")], + service_checks: vec![make_check("kubelet")], + addon_checks: vec![], + detected_addons: addons::DetectedAddons::default(), + } + } + + /// Create a DiagnosticsComponent for single node view + fn create_single_node_component() -> DiagnosticsComponent { + let mut component = DiagnosticsComponent::new( + "test-node".to_string(), + "10.0.0.1".to_string(), + "controlplane".to_string(), + None, + ); + + // Set up data + let data = make_diagnostics_data("test-node"); + component.state.set_data(data); + component + } + + /// Create a DiagnosticsComponent for group view + fn create_group_component() -> DiagnosticsComponent { + let nodes = vec![ + ("node-1".to_string(), "10.0.0.1".to_string()), + ("node-2".to_string(), "10.0.0.2".to_string()), + ]; + let mut component = DiagnosticsComponent::new_group( + "Control Plane".to_string(), + "controlplane".to_string(), + nodes, + None, + None, + ); + + // Add node data + component.add_node_diagnostics("node-1".to_string(), make_diagnostics_data("node-1")); + component.add_node_diagnostics("node-2".to_string(), make_diagnostics_data("node-2")); + + component + } + + // ========================================================================== + // Tests for get_display_data() + // ========================================================================== + + #[test] + fn test_get_display_data_single_node_returns_merged_data() { + let component = create_single_node_component(); + + let result = component.get_display_data(); + assert!(result.is_some()); + + let data = result.unwrap(); + assert_eq!(data.system_checks.len(), 2); + assert_eq!(data.kubernetes_checks.len(), 1); + assert!(data.system_checks.iter().any(|c| c.name == "cpu")); + assert!(data.system_checks.iter().any(|c| c.name == "memory")); + } + + #[test] + fn test_get_display_data_group_interleaved_returns_merged_data() { + let mut component = create_group_component(); + component.group_view_mode = GroupViewMode::Interleaved; + + let result = component.get_display_data(); + assert!(result.is_some()); + + let data = result.unwrap(); + // Should have merged checks from both nodes (4 system checks total: 2 per node) + assert_eq!(data.system_checks.len(), 4); + // Check names should be prefixed with hostname + assert!(data + .system_checks + .iter() + .any(|c| c.name.starts_with("[node-1]") || c.name.starts_with("[node-2]"))); + } + + #[test] + fn test_get_display_data_group_bynode_returns_selected_node_data() { + let mut component = create_group_component(); + component.group_view_mode = GroupViewMode::ByNode; + component.selected_node_tab = 0; // Select first node + + let result = component.get_display_data(); + assert!(result.is_some()); + + let data = result.unwrap(); + // Should only have data from node-1 + assert_eq!(data.hostname, "node-1"); + assert_eq!(data.system_checks.len(), 2); + // No prefixing in ByNode mode - raw node data + assert!(data.system_checks.iter().any(|c| c.name == "cpu")); + } + + #[test] + fn test_get_display_data_group_bynode_second_node() { + let mut component = create_group_component(); + component.group_view_mode = GroupViewMode::ByNode; + component.selected_node_tab = 1; // Select second node + + let result = component.get_display_data(); + assert!(result.is_some()); + + let data = result.unwrap(); + // Should only have data from node-2 + assert_eq!(data.hostname, "node-2"); + assert_eq!(data.system_checks.len(), 2); + } + + #[test] + fn test_get_display_data_returns_none_when_tab_out_of_bounds() { + let mut component = create_group_component(); + component.group_view_mode = GroupViewMode::ByNode; + component.selected_node_tab = 99; // Invalid tab index + + let result = component.get_display_data(); + assert!(result.is_none(), "Should return None for invalid tab index"); + } + + #[test] + fn test_get_display_data_group_bynode_with_no_data_for_node() { + let nodes = vec![ + ("node-1".to_string(), "10.0.0.1".to_string()), + ("node-2".to_string(), "10.0.0.2".to_string()), + ]; + let mut component = DiagnosticsComponent::new_group( + "Control Plane".to_string(), + "controlplane".to_string(), + nodes, + None, + None, + ); + component.group_view_mode = GroupViewMode::ByNode; + + // Only add data for node-1 + component.add_node_diagnostics("node-1".to_string(), make_diagnostics_data("node-1")); + + // Select node-2 which has no data + component.selected_node_tab = 1; + + let result = component.get_display_data(); + assert!( + result.is_none(), + "Should return None when selected node has no data" + ); + } + + #[test] + fn test_get_display_data_single_node_not_affected_by_view_mode() { + let mut component = create_single_node_component(); + + // Even with group view mode set, single node should ignore it + component.group_view_mode = GroupViewMode::ByNode; + + let result = component.get_display_data(); + assert!(result.is_some()); + + // Should still get the single node's data + let data = result.unwrap(); + assert_eq!(data.system_checks.len(), 2); + } +} diff --git a/crates/talos-pilot-tui/src/components/multi_logs.rs b/crates/talos-pilot-tui/src/components/multi_logs.rs index 5f6a388..eda2d15 100644 --- a/crates/talos-pilot-tui/src/components/multi_logs.rs +++ b/crates/talos-pilot-tui/src/components/multi_logs.rs @@ -93,6 +93,16 @@ impl LogLevel { } } +/// View mode for group logs +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum GroupViewMode { + /// Interleaved logs from all nodes (default) + #[default] + Interleaved, + /// Logs organized by node (tabbed view) + ByNode, +} + /// A log entry from any service #[derive(Debug, Clone)] pub(crate) struct MultiLogEntry { @@ -110,6 +120,8 @@ pub(crate) struct MultiLogEntry { message: String, /// Pre-computed lowercase for search search_text: String, + /// Node hostname (for group view) + node_hostname: String, } /// Service state for sidebar @@ -160,7 +172,7 @@ pub(crate) struct MultiLogsData { /// Multi-service logs component pub struct MultiLogsComponent { - /// Node IP being viewed + /// Node IP being viewed (for single node mode) node_ip: String, /// Node role (controlplane/worker) node_role: String, @@ -202,17 +214,17 @@ pub struct MultiLogsComponent { /// Current match index current_match: usize, - /// Talos client for streaming + /// Talos client for streaming (single node mode) client: Option, /// Tail lines setting tail_lines: i32, /// Whether streaming is active streaming: bool, - /// Channel to receive streamed log lines (service_id, line) - stream_rx: Option>, + /// Channel to receive streamed log lines (hostname, service_id, line) + stream_rx: Option>, /// Sender for stream aggregator (kept alive to prevent channel close) #[allow(dead_code)] - stream_tx: Option>, + stream_tx: Option>, /// Animation frame for pulsing indicator pulse_frame: u8, /// Whether to wrap long lines @@ -221,10 +233,24 @@ pub struct MultiLogsComponent { selection_start: Option, /// Current cursor position in visible_indices (always tracked, moves with navigation) cursor: usize, + + // Group view fields + /// Whether this is a group view (multiple nodes) + is_group_view: bool, + /// Group name (e.g., "Control Plane", "Workers") + group_name: String, + /// Nodes in the group: Vec<(hostname, ip)> + nodes: Vec<(String, String)>, + /// Current view mode for group logs + view_mode: GroupViewMode, + /// Clients for each node (hostname -> client) + node_clients: std::collections::HashMap, + /// Selected node tab index (for ByNode view mode) + selected_node_tab: usize, } impl MultiLogsComponent { - /// Create a new multi-logs component + /// Create a new multi-logs component for single node view /// - active_services: services to initially show logs for /// - all_services: all available services (inactive ones shown greyed out in sidebar) pub fn new( @@ -320,9 +346,126 @@ impl MultiLogsComponent { wrap: false, selection_start: None, cursor: 0, + // Group view fields (not used in single node mode) + is_group_view: false, + group_name: String::new(), + nodes: Vec::new(), + view_mode: GroupViewMode::default(), + node_clients: std::collections::HashMap::new(), + selected_node_tab: 0, } } + /// Create a new multi-logs component for group view (multiple nodes) + /// - group_name: Name of the group (e.g., "Control Plane", "Workers") + /// - node_role: Role of nodes in this group + /// - nodes: Vec of (hostname, ip) for each node + /// - services: Common services across all nodes + pub fn new_group( + group_name: String, + node_role: String, + nodes: Vec<(String, String)>, + services: Vec, + ) -> Self { + // All services are active by default for group view + let service_states: Vec = services + .into_iter() + .enumerate() + .map(|(i, id)| ServiceState { + color: SERVICE_COLORS[i % SERVICE_COLORS.len()], + active: true, + id, + entry_count: 0, + }) + .collect(); + + let mut sidebar_state = ListState::default(); + sidebar_state.select(Some(0)); + + // Initialize level filters (all active by default) + let levels = vec![ + LevelState { + level: LogLevel::Error, + active: true, + entry_count: 0, + }, + LevelState { + level: LogLevel::Warn, + active: true, + entry_count: 0, + }, + LevelState { + level: LogLevel::Info, + active: true, + entry_count: 0, + }, + LevelState { + level: LogLevel::Debug, + active: true, + entry_count: 0, + }, + LevelState { + level: LogLevel::Unknown, + active: true, + entry_count: 0, + }, + ]; + let mut levels_state = ListState::default(); + levels_state.select(Some(0)); + + Self { + node_ip: String::new(), // Not used in group view + node_role, + services: service_states, + selected_service: 0, + sidebar_state, + levels, + selected_level: 0, + levels_state, + state: { + let mut state = AsyncState::new(); + state.start_loading(); + state.set_data(MultiLogsData::default()); + state + }, + floating_pane: FloatingPane::None, + scroll: 0, + viewport_height: 20, + following: true, + search_mode: SearchMode::Off, + search_query: String::new(), + match_set: HashSet::new(), + match_order: Vec::new(), + current_match: 0, + client: None, + tail_lines: 500, + streaming: false, + stream_rx: None, + stream_tx: None, + pulse_frame: 0, + wrap: false, + selection_start: None, + cursor: 0, + // Group view fields + is_group_view: true, + group_name, + nodes, + view_mode: GroupViewMode::default(), + node_clients: std::collections::HashMap::new(), + selected_node_tab: 0, + } + } + + /// Set up clients for multiple nodes (group view) + pub fn set_multi_node_clients( + &mut self, + clients: std::collections::HashMap, + tail_lines: i32, + ) { + self.node_clients = clients; + self.tail_lines = tail_lines; + } + /// Set the Talos client for streaming pub fn set_client(&mut self, client: talos_rs::TalosClient, tail_lines: i32) { self.client = Some(client); @@ -341,6 +484,15 @@ impl MultiLogsComponent { /// Start streaming logs from all active services pub fn start_streaming(&mut self) { + if self.is_group_view { + self.start_multi_node_streaming(); + } else { + self.start_single_node_streaming(); + } + } + + /// Start streaming for single node mode + fn start_single_node_streaming(&mut self) { let client = match &self.client { Some(c) => c.clone(), None => return, @@ -349,8 +501,8 @@ impl MultiLogsComponent { // Stop any existing streams self.stop_streaming(); - // Create aggregated channel - let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<(String, String)>(); + // Create aggregated channel - now includes hostname (empty for single node) + let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<(String, String, String)>(); self.stream_tx = Some(tx.clone()); self.stream_rx = Some(rx); self.streaming = true; @@ -370,7 +522,8 @@ impl MultiLogsComponent { match client.logs_stream(&service_id, tail_lines).await { Ok(mut stream_rx) => { while let Some(line) = stream_rx.recv().await { - if tx.send((service_id.clone(), line)).is_err() { + // Empty hostname for single node mode + if tx.send((String::new(), service_id.clone(), line)).is_err() { // Channel closed, stop this stream break; } @@ -384,6 +537,58 @@ impl MultiLogsComponent { } } + /// Start streaming logs from multiple nodes (group view) + pub fn start_multi_node_streaming(&mut self) { + if self.node_clients.is_empty() { + return; + } + + // Stop any existing streams + self.stop_streaming(); + + // Create aggregated channel - (hostname, service_id, line) + let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<(String, String, String)>(); + self.stream_tx = Some(tx.clone()); + self.stream_rx = Some(rx); + self.streaming = true; + + // Spawn stream tasks for each node + active service combination + for (hostname, client) in &self.node_clients { + for service in &self.services { + if !service.active { + continue; + } + + let hostname = hostname.clone(); + let service_id = service.id.clone(); + let client = client.clone(); + let tx = tx.clone(); + let tail_lines = self.tail_lines; + + tokio::spawn(async move { + match client.logs_stream(&service_id, tail_lines).await { + Ok(mut stream_rx) => { + while let Some(line) = stream_rx.recv().await { + if tx.send((hostname.clone(), service_id.clone(), line)).is_err() { + // Channel closed, stop this stream + break; + } + } + } + Err(e) => { + tracing::warn!( + "Failed to start log stream for {}:{}: {}", + hostname, + service_id, + e + ); + } + } + }); + } + } + } + /// Stop all log streams pub fn stop_streaming(&mut self) { self.streaming = false; @@ -399,7 +604,7 @@ impl MultiLogsComponent { /// Process incoming streamed log entries (call on tick) fn process_stream_entries(&mut self) { // First, drain the channel into a local vec to avoid borrow issues - let raw_lines: Vec<(String, String)> = { + let raw_lines: Vec<(String, String, String)> = { let rx = match &mut self.stream_rx { Some(r) => r, None => return, @@ -441,9 +646,9 @@ impl MultiLogsComponent { // Now parse entries (can access self freely) let new_entries: Vec = raw_lines .into_iter() - .map(|(service_id, line)| { + .map(|(hostname, service_id, line)| { let color = self.get_service_color(&service_id); - Self::parse_line(&line, &service_id, color) + Self::parse_line_with_hostname(&line, &service_id, color, &hostname) }) .collect(); @@ -536,7 +741,7 @@ impl MultiLogsComponent { .unwrap_or(Color::White) } - /// Set log content from multiple services + /// Set log content from multiple services (single node mode) pub fn set_logs(&mut self, logs: Vec<(String, String)>) { // Parse entries (need service colors first) let mut new_entries = Vec::new(); @@ -580,8 +785,63 @@ impl MultiLogsComponent { } } + /// Set log content from multiple nodes (group view mode) + /// logs: Vec<(hostname, service_id, content)> + pub fn set_group_logs(&mut self, logs: Vec<(String, String, String)>) { + let mut new_entries = Vec::new(); + + for (hostname, service_id, content) in logs { + let color = self.get_service_color(&service_id); + + for line in content.lines() { + if line.trim().is_empty() { + continue; + } + + let entry = Self::parse_line_with_hostname(line, &service_id, color, &hostname); + new_entries.push(entry); + } + } + + // Sort by timestamp + new_entries.sort_by_key(|e| e.timestamp_sort); + + // Enforce max entries (ring buffer behavior) + if new_entries.len() > MAX_LOG_ENTRIES { + new_entries.drain(0..new_entries.len() - MAX_LOG_ENTRIES); + } + + // Update data + if let Some(data) = self.data_mut() { + data.entries = new_entries; + } + + // Update counts + self.update_counts(); + + // Build visible indices + self.rebuild_visible_indices(); + + self.state.mark_loaded(); + + // Scroll to bottom if following + if self.following { + self.scroll_to_bottom(); + } + } + /// Parse a log line into a MultiLogEntry fn parse_line(line: &str, service_id: &str, color: Color) -> MultiLogEntry { + Self::parse_line_with_hostname(line, service_id, color, "") + } + + /// Parse a log line into a MultiLogEntry with hostname (for group view) + fn parse_line_with_hostname( + line: &str, + service_id: &str, + color: Color, + hostname: &str, + ) -> MultiLogEntry { let line = line.trim(); let search_text = line.to_lowercase(); @@ -597,6 +857,7 @@ impl MultiLogsComponent { level, message, search_text, + node_hostname: hostname.to_string(), } } @@ -1416,6 +1677,39 @@ impl MultiLogsComponent { let Some(data) = self.data() else { return }; + // For ByNode view, draw node tabs first + let (tabs_height, logs_area) = if self.is_group_view && self.view_mode == GroupViewMode::ByNode { + // Draw node tabs at the top + let tabs_area = Rect::new(area.x, area.y, area.width, 1); + let mut tab_spans = Vec::new(); + for (i, (hostname, _)) in self.nodes.iter().enumerate() { + let short_name = if hostname.len() > 10 { + &hostname[..10] + } else { + hostname.as_str() + }; + if i == self.selected_node_tab { + tab_spans.push(Span::styled( + format!(" [{}] ", short_name), + Style::default().fg(Color::Black).bg(Color::Cyan).bold(), + )); + } else { + tab_spans.push(Span::styled( + format!(" {} ", short_name), + Style::default().fg(Color::Gray), + )); + } + } + tab_spans.push(Span::raw(" ")); + tab_spans.push(Span::styled("[/]", Style::default().fg(Color::Yellow))); + tab_spans.push(Span::styled(" switch", Style::default().dim())); + let tabs = Paragraph::new(Line::from(tab_spans)); + frame.render_widget(tabs, tabs_area); + (1, Rect::new(area.x, area.y + 1, area.width, area.height.saturating_sub(1))) + } else { + (0, area) + }; + if data.visible_indices.is_empty() { let msg = if self.active_count() == 0 || self.active_level_count() == 0 { " No services/levels selected. Press 's' or 'l' to open filters." @@ -1423,22 +1717,47 @@ impl MultiLogsComponent { " No log entries" }; let empty = Paragraph::new(Line::from(Span::raw(msg).dim())); - frame.render_widget(empty, area); + frame.render_widget(empty, logs_area); + return; + } + + // For ByNode view, filter entries to show only the selected node + let filtered_indices: Vec = if self.is_group_view && self.view_mode == GroupViewMode::ByNode { + if let Some((hostname, _)) = self.nodes.get(self.selected_node_tab) { + data.visible_indices + .iter() + .copied() + .filter(|&idx| data.entries.get(idx).map(|e| &e.node_hostname == hostname).unwrap_or(false)) + .collect() + } else { + data.visible_indices.clone() + } + } else { + data.visible_indices.clone() + }; + + if filtered_indices.is_empty() { + let msg = " No log entries for this node"; + let empty = Paragraph::new(Line::from(Span::raw(msg).dim())); + frame.render_widget(empty, logs_area); return; } - let visible_height = area.height as usize; - let content_width = area.width.saturating_sub(1) as usize; // -1 for scrollbar + let visible_height = logs_area.height as usize; + let content_width = logs_area.width.saturating_sub(1) as usize; // -1 for scrollbar // Safety clamp scroll to valid range (max is where last entry is at viewport bottom) - let total = data.visible_indices.len(); + let total = filtered_indices.len(); let max_start = total.saturating_sub(visible_height); let start = (self.scroll as usize).min(max_start); let end = (start + visible_height).min(total); + // Use filtered_indices instead of data.visible_indices for the rest + let _ = tabs_height; // Suppress unused warning + let mut lines: Vec = Vec::new(); - for (vi, &entry_idx) in data.visible_indices[start..end].iter().enumerate() { + for (vi, &entry_idx) in filtered_indices[start..end].iter().enumerate() { let visible_idx = start + vi; let entry = &data.entries[entry_idx]; let is_current_match = self.is_current_match(visible_idx); @@ -1487,12 +1806,37 @@ impl MultiLogsComponent { } spans.push(Span::raw(" ")); - // Service name (colored, fixed width) - spans.push(Span::styled( - format!("{:<12}", entry.service_id), - Style::default().fg(entry.service_color), - )); - spans.push(Span::raw(" ")); + // For group view in interleaved mode, show node:service prefix + // For single node or ByNode mode, show just service + let prefix_width = if self.is_group_view && self.view_mode == GroupViewMode::Interleaved { + // Show shortened node hostname and service + let short_host = if entry.node_hostname.len() > 8 { + &entry.node_hostname[..8] + } else { + &entry.node_hostname + }; + spans.push(Span::styled( + format!("{:<8}", short_host), + Style::default().fg(Color::Yellow), + )); + spans.push(Span::raw(":")); + spans.push(Span::styled( + format!("{:<10}", entry.service_id), + Style::default().fg(entry.service_color), + )); + spans.push(Span::raw(" ")); + // indicator(1) + time(8) + space(1) + node(8) + colon(1) + service(10) + space(1) + level(3) + space(1) + 1 + 8 + 1 + 8 + 1 + 10 + 1 + 3 + 1 + } else { + // Service name (colored, fixed width) + spans.push(Span::styled( + format!("{:<12}", entry.service_id), + Style::default().fg(entry.service_color), + )); + spans.push(Span::raw(" ")); + // indicator(1) + time(8) + space(1) + service(12) + space(1) + level(3) + space(1) + 1 + 8 + 1 + 12 + 1 + 3 + 1 + }; // Level badge let level_style = Style::default() @@ -1503,7 +1847,6 @@ impl MultiLogsComponent { spans.push(Span::raw(" ")); // Message with optional highlighting - let prefix_width = 1 + 8 + 1 + 12 + 1 + 3 + 1; // indicator + time + service + level let available = content_width.saturating_sub(prefix_width); if self.wrap && entry.message.len() > available { @@ -1568,19 +1911,19 @@ impl MultiLogsComponent { } let logs = Paragraph::new(lines); - frame.render_widget(logs, area); + frame.render_widget(logs, logs_area); // Scrollbar - if data.visible_indices.len() > visible_height { + if filtered_indices.len() > visible_height { let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) .begin_symbol(Some("▲")) .end_symbol(Some("▼")) .track_symbol(Some("│")) .thumb_symbol("█"); - let mut scrollbar_state = ScrollbarState::new(data.visible_indices.len()) + let mut scrollbar_state = ScrollbarState::new(filtered_indices.len()) .position(self.scroll as usize) .viewport_content_length(visible_height); - frame.render_stateful_widget(scrollbar, area, &mut scrollbar_state); + frame.render_stateful_widget(scrollbar, logs_area, &mut scrollbar_state); } } } @@ -1857,6 +2200,48 @@ impl Component for MultiLogsComponent { Ok(None) } + // Toggle view mode (Interleaved / ByNode) - only for group view + KeyCode::Char('v') => { + if self.is_group_view { + self.view_mode = match self.view_mode { + GroupViewMode::Interleaved => { + // Switching to ByNode - reset cursor and scroll + self.cursor = 0; + self.scroll = 0; + GroupViewMode::ByNode + } + GroupViewMode::ByNode => { + // Switching to Interleaved - reset cursor and scroll + self.cursor = 0; + self.scroll = 0; + GroupViewMode::Interleaved + } + }; + } + Ok(None) + } + + // Navigate between node tabs in ByNode view mode + KeyCode::Char('[') => { + if self.is_group_view && self.view_mode == GroupViewMode::ByNode { + self.selected_node_tab = self.selected_node_tab.saturating_sub(1); + // Reset cursor and scroll for the new tab + self.cursor = 0; + self.scroll = 0; + } + Ok(None) + } + KeyCode::Char(']') => { + if self.is_group_view && self.view_mode == GroupViewMode::ByNode { + let max_tab = self.nodes.len().saturating_sub(1); + self.selected_node_tab = (self.selected_node_tab + 1).min(max_tab); + // Reset cursor and scroll for the new tab + self.cursor = 0; + self.scroll = 0; + } + Ok(None) + } + // Visual line selection mode KeyCode::Char('V') => { if self.in_visual_mode() { @@ -1933,21 +2318,49 @@ impl Component for MultiLogsComponent { Span::styled("○ PAUSED ", Style::default().fg(Color::DarkGray)) }; - let mut header_spans = vec![ - Span::raw(" Multi-Service Logs: ").bold().fg(Color::Cyan), - Span::raw(&self.node_ip).fg(Color::White), - Span::raw(format!(" ({})", self.node_role)).dim(), - Span::raw(" "), - follow_indicator, - Span::raw(format!( - " [{}/{} svcs, {}/{} lvls]", - self.active_count(), - self.services.len(), - self.active_level_count(), - self.levels.len() - )) - .dim(), - ]; + // Build header based on whether this is a group view or single node view + let mut header_spans = if self.is_group_view { + // Group view header: "Group Logs: Control Plane (3 nodes) (controlplane)" + let view_mode_indicator = match self.view_mode { + GroupViewMode::Interleaved => "[Interleaved]", + GroupViewMode::ByNode => "[ByNode]", + }; + vec![ + Span::raw(" Group Logs: ").bold().fg(Color::Cyan), + Span::raw(&self.group_name).fg(Color::White), + Span::raw(format!(" ({} nodes)", self.nodes.len())).fg(Color::Yellow), + Span::raw(format!(" ({})", self.node_role)).dim(), + Span::raw(" "), + follow_indicator, + Span::raw(format!( + " [{}/{} svcs, {}/{} lvls]", + self.active_count(), + self.services.len(), + self.active_level_count(), + self.levels.len() + )) + .dim(), + Span::raw(" "), + Span::raw(view_mode_indicator).fg(Color::Magenta), + ] + } else { + // Single node view header + vec![ + Span::raw(" Multi-Service Logs: ").bold().fg(Color::Cyan), + Span::raw(&self.node_ip).fg(Color::White), + Span::raw(format!(" ({})", self.node_role)).dim(), + Span::raw(" "), + follow_indicator, + Span::raw(format!( + " [{}/{} svcs, {}/{} lvls]", + self.active_count(), + self.services.len(), + self.active_level_count(), + self.levels.len() + )) + .dim(), + ] + }; // Show match count when searching if !self.match_order.is_empty() { @@ -2094,7 +2507,7 @@ impl Component for MultiLogsComponent { } else { let stream_text = if self.streaming { " stop" } else { " stream" }; let wrap_text = if self.wrap { " nowrap" } else { " wrap" }; - vec![ + let mut spans = vec![ Span::raw(" [s]").fg(Color::Yellow), Span::raw(" svcs").dim(), Span::raw(" "), @@ -2112,6 +2525,14 @@ impl Component for MultiLogsComponent { Span::raw(" "), Span::raw("[w]").fg(Color::Yellow), Span::raw(wrap_text).dim(), + ]; + // Add view mode toggle for group view + if self.is_group_view { + spans.push(Span::raw(" ")); + spans.push(Span::raw("[v]").fg(Color::Yellow)); + spans.push(Span::raw(" view").dim()); + } + spans.extend([ Span::raw(" "), Span::raw("[V]").fg(Color::Yellow), Span::raw(" visual").dim(), @@ -2121,7 +2542,8 @@ impl Component for MultiLogsComponent { Span::raw(" "), Span::raw("[q]").fg(Color::Yellow), Span::raw(" back").dim(), - ] + ]); + spans }; let footer = Paragraph::new(Line::from(footer_spans)).block( diff --git a/crates/talos-pilot-tui/src/components/network.rs b/crates/talos-pilot-tui/src/components/network.rs index 8a24e29..e1ed33b 100644 --- a/crates/talos-pilot-tui/src/components/network.rs +++ b/crates/talos-pilot-tui/src/components/network.rs @@ -95,6 +95,16 @@ pub enum ConnSortBy { Port, // Sort by local port } +/// View mode for group network +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum GroupViewMode { + /// Interleaved network data from all nodes (default) + #[default] + Interleaved, + /// Network data organized by node (tabbed view) + ByNode, +} + /// Pending action requiring confirmation #[derive(Debug, Clone)] pub enum PendingAction { @@ -207,6 +217,15 @@ pub struct NetworkData { pub kubespan_enabled: Option, } +/// Per-node network data for group view +#[derive(Debug, Clone, Default)] +pub struct NodeNetworkData { + /// Node hostname + pub hostname: String, + /// Network data for this node + pub data: NetworkData, +} + /// Network stats component for viewing node network interfaces pub struct NetworkStatsComponent { /// Node hostname @@ -274,6 +293,20 @@ pub struct NetworkStatsComponent { kubespan_selected: usize, /// KubeSpan table state kubespan_table_state: TableState, + + // Group view fields + /// Whether this is a group view (multiple nodes) + is_group_view: bool, + /// Group name (e.g., "Control Plane", "Workers") + group_name: String, + /// Nodes in the group: Vec<(hostname, ip)> + nodes: Vec<(String, String)>, + /// Current view mode for group network + group_view_mode: GroupViewMode, + /// Per-node network data + node_data: HashMap, + /// Selected node tab index (for ByNode view mode) + selected_node_tab: usize, } impl Default for NetworkStatsComponent { @@ -321,7 +354,120 @@ impl NetworkStatsComponent { state.select(Some(0)); state }, + // Group view fields (not used in single node mode) + is_group_view: false, + group_name: String::new(), + nodes: Vec::new(), + group_view_mode: GroupViewMode::default(), + node_data: HashMap::new(), + selected_node_tab: 0, + } + } + + /// Create a new network stats component for group view (multiple nodes) + /// - group_name: Name of the group (e.g., "Control Plane", "Workers") + /// - nodes: Vec of (hostname, ip) for each node + pub fn new_group(group_name: String, nodes: Vec<(String, String)>) -> Self { + let mut table_state = TableState::default(); + table_state.select(Some(0)); + let mut conn_table_state = TableState::default(); + conn_table_state.select(Some(0)); + + Self { + hostname: group_name.clone(), + address: String::new(), + state: AsyncState::new(), + selected: 0, + table_state, + sort_by: SortBy::Traffic, + auto_refresh: true, + view_mode: ViewMode::Interfaces, + selected_interface: None, + filtered_connections: Vec::new(), + conn_selected: 0, + conn_table_state, + conn_sort_by: ConnSortBy::State, + listening_only: false, + show_all_connections: false, + conn_selection_start: None, + conn_viewport_height: 20, + pending_action: None, + status_message: None, + pending_restart_service: None, + show_output_pane: false, + command_output: None, + file_viewer: None, + capture: CaptureData::default(), + client: None, + kubespan_selected: 0, + kubespan_table_state: { + let mut state = TableState::default(); + state.select(Some(0)); + state + }, + // Group view fields + is_group_view: true, + group_name, + nodes, + group_view_mode: GroupViewMode::default(), + node_data: HashMap::new(), + selected_node_tab: 0, + } + } + + /// Add network data from a node (for group view) + pub fn add_node_network(&mut self, hostname: String, devices: Vec) { + if !self.is_group_view { + return; + } + + // Store node data + let mut node_network = NodeNetworkData { + hostname: hostname.clone(), + data: NetworkData::default(), + }; + node_network.data.devices = devices; + + self.node_data.insert(hostname, node_network); + + // Rebuild merged view + self.rebuild_group_data(); + } + + /// Rebuild merged network data for group view + fn rebuild_group_data(&mut self) { + if !self.is_group_view { + return; + } + + // Merge all devices (prefixed with hostname for disambiguation) + let mut merged_devices = Vec::new(); + + for node_data in self.node_data.values() { + for device in &node_data.data.devices { + // Clone and prefix device name with hostname for clarity + let mut prefixed_device = device.clone(); + prefixed_device.name = format!("{}:{}", node_data.hostname, device.name); + merged_devices.push(prefixed_device); + } + } + + // Update the main state + if self.state.data().is_none() { + self.state.set_data(NetworkData::default()); + } + + if let Some(data) = self.state.data_mut() { + data.devices = merged_devices; + + // Calculate totals + data.total_rx_rate = data.rates.values().map(|r| r.rx_bytes_per_sec).sum(); + data.total_tx_rate = data.rates.values().map(|r| r.tx_bytes_per_sec).sum(); + data.total_errors = data.devices.iter().map(|d| d.total_errors()).sum(); + data.total_dropped = data.devices.iter().map(|d| d.total_dropped()).sum(); } + + self.state.mark_loaded(); } /// Get a reference to the loaded data @@ -963,12 +1109,56 @@ impl NetworkStatsComponent { Span::raw("") }; - let spans = vec![ + // Build header spans based on single node vs group view + let mut spans = vec![ Span::styled("Network: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(&self.hostname), - Span::styled(" (", Style::default().fg(Color::DarkGray)), - Span::raw(&self.address), - Span::styled(")", Style::default().fg(Color::DarkGray)), + ]; + + if self.is_group_view { + // Group view header + spans.push(Span::styled(&self.group_name, Style::default().fg(Color::Cyan))); + spans.push(Span::styled( + format!(" ({} nodes)", self.nodes.len()), + Style::default().fg(Color::DarkGray), + )); + + // View mode indicator + let view_mode_label = match self.group_view_mode { + GroupViewMode::Interleaved => "[MERGED]", + GroupViewMode::ByNode => "[BY NODE]", + }; + spans.push(Span::raw(" ")); + spans.push(Span::styled( + view_mode_label, + Style::default().fg(Color::Green), + )); + + // Node tabs for ByNode mode + if self.group_view_mode == GroupViewMode::ByNode && !self.nodes.is_empty() { + spans.push(Span::raw(" ")); + for (i, (hostname, _)) in self.nodes.iter().enumerate() { + if i == self.selected_node_tab { + spans.push(Span::styled( + format!("[{}]", hostname), + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + )); + } else { + spans.push(Span::styled( + format!(" {} ", hostname), + Style::default().fg(Color::DarkGray), + )); + } + } + } + } else { + // Single node header + spans.push(Span::raw(&self.hostname)); + spans.push(Span::styled(" (", Style::default().fg(Color::DarkGray))); + spans.push(Span::raw(&self.address)); + spans.push(Span::styled(")", Style::default().fg(Color::DarkGray))); + } + + spans.extend(vec![ Span::raw(" "), Span::styled(&device_count, Style::default().fg(Color::DarkGray)), Span::styled(auto_indicator, Style::default().fg(Color::Yellow)), @@ -976,7 +1166,7 @@ impl NetworkStatsComponent { tab_ifaces, conns_indicator, tab_kubespan, - ]; + ]); let header = Paragraph::new(Line::from(spans)); frame.render_widget(header, area); @@ -1188,6 +1378,19 @@ impl NetworkStatsComponent { } } + /// Get devices to display based on view mode (ByNode or Interleaved) + fn get_display_devices(&self) -> Option> { + if self.is_group_view && self.group_view_mode == GroupViewMode::ByNode { + // ByNode mode: show devices from selected node only + let (hostname, _) = self.nodes.get(self.selected_node_tab)?; + let node_data = self.node_data.get(hostname)?; + Some(node_data.data.devices.iter().collect()) + } else { + // Interleaved mode: use merged data + self.data().map(|d| d.devices.iter().collect()) + } + } + /// Draw the device table fn draw_device_table(&mut self, frame: &mut Frame, area: Rect) { // Build column headers with sort indicators @@ -1215,19 +1418,33 @@ impl NetworkStatsComponent { .style(Style::default().add_modifier(Modifier::DIM)) .bottom_margin(1); - // Get data for building rows - let Some(data) = self.data() else { - let table = Table::new(Vec::::new(), [Constraint::Fill(1)]).header(header); - frame.render_stateful_widget(table, area, &mut self.table_state); - return; + // Get devices based on view mode (ByNode or Interleaved) + let devices = match self.get_display_devices() { + Some(d) => d, + None => { + let table = Table::new(Vec::::new(), [Constraint::Fill(1)]).header(header); + frame.render_stateful_widget(table, area, &mut self.table_state); + return; + } + }; + + // Get rates from the appropriate source + let rates = if self.is_group_view && self.group_view_mode == GroupViewMode::ByNode { + if let Some((hostname, _)) = self.nodes.get(self.selected_node_tab) { + self.node_data.get(hostname).map(|nd| &nd.data.rates) + } else { + None + } + } else { + self.data().map(|d| &d.rates) }; + let rates = rates.cloned().unwrap_or_default(); - let rows: Vec = data - .devices + let rows: Vec = devices .iter() .enumerate() .map(|(idx, dev)| { - let rate = data.rates.get(&dev.name); + let rate = rates.get(&dev.name); let rx_rate = rate .map(|r| NetDevStats::format_rate(r.rx_bytes_per_sec)) .unwrap_or_else(|| "0 B/s".to_string()); @@ -1305,15 +1522,29 @@ impl NetworkStatsComponent { /// Draw the detail section for selected device fn draw_detail_section(&self, frame: &mut Frame, area: Rect) { - let Some(data) = self.data() else { - return; + // Get devices and rates based on view mode + let (devices, rates) = if self.is_group_view && self.group_view_mode == GroupViewMode::ByNode { + // ByNode mode: get from selected node + let Some((hostname, _)) = self.nodes.get(self.selected_node_tab) else { + return; + }; + let Some(node_data) = self.node_data.get(hostname) else { + return; + }; + (&node_data.data.devices, &node_data.data.rates) + } else { + // Interleaved mode: use merged data + let Some(data) = self.data() else { + return; + }; + (&data.devices, &data.rates) }; - let Some(dev) = data.devices.get(self.selected) else { + let Some(dev) = devices.get(self.selected) else { return; }; - let rate = data.rates.get(&dev.name); + let rate = rates.get(&dev.name); let rx_rate = rate .map(|r| NetDevStats::format_rate(r.rx_bytes_per_sec)) .unwrap_or_else(|| "0 B/s".to_string()); @@ -1399,8 +1630,25 @@ impl NetworkStatsComponent { ]; // Add connection summary line if we have connection data - if !data.connections.is_empty() { - let cc = &data.conn_counts; + // Get connection data from the appropriate source + let (connections, conn_counts) = if self.is_group_view && self.group_view_mode == GroupViewMode::ByNode { + if let Some((hostname, _)) = self.nodes.get(self.selected_node_tab) { + if let Some(node_data) = self.node_data.get(hostname) { + (&node_data.data.connections, &node_data.data.conn_counts) + } else { + return; + } + } else { + return; + } + } else if let Some(data) = self.data() { + (&data.connections, &data.conn_counts) + } else { + return; + }; + + if !connections.is_empty() { + let cc = conn_counts; lines.push(Line::from(vec![ Span::styled( "Connections: ", @@ -1480,7 +1728,23 @@ impl NetworkStatsComponent { ("BPF:OFF", Color::Yellow) }; - let spans = vec![ + let mut spans = vec![]; + + // Add view mode toggle hint if in group view + if self.is_group_view { + spans.push(Span::styled("[v]", Style::default().fg(Color::Cyan))); + spans.push(Span::raw(" view ")); + + // Add tab navigation hint if in ByNode mode + if self.group_view_mode == GroupViewMode::ByNode { + spans.push(Span::styled("[", Style::default().fg(Color::Cyan))); + spans.push(Span::styled("/", Style::default().fg(Color::DarkGray))); + spans.push(Span::styled("]", Style::default().fg(Color::Cyan))); + spans.push(Span::raw(" tabs ")); + } + } + + spans.extend(vec![ Span::styled("[Tab]", Style::default().fg(Color::Cyan)), Span::raw(" views "), Span::styled("[Enter]", Style::default().fg(Color::Cyan)), @@ -1495,7 +1759,7 @@ impl NetworkStatsComponent { Span::raw(format!(" {} ", auto_label)), Span::styled("[q]", Style::default().fg(Color::Cyan)), Span::raw(" back"), - ]; + ]); let footer = Paragraph::new(Line::from(spans)).style(Style::default().fg(Color::DarkGray)); frame.render_widget(footer, area); @@ -2380,6 +2644,43 @@ impl NetworkStatsComponent { self.toggle_bpf_filter(); Ok(None) } + KeyCode::Char('v') => { + // Toggle view mode (only in group view) + if self.is_group_view { + self.group_view_mode = match self.group_view_mode { + GroupViewMode::Interleaved => GroupViewMode::ByNode, + GroupViewMode::ByNode => GroupViewMode::Interleaved, + }; + // Reset selection when changing view mode + self.selected = 0; + self.table_state.select(Some(0)); + } + Ok(None) + } + KeyCode::Char('[') => { + // Previous node tab (only in group view with ByNode mode) + if self.is_group_view && self.group_view_mode == GroupViewMode::ByNode { + if self.selected_node_tab > 0 { + self.selected_node_tab -= 1; + // Reset selection when changing tabs + self.selected = 0; + self.table_state.select(Some(0)); + } + } + Ok(None) + } + KeyCode::Char(']') => { + // Next node tab (only in group view with ByNode mode) + if self.is_group_view && self.group_view_mode == GroupViewMode::ByNode { + if self.selected_node_tab + 1 < self.nodes.len() { + self.selected_node_tab += 1; + // Reset selection when changing tabs + self.selected = 0; + self.table_state.select(Some(0)); + } + } + Ok(None) + } _ => Ok(None), } } @@ -3696,3 +3997,159 @@ impl NetworkStatsComponent { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Create a test NetDevStats + fn make_device(name: &str) -> NetDevStats { + NetDevStats { + name: name.to_string(), + rx_bytes: 1000, + rx_packets: 10, + rx_errors: 0, + rx_dropped: 0, + tx_bytes: 500, + tx_packets: 5, + tx_errors: 0, + tx_dropped: 0, + } + } + + /// Create a NetworkStatsComponent for single node view + fn create_single_node_component() -> NetworkStatsComponent { + let mut component = + NetworkStatsComponent::new("test-node".to_string(), "10.0.0.1".to_string()); + + // Set up data with devices + let mut data = NetworkData::default(); + data.devices = vec![make_device("eth0"), make_device("lo"), make_device("cni0")]; + + component.state.set_data(data); + component + } + + /// Create a NetworkStatsComponent for group view + fn create_group_component() -> NetworkStatsComponent { + let nodes = vec![ + ("node-1".to_string(), "10.0.0.1".to_string()), + ("node-2".to_string(), "10.0.0.2".to_string()), + ]; + let mut component = NetworkStatsComponent::new_group("Control Plane".to_string(), nodes); + + // Add node data + component.add_node_network( + "node-1".to_string(), + vec![make_device("eth0"), make_device("lo")], + ); + component.add_node_network( + "node-2".to_string(), + vec![make_device("eth0"), make_device("cni0")], + ); + + component + } + + // ========================================================================== + // Tests for get_display_devices() + // ========================================================================== + + #[test] + fn test_get_display_devices_single_node_returns_merged_data() { + let component = create_single_node_component(); + + // Single node view should return the merged data regardless of view mode + let result = component.get_display_devices(); + assert!(result.is_some()); + + let devices = result.unwrap(); + assert_eq!(devices.len(), 3); + assert!(devices.iter().any(|d| d.name == "eth0")); + assert!(devices.iter().any(|d| d.name == "lo")); + assert!(devices.iter().any(|d| d.name == "cni0")); + } + + #[test] + fn test_get_display_devices_group_interleaved_returns_merged_data() { + let mut component = create_group_component(); + component.group_view_mode = GroupViewMode::Interleaved; + + let result = component.get_display_devices(); + assert!(result.is_some()); + + let devices = result.unwrap(); + // Should have merged devices from both nodes (prefixed with hostname) + assert_eq!(devices.len(), 4); + // Device names should be prefixed with hostname + assert!(devices + .iter() + .any(|d| d.name.contains("node-1:") || d.name.contains("node-2:"))); + } + + #[test] + fn test_get_display_devices_group_bynode_returns_selected_node_data() { + let mut component = create_group_component(); + component.group_view_mode = GroupViewMode::ByNode; + component.selected_node_tab = 0; // Select first node + + let result = component.get_display_devices(); + assert!(result.is_some()); + + let devices = result.unwrap(); + // Should only have devices from node-1 + assert_eq!(devices.len(), 2); + assert!(devices.iter().any(|d| d.name == "eth0")); + assert!(devices.iter().any(|d| d.name == "lo")); + assert!(!devices.iter().any(|d| d.name == "cni0")); + } + + #[test] + fn test_get_display_devices_group_bynode_second_node() { + let mut component = create_group_component(); + component.group_view_mode = GroupViewMode::ByNode; + component.selected_node_tab = 1; // Select second node + + let result = component.get_display_devices(); + assert!(result.is_some()); + + let devices = result.unwrap(); + // Should only have devices from node-2 + assert_eq!(devices.len(), 2); + assert!(devices.iter().any(|d| d.name == "eth0")); + assert!(devices.iter().any(|d| d.name == "cni0")); + assert!(!devices.iter().any(|d| d.name == "lo")); + } + + #[test] + fn test_get_display_devices_returns_none_when_tab_out_of_bounds() { + let mut component = create_group_component(); + component.group_view_mode = GroupViewMode::ByNode; + component.selected_node_tab = 99; // Invalid tab index + + let result = component.get_display_devices(); + assert!(result.is_none(), "Should return None for invalid tab index"); + } + + #[test] + fn test_get_display_devices_group_bynode_with_no_data_for_node() { + let nodes = vec![ + ("node-1".to_string(), "10.0.0.1".to_string()), + ("node-2".to_string(), "10.0.0.2".to_string()), + ]; + let mut component = NetworkStatsComponent::new_group("Control Plane".to_string(), nodes); + component.group_view_mode = GroupViewMode::ByNode; + + // Only add data for node-1 + component.add_node_network("node-1".to_string(), vec![make_device("eth0")]); + + // Select node-2 which has no data + component.selected_node_tab = 1; + + let result = component.get_display_devices(); + assert!( + result.is_none(), + "Should return None when selected node has no data" + ); + } +} diff --git a/crates/talos-pilot-tui/src/components/processes.rs b/crates/talos-pilot-tui/src/components/processes.rs index f808b64..f0ae8cd 100644 --- a/crates/talos-pilot-tui/src/components/processes.rs +++ b/crates/talos-pilot-tui/src/components/processes.rs @@ -48,9 +48,19 @@ pub enum Mode { Filtering, } +/// View mode for group processes +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum GroupViewMode { + /// Interleaved processes from all nodes (default) + #[default] + Interleaved, + /// Processes organized by node (tabbed view) + ByNode, +} + /// State counts for summary bar #[derive(Debug, Clone, Default)] -struct StateCounts { +pub struct StateCounts { running: usize, sleeping: usize, disk_wait: usize, @@ -70,6 +80,31 @@ struct DisplayEntry { ancestors_have_siblings: Vec, } +/// Per-node process data for group view +#[derive(Debug, Clone, Default)] +pub struct NodeProcessData { + /// Node hostname + pub hostname: String, + /// Processes from this node + pub processes: Vec, + /// State counts for this node + pub state_counts: StateCounts, + /// CPU percentages per process (pid -> percentage) + pub cpu_percentages: HashMap, + /// Previous CPU times (for calculating per-process CPU %) + pub prev_cpu_times: HashMap, + /// Time of last CPU measurement + pub last_cpu_sample: Option, + /// CPU count on this node + pub cpu_count: usize, + /// Total memory on this node + pub total_memory: u64, + /// Memory usage percentage + pub memory_usage_percent: f32, + /// Load average + pub load_avg: (f64, f64, f64), +} + /// Loaded process data (wrapped by AsyncState) #[derive(Debug, Clone, Default)] pub(crate) struct ProcessesData { @@ -142,6 +177,20 @@ pub struct ProcessesComponent { /// Client for API calls client: Option, + + // Group view fields + /// Whether this is a group view (multiple nodes) + is_group_view: bool, + /// Group name (e.g., "Control Plane", "Workers") + group_name: String, + /// Nodes in the group: Vec<(hostname, ip)> + nodes: Vec<(String, String)>, + /// Current view mode for group processes + view_mode: GroupViewMode, + /// Per-node process data + node_data: HashMap, + /// Selected node tab index (for ByNode view mode) + selected_node_tab: usize, } impl Default for ProcessesComponent { @@ -177,7 +226,128 @@ impl ProcessesComponent { filter: None, auto_refresh: true, client: None, + // Group view fields (not used in single node mode) + is_group_view: false, + group_name: String::new(), + nodes: Vec::new(), + view_mode: GroupViewMode::default(), + node_data: HashMap::new(), + selected_node_tab: 0, + } + } + + /// Create a new processes component for group view (multiple nodes) + /// - group_name: Name of the group (e.g., "Control Plane", "Workers") + /// - nodes: Vec of (hostname, ip) for each node + pub fn new_group(group_name: String, nodes: Vec<(String, String)>) -> Self { + let mut table_state = TableState::default(); + table_state.select(Some(0)); + + // Initialize with empty data + let initial_data = ProcessesData { + hostname: group_name.clone(), + address: String::new(), + ..Default::default() + }; + let mut state = AsyncState::new(); + state.set_data(initial_data); + + Self { + state, + selected: 0, + table_state, + sort_by: SortBy::CpuPercent, + tree_view: false, + tree_root: None, + state_filter: None, + mode: Mode::Normal, + filter_input: String::new(), + filter: None, + auto_refresh: true, + client: None, + // Group view fields + is_group_view: true, + group_name, + nodes, + view_mode: GroupViewMode::default(), + node_data: HashMap::new(), + selected_node_tab: 0, + } + } + + /// Add processes from a node (for group view) + pub fn add_node_processes(&mut self, hostname: String, processes: Vec) { + if !self.is_group_view { + return; + } + + // Calculate state counts + let mut state_counts = StateCounts::default(); + for proc in &processes { + match proc.state { + ProcessState::Running => state_counts.running += 1, + ProcessState::Sleeping => state_counts.sleeping += 1, + ProcessState::DiskSleep => state_counts.disk_wait += 1, + ProcessState::Zombie => state_counts.zombie += 1, + _ => {} + } + } + + // Store node data + let node_data = NodeProcessData { + hostname: hostname.clone(), + processes, + state_counts, + cpu_percentages: HashMap::new(), + prev_cpu_times: HashMap::new(), + last_cpu_sample: None, + cpu_count: 0, + total_memory: 0, + memory_usage_percent: 0.0, + load_avg: (0.0, 0.0, 0.0), + }; + self.node_data.insert(hostname, node_data); + + // Rebuild merged view + self.rebuild_group_data(); + } + + /// Rebuild merged process data for group view + fn rebuild_group_data(&mut self) { + if !self.is_group_view { + return; } + + // Collect merged data first (to avoid borrow conflicts) + let mut merged_processes = Vec::new(); + let mut merged_cpu_percentages = HashMap::new(); + let mut merged_state_counts = StateCounts::default(); + + for node_data in self.node_data.values() { + for proc in &node_data.processes { + merged_processes.push(proc.clone()); + } + // Merge CPU percentages + for (pid, pct) in &node_data.cpu_percentages { + merged_cpu_percentages.insert(*pid, *pct); + } + // Merge state counts + merged_state_counts.running += node_data.state_counts.running; + merged_state_counts.sleeping += node_data.state_counts.sleeping; + merged_state_counts.disk_wait += node_data.state_counts.disk_wait; + merged_state_counts.zombie += node_data.state_counts.zombie; + } + + // Now apply to data + if let Some(data) = self.data_mut() { + data.processes = merged_processes; + data.cpu_percentages = merged_cpu_percentages; + data.state_counts = merged_state_counts; + } + + // Sort and filter + self.sort_processes(); + self.apply_filter(); } /// Set the client for API calls @@ -816,7 +986,7 @@ impl ProcessesComponent { None => return, }; - let sort_indicator = format!("[{}▼]", self.sort_by.label()); + let sort_indicator = format!("[{}]", self.sort_by.label()); let tree_indicator = if self.tree_view { if let Some(root_pid) = self.tree_root { // Find root process name @@ -835,40 +1005,81 @@ impl ProcessesComponent { }; let proc_count = format!("{} procs", data.display_entries.len()); - // System resources info - let cpu_info = if data.cpu_count > 0 { - format!("{} CPU", data.cpu_count) - } else { - String::new() - }; - let mem_info = if data.total_memory > 0 { - format!("{} RAM", format_bytes_compact(data.total_memory)) - } else { - String::new() - }; - + // Build header based on single node vs group view let mut spans = vec![ Span::styled("Processes: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(&data.hostname), - Span::styled(" (", Style::default().fg(Color::DarkGray)), - Span::raw(&data.address), - Span::styled(")", Style::default().fg(Color::DarkGray)), ]; - // Add system info - if !cpu_info.is_empty() || !mem_info.is_empty() { + if self.is_group_view { + // Group view header + spans.push(Span::styled(&self.group_name, Style::default().fg(Color::Cyan))); + spans.push(Span::styled( + format!(" ({} nodes)", self.nodes.len()), + Style::default().fg(Color::DarkGray), + )); + + // View mode indicator + let view_mode_label = match self.view_mode { + GroupViewMode::Interleaved => "[MERGED]", + GroupViewMode::ByNode => "[BY NODE]", + }; spans.push(Span::raw(" ")); - spans.push(Span::styled("[", Style::default().fg(Color::DarkGray))); - if !cpu_info.is_empty() { - spans.push(Span::styled(&cpu_info, Style::default().fg(Color::Cyan))); - } - if !cpu_info.is_empty() && !mem_info.is_empty() { - spans.push(Span::styled(" | ", Style::default().fg(Color::DarkGray))); + spans.push(Span::styled( + view_mode_label, + Style::default().fg(Color::Green), + )); + + // Node tabs for ByNode mode + if self.view_mode == GroupViewMode::ByNode && !self.nodes.is_empty() { + spans.push(Span::raw(" ")); + for (i, (hostname, _)) in self.nodes.iter().enumerate() { + if i == self.selected_node_tab { + spans.push(Span::styled( + format!("[{}]", hostname), + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + )); + } else { + spans.push(Span::styled( + format!(" {} ", hostname), + Style::default().fg(Color::DarkGray), + )); + } + } } - if !mem_info.is_empty() { - spans.push(Span::styled(&mem_info, Style::default().fg(Color::Magenta))); + } else { + // Single node header + spans.push(Span::raw(data.hostname.clone())); + spans.push(Span::styled(" (", Style::default().fg(Color::DarkGray))); + spans.push(Span::raw(data.address.clone())); + spans.push(Span::styled(")", Style::default().fg(Color::DarkGray))); + + // System resources info + let cpu_info = if data.cpu_count > 0 { + format!("{} CPU", data.cpu_count) + } else { + String::new() + }; + let mem_info = if data.total_memory > 0 { + format!("{} RAM", format_bytes_compact(data.total_memory)) + } else { + String::new() + }; + + // Add system info + if !cpu_info.is_empty() || !mem_info.is_empty() { + spans.push(Span::raw(" ")); + spans.push(Span::styled("[", Style::default().fg(Color::DarkGray))); + if !cpu_info.is_empty() { + spans.push(Span::styled(cpu_info.clone(), Style::default().fg(Color::Cyan))); + } + if !cpu_info.is_empty() && !mem_info.is_empty() { + spans.push(Span::styled(" | ", Style::default().fg(Color::DarkGray))); + } + if !mem_info.is_empty() { + spans.push(Span::styled(mem_info, Style::default().fg(Color::Magenta))); + } + spans.push(Span::styled("]", Style::default().fg(Color::DarkGray))); } - spans.push(Span::styled("]", Style::default().fg(Color::DarkGray))); } spans.push(Span::raw(" ")); @@ -1105,20 +1316,99 @@ impl ProcessesComponent { let data = self.data()?; let tree_view = self.tree_view; let max_cmd_len = area.width.saturating_sub(45) as usize; - let max_mem = data - .processes + + // Get effective processes based on view mode + let (processes, cpu_percentages) = if self.is_group_view && self.view_mode == GroupViewMode::ByNode { + // ByNode mode: show processes from selected node only + if let Some((hostname, _)) = self.nodes.get(self.selected_node_tab) { + if let Some(node_data) = self.node_data.get(hostname) { + (&node_data.processes, &node_data.cpu_percentages) + } else { + return None; + } + } else { + return None; + } + } else { + // Interleaved mode or single node: use merged data + (&data.processes, &data.cpu_percentages) + }; + + let max_mem = processes .iter() .map(|p| p.resident_memory) .max() .unwrap_or(0); + // For ByNode mode, iterate over processes directly since display_entries refers to merged data + if self.is_group_view && self.view_mode == GroupViewMode::ByNode { + return Some( + processes + .iter() + .map(|proc| { + let cpu_pct = cpu_percentages.get(&proc.pid).copied().unwrap_or(0.0); + let cpu_color = if cpu_pct > 50.0 { + Color::Red + } else if cpu_pct > 10.0 { + Color::Yellow + } else if cpu_pct > 0.1 { + Color::Green + } else { + Color::default() + }; + + let mem_color = if max_mem == 0 { + Color::default() + } else { + let ratio = proc.resident_memory as f64 / max_mem as f64; + if ratio > 0.7 { + Color::Red + } else if ratio > 0.3 { + Color::Yellow + } else { + Color::default() + } + }; + + let state_color = Self::state_color(&proc.state); + let cpu_str = format!("{:>5.1}% {}", cpu_pct, proc.cpu_time_human()); + let mem_str = proc.resident_memory_human(); + let command = proc.display_command(); + + let cmd_display = if command.len() > max_cmd_len { + format!("{}...", &command[..max_cmd_len.saturating_sub(3)]) + } else { + command.to_string() + }; + + let is_interesting = matches!( + proc.state, + ProcessState::Running | ProcessState::Zombie | ProcessState::DiskSleep + ); + ( + proc.pid, + cpu_str, + mem_str, + proc.state.short().to_string(), + cmd_display, + cpu_color, + mem_color, + state_color, + is_interesting, + ) + }) + .collect(), + ); + } + + // For merged view or single node, use display_entries Some( data.display_entries .iter() .filter_map(|entry| { - let proc = data.processes.get(entry.process_idx)?; + let proc = processes.get(entry.process_idx)?; - let cpu_pct = data.cpu_percentages.get(&proc.pid).copied().unwrap_or(0.0); + let cpu_pct = cpu_percentages.get(&proc.pid).copied().unwrap_or(0.0); let cpu_color = if cpu_pct > 50.0 { Color::Red } else if cpu_pct > 10.0 { @@ -1375,11 +1665,28 @@ impl ProcessesComponent { _ => Span::raw(""), }; - let line = Line::from(vec![ + let mut spans = vec![ Span::styled("[1]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" cpu "), Span::styled("[2]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" mem "), + ]; + + // Add view mode toggle hint if in group view + if self.is_group_view { + spans.push(Span::styled("[v]", Style::default().add_modifier(Modifier::BOLD))); + spans.push(Span::raw(" view ")); + + // Add tab navigation hint if in ByNode mode + if self.view_mode == GroupViewMode::ByNode { + spans.push(Span::styled("[", Style::default().add_modifier(Modifier::BOLD))); + spans.push(Span::styled("/", Style::default().fg(Color::DarkGray))); + spans.push(Span::styled("]", Style::default().add_modifier(Modifier::BOLD))); + spans.push(Span::raw(" tabs ")); + } + } + + spans.extend(vec![ Span::styled("[t]", Style::default().add_modifier(Modifier::BOLD)), Span::raw(" subtree "), Span::styled("[T]", Style::default().add_modifier(Modifier::BOLD)), @@ -1401,6 +1708,8 @@ impl ProcessesComponent { Span::raw(" back"), ]); + let line = Line::from(spans); + let para = Paragraph::new(line); frame.render_widget(para, area); } @@ -1615,6 +1924,43 @@ impl ProcessesComponent { } Ok(None) } + KeyCode::Char('v') => { + // Toggle view mode (only in group view) + if self.is_group_view { + self.view_mode = match self.view_mode { + GroupViewMode::Interleaved => GroupViewMode::ByNode, + GroupViewMode::ByNode => GroupViewMode::Interleaved, + }; + // Reset selection when changing view mode + self.selected = 0; + self.table_state.select(Some(0)); + } + Ok(None) + } + KeyCode::Char('[') => { + // Previous node tab (only in group view with ByNode mode) + if self.is_group_view && self.view_mode == GroupViewMode::ByNode { + if self.selected_node_tab > 0 { + self.selected_node_tab -= 1; + // Reset selection when changing tabs + self.selected = 0; + self.table_state.select(Some(0)); + } + } + Ok(None) + } + KeyCode::Char(']') => { + // Next node tab (only in group view with ByNode mode) + if self.is_group_view && self.view_mode == GroupViewMode::ByNode { + if self.selected_node_tab + 1 < self.nodes.len() { + self.selected_node_tab += 1; + // Reset selection when changing tabs + self.selected = 0; + self.table_state.select(Some(0)); + } + } + Ok(None) + } _ => Ok(None), } } diff --git a/crates/talos-pilot-tui/src/components/storage.rs b/crates/talos-pilot-tui/src/components/storage.rs index f544004..1f837f3 100644 --- a/crates/talos-pilot-tui/src/components/storage.rs +++ b/crates/talos-pilot-tui/src/components/storage.rs @@ -46,6 +46,27 @@ impl StorageViewMode { } } +/// View mode for group storage +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum GroupViewMode { + /// Interleaved storage data from all nodes (default) + #[default] + Interleaved, + /// Storage data organized by node (tabbed view) + ByNode, +} + +/// Per-node storage data for group view +#[derive(Debug, Clone, Default)] +pub struct NodeStorageData { + /// Node hostname + pub hostname: String, + /// Disks from this node + pub disks: Vec, + /// Volumes from this node + pub volumes: Vec, +} + /// Loaded storage data (wrapped by AsyncState) #[derive(Debug, Clone, Default)] pub struct StorageData { @@ -88,6 +109,20 @@ pub struct StorageComponent { /// Config path for authentication config_path: Option, + + // Group view fields + /// Whether this is a group view (multiple nodes) + is_group_view: bool, + /// Group name (e.g., "Control Plane", "Workers") + group_name: String, + /// Nodes in the group: Vec<(hostname, ip)> + nodes: Vec<(String, String)>, + /// Current view mode for group storage + group_view_mode: GroupViewMode, + /// Per-node storage data + node_data: std::collections::HashMap, + /// Selected node tab index (for ByNode view mode) + selected_node_tab: usize, } impl Default for StorageComponent { @@ -131,7 +166,96 @@ impl StorageComponent { node_address, context, config_path, + // Group view fields (not used in single node mode) + is_group_view: false, + group_name: String::new(), + nodes: Vec::new(), + group_view_mode: GroupViewMode::default(), + node_data: std::collections::HashMap::new(), + selected_node_tab: 0, + } + } + + /// Create a new storage component for group view (multiple nodes) + /// - group_name: Name of the group (e.g., "Control Plane", "Workers") + /// - nodes: Vec of (hostname, ip) for each node + pub fn new_group(group_name: String, nodes: Vec<(String, String)>) -> Self { + let mut disk_table_state = TableState::default(); + disk_table_state.select(Some(0)); + let mut volume_table_state = TableState::default(); + volume_table_state.select(Some(0)); + + let initial_data = StorageData { + hostname: group_name.clone(), + address: String::new(), + ..Default::default() + }; + + Self { + state: AsyncState::with_data(initial_data), + view_mode: StorageViewMode::Disks, + disk_table_state, + volume_table_state, + auto_refresh: true, + client: None, + node_address: None, + context: None, + config_path: None, + // Group view fields + is_group_view: true, + group_name, + nodes, + group_view_mode: GroupViewMode::default(), + node_data: std::collections::HashMap::new(), + selected_node_tab: 0, + } + } + + /// Add storage data from a node (for group view) + pub fn add_node_storage(&mut self, hostname: String, disks: Vec, volumes: Vec) { + if !self.is_group_view { + return; } + + // Store node data + let node_storage = NodeStorageData { + hostname: hostname.clone(), + disks, + volumes, + }; + self.node_data.insert(hostname, node_storage); + + // Rebuild merged view + self.rebuild_group_data(); + } + + /// Rebuild merged storage data for group view + fn rebuild_group_data(&mut self) { + if !self.is_group_view { + return; + } + + // Get or create data + let mut data = self.state.take_data().unwrap_or_default(); + + // Merge all disks and volumes (prefixed with hostname) + data.disks.clear(); + data.volumes.clear(); + + for node_data in self.node_data.values() { + for disk in &node_data.disks { + let mut prefixed_disk = disk.clone(); + prefixed_disk.dev_path = format!("{}:{}", node_data.hostname, disk.dev_path); + data.disks.push(prefixed_disk); + } + for volume in &node_data.volumes { + let mut prefixed_volume = volume.clone(); + prefixed_volume.id = format!("{}:{}", node_data.hostname, volume.id); + data.volumes.push(prefixed_volume); + } + } + + self.state.set_data(data); } /// Set the client for API calls @@ -255,6 +379,32 @@ impl StorageComponent { } } + /// Get disks to display based on view mode (ByNode or Interleaved) + fn get_display_disks(&self) -> Option> { + if self.is_group_view && self.group_view_mode == GroupViewMode::ByNode { + // ByNode mode: show disks from selected node only + let (hostname, _) = self.nodes.get(self.selected_node_tab)?; + let node_data = self.node_data.get(hostname)?; + Some(node_data.disks.iter().collect()) + } else { + // Interleaved mode: use merged data + self.data().map(|d| d.disks.iter().collect()) + } + } + + /// Get volumes to display based on view mode (ByNode or Interleaved) + fn get_display_volumes(&self) -> Option> { + if self.is_group_view && self.group_view_mode == GroupViewMode::ByNode { + // ByNode mode: show volumes from selected node only + let (hostname, _) = self.nodes.get(self.selected_node_tab)?; + let node_data = self.node_data.get(hostname)?; + Some(node_data.volumes.iter().collect()) + } else { + // Interleaved mode: use merged data + self.data().map(|d| d.volumes.iter().collect()) + } + } + /// Draw the disks view fn draw_disks_view(&mut self, frame: &mut Frame, area: Rect) { let chunks = Layout::vertical([ @@ -273,8 +423,8 @@ impl StorageComponent { ]) .height(1); - let rows: Vec = if let Some(data) = self.data() { - data.disks + let rows: Vec = if let Some(disks) = self.get_display_disks() { + disks .iter() .map(|disk| { let disk_type = if disk.cdrom { @@ -337,8 +487,8 @@ impl StorageComponent { .title(" Details ") .title_style(Style::default().fg(Color::Yellow)); - let content = if let Some(data) = self.data() { - if let Some(disk) = data.disks.get(self.selected_disk_index()) { + let content = if let Some(disks) = self.get_display_disks() { + if let Some(disk) = disks.get(self.selected_disk_index()) { let mut lines = vec![ Line::from(vec![ Span::styled("Device: ", Style::default().fg(Color::Gray)), @@ -409,8 +559,8 @@ impl StorageComponent { ]) .height(1); - let rows: Vec = if let Some(data) = self.data() { - data.volumes + let rows: Vec = if let Some(volumes) = self.get_display_volumes() { + volumes .iter() .map(|vol| { let phase_color = match vol.phase.as_str() { @@ -475,8 +625,8 @@ impl StorageComponent { .title(" Details ") .title_style(Style::default().fg(Color::Yellow)); - let content = if let Some(data) = self.data() { - if let Some(vol) = data.volumes.get(self.selected_volume_index()) { + let content = if let Some(volumes) = self.get_display_volumes() { + if let Some(vol) = volumes.get(self.selected_volume_index()) { vec![ Line::from(vec![ Span::styled("Volume: ", Style::default().fg(Color::Gray)), @@ -534,14 +684,53 @@ impl StorageComponent { }) .collect(); - let hostname = self.data().map(|d| d.hostname.clone()).unwrap_or_default(); - let mut line_spans = tab_spans; line_spans.push(Span::raw(" ")); - line_spans.push(Span::styled( - format!("Node: {}", hostname), - Style::default().fg(Color::DarkGray), - )); + + if self.is_group_view { + // Group view header + line_spans.push(Span::styled(&self.group_name, Style::default().fg(Color::Cyan))); + line_spans.push(Span::styled( + format!(" ({} nodes)", self.nodes.len()), + Style::default().fg(Color::DarkGray), + )); + + // View mode indicator + let view_mode_label = match self.group_view_mode { + GroupViewMode::Interleaved => "[MERGED]", + GroupViewMode::ByNode => "[BY NODE]", + }; + line_spans.push(Span::raw(" ")); + line_spans.push(Span::styled( + view_mode_label, + Style::default().fg(Color::Green), + )); + + // Node tabs for ByNode mode + if self.group_view_mode == GroupViewMode::ByNode && !self.nodes.is_empty() { + line_spans.push(Span::raw(" ")); + for (i, (hostname, _)) in self.nodes.iter().enumerate() { + if i == self.selected_node_tab { + line_spans.push(Span::styled( + format!("[{}]", hostname), + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + )); + } else { + line_spans.push(Span::styled( + format!(" {} ", hostname), + Style::default().fg(Color::DarkGray), + )); + } + } + } + } else { + // Single node header + let hostname = self.data().map(|d| d.hostname.clone()).unwrap_or_default(); + line_spans.push(Span::styled( + format!("Node: {}", hostname), + Style::default().fg(Color::DarkGray), + )); + } let tabs_line = Line::from(line_spans); let paragraph = Paragraph::new(tabs_line); @@ -567,6 +756,40 @@ impl Component for StorageComponent { KeyCode::Char('r') => { return Ok(Some(Action::Refresh)); } + KeyCode::Char('v') => { + // Toggle view mode (only in group view) + if self.is_group_view { + self.group_view_mode = match self.group_view_mode { + GroupViewMode::Interleaved => GroupViewMode::ByNode, + GroupViewMode::ByNode => GroupViewMode::Interleaved, + }; + // Reset selection when changing view mode + self.disk_table_state.select(Some(0)); + self.volume_table_state.select(Some(0)); + } + } + KeyCode::Char('[') => { + // Previous node tab (only in group view with ByNode mode) + if self.is_group_view && self.group_view_mode == GroupViewMode::ByNode { + if self.selected_node_tab > 0 { + self.selected_node_tab -= 1; + // Reset selection when changing tabs + self.disk_table_state.select(Some(0)); + self.volume_table_state.select(Some(0)); + } + } + } + KeyCode::Char(']') => { + // Next node tab (only in group view with ByNode mode) + if self.is_group_view && self.group_view_mode == GroupViewMode::ByNode { + if self.selected_node_tab + 1 < self.nodes.len() { + self.selected_node_tab += 1; + // Reset selection when changing tabs + self.disk_table_state.select(Some(0)); + self.volume_table_state.select(Some(0)); + } + } + } _ => {} } Ok(None) @@ -616,7 +839,23 @@ impl Component for StorageComponent { } // Draw help line - let help = Line::from(vec![ + let mut help_spans = vec![]; + + // Add view mode toggle hint if in group view + if self.is_group_view { + help_spans.push(Span::styled("v", Style::default().fg(Color::Cyan))); + help_spans.push(Span::raw(" view ")); + + // Add tab navigation hint if in ByNode mode + if self.group_view_mode == GroupViewMode::ByNode { + help_spans.push(Span::styled("[", Style::default().fg(Color::Cyan))); + help_spans.push(Span::styled("/", Style::default().fg(Color::DarkGray))); + help_spans.push(Span::styled("]", Style::default().fg(Color::Cyan))); + help_spans.push(Span::raw(" tabs ")); + } + } + + help_spans.extend(vec![ Span::styled(" Tab", Style::default().fg(Color::Cyan)), Span::raw(" switch view "), Span::styled("↑/↓", Style::default().fg(Color::Cyan)), @@ -626,9 +865,271 @@ impl Component for StorageComponent { Span::styled("Esc", Style::default().fg(Color::Cyan)), Span::raw(" back"), ]); + + let help = Line::from(help_spans); let help_paragraph = Paragraph::new(help).style(Style::default().fg(Color::DarkGray)); frame.render_widget(help_paragraph, chunks[2]); Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Create a test DiskInfo + fn make_disk(id: &str) -> DiskInfo { + DiskInfo { + id: id.to_string(), + dev_path: format!("/dev/{}", id), + size: 500_000_000_000, + size_pretty: "500 GB".to_string(), + model: Some("Test Disk".to_string()), + serial: Some("ABC123".to_string()), + transport: Some("sata".to_string()), + rotational: false, + readonly: false, + cdrom: false, + wwid: None, + bus_path: None, + } + } + + /// Create a test VolumeStatus + fn make_volume(id: &str) -> VolumeStatus { + VolumeStatus { + id: id.to_string(), + encryption_provider: None, + phase: "ready".to_string(), + size: "10 GB".to_string(), + filesystem: Some("xfs".to_string()), + mount_location: Some(format!("/var/{}", id)), + } + } + + /// Create a StorageComponent for single node view + fn create_single_node_component() -> StorageComponent { + let mut component = StorageComponent::new( + "test-node".to_string(), + "10.0.0.1".to_string(), + None, + None, + ); + + // Set up data + let mut data = StorageData::default(); + data.disks = vec![make_disk("sda"), make_disk("sdb")]; + data.volumes = vec![make_volume("STATE"), make_volume("EPHEMERAL")]; + + component.state.set_data(data); + component + } + + /// Create a StorageComponent for group view + fn create_group_component() -> StorageComponent { + let nodes = vec![ + ("node-1".to_string(), "10.0.0.1".to_string()), + ("node-2".to_string(), "10.0.0.2".to_string()), + ]; + let mut component = StorageComponent::new_group("Control Plane".to_string(), nodes); + + // Add node data + component.add_node_storage( + "node-1".to_string(), + vec![make_disk("sda"), make_disk("sdb")], + vec![make_volume("STATE")], + ); + component.add_node_storage( + "node-2".to_string(), + vec![make_disk("nvme0n1")], + vec![make_volume("STATE"), make_volume("EPHEMERAL")], + ); + + component + } + + // ========================================================================== + // Tests for get_display_disks() + // ========================================================================== + + #[test] + fn test_get_display_disks_single_node_returns_all_disks() { + let component = create_single_node_component(); + + let result = component.get_display_disks(); + assert!(result.is_some()); + + let disks = result.unwrap(); + assert_eq!(disks.len(), 2); + assert!(disks.iter().any(|d| d.id == "sda")); + assert!(disks.iter().any(|d| d.id == "sdb")); + } + + #[test] + fn test_get_display_disks_group_interleaved_returns_merged_data() { + let mut component = create_group_component(); + component.group_view_mode = GroupViewMode::Interleaved; + + let result = component.get_display_disks(); + assert!(result.is_some()); + + let disks = result.unwrap(); + // Should have merged disks from both nodes (3 total) + assert_eq!(disks.len(), 3); + // Dev paths should be prefixed with hostname + assert!(disks + .iter() + .any(|d| d.dev_path.contains("node-1:") || d.dev_path.contains("node-2:"))); + } + + #[test] + fn test_get_display_disks_group_bynode_returns_selected_node_data() { + let mut component = create_group_component(); + component.group_view_mode = GroupViewMode::ByNode; + component.selected_node_tab = 0; // Select first node + + let result = component.get_display_disks(); + assert!(result.is_some()); + + let disks = result.unwrap(); + // Should only have disks from node-1 + assert_eq!(disks.len(), 2); + assert!(disks.iter().any(|d| d.id == "sda")); + assert!(disks.iter().any(|d| d.id == "sdb")); + assert!(!disks.iter().any(|d| d.id == "nvme0n1")); + } + + #[test] + fn test_get_display_disks_group_bynode_second_node() { + let mut component = create_group_component(); + component.group_view_mode = GroupViewMode::ByNode; + component.selected_node_tab = 1; // Select second node + + let result = component.get_display_disks(); + assert!(result.is_some()); + + let disks = result.unwrap(); + // Should only have disks from node-2 + assert_eq!(disks.len(), 1); + assert!(disks.iter().any(|d| d.id == "nvme0n1")); + assert!(!disks.iter().any(|d| d.id == "sda")); + } + + #[test] + fn test_get_display_disks_returns_none_when_tab_out_of_bounds() { + let mut component = create_group_component(); + component.group_view_mode = GroupViewMode::ByNode; + component.selected_node_tab = 99; // Invalid tab index + + let result = component.get_display_disks(); + assert!(result.is_none(), "Should return None for invalid tab index"); + } + + // ========================================================================== + // Tests for get_display_volumes() + // ========================================================================== + + #[test] + fn test_get_display_volumes_single_node_returns_all_volumes() { + let component = create_single_node_component(); + + let result = component.get_display_volumes(); + assert!(result.is_some()); + + let volumes = result.unwrap(); + assert_eq!(volumes.len(), 2); + assert!(volumes.iter().any(|v| v.id == "STATE")); + assert!(volumes.iter().any(|v| v.id == "EPHEMERAL")); + } + + #[test] + fn test_get_display_volumes_group_interleaved_returns_merged_data() { + let mut component = create_group_component(); + component.group_view_mode = GroupViewMode::Interleaved; + + let result = component.get_display_volumes(); + assert!(result.is_some()); + + let volumes = result.unwrap(); + // Should have merged volumes from both nodes (3 total) + assert_eq!(volumes.len(), 3); + // IDs should be prefixed with hostname + assert!(volumes + .iter() + .any(|v| v.id.contains("node-1:") || v.id.contains("node-2:"))); + } + + #[test] + fn test_get_display_volumes_group_bynode_returns_selected_node_data() { + let mut component = create_group_component(); + component.group_view_mode = GroupViewMode::ByNode; + component.selected_node_tab = 0; // Select first node + + let result = component.get_display_volumes(); + assert!(result.is_some()); + + let volumes = result.unwrap(); + // Should only have volumes from node-1 (1 volume: STATE) + assert_eq!(volumes.len(), 1); + assert!(volumes.iter().any(|v| v.id == "STATE")); + } + + #[test] + fn test_get_display_volumes_group_bynode_second_node() { + let mut component = create_group_component(); + component.group_view_mode = GroupViewMode::ByNode; + component.selected_node_tab = 1; // Select second node + + let result = component.get_display_volumes(); + assert!(result.is_some()); + + let volumes = result.unwrap(); + // Should only have volumes from node-2 (2 volumes: STATE, EPHEMERAL) + assert_eq!(volumes.len(), 2); + assert!(volumes.iter().any(|v| v.id == "STATE")); + assert!(volumes.iter().any(|v| v.id == "EPHEMERAL")); + } + + #[test] + fn test_get_display_volumes_returns_none_when_tab_out_of_bounds() { + let mut component = create_group_component(); + component.group_view_mode = GroupViewMode::ByNode; + component.selected_node_tab = 99; // Invalid tab index + + let result = component.get_display_volumes(); + assert!(result.is_none(), "Should return None for invalid tab index"); + } + + #[test] + fn test_get_display_volumes_group_bynode_with_no_data_for_node() { + let nodes = vec![ + ("node-1".to_string(), "10.0.0.1".to_string()), + ("node-2".to_string(), "10.0.0.2".to_string()), + ]; + let mut component = StorageComponent::new_group("Control Plane".to_string(), nodes); + component.group_view_mode = GroupViewMode::ByNode; + + // Only add data for node-1 + component.add_node_storage( + "node-1".to_string(), + vec![make_disk("sda")], + vec![make_volume("STATE")], + ); + + // Select node-2 which has no data + component.selected_node_tab = 1; + + let result = component.get_display_volumes(); + assert!( + result.is_none(), + "Should return None when selected node has no data" + ); + + let disk_result = component.get_display_disks(); + assert!( + disk_result.is_none(), + "Should return None for disks when selected node has no data" + ); + } +} From 3fbc85d96fb3d777a7ef94099b59edc643a125a9 Mon Sep 17 00:00:00 2001 From: perfectra1n Date: Sun, 18 Jan 2026 12:50:47 -0800 Subject: [PATCH 2/4] feat: make sure to have refresh work in group view --- crates/talos-pilot-tui/src/app.rs | 12 ++++ .../src/components/diagnostics/mod.rs | 57 ++++++++++++++++++ .../talos-pilot-tui/src/components/network.rs | 42 +++++++++++++ .../src/components/processes.rs | 43 +++++++++++++ .../talos-pilot-tui/src/components/storage.rs | 60 +++++++++++++++++++ 5 files changed, 214 insertions(+) diff --git a/crates/talos-pilot-tui/src/app.rs b/crates/talos-pilot-tui/src/app.rs index 169317d..9f940ba 100644 --- a/crates/talos-pilot-tui/src/app.rs +++ b/crates/talos-pilot-tui/src/app.rs @@ -1273,6 +1273,9 @@ impl App { // Fetch processes from all nodes if let Some(client) = self.cluster.client() { + // Set the base cluster client for group refresh capability + processes.set_client(client.clone()); + for (hostname, ip) in &nodes { let node_client = client.with_node(ip); match node_client.processes().await { @@ -1305,6 +1308,9 @@ impl App { // Fetch network stats from all nodes if let Some(client) = self.cluster.client() { + // Set the base cluster client for group refresh capability + network.set_client(client.clone()); + for (hostname, ip) in &nodes { let node_client = client.with_node(ip); match node_client.network_device_stats().await { @@ -1339,6 +1345,9 @@ impl App { let context = self.cluster.current_context_name().map(|s| s.to_string()); let config_path = self.cluster.config_path().map(|s| s.to_string()); + // Set context and config for group refresh capability + storage.set_context(context.clone(), config_path.clone()); + // Fetch storage info from all nodes using talosctl for (hostname, ip) in &nodes { // Extract IP without port @@ -1388,6 +1397,9 @@ impl App { // Fetch diagnostics from all nodes if let Some(client) = self.cluster.client() { + // Set the base cluster client for group refresh capability + diagnostics.set_client(client.clone()); + for (hostname, ip) in &nodes { let node_client = client.with_node(ip); diff --git a/crates/talos-pilot-tui/src/components/diagnostics/mod.rs b/crates/talos-pilot-tui/src/components/diagnostics/mod.rs index 1779b52..d615378 100644 --- a/crates/talos-pilot-tui/src/components/diagnostics/mod.rs +++ b/crates/talos-pilot-tui/src/components/diagnostics/mod.rs @@ -594,6 +594,11 @@ impl DiagnosticsComponent { } }; + // Handle group view refresh + if self.is_group_view { + return self.refresh_group(client).await; + } + self.state.start_loading(); let timeout = std::time::Duration::from_secs(15); @@ -764,6 +769,58 @@ impl DiagnosticsComponent { Ok(()) } + /// Refresh diagnostics data for group view (multiple nodes) + fn refresh_group( + &mut self, + client: TalosClient, + ) -> std::pin::Pin> + Send + '_>> { + Box::pin(async move { + self.state.start_loading(); + + // Clear existing node data + self.node_data.clear(); + + // Clone fields to avoid borrow issues + let nodes = self.nodes.clone(); + let node_role = self.node_role.clone(); + let config_path = self.config_path.clone(); + let controlplane_endpoint = self.controlplane_endpoint.clone(); + + // Fetch diagnostics from all nodes + for (hostname, ip) in &nodes { + let node_client = client.with_node(ip); + + // Create a temporary single-node diagnostics component to run checks + let mut temp_diag = DiagnosticsComponent::new( + hostname.clone(), + ip.clone(), + node_role.clone(), + config_path.clone(), + ); + temp_diag.set_client(node_client); + temp_diag.set_controlplane_endpoint(controlplane_endpoint.clone()); + + // Run the diagnostics checks + match temp_diag.refresh().await { + Ok(_) => { + // Extract the data and add it to the group component + if let Some(data) = temp_diag.take_data() { + self.add_node_diagnostics(hostname.clone(), data); + } + } + Err(e) => { + tracing::warn!("Failed to fetch diagnostics from {}: {}", hostname, e); + } + } + } + + // Reset selection + self.ensure_valid_selection(); + self.state.mark_loaded(); + Ok(()) + }) + } + /// Get category title fn category_title(&self, idx: usize) -> &'static str { match idx { diff --git a/crates/talos-pilot-tui/src/components/network.rs b/crates/talos-pilot-tui/src/components/network.rs index e1ed33b..b5e542d 100644 --- a/crates/talos-pilot-tui/src/components/network.rs +++ b/crates/talos-pilot-tui/src/components/network.rs @@ -497,6 +497,11 @@ impl NetworkStatsComponent { return Ok(()); }; + // Handle group view refresh + if self.is_group_view { + return self.refresh_group(client).await; + } + self.state.start_loading(); // Ensure we have data to update @@ -607,6 +612,43 @@ impl NetworkStatsComponent { Ok(()) } + /// Refresh network data for group view (multiple nodes) + async fn refresh_group(&mut self, client: TalosClient) -> Result<()> { + self.state.start_loading(); + + // Clear existing node data + self.node_data.clear(); + + // Clone nodes to avoid borrow issues + let nodes = self.nodes.clone(); + + // Fetch network stats from all nodes + for (hostname, ip) in &nodes { + let node_client = client.with_node(ip); + match node_client.network_device_stats().await { + Ok(node_stats_list) => { + // The API returns Vec, extract devices from each + for ns in node_stats_list { + self.add_node_network(hostname.clone(), ns.devices); + } + } + Err(e) => { + tracing::warn!("Failed to fetch network stats from {}: {}", hostname, e); + } + } + } + + // Reset selection if needed + let device_count = self.data().map(|d| d.devices.len()).unwrap_or(0); + if device_count > 0 && self.selected >= device_count { + self.selected = 0; + } + self.table_state.select(Some(self.selected)); + + self.state.mark_loaded(); + Ok(()) + } + /// Refresh KubeSpan peer data via talosctl async fn refresh_kubespan_data(&mut self) { let node = self.address.clone(); diff --git a/crates/talos-pilot-tui/src/components/processes.rs b/crates/talos-pilot-tui/src/components/processes.rs index f0ae8cd..9a21dd2 100644 --- a/crates/talos-pilot-tui/src/components/processes.rs +++ b/crates/talos-pilot-tui/src/components/processes.rs @@ -583,6 +583,11 @@ impl ProcessesComponent { return Ok(()); }; + // Handle group view refresh + if self.is_group_view { + return self.refresh_group(client).await; + } + self.state.start_loading(); // Get or create data, preserving previous CPU state for delta calculations @@ -678,6 +683,44 @@ impl ProcessesComponent { Ok(()) } + /// Refresh process data for group view (multiple nodes) + async fn refresh_group(&mut self, client: TalosClient) -> Result<()> { + self.state.start_loading(); + + // Clear existing node data + self.node_data.clear(); + + // Clone nodes to avoid borrow issues + let nodes = self.nodes.clone(); + + // Fetch processes from all nodes + for (hostname, ip) in &nodes { + let node_client = client.with_node(ip); + match node_client.processes().await { + Ok(node_processes_list) => { + // The API returns Vec, extract processes from first (should only be one) + for np in node_processes_list { + self.add_node_processes(hostname.clone(), np.processes); + } + } + Err(e) => { + tracing::warn!("Failed to fetch processes from {}: {}", hostname, e); + } + } + } + + // Reset selection if needed + if let Some(data) = self.data() { + if !data.display_entries.is_empty() && self.selected >= data.display_entries.len() { + self.selected = 0; + } + } + self.table_state.select(Some(self.selected)); + + self.state.mark_loaded(); + Ok(()) + } + /// Calculate state counts from processes (static method) fn calculate_state_counts_into(data: &mut ProcessesData) { data.state_counts = StateCounts::default(); diff --git a/crates/talos-pilot-tui/src/components/storage.rs b/crates/talos-pilot-tui/src/components/storage.rs index 1f837f3..4e37fd7 100644 --- a/crates/talos-pilot-tui/src/components/storage.rs +++ b/crates/talos-pilot-tui/src/components/storage.rs @@ -263,6 +263,12 @@ impl StorageComponent { self.client = Some(client); } + /// Set context and config path for talosctl commands (used for group view refresh) + pub fn set_context(&mut self, context: Option, config_path: Option) { + self.context = context; + self.config_path = config_path; + } + /// Set error message pub fn set_error(&mut self, error: String) { self.state.set_error(error); @@ -275,6 +281,11 @@ impl StorageComponent { /// Refresh storage data pub async fn refresh(&mut self) -> Result<()> { + // Handle group view refresh + if self.is_group_view { + return self.refresh_group().await; + } + self.state.start_loading(); let Some(node) = &self.node_address else { @@ -317,6 +328,55 @@ impl StorageComponent { Ok(()) } + /// Refresh storage data for group view (multiple nodes) + async fn refresh_group(&mut self) -> Result<()> { + let Some(context) = self.context.clone() else { + self.state.set_error("No context configured"); + return Ok(()); + }; + + self.state.start_loading(); + + // Clear existing node data + self.node_data.clear(); + + // Clone nodes to avoid borrow issues + let nodes = self.nodes.clone(); + let config_path = self.config_path.clone(); + + // Fetch storage info from all nodes using talosctl + for (hostname, ip) in &nodes { + // Extract IP without port + let node_ip = ip.split(':').next().unwrap_or(ip); + + // Fetch disks + match get_disks_for_node(&context, node_ip, config_path.as_deref()).await { + Ok(disks) => { + // Fetch volumes + match get_volume_status_for_node(&context, node_ip, config_path.as_deref()).await { + Ok(volumes) => { + self.add_node_storage(hostname.clone(), disks, volumes); + } + Err(e) => { + tracing::warn!("Failed to fetch volumes from {}: {}", hostname, e); + self.add_node_storage(hostname.clone(), disks, Vec::new()); + } + } + } + Err(e) => { + tracing::warn!("Failed to fetch disks from {}: {}", hostname, e); + } + } + } + + // Reset selection if needed + self.disk_table_state.select(Some(0)); + self.volume_table_state.select(Some(0)); + + self.state.mark_loaded(); + Ok(()) + } + /// Get selected disk index fn selected_disk_index(&self) -> usize { self.disk_table_state.selected().unwrap_or(0) From 56f186e6f3da30f100385e097fcce8d425b169e5 Mon Sep 17 00:00:00 2001 From: Kenny Udovic Date: Tue, 20 Jan 2026 22:09:28 -0500 Subject: [PATCH 3/4] chore: clippy warnings and fmt --- crates/talos-pilot-tui/src/app.rs | 54 +- .../talos-pilot-tui/src/components/cluster.rs | 1609 +++++++++-------- .../src/components/diagnostics/mod.rs | 60 +- .../src/components/multi_logs.rs | 105 +- .../talos-pilot-tui/src/components/network.rs | 112 +- .../src/components/processes.rs | 98 +- .../talos-pilot-tui/src/components/storage.rs | 82 +- 7 files changed, 1129 insertions(+), 991 deletions(-) diff --git a/crates/talos-pilot-tui/src/app.rs b/crates/talos-pilot-tui/src/app.rs index 9f940ba..97e0511 100644 --- a/crates/talos-pilot-tui/src/app.rs +++ b/crates/talos-pilot-tui/src/app.rs @@ -994,11 +994,7 @@ impl App { } } Err(e) => { - tracing::warn!( - "Failed to fetch logs from {}: {}", - hostname, - e - ); + tracing::warn!("Failed to fetch logs from {}: {}", hostname, e); } } } @@ -1269,7 +1265,8 @@ impl App { ); // Create processes component in group mode - let mut processes = ProcessesComponent::new_group(group_name.clone(), nodes.clone()); + let mut processes = + ProcessesComponent::new_group(group_name.clone(), nodes.clone()); // Fetch processes from all nodes if let Some(client) = self.cluster.client() { @@ -1286,7 +1283,11 @@ impl App { } } Err(e) => { - tracing::warn!("Failed to fetch processes from {}: {}", hostname, e); + tracing::warn!( + "Failed to fetch processes from {}: {}", + hostname, + e + ); } } } @@ -1304,7 +1305,8 @@ impl App { ); // Create network component in group mode - let mut network = NetworkStatsComponent::new_group(group_name.clone(), nodes.clone()); + let mut network = + NetworkStatsComponent::new_group(group_name.clone(), nodes.clone()); // Fetch network stats from all nodes if let Some(client) = self.cluster.client() { @@ -1321,7 +1323,11 @@ impl App { } } Err(e) => { - tracing::warn!("Failed to fetch network stats from {}: {}", hostname, e); + tracing::warn!( + "Failed to fetch network stats from {}: {}", + hostname, + e + ); } } } @@ -1354,16 +1360,32 @@ impl App { let node_ip = ip.split(':').next().unwrap_or(ip); if let Some(ctx) = &context { // Fetch disks - match talos_rs::get_disks_for_node(ctx, node_ip, config_path.as_deref()).await { + match talos_rs::get_disks_for_node(ctx, node_ip, config_path.as_deref()) + .await + { Ok(disks) => { // Fetch volumes - match talos_rs::get_volume_status_for_node(ctx, node_ip, config_path.as_deref()).await { + match talos_rs::get_volume_status_for_node( + ctx, + node_ip, + config_path.as_deref(), + ) + .await + { Ok(volumes) => { storage.add_node_storage(hostname.clone(), disks, volumes); } Err(e) => { - tracing::warn!("Failed to fetch volumes from {}: {}", hostname, e); - storage.add_node_storage(hostname.clone(), disks, Vec::new()); + tracing::warn!( + "Failed to fetch volumes from {}: {}", + hostname, + e + ); + storage.add_node_storage( + hostname.clone(), + disks, + Vec::new(), + ); } } } @@ -1422,7 +1444,11 @@ impl App { } } Err(e) => { - tracing::warn!("Failed to fetch diagnostics from {}: {}", hostname, e); + tracing::warn!( + "Failed to fetch diagnostics from {}: {}", + hostname, + e + ); } } } diff --git a/crates/talos-pilot-tui/src/components/cluster.rs b/crates/talos-pilot-tui/src/components/cluster.rs index ec1393f..444ae99 100644 --- a/crates/talos-pilot-tui/src/components/cluster.rs +++ b/crates/talos-pilot-tui/src/components/cluster.rs @@ -838,7 +838,11 @@ impl ClusterComponent { if nodes.is_empty() { None } else { - Some(("Control Plane".to_string(), "controlplane".to_string(), nodes)) + Some(( + "Control Plane".to_string(), + "controlplane".to_string(), + nodes, + )) } } NodeListItem::WorkersHeader(cluster_idx) => { @@ -1150,7 +1154,9 @@ impl Component for ClusterComponent { if let Some((group_name, role, nodes)) = self.current_group_nodes() { let services = self.common_services_for_group(&nodes); if !services.is_empty() { - Ok(Some(Action::ShowGroupLogs(group_name, role, nodes, services))) + Ok(Some(Action::ShowGroupLogs( + group_name, role, nodes, services, + ))) } else { Ok(None) } @@ -1404,498 +1410,163 @@ impl Component for ClusterComponent { } } -#[cfg(test)] -mod tests { - use super::*; - - /// Create a test ServiceInfo with the given ID - fn make_service(id: &str) -> ServiceInfo { - ServiceInfo { - id: id.to_string(), - state: "Running".to_string(), - health: None, - } - } +impl ClusterComponent { + /// Draw compact header with status indicators + fn draw_header(&self, frame: &mut Frame, area: Rect) { + // Count connected clusters + let connected_count = self.clusters.iter().filter(|c| c.connected).count(); + let total_count = self.clusters.len(); - /// Create a test VersionInfo for a node - fn make_version_info(node: &str) -> VersionInfo { - VersionInfo { - node: node.to_string(), - version: "v1.8.0".to_string(), - sha: "abc123".to_string(), - built: "2024-01-01".to_string(), - go_version: "1.22".to_string(), - os: "linux".to_string(), - arch: "amd64".to_string(), - platform: "metal".to_string(), - } - } + let (status_indicator, status_text) = if connected_count == total_count && total_count > 0 { + ( + Span::styled(" ● ", Style::default().fg(Color::Green)), + "Connected", + ) + } else if connected_count > 0 { + ( + Span::styled(" ◐ ", Style::default().fg(Color::Yellow)), + "Partial", + ) + } else if total_count == 0 { + ( + Span::styled(" ○ ", Style::default().fg(Color::DarkGray)), + "No clusters", + ) + } else { + ( + Span::styled(" ✗ ", Style::default().fg(Color::Red)), + "Disconnected", + ) + }; - /// Create a test NodeServices for a node with given service IDs - fn make_node_services(node: &str, service_ids: &[&str]) -> NodeServices { - NodeServices { - node: node.to_string(), - services: service_ids.iter().map(|id| make_service(id)).collect(), - } - } + // Cluster count + let cluster_count_span = if total_count > 1 { + vec![ + Span::raw(" "), + Span::styled( + format!("{} clusters", total_count), + Style::default().fg(Color::DarkGray), + ), + ] + } else { + vec![] + }; - /// Create a ClusterComponent with test data - fn create_test_component() -> ClusterComponent { - let mut component = ClusterComponent::new(None, None); + let mut header_spans = vec![ + Span::styled( + " talos-pilot ", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + status_indicator, + Span::styled(status_text, Style::default().dim()), + ]; + header_spans.extend(cluster_count_span); - // Create a cluster with controlplane and worker nodes - let cluster = ClusterData { - name: "test-cluster".to_string(), - client: None, - connected: true, - error: None, - versions: vec![ - make_version_info("cp-node-1"), - make_version_info("cp-node-2"), - make_version_info("worker-1"), - make_version_info("worker-2"), - ], - services: vec![ - // Control plane nodes have etcd - make_node_services("cp-node-1", &["etcd", "kubelet", "apid", "containerd"]), - make_node_services("cp-node-2", &["etcd", "kubelet", "apid", "containerd"]), - // Worker nodes do not have etcd - make_node_services("worker-1", &["kubelet", "apid", "containerd"]), - make_node_services("worker-2", &["kubelet", "apid", "containerd"]), - ], - memory: Vec::new(), - load_avg: Vec::new(), - cpu_info: Vec::new(), - etcd_members: Vec::new(), - discovery_members: Vec::new(), - etcd_summary: None, - node_ips: HashMap::from([ - ("cp-node-1".to_string(), "10.0.0.1".to_string()), - ("cp-node-2".to_string(), "10.0.0.2".to_string()), - ("worker-1".to_string(), "10.0.0.3".to_string()), - ("worker-2".to_string(), "10.0.0.4".to_string()), - ]), - expanded: true, - controlplane_expanded: true, - workers_expanded: true, - }; + // Active cluster name on the right + let active_name = self + .clusters + .get(self.active_cluster) + .map(|c| c.name.clone()) + .unwrap_or_else(|| "none".to_string()); - component.clusters.push(cluster); - component.active_cluster = 0; - component - } + let left_content = Line::from(header_spans); + let right_content = Span::styled( + format!(" {} ", active_name), + Style::default().fg(Color::DarkGray), + ); - // ========================================================================== - // Tests for current_group_nodes() - // ========================================================================== + // Render header + let header_block = Block::default() + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(Color::DarkGray)); - #[test] - fn test_current_group_nodes_returns_none_for_cluster_header() { - let mut component = create_test_component(); - component.selected_item = NodeListItem::ClusterHeader(0); + let inner = header_block.inner(area); + frame.render_widget(header_block, area); + frame.render_widget(Paragraph::new(left_content), inner); - let result = component.current_group_nodes(); - assert!(result.is_none(), "ClusterHeader should not return group nodes"); + // Right-align active cluster name + let right_area = Rect { + x: area.x + area.width.saturating_sub(active_name.len() as u16 + 3), + y: area.y, + width: active_name.len() as u16 + 3, + height: 1, + }; + frame.render_widget(Paragraph::new(right_content), right_area); } - #[test] - fn test_current_group_nodes_returns_controlplane_when_on_controlplane_header() { - let mut component = create_test_component(); - component.selected_item = NodeListItem::ControlPlaneHeader(0); + /// Draw the nodes pane (left column) with navigation menu below + fn draw_nodes_pane(&self, frame: &mut Frame, area: Rect) { + // Focus indication - cyan border when focused + let border_color = if self.focused_pane == FocusedPane::Nodes { + Color::Cyan + } else { + Color::DarkGray + }; - let result = component.current_group_nodes(); - assert!(result.is_some(), "ControlPlaneHeader should return group nodes"); + let block = Block::default() + .title(" Nodes ") + .title_style( + Style::default().fg(if self.focused_pane == FocusedPane::Nodes { + Color::Cyan + } else { + Color::White + }), + ) + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)); - let (group_name, role, nodes) = result.unwrap(); - assert_eq!(group_name, "Control Plane"); - assert_eq!(role, "controlplane"); - assert_eq!(nodes.len(), 2, "Should have 2 control plane nodes"); + let inner = block.inner(area); + frame.render_widget(block, area); - // Verify node hostnames and IPs - assert!(nodes.iter().any(|(h, ip)| h == "cp-node-1" && ip == "10.0.0.1")); - assert!(nodes.iter().any(|(h, ip)| h == "cp-node-2" && ip == "10.0.0.2")); - } + // Split inner area: nodes list at top, nav menu at bottom + let menu_height = NavMenuItem::ALL.len() as u16 + 1; // +1 for separator + let pane_layout = Layout::vertical([ + Constraint::Min(3), // Nodes list + Constraint::Length(menu_height), // Navigation menu (vertical) + ]) + .split(inner); - #[test] - fn test_current_group_nodes_returns_workers_when_on_workers_header() { - let mut component = create_test_component(); - component.selected_item = NodeListItem::WorkersHeader(0); + if self.clusters.is_empty() { + let msg = Paragraph::new(Line::from(Span::styled( + " No clusters found", + Style::default().dim(), + ))); + frame.render_widget(msg, pane_layout[0]); + } else { + // Build multi-cluster accordion-style node list + let mut lines = Vec::new(); + let nodes_focused = self.focused_pane == FocusedPane::Nodes; - let result = component.current_group_nodes(); - assert!(result.is_some(), "WorkersHeader should return group nodes"); + for (cluster_idx, cluster) in self.clusters.iter().enumerate() { + // Cluster header + let is_cluster_selected = + self.selected_item == NodeListItem::ClusterHeader(cluster_idx); + let expand_icon = if cluster.expanded { "▼" } else { "▶" }; + let selector = if is_cluster_selected && nodes_focused { + "▸" + } else { + " " + }; - let (group_name, role, nodes) = result.unwrap(); - assert_eq!(group_name, "Workers"); - assert_eq!(role, "worker"); - assert_eq!(nodes.len(), 2, "Should have 2 worker nodes"); + // Status indicator + let status_symbol = if cluster.connected { "●" } else { "○" }; + let status_color = if cluster.connected { + Color::Green + } else { + Color::Red + }; - // Verify node hostnames and IPs - assert!(nodes.iter().any(|(h, ip)| h == "worker-1" && ip == "10.0.0.3")); - assert!(nodes.iter().any(|(h, ip)| h == "worker-2" && ip == "10.0.0.4")); - } + let header_style = if is_cluster_selected { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Magenta) + }; - #[test] - fn test_current_group_nodes_returns_none_when_no_controlplane_nodes() { - let mut component = ClusterComponent::new(None, None); - - // Create a cluster with only workers (no etcd services) - let cluster = ClusterData { - name: "workers-only".to_string(), - client: None, - connected: true, - error: None, - versions: vec![make_version_info("worker-1")], - services: vec![make_node_services("worker-1", &["kubelet", "containerd"])], - memory: Vec::new(), - load_avg: Vec::new(), - cpu_info: Vec::new(), - etcd_members: Vec::new(), - discovery_members: Vec::new(), - etcd_summary: None, - node_ips: HashMap::from([("worker-1".to_string(), "10.0.0.1".to_string())]), - expanded: true, - controlplane_expanded: true, - workers_expanded: true, - }; - - component.clusters.push(cluster); - component.selected_item = NodeListItem::ControlPlaneHeader(0); - - let result = component.current_group_nodes(); - assert!( - result.is_none(), - "ControlPlaneHeader with no controlplane nodes should return None" - ); - } - - #[test] - fn test_current_group_nodes_returns_none_when_no_worker_nodes() { - let mut component = ClusterComponent::new(None, None); - - // Create a cluster with only control plane nodes - let cluster = ClusterData { - name: "cp-only".to_string(), - client: None, - connected: true, - error: None, - versions: vec![make_version_info("cp-node-1")], - services: vec![make_node_services("cp-node-1", &["etcd", "kubelet"])], - memory: Vec::new(), - load_avg: Vec::new(), - cpu_info: Vec::new(), - etcd_members: Vec::new(), - discovery_members: Vec::new(), - etcd_summary: None, - node_ips: HashMap::from([("cp-node-1".to_string(), "10.0.0.1".to_string())]), - expanded: true, - controlplane_expanded: true, - workers_expanded: true, - }; - - component.clusters.push(cluster); - component.selected_item = NodeListItem::WorkersHeader(0); - - let result = component.current_group_nodes(); - assert!( - result.is_none(), - "WorkersHeader with no worker nodes should return None" - ); - } - - #[test] - fn test_current_group_nodes_returns_none_for_individual_nodes() { - let mut component = create_test_component(); - - // Test ControlPlaneNode - component.selected_item = NodeListItem::ControlPlaneNode(0, 0); - assert!( - component.current_group_nodes().is_none(), - "ControlPlaneNode should return None" - ); - - // Test WorkerNode - component.selected_item = NodeListItem::WorkerNode(0, 0); - assert!( - component.current_group_nodes().is_none(), - "WorkerNode should return None" - ); - } - - // ========================================================================== - // Tests for common_services_for_group() - // ========================================================================== - - #[test] - fn test_common_services_for_group_returns_empty_for_empty_nodes() { - let component = create_test_component(); - - let result = component.common_services_for_group(&[]); - assert!(result.is_empty(), "Empty nodes should return empty services"); - } - - #[test] - fn test_common_services_for_group_returns_all_services_for_single_node() { - let component = create_test_component(); - - // Single control plane node - let nodes = vec![("cp-node-1".to_string(), "10.0.0.1".to_string())]; - - let result = component.common_services_for_group(&nodes); - assert_eq!(result.len(), 4, "Single node should return all its services"); - assert!(result.contains(&"etcd".to_string())); - assert!(result.contains(&"kubelet".to_string())); - assert!(result.contains(&"apid".to_string())); - assert!(result.contains(&"containerd".to_string())); - } - - #[test] - fn test_common_services_for_group_returns_intersection_for_multiple_nodes() { - let component = create_test_component(); - - // Control plane and worker nodes have different services - let nodes = vec![ - ("cp-node-1".to_string(), "10.0.0.1".to_string()), - ("worker-1".to_string(), "10.0.0.3".to_string()), - ]; - - let result = component.common_services_for_group(&nodes); - - // Common services: kubelet, apid, containerd (not etcd which is only on cp) - assert_eq!( - result.len(), - 3, - "Should return only common services (intersection)" - ); - assert!(result.contains(&"kubelet".to_string())); - assert!(result.contains(&"apid".to_string())); - assert!(result.contains(&"containerd".to_string())); - assert!( - !result.contains(&"etcd".to_string()), - "etcd should not be in common services" - ); - } - - #[test] - fn test_common_services_for_group_returns_empty_when_no_common_services() { - let mut component = ClusterComponent::new(None, None); - - // Create nodes with completely different services - let cluster = ClusterData { - name: "test".to_string(), - client: None, - connected: true, - error: None, - versions: vec![make_version_info("node-a"), make_version_info("node-b")], - services: vec![ - make_node_services("node-a", &["service-a", "service-b"]), - make_node_services("node-b", &["service-c", "service-d"]), - ], - memory: Vec::new(), - load_avg: Vec::new(), - cpu_info: Vec::new(), - etcd_members: Vec::new(), - discovery_members: Vec::new(), - etcd_summary: None, - node_ips: HashMap::from([ - ("node-a".to_string(), "10.0.0.1".to_string()), - ("node-b".to_string(), "10.0.0.2".to_string()), - ]), - expanded: true, - controlplane_expanded: true, - workers_expanded: true, - }; - - component.clusters.push(cluster); - component.active_cluster = 0; - - let nodes = vec![ - ("node-a".to_string(), "10.0.0.1".to_string()), - ("node-b".to_string(), "10.0.0.2".to_string()), - ]; - - let result = component.common_services_for_group(&nodes); - assert!( - result.is_empty(), - "Nodes with no common services should return empty" - ); - } - - #[test] - fn test_common_services_for_group_sorts_results() { - let component = create_test_component(); - - // Use two control plane nodes that have the same services - let nodes = vec![ - ("cp-node-1".to_string(), "10.0.0.1".to_string()), - ("cp-node-2".to_string(), "10.0.0.2".to_string()), - ]; - - let result = component.common_services_for_group(&nodes); - - // Verify the result is sorted - let mut sorted = result.clone(); - sorted.sort(); - assert_eq!(result, sorted, "Results should be sorted alphabetically"); - } -} - -impl ClusterComponent { - /// Draw compact header with status indicators - fn draw_header(&self, frame: &mut Frame, area: Rect) { - // Count connected clusters - let connected_count = self.clusters.iter().filter(|c| c.connected).count(); - let total_count = self.clusters.len(); - - let (status_indicator, status_text) = if connected_count == total_count && total_count > 0 { - ( - Span::styled(" ● ", Style::default().fg(Color::Green)), - "Connected", - ) - } else if connected_count > 0 { - ( - Span::styled(" ◐ ", Style::default().fg(Color::Yellow)), - "Partial", - ) - } else if total_count == 0 { - ( - Span::styled(" ○ ", Style::default().fg(Color::DarkGray)), - "No clusters", - ) - } else { - ( - Span::styled(" ✗ ", Style::default().fg(Color::Red)), - "Disconnected", - ) - }; - - // Cluster count - let cluster_count_span = if total_count > 1 { - vec![ - Span::raw(" "), - Span::styled( - format!("{} clusters", total_count), - Style::default().fg(Color::DarkGray), - ), - ] - } else { - vec![] - }; - - let mut header_spans = vec![ - Span::styled( - " talos-pilot ", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ), - status_indicator, - Span::styled(status_text, Style::default().dim()), - ]; - header_spans.extend(cluster_count_span); - - // Active cluster name on the right - let active_name = self - .clusters - .get(self.active_cluster) - .map(|c| c.name.clone()) - .unwrap_or_else(|| "none".to_string()); - - let left_content = Line::from(header_spans); - let right_content = Span::styled( - format!(" {} ", active_name), - Style::default().fg(Color::DarkGray), - ); - - // Render header - let header_block = Block::default() - .borders(Borders::BOTTOM) - .border_style(Style::default().fg(Color::DarkGray)); - - let inner = header_block.inner(area); - frame.render_widget(header_block, area); - frame.render_widget(Paragraph::new(left_content), inner); - - // Right-align active cluster name - let right_area = Rect { - x: area.x + area.width.saturating_sub(active_name.len() as u16 + 3), - y: area.y, - width: active_name.len() as u16 + 3, - height: 1, - }; - frame.render_widget(Paragraph::new(right_content), right_area); - } - - /// Draw the nodes pane (left column) with navigation menu below - fn draw_nodes_pane(&self, frame: &mut Frame, area: Rect) { - // Focus indication - cyan border when focused - let border_color = if self.focused_pane == FocusedPane::Nodes { - Color::Cyan - } else { - Color::DarkGray - }; - - let block = Block::default() - .title(" Nodes ") - .title_style( - Style::default().fg(if self.focused_pane == FocusedPane::Nodes { - Color::Cyan - } else { - Color::White - }), - ) - .borders(Borders::ALL) - .border_style(Style::default().fg(border_color)); - - let inner = block.inner(area); - frame.render_widget(block, area); - - // Split inner area: nodes list at top, nav menu at bottom - let menu_height = NavMenuItem::ALL.len() as u16 + 1; // +1 for separator - let pane_layout = Layout::vertical([ - Constraint::Min(3), // Nodes list - Constraint::Length(menu_height), // Navigation menu (vertical) - ]) - .split(inner); - - if self.clusters.is_empty() { - let msg = Paragraph::new(Line::from(Span::styled( - " No clusters found", - Style::default().dim(), - ))); - frame.render_widget(msg, pane_layout[0]); - } else { - // Build multi-cluster accordion-style node list - let mut lines = Vec::new(); - let nodes_focused = self.focused_pane == FocusedPane::Nodes; - - for (cluster_idx, cluster) in self.clusters.iter().enumerate() { - // Cluster header - let is_cluster_selected = - self.selected_item == NodeListItem::ClusterHeader(cluster_idx); - let expand_icon = if cluster.expanded { "▼" } else { "▶" }; - let selector = if is_cluster_selected && nodes_focused { - "▸" - } else { - " " - }; - - // Status indicator - let status_symbol = if cluster.connected { "●" } else { "○" }; - let status_color = if cluster.connected { - Color::Green - } else { - Color::Red - }; - - let header_style = if is_cluster_selected { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::Magenta) - }; - - let node_count = cluster.versions.len(); + let node_count = cluster.versions.len(); // Build etcd status for this cluster let etcd_spans: Vec = if let Some(etcd) = &cluster.etcd_summary { @@ -2125,362 +1796,726 @@ impl ClusterComponent { let menu_focused = self.focused_pane == FocusedPane::Menu; let mut lines = Vec::new(); - // Separator line with focus color - let sep_color = if menu_focused { - Color::Cyan + // Separator line with focus color + let sep_color = if menu_focused { + Color::Cyan + } else { + Color::DarkGray + }; + let sep_text = if menu_focused { + " Navigate ".to_string() + } else { + "─".repeat(area.width as usize) + }; + lines.push(Line::from(Span::styled( + sep_text, + Style::default().fg(sep_color), + ))); + + // Menu items (1-indexed, 0 means not in menu) + for (i, item) in NavMenuItem::ALL.iter().enumerate() { + let menu_index = i + 1; // 1-based for selection + let is_selected = menu_index == self.selected_menu_item; + let show_selector = is_selected && menu_focused; + + let selector = if show_selector { "▸" } else { " " }; + + let hotkey_style = if show_selector { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default().fg(Color::Yellow) + }; + + let label_style = if show_selector { + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else if is_selected && !menu_focused { + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Gray) + }; + + lines.push(Line::from(vec![ + Span::styled( + format!(" {} ", selector), + if show_selector { + Style::default().fg(Color::Cyan) + } else { + Style::default() + }, + ), + Span::styled(format!("[{}] ", item.hotkey()), hotkey_style), + Span::styled(item.label(), label_style), + ])); + } + + frame.render_widget(Paragraph::new(lines), area); + } + + /// Render a compact ASCII bar for percentage values + fn render_compact_bar(pct: f32, width: usize) -> String { + let filled = ((pct / 100.0) * width as f32).round() as usize; + let empty = width.saturating_sub(filled); + format!( + "{}{}{:>3}%", + "█".repeat(filled), + "░".repeat(empty), + pct as u8 + ) + } + + /// Draw the details pane (right column) + fn draw_details_pane(&self, frame: &mut Frame, area: Rect) { + // Focus indication - cyan border when focused + let border_color = if self.focused_pane == FocusedPane::Services { + Color::Cyan + } else { + Color::DarkGray + }; + + // Get active cluster data + let cluster_idx = self.active_cluster; + let cluster = self.clusters.get(cluster_idx); + + // Check if cluster is connected but has no etcd members (not bootstrapped) + let needs_bootstrap = cluster + .map(|c| c.connected && c.etcd_members.is_empty() && c.versions.is_empty()) + .unwrap_or(false); + + if needs_bootstrap { + let block = Block::default() + .title(" Bootstrap Required ") + .title_style(Style::default().fg(Color::Yellow)) + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)); + + // Get control plane IP from talosconfig endpoints + let config_result = match &self.config_path { + Some(path) => { + let path_buf = std::path::PathBuf::from(path); + TalosConfig::load_from(&path_buf) + } + None => TalosConfig::load_default(), + }; + let cp_ip = config_result + .ok() + .and_then(|config| { + config + .current_context() + .and_then(|ctx| ctx.endpoints.first()) + .map(|e| e.split(':').next().unwrap_or(e).to_string()) + }) + .unwrap_or_else(|| "".to_string()); + + let lines = vec![ + Line::from(""), + Line::from(vec![Span::styled( + " Cluster not yet bootstrapped.", + Style::default().fg(Color::Yellow), + )]), + Line::from(""), + Line::from(vec![Span::styled( + " To bootstrap, run:", + Style::default().dim(), + )]), + Line::from(""), + Line::from(vec![Span::styled( + format!(" talosctl bootstrap -n {}", cp_ip), + Style::default().fg(Color::Cyan), + )]), + Line::from(""), + Line::from(vec![Span::styled( + " This initializes etcd and starts", + Style::default().dim(), + )]), + Line::from(vec![Span::styled( + " the Kubernetes control plane.", + Style::default().dim(), + )]), + ]; + + let msg = Paragraph::new(lines).block(block); + frame.render_widget(msg, area); + return; + } + + let versions_empty = cluster.map(|c| c.versions.is_empty()).unwrap_or(true); + if versions_empty { + let block = Block::default() + .title(" Details ") + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)); + let msg = Paragraph::new(Line::from(Span::styled( + " No node selected", + Style::default().dim(), + ))) + .block(block); + frame.render_widget(msg, area); + return; + } + + // Check if we have a node selected (vs a header) + let Some(node_name_str) = self.current_node_name() else { + // Header selected - show group summary + let (title, count) = match &self.selected_item { + NodeListItem::ClusterHeader(idx) => { + let name = self + .clusters + .get(*idx) + .map(|c| c.name.as_str()) + .unwrap_or("Cluster"); + let node_count = self + .clusters + .get(*idx) + .map(|c| c.versions.len()) + .unwrap_or(0); + (name.to_string(), node_count) + } + NodeListItem::ControlPlaneHeader(idx) => ( + "Control Plane".to_string(), + self.controlplane_nodes_for(*idx).len(), + ), + NodeListItem::WorkersHeader(idx) => { + ("Workers".to_string(), self.worker_nodes_for(*idx).len()) + } + _ => ("Details".to_string(), 0), + }; + let block = Block::default() + .title(format!(" {} ", title)) + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)); + let msg = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + format!(" {} nodes in this group", count), + Style::default().dim(), + )), + Line::from(""), + Line::from(Span::styled( + " Press Enter or Space to expand/collapse", + Style::default().dim(), + )), + Line::from(Span::styled( + " Navigate down to select a node", + Style::default().dim(), + )), + ]) + .block(block); + frame.render_widget(msg, area); + return; + }; + + let node_name = if node_name_str.is_empty() { + "node-0".to_string() } else { - Color::DarkGray + node_name_str.clone() }; - let sep_text = if menu_focused { - " Navigate ".to_string() + let node_ip = self.node_ips().get(&node_name).cloned().unwrap_or_default(); + let role = if self + .get_node_services(&node_name) + .map(|s| s.iter().any(|svc| svc.id == "etcd")) + .unwrap_or(false) + { + "controlplane" } else { - "─".repeat(area.width as usize) + "worker" }; - lines.push(Line::from(Span::styled( - sep_text, - Style::default().fg(sep_color), - ))); - // Menu items (1-indexed, 0 means not in menu) - for (i, item) in NavMenuItem::ALL.iter().enumerate() { - let menu_index = i + 1; // 1-based for selection - let is_selected = menu_index == self.selected_menu_item; - let show_selector = is_selected && menu_focused; + let title = format!(" {} · {} ", node_name, role); + let block = Block::default() + .title(title) + .title_style( + Style::default().fg(if self.focused_pane == FocusedPane::Services { + Color::Cyan + } else { + Color::White + }), + ) + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)); - let selector = if show_selector { "▸" } else { " " }; + let inner = block.inner(area); + frame.render_widget(block, area); - let hotkey_style = if show_selector { - Style::default().fg(Color::Black).bg(Color::Cyan) + // Split into resources and services + let panel_layout = Layout::vertical([ + Constraint::Length(5), // Resources + Constraint::Min(4), // Services + ]) + .split(inner); + + // Resources section + let mut resource_lines = vec![Line::from(vec![ + Span::styled(" IP: ", Style::default().dim()), + Span::styled(&node_ip, Style::default().fg(Color::DarkGray)), + ])]; + + // Memory bar + if let Some(mem) = self.get_node_memory(&node_name) { + let pct = mem.usage_percent(); + let used_gb = (mem.mem_total - mem.mem_available) as f64 / 1024.0 / 1024.0 / 1024.0; + let total_gb = mem.mem_total as f64 / 1024.0 / 1024.0 / 1024.0; + let bar = Self::render_compact_bar(pct, 10); + let color = if pct > 90.0 { + Color::Red + } else if pct > 70.0 { + Color::Yellow } else { - Style::default().fg(Color::Yellow) + Color::Green }; + resource_lines.push(Line::from(vec![ + Span::styled(" Memory: ", Style::default().dim()), + Span::styled(bar, Style::default().fg(color)), + Span::styled( + format!(" {:.1}/{:.1}GB", used_gb, total_gb), + Style::default().dim(), + ), + ])); + } - let label_style = if show_selector { - Style::default() - .fg(Color::Black) - .bg(Color::Cyan) - .add_modifier(Modifier::BOLD) - } else if is_selected && !menu_focused { - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD) + // Load average + if let Some(load) = self.get_node_load_avg(&node_name) { + let color = if load.load1 > 4.0 { + Color::Red + } else if load.load1 > 2.0 { + Color::Yellow } else { - Style::default().fg(Color::Gray) + Color::Green }; + resource_lines.push(Line::from(vec![ + Span::styled(" Load: ", Style::default().dim()), + Span::styled(format!("{:.2}", load.load1), Style::default().fg(color)), + Span::styled( + format!(" {:.2} {:.2} (1/5/15m)", load.load5, load.load15), + Style::default().dim(), + ), + ])); + } - lines.push(Line::from(vec![ + // CPU info + if let Some(cpu) = self.get_node_cpu_info(&node_name) { + resource_lines.push(Line::from(vec![ + Span::styled(" CPU: ", Style::default().dim()), Span::styled( - format!(" {} ", selector), - if show_selector { - Style::default().fg(Color::Cyan) - } else { - Style::default() - }, + format!("{} cores", cpu.cpu_count), + Style::default().fg(Color::White), ), - Span::styled(format!("[{}] ", item.hotkey()), hotkey_style), - Span::styled(item.label(), label_style), + Span::styled(format!(" @ {:.0}MHz", cpu.mhz), Style::default().dim()), ])); } - frame.render_widget(Paragraph::new(lines), area); + frame.render_widget(Paragraph::new(resource_lines), panel_layout[0]); + + // Services section + if let Some(services) = self.get_node_services(&node_name) { + let running = services.iter().filter(|s| s.state == "Running").count(); + let mut svc_lines = vec![Line::from(vec![Span::styled( + format!(" Services ({}/{})", running, services.len()), + Style::default().fg(Color::Gray), + )])]; + + for (i, svc) in services.iter().enumerate() { + let health_symbol = svc + .health + .as_ref() + .map(|h| if h.healthy { "●" } else { "○" }) + .unwrap_or("●"); + let health_color = if health_symbol == "●" { + Color::Green + } else { + Color::Red + }; + + // Highlight selected service when services pane is focused + let is_selected = + i == self.selected_service && self.focused_pane == FocusedPane::Services; + let name_style = if is_selected { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + let selector = if is_selected { "▸" } else { " " }; + + svc_lines.push(Line::from(vec![ + Span::raw(format!(" {}", selector)), + Span::styled(health_symbol, Style::default().fg(health_color)), + Span::raw(" "), + Span::styled(&svc.id, name_style), + Span::styled(format!(" ({})", svc.state), Style::default().dim()), + ])); + } + + frame.render_widget(Paragraph::new(svc_lines), panel_layout[1]); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Create a test ServiceInfo with the given ID + fn make_service(id: &str) -> ServiceInfo { + ServiceInfo { + id: id.to_string(), + state: "Running".to_string(), + health: None, + } + } + + /// Create a test VersionInfo for a node + fn make_version_info(node: &str) -> VersionInfo { + VersionInfo { + node: node.to_string(), + version: "v1.8.0".to_string(), + sha: "abc123".to_string(), + built: "2024-01-01".to_string(), + go_version: "1.22".to_string(), + os: "linux".to_string(), + arch: "amd64".to_string(), + platform: "metal".to_string(), + } + } + + /// Create a test NodeServices for a node with given service IDs + fn make_node_services(node: &str, service_ids: &[&str]) -> NodeServices { + NodeServices { + node: node.to_string(), + services: service_ids.iter().map(|id| make_service(id)).collect(), + } + } + + /// Create a ClusterComponent with test data + fn create_test_component() -> ClusterComponent { + let mut component = ClusterComponent::new(None, None); + + // Create a cluster with controlplane and worker nodes + let cluster = ClusterData { + name: "test-cluster".to_string(), + client: None, + connected: true, + error: None, + versions: vec![ + make_version_info("cp-node-1"), + make_version_info("cp-node-2"), + make_version_info("worker-1"), + make_version_info("worker-2"), + ], + services: vec![ + // Control plane nodes have etcd + make_node_services("cp-node-1", &["etcd", "kubelet", "apid", "containerd"]), + make_node_services("cp-node-2", &["etcd", "kubelet", "apid", "containerd"]), + // Worker nodes do not have etcd + make_node_services("worker-1", &["kubelet", "apid", "containerd"]), + make_node_services("worker-2", &["kubelet", "apid", "containerd"]), + ], + memory: Vec::new(), + load_avg: Vec::new(), + cpu_info: Vec::new(), + etcd_members: Vec::new(), + discovery_members: Vec::new(), + etcd_summary: None, + node_ips: HashMap::from([ + ("cp-node-1".to_string(), "10.0.0.1".to_string()), + ("cp-node-2".to_string(), "10.0.0.2".to_string()), + ("worker-1".to_string(), "10.0.0.3".to_string()), + ("worker-2".to_string(), "10.0.0.4".to_string()), + ]), + expanded: true, + controlplane_expanded: true, + workers_expanded: true, + }; + + component.clusters.push(cluster); + component.active_cluster = 0; + component } - /// Render a compact ASCII bar for percentage values - fn render_compact_bar(pct: f32, width: usize) -> String { - let filled = ((pct / 100.0) * width as f32).round() as usize; - let empty = width.saturating_sub(filled); - format!( - "{}{}{:>3}%", - "█".repeat(filled), - "░".repeat(empty), - pct as u8 - ) + // ========================================================================== + // Tests for current_group_nodes() + // ========================================================================== + + #[test] + fn test_current_group_nodes_returns_none_for_cluster_header() { + let mut component = create_test_component(); + component.selected_item = NodeListItem::ClusterHeader(0); + + let result = component.current_group_nodes(); + assert!( + result.is_none(), + "ClusterHeader should not return group nodes" + ); } - /// Draw the details pane (right column) - fn draw_details_pane(&self, frame: &mut Frame, area: Rect) { - // Focus indication - cyan border when focused - let border_color = if self.focused_pane == FocusedPane::Services { - Color::Cyan - } else { - Color::DarkGray - }; + #[test] + fn test_current_group_nodes_returns_controlplane_when_on_controlplane_header() { + let mut component = create_test_component(); + component.selected_item = NodeListItem::ControlPlaneHeader(0); - // Get active cluster data - let cluster_idx = self.active_cluster; - let cluster = self.clusters.get(cluster_idx); + let result = component.current_group_nodes(); + assert!( + result.is_some(), + "ControlPlaneHeader should return group nodes" + ); - // Check if cluster is connected but has no etcd members (not bootstrapped) - let needs_bootstrap = cluster - .map(|c| c.connected && c.etcd_members.is_empty() && c.versions.is_empty()) - .unwrap_or(false); + let (group_name, role, nodes) = result.unwrap(); + assert_eq!(group_name, "Control Plane"); + assert_eq!(role, "controlplane"); + assert_eq!(nodes.len(), 2, "Should have 2 control plane nodes"); - if needs_bootstrap { - let block = Block::default() - .title(" Bootstrap Required ") - .title_style(Style::default().fg(Color::Yellow)) - .borders(Borders::ALL) - .border_style(Style::default().fg(border_color)); + // Verify node hostnames and IPs + assert!( + nodes + .iter() + .any(|(h, ip)| h == "cp-node-1" && ip == "10.0.0.1") + ); + assert!( + nodes + .iter() + .any(|(h, ip)| h == "cp-node-2" && ip == "10.0.0.2") + ); + } - // Get control plane IP from talosconfig endpoints - let config_result = match &self.config_path { - Some(path) => { - let path_buf = std::path::PathBuf::from(path); - TalosConfig::load_from(&path_buf) - } - None => TalosConfig::load_default(), - }; - let cp_ip = config_result - .ok() - .and_then(|config| { - config - .current_context() - .and_then(|ctx| ctx.endpoints.first()) - .map(|e| e.split(':').next().unwrap_or(e).to_string()) - }) - .unwrap_or_else(|| "".to_string()); + #[test] + fn test_current_group_nodes_returns_workers_when_on_workers_header() { + let mut component = create_test_component(); + component.selected_item = NodeListItem::WorkersHeader(0); - let lines = vec![ - Line::from(""), - Line::from(vec![Span::styled( - " Cluster not yet bootstrapped.", - Style::default().fg(Color::Yellow), - )]), - Line::from(""), - Line::from(vec![Span::styled( - " To bootstrap, run:", - Style::default().dim(), - )]), - Line::from(""), - Line::from(vec![Span::styled( - format!(" talosctl bootstrap -n {}", cp_ip), - Style::default().fg(Color::Cyan), - )]), - Line::from(""), - Line::from(vec![Span::styled( - " This initializes etcd and starts", - Style::default().dim(), - )]), - Line::from(vec![Span::styled( - " the Kubernetes control plane.", - Style::default().dim(), - )]), - ]; + let result = component.current_group_nodes(); + assert!(result.is_some(), "WorkersHeader should return group nodes"); - let msg = Paragraph::new(lines).block(block); - frame.render_widget(msg, area); - return; - } + let (group_name, role, nodes) = result.unwrap(); + assert_eq!(group_name, "Workers"); + assert_eq!(role, "worker"); + assert_eq!(nodes.len(), 2, "Should have 2 worker nodes"); - let versions_empty = cluster.map(|c| c.versions.is_empty()).unwrap_or(true); - if versions_empty { - let block = Block::default() - .title(" Details ") - .borders(Borders::ALL) - .border_style(Style::default().fg(border_color)); - let msg = Paragraph::new(Line::from(Span::styled( - " No node selected", - Style::default().dim(), - ))) - .block(block); - frame.render_widget(msg, area); - return; - } + // Verify node hostnames and IPs + assert!( + nodes + .iter() + .any(|(h, ip)| h == "worker-1" && ip == "10.0.0.3") + ); + assert!( + nodes + .iter() + .any(|(h, ip)| h == "worker-2" && ip == "10.0.0.4") + ); + } - // Check if we have a node selected (vs a header) - let Some(node_name_str) = self.current_node_name() else { - // Header selected - show group summary - let (title, count) = match &self.selected_item { - NodeListItem::ClusterHeader(idx) => { - let name = self - .clusters - .get(*idx) - .map(|c| c.name.as_str()) - .unwrap_or("Cluster"); - let node_count = self - .clusters - .get(*idx) - .map(|c| c.versions.len()) - .unwrap_or(0); - (name.to_string(), node_count) - } - NodeListItem::ControlPlaneHeader(idx) => ( - "Control Plane".to_string(), - self.controlplane_nodes_for(*idx).len(), - ), - NodeListItem::WorkersHeader(idx) => { - ("Workers".to_string(), self.worker_nodes_for(*idx).len()) - } - _ => ("Details".to_string(), 0), - }; - let block = Block::default() - .title(format!(" {} ", title)) - .borders(Borders::ALL) - .border_style(Style::default().fg(border_color)); - let msg = Paragraph::new(vec![ - Line::from(""), - Line::from(Span::styled( - format!(" {} nodes in this group", count), - Style::default().dim(), - )), - Line::from(""), - Line::from(Span::styled( - " Press Enter or Space to expand/collapse", - Style::default().dim(), - )), - Line::from(Span::styled( - " Navigate down to select a node", - Style::default().dim(), - )), - ]) - .block(block); - frame.render_widget(msg, area); - return; - }; + #[test] + fn test_current_group_nodes_returns_none_when_no_controlplane_nodes() { + let mut component = ClusterComponent::new(None, None); - let node_name = if node_name_str.is_empty() { - "node-0".to_string() - } else { - node_name_str.clone() + // Create a cluster with only workers (no etcd services) + let cluster = ClusterData { + name: "workers-only".to_string(), + client: None, + connected: true, + error: None, + versions: vec![make_version_info("worker-1")], + services: vec![make_node_services("worker-1", &["kubelet", "containerd"])], + memory: Vec::new(), + load_avg: Vec::new(), + cpu_info: Vec::new(), + etcd_members: Vec::new(), + discovery_members: Vec::new(), + etcd_summary: None, + node_ips: HashMap::from([("worker-1".to_string(), "10.0.0.1".to_string())]), + expanded: true, + controlplane_expanded: true, + workers_expanded: true, }; - let node_ip = self.node_ips().get(&node_name).cloned().unwrap_or_default(); - let role = if self - .get_node_services(&node_name) - .map(|s| s.iter().any(|svc| svc.id == "etcd")) - .unwrap_or(false) - { - "controlplane" - } else { - "worker" + + component.clusters.push(cluster); + component.selected_item = NodeListItem::ControlPlaneHeader(0); + + let result = component.current_group_nodes(); + assert!( + result.is_none(), + "ControlPlaneHeader with no controlplane nodes should return None" + ); + } + + #[test] + fn test_current_group_nodes_returns_none_when_no_worker_nodes() { + let mut component = ClusterComponent::new(None, None); + + // Create a cluster with only control plane nodes + let cluster = ClusterData { + name: "cp-only".to_string(), + client: None, + connected: true, + error: None, + versions: vec![make_version_info("cp-node-1")], + services: vec![make_node_services("cp-node-1", &["etcd", "kubelet"])], + memory: Vec::new(), + load_avg: Vec::new(), + cpu_info: Vec::new(), + etcd_members: Vec::new(), + discovery_members: Vec::new(), + etcd_summary: None, + node_ips: HashMap::from([("cp-node-1".to_string(), "10.0.0.1".to_string())]), + expanded: true, + controlplane_expanded: true, + workers_expanded: true, }; - let title = format!(" {} · {} ", node_name, role); - let block = Block::default() - .title(title) - .title_style( - Style::default().fg(if self.focused_pane == FocusedPane::Services { - Color::Cyan - } else { - Color::White - }), - ) - .borders(Borders::ALL) - .border_style(Style::default().fg(border_color)); + component.clusters.push(cluster); + component.selected_item = NodeListItem::WorkersHeader(0); - let inner = block.inner(area); - frame.render_widget(block, area); + let result = component.current_group_nodes(); + assert!( + result.is_none(), + "WorkersHeader with no worker nodes should return None" + ); + } - // Split into resources and services - let panel_layout = Layout::vertical([ - Constraint::Length(5), // Resources - Constraint::Min(4), // Services - ]) - .split(inner); + #[test] + fn test_current_group_nodes_returns_none_for_individual_nodes() { + let mut component = create_test_component(); - // Resources section - let mut resource_lines = vec![Line::from(vec![ - Span::styled(" IP: ", Style::default().dim()), - Span::styled(&node_ip, Style::default().fg(Color::DarkGray)), - ])]; + // Test ControlPlaneNode + component.selected_item = NodeListItem::ControlPlaneNode(0, 0); + assert!( + component.current_group_nodes().is_none(), + "ControlPlaneNode should return None" + ); - // Memory bar - if let Some(mem) = self.get_node_memory(&node_name) { - let pct = mem.usage_percent(); - let used_gb = (mem.mem_total - mem.mem_available) as f64 / 1024.0 / 1024.0 / 1024.0; - let total_gb = mem.mem_total as f64 / 1024.0 / 1024.0 / 1024.0; - let bar = Self::render_compact_bar(pct, 10); - let color = if pct > 90.0 { - Color::Red - } else if pct > 70.0 { - Color::Yellow - } else { - Color::Green - }; - resource_lines.push(Line::from(vec![ - Span::styled(" Memory: ", Style::default().dim()), - Span::styled(bar, Style::default().fg(color)), - Span::styled( - format!(" {:.1}/{:.1}GB", used_gb, total_gb), - Style::default().dim(), - ), - ])); - } + // Test WorkerNode + component.selected_item = NodeListItem::WorkerNode(0, 0); + assert!( + component.current_group_nodes().is_none(), + "WorkerNode should return None" + ); + } - // Load average - if let Some(load) = self.get_node_load_avg(&node_name) { - let color = if load.load1 > 4.0 { - Color::Red - } else if load.load1 > 2.0 { - Color::Yellow - } else { - Color::Green - }; - resource_lines.push(Line::from(vec![ - Span::styled(" Load: ", Style::default().dim()), - Span::styled(format!("{:.2}", load.load1), Style::default().fg(color)), - Span::styled( - format!(" {:.2} {:.2} (1/5/15m)", load.load5, load.load15), - Style::default().dim(), - ), - ])); - } + // ========================================================================== + // Tests for common_services_for_group() + // ========================================================================== - // CPU info - if let Some(cpu) = self.get_node_cpu_info(&node_name) { - resource_lines.push(Line::from(vec![ - Span::styled(" CPU: ", Style::default().dim()), - Span::styled( - format!("{} cores", cpu.cpu_count), - Style::default().fg(Color::White), - ), - Span::styled(format!(" @ {:.0}MHz", cpu.mhz), Style::default().dim()), - ])); - } + #[test] + fn test_common_services_for_group_returns_empty_for_empty_nodes() { + let component = create_test_component(); - frame.render_widget(Paragraph::new(resource_lines), panel_layout[0]); + let result = component.common_services_for_group(&[]); + assert!( + result.is_empty(), + "Empty nodes should return empty services" + ); + } - // Services section - if let Some(services) = self.get_node_services(&node_name) { - let running = services.iter().filter(|s| s.state == "Running").count(); - let mut svc_lines = vec![Line::from(vec![Span::styled( - format!(" Services ({}/{})", running, services.len()), - Style::default().fg(Color::Gray), - )])]; + #[test] + fn test_common_services_for_group_returns_all_services_for_single_node() { + let component = create_test_component(); - for (i, svc) in services.iter().enumerate() { - let health_symbol = svc - .health - .as_ref() - .map(|h| if h.healthy { "●" } else { "○" }) - .unwrap_or("●"); - let health_color = if health_symbol == "●" { - Color::Green - } else { - Color::Red - }; + // Single control plane node + let nodes = vec![("cp-node-1".to_string(), "10.0.0.1".to_string())]; - // Highlight selected service when services pane is focused - let is_selected = - i == self.selected_service && self.focused_pane == FocusedPane::Services; - let name_style = if is_selected { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::White) - }; - let selector = if is_selected { "▸" } else { " " }; + let result = component.common_services_for_group(&nodes); + assert_eq!( + result.len(), + 4, + "Single node should return all its services" + ); + assert!(result.contains(&"etcd".to_string())); + assert!(result.contains(&"kubelet".to_string())); + assert!(result.contains(&"apid".to_string())); + assert!(result.contains(&"containerd".to_string())); + } - svc_lines.push(Line::from(vec![ - Span::raw(format!(" {}", selector)), - Span::styled(health_symbol, Style::default().fg(health_color)), - Span::raw(" "), - Span::styled(&svc.id, name_style), - Span::styled(format!(" ({})", svc.state), Style::default().dim()), - ])); - } + #[test] + fn test_common_services_for_group_returns_intersection_for_multiple_nodes() { + let component = create_test_component(); - frame.render_widget(Paragraph::new(svc_lines), panel_layout[1]); - } + // Control plane and worker nodes have different services + let nodes = vec![ + ("cp-node-1".to_string(), "10.0.0.1".to_string()), + ("worker-1".to_string(), "10.0.0.3".to_string()), + ]; + + let result = component.common_services_for_group(&nodes); + + // Common services: kubelet, apid, containerd (not etcd which is only on cp) + assert_eq!( + result.len(), + 3, + "Should return only common services (intersection)" + ); + assert!(result.contains(&"kubelet".to_string())); + assert!(result.contains(&"apid".to_string())); + assert!(result.contains(&"containerd".to_string())); + assert!( + !result.contains(&"etcd".to_string()), + "etcd should not be in common services" + ); + } + + #[test] + fn test_common_services_for_group_returns_empty_when_no_common_services() { + let mut component = ClusterComponent::new(None, None); + + // Create nodes with completely different services + let cluster = ClusterData { + name: "test".to_string(), + client: None, + connected: true, + error: None, + versions: vec![make_version_info("node-a"), make_version_info("node-b")], + services: vec![ + make_node_services("node-a", &["service-a", "service-b"]), + make_node_services("node-b", &["service-c", "service-d"]), + ], + memory: Vec::new(), + load_avg: Vec::new(), + cpu_info: Vec::new(), + etcd_members: Vec::new(), + discovery_members: Vec::new(), + etcd_summary: None, + node_ips: HashMap::from([ + ("node-a".to_string(), "10.0.0.1".to_string()), + ("node-b".to_string(), "10.0.0.2".to_string()), + ]), + expanded: true, + controlplane_expanded: true, + workers_expanded: true, + }; + + component.clusters.push(cluster); + component.active_cluster = 0; + + let nodes = vec![ + ("node-a".to_string(), "10.0.0.1".to_string()), + ("node-b".to_string(), "10.0.0.2".to_string()), + ]; + + let result = component.common_services_for_group(&nodes); + assert!( + result.is_empty(), + "Nodes with no common services should return empty" + ); + } + + #[test] + fn test_common_services_for_group_sorts_results() { + let component = create_test_component(); + + // Use two control plane nodes that have the same services + let nodes = vec![ + ("cp-node-1".to_string(), "10.0.0.1".to_string()), + ("cp-node-2".to_string(), "10.0.0.2".to_string()), + ]; + + let result = component.common_services_for_group(&nodes); + + // Verify the result is sorted + let mut sorted = result.clone(); + sorted.sort(); + assert_eq!(result, sorted, "Results should be sorted alphabetically"); } } diff --git a/crates/talos-pilot-tui/src/components/diagnostics/mod.rs b/crates/talos-pilot-tui/src/components/diagnostics/mod.rs index d615378..ea0ecd5 100644 --- a/crates/talos-pilot-tui/src/components/diagnostics/mod.rs +++ b/crates/talos-pilot-tui/src/components/diagnostics/mod.rs @@ -195,7 +195,7 @@ impl DiagnosticsComponent { group_view_mode: GroupViewMode::default(), node_data: std::collections::HashMap::new(), selected_node_tab: 0, - node_role: node_role, + node_role, } } @@ -1224,24 +1224,26 @@ impl Component for DiagnosticsComponent { } KeyCode::Char('[') => { // Previous node tab (only in group view with ByNode mode) - if self.is_group_view && self.group_view_mode == GroupViewMode::ByNode { - if self.selected_node_tab > 0 { - self.selected_node_tab -= 1; - // Reset selection when changing tabs - self.selected_check = 0; - self.table_state.select(Some(0)); - } + if self.is_group_view + && self.group_view_mode == GroupViewMode::ByNode + && self.selected_node_tab > 0 + { + self.selected_node_tab -= 1; + // Reset selection when changing tabs + self.selected_check = 0; + self.table_state.select(Some(0)); } } KeyCode::Char(']') => { // Next node tab (only in group view with ByNode mode) - if self.is_group_view && self.group_view_mode == GroupViewMode::ByNode { - if self.selected_node_tab + 1 < self.nodes.len() { - self.selected_node_tab += 1; - // Reset selection when changing tabs - self.selected_check = 0; - self.table_state.select(Some(0)); - } + if self.is_group_view + && self.group_view_mode == GroupViewMode::ByNode + && self.selected_node_tab + 1 < self.nodes.len() + { + self.selected_node_tab += 1; + // Reset selection when changing tabs + self.selected_check = 0; + self.table_state.select(Some(0)); } } _ => {} @@ -1283,7 +1285,10 @@ impl Component for DiagnosticsComponent { // Build header spans based on single node vs group view let header_spans = if self.is_group_view { let mut spans = vec![ - Span::styled(" Diagnostics: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled( + " Diagnostics: ", + Style::default().add_modifier(Modifier::BOLD), + ), Span::styled(&self.group_name, Style::default().fg(Color::Cyan)), Span::styled( format!(" ({} nodes)", self.nodes.len()), @@ -1309,7 +1314,9 @@ impl Component for DiagnosticsComponent { if i == self.selected_node_tab { spans.push(Span::styled( format!("[{}]", node_hostname), - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), )); } else { spans.push(Span::styled( @@ -1323,12 +1330,10 @@ impl Component for DiagnosticsComponent { spans } else { // Single node header - vec![ - Span::styled( - format!(" Diagnostics: {} ({}) [{}] ", hostname, address, cni_label), - Style::default().add_modifier(Modifier::BOLD), - ), - ] + vec![Span::styled( + format!(" Diagnostics: {} ({}) [{}] ", hostname, address, cni_label), + Style::default().add_modifier(Modifier::BOLD), + )] }; // Header @@ -1541,10 +1546,11 @@ mod tests { // Should have merged checks from both nodes (4 system checks total: 2 per node) assert_eq!(data.system_checks.len(), 4); // Check names should be prefixed with hostname - assert!(data - .system_checks - .iter() - .any(|c| c.name.starts_with("[node-1]") || c.name.starts_with("[node-2]"))); + assert!( + data.system_checks + .iter() + .any(|c| c.name.starts_with("[node-1]") || c.name.starts_with("[node-2]")) + ); } #[test] diff --git a/crates/talos-pilot-tui/src/components/multi_logs.rs b/crates/talos-pilot-tui/src/components/multi_logs.rs index eda2d15..ba0f64d 100644 --- a/crates/talos-pilot-tui/src/components/multi_logs.rs +++ b/crates/talos-pilot-tui/src/components/multi_logs.rs @@ -569,7 +569,10 @@ impl MultiLogsComponent { match client.logs_stream(&service_id, tail_lines).await { Ok(mut stream_rx) => { while let Some(line) = stream_rx.recv().await { - if tx.send((hostname.clone(), service_id.clone(), line)).is_err() { + if tx + .send((hostname.clone(), service_id.clone(), line)) + .is_err() + { // Channel closed, stop this stream break; } @@ -1678,37 +1681,46 @@ impl MultiLogsComponent { let Some(data) = self.data() else { return }; // For ByNode view, draw node tabs first - let (tabs_height, logs_area) = if self.is_group_view && self.view_mode == GroupViewMode::ByNode { - // Draw node tabs at the top - let tabs_area = Rect::new(area.x, area.y, area.width, 1); - let mut tab_spans = Vec::new(); - for (i, (hostname, _)) in self.nodes.iter().enumerate() { - let short_name = if hostname.len() > 10 { - &hostname[..10] - } else { - hostname.as_str() - }; - if i == self.selected_node_tab { - tab_spans.push(Span::styled( - format!(" [{}] ", short_name), - Style::default().fg(Color::Black).bg(Color::Cyan).bold(), - )); - } else { - tab_spans.push(Span::styled( - format!(" {} ", short_name), - Style::default().fg(Color::Gray), - )); + let (tabs_height, logs_area) = + if self.is_group_view && self.view_mode == GroupViewMode::ByNode { + // Draw node tabs at the top + let tabs_area = Rect::new(area.x, area.y, area.width, 1); + let mut tab_spans = Vec::new(); + for (i, (hostname, _)) in self.nodes.iter().enumerate() { + let short_name = if hostname.len() > 10 { + &hostname[..10] + } else { + hostname.as_str() + }; + if i == self.selected_node_tab { + tab_spans.push(Span::styled( + format!(" [{}] ", short_name), + Style::default().fg(Color::Black).bg(Color::Cyan).bold(), + )); + } else { + tab_spans.push(Span::styled( + format!(" {} ", short_name), + Style::default().fg(Color::Gray), + )); + } } - } - tab_spans.push(Span::raw(" ")); - tab_spans.push(Span::styled("[/]", Style::default().fg(Color::Yellow))); - tab_spans.push(Span::styled(" switch", Style::default().dim())); - let tabs = Paragraph::new(Line::from(tab_spans)); - frame.render_widget(tabs, tabs_area); - (1, Rect::new(area.x, area.y + 1, area.width, area.height.saturating_sub(1))) - } else { - (0, area) - }; + tab_spans.push(Span::raw(" ")); + tab_spans.push(Span::styled("[/]", Style::default().fg(Color::Yellow))); + tab_spans.push(Span::styled(" switch", Style::default().dim())); + let tabs = Paragraph::new(Line::from(tab_spans)); + frame.render_widget(tabs, tabs_area); + ( + 1, + Rect::new( + area.x, + area.y + 1, + area.width, + area.height.saturating_sub(1), + ), + ) + } else { + (0, area) + }; if data.visible_indices.is_empty() { let msg = if self.active_count() == 0 || self.active_level_count() == 0 { @@ -1722,19 +1734,25 @@ impl MultiLogsComponent { } // For ByNode view, filter entries to show only the selected node - let filtered_indices: Vec = if self.is_group_view && self.view_mode == GroupViewMode::ByNode { - if let Some((hostname, _)) = self.nodes.get(self.selected_node_tab) { - data.visible_indices - .iter() - .copied() - .filter(|&idx| data.entries.get(idx).map(|e| &e.node_hostname == hostname).unwrap_or(false)) - .collect() + let filtered_indices: Vec = + if self.is_group_view && self.view_mode == GroupViewMode::ByNode { + if let Some((hostname, _)) = self.nodes.get(self.selected_node_tab) { + data.visible_indices + .iter() + .copied() + .filter(|&idx| { + data.entries + .get(idx) + .map(|e| &e.node_hostname == hostname) + .unwrap_or(false) + }) + .collect() + } else { + data.visible_indices.clone() + } } else { data.visible_indices.clone() - } - } else { - data.visible_indices.clone() - }; + }; if filtered_indices.is_empty() { let msg = " No log entries for this node"; @@ -1808,7 +1826,8 @@ impl MultiLogsComponent { // For group view in interleaved mode, show node:service prefix // For single node or ByNode mode, show just service - let prefix_width = if self.is_group_view && self.view_mode == GroupViewMode::Interleaved { + let prefix_width = if self.is_group_view && self.view_mode == GroupViewMode::Interleaved + { // Show shortened node hostname and service let short_host = if entry.node_hostname.len() > 8 { &entry.node_hostname[..8] diff --git a/crates/talos-pilot-tui/src/components/network.rs b/crates/talos-pilot-tui/src/components/network.rs index b5e542d..0ed91af 100644 --- a/crates/talos-pilot-tui/src/components/network.rs +++ b/crates/talos-pilot-tui/src/components/network.rs @@ -1152,13 +1152,17 @@ impl NetworkStatsComponent { }; // Build header spans based on single node vs group view - let mut spans = vec![ - Span::styled("Network: ", Style::default().add_modifier(Modifier::BOLD)), - ]; + let mut spans = vec![Span::styled( + "Network: ", + Style::default().add_modifier(Modifier::BOLD), + )]; if self.is_group_view { // Group view header - spans.push(Span::styled(&self.group_name, Style::default().fg(Color::Cyan))); + spans.push(Span::styled( + &self.group_name, + Style::default().fg(Color::Cyan), + )); spans.push(Span::styled( format!(" ({} nodes)", self.nodes.len()), Style::default().fg(Color::DarkGray), @@ -1182,7 +1186,9 @@ impl NetworkStatsComponent { if i == self.selected_node_tab { spans.push(Span::styled( format!("[{}]", hostname), - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), )); } else { spans.push(Span::styled( @@ -1565,22 +1571,23 @@ impl NetworkStatsComponent { /// Draw the detail section for selected device fn draw_detail_section(&self, frame: &mut Frame, area: Rect) { // Get devices and rates based on view mode - let (devices, rates) = if self.is_group_view && self.group_view_mode == GroupViewMode::ByNode { - // ByNode mode: get from selected node - let Some((hostname, _)) = self.nodes.get(self.selected_node_tab) else { - return; - }; - let Some(node_data) = self.node_data.get(hostname) else { - return; - }; - (&node_data.data.devices, &node_data.data.rates) - } else { - // Interleaved mode: use merged data - let Some(data) = self.data() else { - return; + let (devices, rates) = + if self.is_group_view && self.group_view_mode == GroupViewMode::ByNode { + // ByNode mode: get from selected node + let Some((hostname, _)) = self.nodes.get(self.selected_node_tab) else { + return; + }; + let Some(node_data) = self.node_data.get(hostname) else { + return; + }; + (&node_data.data.devices, &node_data.data.rates) + } else { + // Interleaved mode: use merged data + let Some(data) = self.data() else { + return; + }; + (&data.devices, &data.rates) }; - (&data.devices, &data.rates) - }; let Some(dev) = devices.get(self.selected) else { return; @@ -1673,21 +1680,22 @@ impl NetworkStatsComponent { // Add connection summary line if we have connection data // Get connection data from the appropriate source - let (connections, conn_counts) = if self.is_group_view && self.group_view_mode == GroupViewMode::ByNode { - if let Some((hostname, _)) = self.nodes.get(self.selected_node_tab) { - if let Some(node_data) = self.node_data.get(hostname) { - (&node_data.data.connections, &node_data.data.conn_counts) + let (connections, conn_counts) = + if self.is_group_view && self.group_view_mode == GroupViewMode::ByNode { + if let Some((hostname, _)) = self.nodes.get(self.selected_node_tab) { + if let Some(node_data) = self.node_data.get(hostname) { + (&node_data.data.connections, &node_data.data.conn_counts) + } else { + return; + } } else { return; } + } else if let Some(data) = self.data() { + (&data.connections, &data.conn_counts) } else { return; - } - } else if let Some(data) = self.data() { - (&data.connections, &data.conn_counts) - } else { - return; - }; + }; if !connections.is_empty() { let cc = conn_counts; @@ -2701,25 +2709,27 @@ impl NetworkStatsComponent { } KeyCode::Char('[') => { // Previous node tab (only in group view with ByNode mode) - if self.is_group_view && self.group_view_mode == GroupViewMode::ByNode { - if self.selected_node_tab > 0 { - self.selected_node_tab -= 1; - // Reset selection when changing tabs - self.selected = 0; - self.table_state.select(Some(0)); - } + if self.is_group_view + && self.group_view_mode == GroupViewMode::ByNode + && self.selected_node_tab > 0 + { + self.selected_node_tab -= 1; + // Reset selection when changing tabs + self.selected = 0; + self.table_state.select(Some(0)); } Ok(None) } KeyCode::Char(']') => { // Next node tab (only in group view with ByNode mode) - if self.is_group_view && self.group_view_mode == GroupViewMode::ByNode { - if self.selected_node_tab + 1 < self.nodes.len() { - self.selected_node_tab += 1; - // Reset selection when changing tabs - self.selected = 0; - self.table_state.select(Some(0)); - } + if self.is_group_view + && self.group_view_mode == GroupViewMode::ByNode + && self.selected_node_tab + 1 < self.nodes.len() + { + self.selected_node_tab += 1; + // Reset selection when changing tabs + self.selected = 0; + self.table_state.select(Some(0)); } Ok(None) } @@ -4065,8 +4075,10 @@ mod tests { NetworkStatsComponent::new("test-node".to_string(), "10.0.0.1".to_string()); // Set up data with devices - let mut data = NetworkData::default(); - data.devices = vec![make_device("eth0"), make_device("lo"), make_device("cni0")]; + let data = NetworkData { + devices: vec![make_device("eth0"), make_device("lo"), make_device("cni0")], + ..Default::default() + }; component.state.set_data(data); component @@ -4124,9 +4136,11 @@ mod tests { // Should have merged devices from both nodes (prefixed with hostname) assert_eq!(devices.len(), 4); // Device names should be prefixed with hostname - assert!(devices - .iter() - .any(|d| d.name.contains("node-1:") || d.name.contains("node-2:"))); + assert!( + devices + .iter() + .any(|d| d.name.contains("node-1:") || d.name.contains("node-2:")) + ); } #[test] diff --git a/crates/talos-pilot-tui/src/components/processes.rs b/crates/talos-pilot-tui/src/components/processes.rs index 9a21dd2..d2486f9 100644 --- a/crates/talos-pilot-tui/src/components/processes.rs +++ b/crates/talos-pilot-tui/src/components/processes.rs @@ -710,10 +710,11 @@ impl ProcessesComponent { } // Reset selection if needed - if let Some(data) = self.data() { - if !data.display_entries.is_empty() && self.selected >= data.display_entries.len() { - self.selected = 0; - } + if let Some(data) = self.data() + && !data.display_entries.is_empty() + && self.selected >= data.display_entries.len() + { + self.selected = 0; } self.table_state.select(Some(self.selected)); @@ -1049,13 +1050,17 @@ impl ProcessesComponent { let proc_count = format!("{} procs", data.display_entries.len()); // Build header based on single node vs group view - let mut spans = vec![ - Span::styled("Processes: ", Style::default().add_modifier(Modifier::BOLD)), - ]; + let mut spans = vec![Span::styled( + "Processes: ", + Style::default().add_modifier(Modifier::BOLD), + )]; if self.is_group_view { // Group view header - spans.push(Span::styled(&self.group_name, Style::default().fg(Color::Cyan))); + spans.push(Span::styled( + &self.group_name, + Style::default().fg(Color::Cyan), + )); spans.push(Span::styled( format!(" ({} nodes)", self.nodes.len()), Style::default().fg(Color::DarkGray), @@ -1079,7 +1084,9 @@ impl ProcessesComponent { if i == self.selected_node_tab { spans.push(Span::styled( format!("[{}]", hostname), - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), )); } else { spans.push(Span::styled( @@ -1113,7 +1120,10 @@ impl ProcessesComponent { spans.push(Span::raw(" ")); spans.push(Span::styled("[", Style::default().fg(Color::DarkGray))); if !cpu_info.is_empty() { - spans.push(Span::styled(cpu_info.clone(), Style::default().fg(Color::Cyan))); + spans.push(Span::styled( + cpu_info.clone(), + Style::default().fg(Color::Cyan), + )); } if !cpu_info.is_empty() && !mem_info.is_empty() { spans.push(Span::styled(" | ", Style::default().fg(Color::DarkGray))); @@ -1361,21 +1371,22 @@ impl ProcessesComponent { let max_cmd_len = area.width.saturating_sub(45) as usize; // Get effective processes based on view mode - let (processes, cpu_percentages) = if self.is_group_view && self.view_mode == GroupViewMode::ByNode { - // ByNode mode: show processes from selected node only - if let Some((hostname, _)) = self.nodes.get(self.selected_node_tab) { - if let Some(node_data) = self.node_data.get(hostname) { - (&node_data.processes, &node_data.cpu_percentages) + let (processes, cpu_percentages) = + if self.is_group_view && self.view_mode == GroupViewMode::ByNode { + // ByNode mode: show processes from selected node only + if let Some((hostname, _)) = self.nodes.get(self.selected_node_tab) { + if let Some(node_data) = self.node_data.get(hostname) { + (&node_data.processes, &node_data.cpu_percentages) + } else { + return None; + } } else { return None; } } else { - return None; - } - } else { - // Interleaved mode or single node: use merged data - (&data.processes, &data.cpu_percentages) - }; + // Interleaved mode or single node: use merged data + (&data.processes, &data.cpu_percentages) + }; let max_mem = processes .iter() @@ -1717,14 +1728,23 @@ impl ProcessesComponent { // Add view mode toggle hint if in group view if self.is_group_view { - spans.push(Span::styled("[v]", Style::default().add_modifier(Modifier::BOLD))); + spans.push(Span::styled( + "[v]", + Style::default().add_modifier(Modifier::BOLD), + )); spans.push(Span::raw(" view ")); // Add tab navigation hint if in ByNode mode if self.view_mode == GroupViewMode::ByNode { - spans.push(Span::styled("[", Style::default().add_modifier(Modifier::BOLD))); + spans.push(Span::styled( + "[", + Style::default().add_modifier(Modifier::BOLD), + )); spans.push(Span::styled("/", Style::default().fg(Color::DarkGray))); - spans.push(Span::styled("]", Style::default().add_modifier(Modifier::BOLD))); + spans.push(Span::styled( + "]", + Style::default().add_modifier(Modifier::BOLD), + )); spans.push(Span::raw(" tabs ")); } } @@ -1982,25 +2002,27 @@ impl ProcessesComponent { } KeyCode::Char('[') => { // Previous node tab (only in group view with ByNode mode) - if self.is_group_view && self.view_mode == GroupViewMode::ByNode { - if self.selected_node_tab > 0 { - self.selected_node_tab -= 1; - // Reset selection when changing tabs - self.selected = 0; - self.table_state.select(Some(0)); - } + if self.is_group_view + && self.view_mode == GroupViewMode::ByNode + && self.selected_node_tab > 0 + { + self.selected_node_tab -= 1; + // Reset selection when changing tabs + self.selected = 0; + self.table_state.select(Some(0)); } Ok(None) } KeyCode::Char(']') => { // Next node tab (only in group view with ByNode mode) - if self.is_group_view && self.view_mode == GroupViewMode::ByNode { - if self.selected_node_tab + 1 < self.nodes.len() { - self.selected_node_tab += 1; - // Reset selection when changing tabs - self.selected = 0; - self.table_state.select(Some(0)); - } + if self.is_group_view + && self.view_mode == GroupViewMode::ByNode + && self.selected_node_tab + 1 < self.nodes.len() + { + self.selected_node_tab += 1; + // Reset selection when changing tabs + self.selected = 0; + self.table_state.select(Some(0)); } Ok(None) } diff --git a/crates/talos-pilot-tui/src/components/storage.rs b/crates/talos-pilot-tui/src/components/storage.rs index 4e37fd7..4f984d4 100644 --- a/crates/talos-pilot-tui/src/components/storage.rs +++ b/crates/talos-pilot-tui/src/components/storage.rs @@ -212,7 +212,12 @@ impl StorageComponent { } /// Add storage data from a node (for group view) - pub fn add_node_storage(&mut self, hostname: String, disks: Vec, volumes: Vec) { + pub fn add_node_storage( + &mut self, + hostname: String, + disks: Vec, + volumes: Vec, + ) { if !self.is_group_view { return; } @@ -353,7 +358,9 @@ impl StorageComponent { match get_disks_for_node(&context, node_ip, config_path.as_deref()).await { Ok(disks) => { // Fetch volumes - match get_volume_status_for_node(&context, node_ip, config_path.as_deref()).await { + match get_volume_status_for_node(&context, node_ip, config_path.as_deref()) + .await + { Ok(volumes) => { self.add_node_storage(hostname.clone(), disks, volumes); } @@ -749,7 +756,10 @@ impl StorageComponent { if self.is_group_view { // Group view header - line_spans.push(Span::styled(&self.group_name, Style::default().fg(Color::Cyan))); + line_spans.push(Span::styled( + &self.group_name, + Style::default().fg(Color::Cyan), + )); line_spans.push(Span::styled( format!(" ({} nodes)", self.nodes.len()), Style::default().fg(Color::DarkGray), @@ -773,7 +783,9 @@ impl StorageComponent { if i == self.selected_node_tab { line_spans.push(Span::styled( format!("[{}]", hostname), - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), )); } else { line_spans.push(Span::styled( @@ -830,24 +842,26 @@ impl Component for StorageComponent { } KeyCode::Char('[') => { // Previous node tab (only in group view with ByNode mode) - if self.is_group_view && self.group_view_mode == GroupViewMode::ByNode { - if self.selected_node_tab > 0 { - self.selected_node_tab -= 1; - // Reset selection when changing tabs - self.disk_table_state.select(Some(0)); - self.volume_table_state.select(Some(0)); - } + if self.is_group_view + && self.group_view_mode == GroupViewMode::ByNode + && self.selected_node_tab > 0 + { + self.selected_node_tab -= 1; + // Reset selection when changing tabs + self.disk_table_state.select(Some(0)); + self.volume_table_state.select(Some(0)); } } KeyCode::Char(']') => { // Next node tab (only in group view with ByNode mode) - if self.is_group_view && self.group_view_mode == GroupViewMode::ByNode { - if self.selected_node_tab + 1 < self.nodes.len() { - self.selected_node_tab += 1; - // Reset selection when changing tabs - self.disk_table_state.select(Some(0)); - self.volume_table_state.select(Some(0)); - } + if self.is_group_view + && self.group_view_mode == GroupViewMode::ByNode + && self.selected_node_tab + 1 < self.nodes.len() + { + self.selected_node_tab += 1; + // Reset selection when changing tabs + self.disk_table_state.select(Some(0)); + self.volume_table_state.select(Some(0)); } } _ => {} @@ -970,17 +984,15 @@ mod tests { /// Create a StorageComponent for single node view fn create_single_node_component() -> StorageComponent { - let mut component = StorageComponent::new( - "test-node".to_string(), - "10.0.0.1".to_string(), - None, - None, - ); + let mut component = + StorageComponent::new("test-node".to_string(), "10.0.0.1".to_string(), None, None); // Set up data - let mut data = StorageData::default(); - data.disks = vec![make_disk("sda"), make_disk("sdb")]; - data.volumes = vec![make_volume("STATE"), make_volume("EPHEMERAL")]; + let data = StorageData { + disks: vec![make_disk("sda"), make_disk("sdb")], + volumes: vec![make_volume("STATE"), make_volume("EPHEMERAL")], + ..Default::default() + }; component.state.set_data(data); component @@ -1038,9 +1050,11 @@ mod tests { // Should have merged disks from both nodes (3 total) assert_eq!(disks.len(), 3); // Dev paths should be prefixed with hostname - assert!(disks - .iter() - .any(|d| d.dev_path.contains("node-1:") || d.dev_path.contains("node-2:"))); + assert!( + disks + .iter() + .any(|d| d.dev_path.contains("node-1:") || d.dev_path.contains("node-2:")) + ); } #[test] @@ -1115,9 +1129,11 @@ mod tests { // Should have merged volumes from both nodes (3 total) assert_eq!(volumes.len(), 3); // IDs should be prefixed with hostname - assert!(volumes - .iter() - .any(|v| v.id.contains("node-1:") || v.id.contains("node-2:"))); + assert!( + volumes + .iter() + .any(|v| v.id.contains("node-1:") || v.id.contains("node-2:")) + ); } #[test] From 8e7df06d2474d535ffd9c118f2a536f18fe1a8de Mon Sep 17 00:00:00 2001 From: Kenny Udovic Date: Tue, 20 Jan 2026 22:36:48 -0500 Subject: [PATCH 4/4] fix: RX / TX on network group view --- .../talos-pilot-tui/src/components/network.rs | 63 +++++++++++++++---- 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/crates/talos-pilot-tui/src/components/network.rs b/crates/talos-pilot-tui/src/components/network.rs index 0ed91af..8811e30 100644 --- a/crates/talos-pilot-tui/src/components/network.rs +++ b/crates/talos-pilot-tui/src/components/network.rs @@ -421,14 +421,45 @@ impl NetworkStatsComponent { return; } - // Store node data - let mut node_network = NodeNetworkData { - hostname: hostname.clone(), - data: NetworkData::default(), - }; - node_network.data.devices = devices; + let now = Instant::now(); + + // Get or create node data, preserving previous samples for rate calculation + let node_network = + self.node_data + .entry(hostname.clone()) + .or_insert_with(|| NodeNetworkData { + hostname: hostname.clone(), + data: NetworkData::default(), + }); + + // Calculate rates if we have previous data + let elapsed_secs = node_network + .data + .last_sample + .map(|t| now.duration_since(t).as_secs_f64()) + .unwrap_or(0.0); + + if elapsed_secs > 0.1 { + for dev in &devices { + if let Some(prev) = node_network.data.prev_devices.get(&dev.name) { + let rate = NetDevRate::from_delta(prev, dev, elapsed_secs); + node_network.data.rates.insert(dev.name.clone(), rate); + } + } + } - self.node_data.insert(hostname, node_network); + // Store current as previous for next calculation + node_network.data.prev_devices.clear(); + for dev in &devices { + node_network + .data + .prev_devices + .insert(dev.name.clone(), dev.clone()); + } + node_network.data.last_sample = Some(now); + + // Store devices + node_network.data.devices = devices; // Rebuild merged view self.rebuild_group_data(); @@ -440,15 +471,22 @@ impl NetworkStatsComponent { return; } - // Merge all devices (prefixed with hostname for disambiguation) + // Merge all devices and rates (prefixed with hostname for disambiguation) let mut merged_devices = Vec::new(); + let mut merged_rates = HashMap::new(); for node_data in self.node_data.values() { for device in &node_data.data.devices { // Clone and prefix device name with hostname for clarity let mut prefixed_device = device.clone(); - prefixed_device.name = format!("{}:{}", node_data.hostname, device.name); + let prefixed_name = format!("{}:{}", node_data.hostname, device.name); + prefixed_device.name = prefixed_name.clone(); merged_devices.push(prefixed_device); + + // Also copy the rate with prefixed key + if let Some(rate) = node_data.data.rates.get(&device.name) { + merged_rates.insert(prefixed_name, rate.clone()); + } } } @@ -459,8 +497,9 @@ impl NetworkStatsComponent { if let Some(data) = self.state.data_mut() { data.devices = merged_devices; + data.rates = merged_rates; - // Calculate totals + // Calculate totals from merged rates data.total_rx_rate = data.rates.values().map(|r| r.rx_bytes_per_sec).sum(); data.total_tx_rate = data.rates.values().map(|r| r.tx_bytes_per_sec).sum(); data.total_errors = data.devices.iter().map(|d| d.total_errors()).sum(); @@ -616,8 +655,8 @@ impl NetworkStatsComponent { async fn refresh_group(&mut self, client: TalosClient) -> Result<()> { self.state.start_loading(); - // Clear existing node data - self.node_data.clear(); + // Note: We don't clear node_data here to preserve prev_devices for rate calculation + // The add_node_network() function uses entry() to update existing entries // Clone nodes to avoid borrow issues let nodes = self.nodes.clone();