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..97e0511 100644 --- a/crates/talos-pilot-tui/src/app.rs +++ b/crates/talos-pilot-tui/src/app.rs @@ -953,6 +953,68 @@ 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 +1256,207 @@ 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() { + // 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 { + 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() { + // 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 { + 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()); + + // 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 + 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() { + // 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); + + // 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..444ae99 100644 --- a/crates/talos-pilot-tui/src/components/cluster.rs +++ b/crates/talos-pilot-tui/src/components/cluster.rs @@ -818,6 +818,88 @@ 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 +1148,20 @@ 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 +1187,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 +1202,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 +1217,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 +1232,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) @@ -2039,3 +2155,367 @@ impl 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"); + } +} diff --git a/crates/talos-pilot-tui/src/components/diagnostics/mod.rs b/crates/talos-pilot-tui/src/components/diagnostics/mod.rs index e322af0..ea0ecd5 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,9 +188,138 @@ 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, } } + /// 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 fn data(&self) -> Option<&DiagnosticsData> { self.state.data() @@ -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 { @@ -411,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); @@ -581,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 { @@ -970,6 +1210,42 @@ 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 + && 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 + && 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 +1282,62 @@ 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 +1345,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 +1412,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 +1439,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 +1455,186 @@ 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..ba0f64d 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,61 @@ 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 +607,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 +649,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 +744,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 +788,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 +860,7 @@ impl MultiLogsComponent { level, message, search_text, + node_hostname: hostname.to_string(), } } @@ -1416,6 +1680,48 @@ 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 +1729,53 @@ 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; } - let visible_height = area.height as usize; - let content_width = area.width.saturating_sub(1) as usize; // -1 for scrollbar + // 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 = 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 +1824,38 @@ 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 +1866,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 +1930,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 +2219,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 +2337,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 +2526,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 +2544,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 +2561,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..8811e30 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,9 +354,161 @@ 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; + } + + 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); + } + } + } + + // 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(); + } + + /// Rebuild merged network data for group view + fn rebuild_group_data(&mut self) { + if !self.is_group_view { + return; + } + + // 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(); + 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()); + } + } + } + + // 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; + data.rates = merged_rates; + + // 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(); + data.total_dropped = data.devices.iter().map(|d| d.total_dropped()).sum(); + } + + self.state.mark_loaded(); + } + /// Get a reference to the loaded data fn data(&self) -> Option<&NetworkData> { self.state.data() @@ -351,6 +536,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 @@ -461,6 +651,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(); + + // 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(); + + // 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(); @@ -963,12 +1190,62 @@ impl NetworkStatsComponent { Span::raw("") }; - let 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)), + // Build header spans based on single node vs group view + 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( + 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 +1253,7 @@ impl NetworkStatsComponent { tab_ifaces, conns_indicator, tab_kubespan, - ]; + ]); let header = Paragraph::new(Line::from(spans)); frame.render_widget(header, area); @@ -1188,6 +1465,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 +1505,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 +1609,30 @@ 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 +1718,26 @@ 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 +1817,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 +1848,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 +2733,45 @@ 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 + && 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 + && 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 +4088,163 @@ 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 data = NetworkData { + devices: vec![make_device("eth0"), make_device("lo"), make_device("cni0")], + ..Default::default() + }; + + 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..d2486f9 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,9 +226,130 @@ 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 pub fn set_client(&mut self, client: TalosClient) { self.client = Some(client); @@ -413,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 @@ -508,6 +683,45 @@ 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() + && !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(); @@ -816,7 +1030,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 +1049,90 @@ 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), + )]; - 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)), - ]; + 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), + )); - // Add system info - if !cpu_info.is_empty() || !mem_info.is_empty() { + // 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 +1369,100 @@ 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 +1719,37 @@ 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 +1771,8 @@ impl ProcessesComponent { Span::raw(" back"), ]); + let line = Line::from(spans); + let para = Paragraph::new(line); frame.render_widget(para, area); } @@ -1615,6 +1987,45 @@ 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 + && 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 + && 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..4f984d4 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,101 @@ 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 @@ -139,6 +268,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); @@ -151,6 +286,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 { @@ -193,6 +333,57 @@ 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) @@ -255,6 +446,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 +490,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 +554,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 +626,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 +692,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 +751,58 @@ 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 +828,42 @@ 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 + && 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 + && 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 +913,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 +939,273 @@ 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 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 + } + + /// 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" + ); + } +}