diff --git a/agent/src/collectors/system.rs b/agent/src/collectors/system.rs index ffbd3f3..5df0aa3 100644 --- a/agent/src/collectors/system.rs +++ b/agent/src/collectors/system.rs @@ -149,18 +149,57 @@ impl SystemCollector { } if total_time > 0 && !cstate_times.is_empty() { - // Sort by time spent (highest first) - cstate_times.sort_by(|a, b| b.1.cmp(&a.1)); + // Sort by C-state order: POLL, C1, C1E, C3, C6, C7s, C8, C9, C10 + cstate_times.sort_by(|a, b| { + let order_a = match a.0.as_str() { + "POLL" => 0, + "C1" => 1, + "C1E" => 2, + "C3" => 3, + "C6" => 4, + "C7s" => 5, + "C8" => 6, + "C9" => 7, + "C10" => 8, + _ => 99, + }; + let order_b = match b.0.as_str() { + "POLL" => 0, + "C1" => 1, + "C1E" => 2, + "C3" => 3, + "C6" => 4, + "C7s" => 5, + "C8" => 6, + "C9" => 7, + "C10" => 8, + _ => 99, + }; + order_a.cmp(&order_b) + }); - // Format all C-states with percentages + // Format C-states as description lines (split into two rows for readability) let mut result = Vec::new(); + let mut current_line = Vec::new(); + for (name, time) in cstate_times { let percent = (time as f32 / total_time as f32) * 100.0; if percent >= 0.1 { // Only show states with at least 0.1% time - result.push(format!("{}: {:.1}%", name, percent)); + current_line.push(format!("{}: {:.1}%", name, percent)); + + // Split into two rows when we have 4 items + if current_line.len() == 4 { + result.push(current_line.join(", ")); + current_line.clear(); + } } } + // Add remaining items as second line + if !current_line.is_empty() { + result.push(current_line.join(", ")); + } + return Some(result); } } diff --git a/dashboard/src/app.rs b/dashboard/src/app.rs index d98b27d..8dbafca 100644 --- a/dashboard/src/app.rs +++ b/dashboard/src/app.rs @@ -475,8 +475,26 @@ impl App { targets.push(HostTarget::from_name(local)); } } - } else if let Some(local) = local_host { - targets.push(HostTarget::from_name(local)); + } else { + // No config file - use auto-discovery with known CMTEC hosts + let known_hosts = vec![ + "cmbox", "labbox", "simonbox", "steambox", "srv01" + ]; + + if let Some(local) = local_host.as_ref() { + targets.push(HostTarget::from_name(local.clone())); + } + + // Add all known hosts for auto-discovery + for hostname in known_hosts { + if targets + .iter() + .any(|existing| existing.name.eq_ignore_ascii_case(hostname)) + { + continue; + } + targets.push(HostTarget::from_name(hostname.to_string())); + } } if targets.is_empty() { diff --git a/dashboard/src/ui/system.rs b/dashboard/src/ui/system.rs index 60474b2..41e2cd6 100644 --- a/dashboard/src/ui/system.rs +++ b/dashboard/src/ui/system.rs @@ -49,102 +49,6 @@ fn render_metrics( // Use agent-calculated statuses let memory_status = status_level_from_agent_status(summary.memory_status.as_ref()); let cpu_status = status_level_from_agent_status(summary.cpu_status.as_ref()); - // Dashboard should NOT calculate colors - agent is the source of truth - - // Memory dataset - use agent-calculated status - let mut memory_dataset = WidgetDataSet::new(vec!["Memory usage".to_string()], Some(WidgetStatus::new(memory_status))); - memory_dataset.add_row( - Some(WidgetStatus::new(memory_status)), - vec![], - vec![format!("{:.1} / {:.1} GB", summary.memory_used_mb / 1000.0, summary.memory_total_mb / 1000.0)], - ); - - // CPU dataset - use agent-calculated status - let mut cpu_dataset = WidgetDataSet::new(vec!["CPU load".to_string(), "CPU temp".to_string()], Some(WidgetStatus::new(cpu_status))); - cpu_dataset.add_row( - Some(WidgetStatus::new(cpu_status)), - vec![], - vec![ - format!("{:.2} • {:.2} • {:.2}", summary.cpu_load_1, summary.cpu_load_5, summary.cpu_load_15), - format_optional_metric(summary.cpu_temp_c, "°C"), - ], - ); - - // C-state dataset - all C-states as columns in one row, ordered properly - let mut cstate_headers = Vec::new(); - let mut cstate_values = Vec::new(); - - if let Some(cstates) = summary.cpu_cstate.as_ref() { - // Parse all C-states first - let mut parsed_cstates: Vec<(String, String)> = Vec::new(); - for cstate_info in cstates { - if let Some((state, percent)) = cstate_info.split_once(": ") { - parsed_cstates.push((state.to_string(), percent.to_string())); - } - } - - // Sort by C-state order: POLL, C1, C1E, C3, C6, C7s, C8, C9, C10 - parsed_cstates.sort_by(|a, b| { - let order_a = match a.0.as_str() { - "POLL" => 0, - "C1" => 1, - "C1E" => 2, - "C3" => 3, - "C6" => 4, - "C7s" => 5, - "C8" => 6, - "C9" => 7, - "C10" => 8, - _ => 99, - }; - let order_b = match b.0.as_str() { - "POLL" => 0, - "C1" => 1, - "C1E" => 2, - "C3" => 3, - "C6" => 4, - "C7s" => 5, - "C8" => 6, - "C9" => 7, - "C10" => 8, - _ => 99, - }; - order_a.cmp(&order_b) - }); - - // Take all available C-states (or limit to fit display) - for (state, percent) in parsed_cstates.into_iter().take(8) { - cstate_headers.push(state); - cstate_values.push(percent); - } - } - - let mut cstate_dataset = WidgetDataSet::new(cstate_headers, Some(WidgetStatus::new(StatusLevel::Ok))); - if !cstate_values.is_empty() { - cstate_dataset.add_row( - Some(WidgetStatus::new(StatusLevel::Ok)), - vec![], - cstate_values, - ); - } else { - cstate_dataset.add_row( - Some(WidgetStatus::new(StatusLevel::Unknown)), - vec![], - vec!["No data".to_string()], - ); - } - - // GPU dataset - GPU data remains in ServiceMetrics, not SystemMetrics - let gpu_status = StatusLevel::Unknown; // GPU not available in SystemMetrics - let mut gpu_dataset = WidgetDataSet::new(vec!["GPU load".to_string(), "GPU temp".to_string()], Some(WidgetStatus::new(gpu_status))); - gpu_dataset.add_row( - Some(WidgetStatus::new(gpu_status)), - vec![], - vec![ - "—".to_string(), // GPU data not in SystemMetrics - "—".to_string(), // GPU data not in SystemMetrics - ], - ); // Determine overall widget status based on worst case from agent statuses let overall_status_level = match (memory_status, cpu_status) { @@ -155,8 +59,27 @@ fn render_metrics( }; let overall_status = Some(WidgetStatus::new(overall_status_level)); - // Render all datasets in a single combined widget - render_combined_widget_data(frame, area, "System".to_string(), overall_status, vec![memory_dataset, cpu_dataset, cstate_dataset, gpu_dataset]); + // Single dataset with RAM, CPU load, CPU temp as columns + let mut system_dataset = WidgetDataSet::new( + vec!["RAM usage".to_string(), "CPU load".to_string(), "CPU temp".to_string()], + overall_status.clone() + ); + + // Use agent-provided C-states as description (agent decides what goes in descriptions) + let cstate_description = summary.cpu_cstate.clone().unwrap_or_default(); + + system_dataset.add_row( + overall_status.clone(), + cstate_description, + vec![ + format!("{:.1} / {:.1} GB", summary.memory_used_mb / 1000.0, summary.memory_total_mb / 1000.0), + format!("{:.2} • {:.2} • {:.2}", summary.cpu_load_1, summary.cpu_load_5, summary.cpu_load_15), + format_optional_metric(summary.cpu_temp_c, "°C"), + ], + ); + + // Render single dataset + render_combined_widget_data(frame, area, "System".to_string(), overall_status, vec![system_dataset]); } fn format_optional_metric(value: Option, unit: &str) -> String { diff --git a/dashboard/src/ui/widget.rs b/dashboard/src/ui/widget.rs index b0ce07e..dc7752a 100644 --- a/dashboard/src/ui/widget.rs +++ b/dashboard/src/ui/widget.rs @@ -286,63 +286,91 @@ fn render_dataset_with_wrapping(frame: &mut Frame, dataset: &WidgetDataSet, inne break; } - let mut cells = vec![]; - - // Status cell (only show on first section) - if col_start == 0 { - match &row.status { + // Check if this is a sub-service - if so, render as full-width row + if row.sub_service.is_some() && col_start == 0 { + // Sub-service: render as full-width spanning row + let is_last_sub_service = is_last_sub_service_in_group(&dataset.rows, row_idx, &row.sub_service); + let tree_char = if is_last_sub_service { "└─" } else { "├─" }; + let service_name = row.values.get(0).cloned().unwrap_or_default(); + + let status_icon = match &row.status { Some(s) => { let color = s.status.to_color(); let icon = s.status.to_icon(); - cells.push(Cell::from(Line::from(vec![Span::styled( - icon.to_string(), - Style::default().fg(color), - )]))); + Span::styled(icon.to_string(), Style::default().fg(color)) }, - None => cells.push(Cell::from("")), - } - } else { - cells.push(Cell::from("")); - } - - // Data cells for this section - for col_idx in col_start..col_end { - if let Some(content) = row.values.get(col_idx) { - if content.is_empty() { - cells.push(Cell::from("")); - } else { - // Check if this is the first column (service name) and if it's a sub-service - let display_content = if col_idx == 0 && row.sub_service.is_some() { - // Determine if this is the last sub-service for this parent - let is_last_sub_service = is_last_sub_service_in_group(&dataset.rows, row_idx, &row.sub_service); - let tree_char = if is_last_sub_service { "└─" } else { "├─" }; - format!("{} {}", tree_char, content) - } else { - content.to_string() - }; - - cells.push(Cell::from(Line::from(vec![Span::styled( - display_content, - neutral_text_style(), - )]))); + None => Span::raw(""), + }; + + let full_content = format!("{} {}", tree_char, service_name); + let full_cell = Cell::from(Line::from(vec![ + status_icon, + Span::raw(" "), + Span::styled(full_content, neutral_text_style()), + ])); + + let full_row = Row::new(vec![full_cell]); + let full_constraints = vec![Constraint::Length(inner.width)]; + let full_table = Table::new(vec![full_row]) + .widths(&full_constraints) + .style(neutral_text_style()); + + frame.render_widget(full_table, Rect { + x: inner.x, + y: current_y, + width: inner.width, + height: 1, + }); + } else if row.sub_service.is_none() { + // Regular service: render with columns as normal + let mut cells = vec![]; + + // Status cell (only show on first section) + if col_start == 0 { + match &row.status { + Some(s) => { + let color = s.status.to_color(); + let icon = s.status.to_icon(); + cells.push(Cell::from(Line::from(vec![Span::styled( + icon.to_string(), + Style::default().fg(color), + )]))); + }, + None => cells.push(Cell::from("")), } } else { cells.push(Cell::from("")); } + + // Data cells for this section + for col_idx in col_start..col_end { + if let Some(content) = row.values.get(col_idx) { + if content.is_empty() { + cells.push(Cell::from("")); + } else { + cells.push(Cell::from(Line::from(vec![Span::styled( + content.to_string(), + neutral_text_style(), + )]))); + } + } else { + cells.push(Cell::from("")); + } + } + + let data_row = Row::new(cells); + let data_table = Table::new(vec![data_row]) + .widths(&constraints) + .column_spacing(col_spacing) + .style(neutral_text_style()); + + frame.render_widget(data_table, Rect { + x: inner.x, + y: current_y, + width: inner.width, + height: 1, + }); } - - let data_row = Row::new(cells); - let data_table = Table::new(vec![data_row]) - .widths(&constraints) - .column_spacing(col_spacing) - .style(neutral_text_style()); - - frame.render_widget(data_table, Rect { - x: inner.x, - y: current_y, - width: inner.width, - height: 1, - }); current_y += 1; // Render description rows if any exist