- Fix host_index calculation for localhost to use actual position in sorted list - Remove incorrect assumption that localhost is always at index 0 - Host navigation (Tab key) now works correctly with all hosts in alphabetical order Fixes issue where only 3 of 5 hosts were accessible via Tab navigation.
630 lines
25 KiB
Rust
630 lines
25 KiB
Rust
use anyhow::Result;
|
|
use crossterm::event::{Event, KeyCode};
|
|
use ratatui::{
|
|
layout::{Constraint, Direction, Layout, Rect},
|
|
style::Style,
|
|
widgets::{Block, Paragraph},
|
|
Frame,
|
|
};
|
|
use std::collections::HashMap;
|
|
use std::time::Instant;
|
|
use tracing::info;
|
|
|
|
pub mod theme;
|
|
pub mod widgets;
|
|
|
|
use crate::metrics::MetricStore;
|
|
use cm_dashboard_shared::{Metric, Status};
|
|
use theme::{Components, Layout as ThemeLayout, StatusIcons, Theme, Typography};
|
|
use widgets::{BackupWidget, CpuWidget, MemoryWidget, ServicesWidget, Widget};
|
|
|
|
/// Widget states for a specific host
|
|
#[derive(Clone)]
|
|
pub struct HostWidgets {
|
|
/// CPU widget state
|
|
pub cpu_widget: CpuWidget,
|
|
/// Memory widget state
|
|
pub memory_widget: MemoryWidget,
|
|
/// Services widget state
|
|
pub services_widget: ServicesWidget,
|
|
/// Backup widget state
|
|
pub backup_widget: BackupWidget,
|
|
/// Last update time for this host
|
|
pub last_update: Option<Instant>,
|
|
}
|
|
|
|
impl HostWidgets {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
cpu_widget: CpuWidget::new(),
|
|
memory_widget: MemoryWidget::new(),
|
|
services_widget: ServicesWidget::new(),
|
|
backup_widget: BackupWidget::new(),
|
|
last_update: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Main TUI application
|
|
pub struct TuiApp {
|
|
/// Widget states per host (hostname -> HostWidgets)
|
|
host_widgets: HashMap<String, HostWidgets>,
|
|
/// Current active host
|
|
current_host: Option<String>,
|
|
/// Available hosts
|
|
available_hosts: Vec<String>,
|
|
/// Host index for navigation
|
|
host_index: usize,
|
|
/// Should quit application
|
|
should_quit: bool,
|
|
/// Track if user manually navigated away from localhost
|
|
user_navigated_away: bool,
|
|
}
|
|
|
|
impl TuiApp {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
host_widgets: HashMap::new(),
|
|
current_host: None,
|
|
available_hosts: Vec::new(),
|
|
host_index: 0,
|
|
should_quit: false,
|
|
user_navigated_away: false,
|
|
}
|
|
}
|
|
|
|
/// Get or create host widgets for the given hostname
|
|
fn get_or_create_host_widgets(&mut self, hostname: &str) -> &mut HostWidgets {
|
|
self.host_widgets
|
|
.entry(hostname.to_string())
|
|
.or_insert_with(HostWidgets::new)
|
|
}
|
|
|
|
/// Update widgets with metrics from store (only for current host)
|
|
pub fn update_metrics(&mut self, metric_store: &MetricStore) {
|
|
if let Some(hostname) = self.current_host.clone() {
|
|
// Only update widgets if we have metrics for this host
|
|
let all_metrics = metric_store.get_metrics_for_host(&hostname);
|
|
if !all_metrics.is_empty() {
|
|
// Get metrics first while hostname is borrowed
|
|
let cpu_metrics: Vec<&Metric> = all_metrics
|
|
.iter()
|
|
.filter(|m| {
|
|
m.name.starts_with("cpu_")
|
|
|| m.name.contains("c_state_")
|
|
|| m.name.starts_with("process_top_")
|
|
})
|
|
.copied()
|
|
.collect();
|
|
let memory_metrics: Vec<&Metric> = all_metrics
|
|
.iter()
|
|
.filter(|m| m.name.starts_with("memory_") || m.name.starts_with("disk_tmp_"))
|
|
.copied()
|
|
.collect();
|
|
let service_metrics: Vec<&Metric> = all_metrics
|
|
.iter()
|
|
.filter(|m| m.name.starts_with("service_"))
|
|
.copied()
|
|
.collect();
|
|
let all_backup_metrics: Vec<&Metric> = all_metrics
|
|
.iter()
|
|
.filter(|m| m.name.starts_with("backup_"))
|
|
.copied()
|
|
.collect();
|
|
|
|
// Now get host widgets and update them
|
|
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
|
|
|
host_widgets.cpu_widget.update_from_metrics(&cpu_metrics);
|
|
host_widgets
|
|
.memory_widget
|
|
.update_from_metrics(&memory_metrics);
|
|
host_widgets
|
|
.services_widget
|
|
.update_from_metrics(&service_metrics);
|
|
host_widgets
|
|
.backup_widget
|
|
.update_from_metrics(&all_backup_metrics);
|
|
|
|
host_widgets.last_update = Some(Instant::now());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Update available hosts with localhost prioritization
|
|
pub fn update_hosts(&mut self, hosts: Vec<String>) {
|
|
// Sort hosts alphabetically
|
|
let mut sorted_hosts = hosts.clone();
|
|
sorted_hosts.sort();
|
|
self.available_hosts = sorted_hosts;
|
|
|
|
// Get the current hostname (localhost) for auto-selection
|
|
let localhost = gethostname::gethostname().to_string_lossy().to_string();
|
|
|
|
// Prioritize localhost if it becomes available, but respect user navigation
|
|
let localhost = gethostname::gethostname().to_string_lossy().to_string();
|
|
if !self.available_hosts.is_empty() {
|
|
if self.available_hosts.contains(&localhost) && !self.user_navigated_away {
|
|
// Localhost is available and user hasn't navigated away - switch to it
|
|
self.current_host = Some(localhost.clone());
|
|
// Find the actual index of localhost in the sorted list
|
|
self.host_index = self.available_hosts.iter().position(|h| h == &localhost).unwrap_or(0);
|
|
} else if self.current_host.is_none() {
|
|
// No current host - select first available (which is localhost if available)
|
|
self.current_host = Some(self.available_hosts[0].clone());
|
|
self.host_index = 0;
|
|
} else if let Some(ref current) = self.current_host {
|
|
if !self.available_hosts.contains(current) {
|
|
// Current host disconnected - select first available and reset navigation flag
|
|
self.current_host = Some(self.available_hosts[0].clone());
|
|
self.host_index = 0;
|
|
self.user_navigated_away = false; // Reset since we're forced to switch
|
|
} else if let Some(index) = self.available_hosts.iter().position(|h| h == current) {
|
|
// Update index for current host
|
|
self.host_index = index;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle keyboard input
|
|
pub fn handle_input(&mut self, event: Event) -> Result<()> {
|
|
if let Event::Key(key) = event {
|
|
match key.code {
|
|
KeyCode::Char('q') => {
|
|
self.should_quit = true;
|
|
}
|
|
KeyCode::Left => {
|
|
self.navigate_host(-1);
|
|
}
|
|
KeyCode::Right => {
|
|
self.navigate_host(1);
|
|
}
|
|
KeyCode::Char('r') => {
|
|
info!("Manual refresh requested");
|
|
// Refresh will be handled by main loop
|
|
}
|
|
KeyCode::Tab => {
|
|
self.navigate_host(1); // Tab cycles to next host
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Navigate between hosts
|
|
fn navigate_host(&mut self, direction: i32) {
|
|
if self.available_hosts.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let len = self.available_hosts.len();
|
|
if direction > 0 {
|
|
self.host_index = (self.host_index + 1) % len;
|
|
} else {
|
|
self.host_index = if self.host_index == 0 {
|
|
len - 1
|
|
} else {
|
|
self.host_index - 1
|
|
};
|
|
}
|
|
|
|
self.current_host = Some(self.available_hosts[self.host_index].clone());
|
|
|
|
// Check if user navigated away from localhost
|
|
let localhost = gethostname::gethostname().to_string_lossy().to_string();
|
|
if let Some(ref current) = self.current_host {
|
|
if current != &localhost {
|
|
self.user_navigated_away = true;
|
|
} else {
|
|
self.user_navigated_away = false; // User navigated back to localhost
|
|
}
|
|
}
|
|
|
|
info!("Switched to host: {}", self.current_host.as_ref().unwrap());
|
|
}
|
|
|
|
/// Render the dashboard (real btop-style multi-panel layout)
|
|
pub fn render(&mut self, frame: &mut Frame, metric_store: &MetricStore) {
|
|
let size = frame.size();
|
|
|
|
// Clear background to true black like btop
|
|
frame.render_widget(
|
|
Block::default().style(Style::default().bg(Theme::background())),
|
|
size,
|
|
);
|
|
|
|
// Create real btop-style layout: multi-panel with borders
|
|
// Top section: title bar
|
|
// Bottom section: split into left (mem + disks) and right (CPU + processes)
|
|
let main_chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(1), // Title bar
|
|
Constraint::Min(0), // Main content area
|
|
])
|
|
.split(size);
|
|
|
|
// New layout: left panels | right services (100% height)
|
|
let content_chunks = ratatui::layout::Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints([
|
|
Constraint::Percentage(ThemeLayout::LEFT_PANEL_WIDTH), // Left side: system, backup
|
|
Constraint::Percentage(ThemeLayout::RIGHT_PANEL_WIDTH), // Right side: services (100% height)
|
|
])
|
|
.split(main_chunks[1]);
|
|
|
|
// Check if backup panel should be shown
|
|
let show_backup = if let Some(hostname) = self.current_host.clone() {
|
|
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
|
host_widgets.backup_widget.has_data()
|
|
} else {
|
|
false
|
|
};
|
|
|
|
// Left side: dynamic layout based on backup data availability
|
|
let left_chunks = if show_backup {
|
|
// Show both system and backup panels
|
|
ratatui::layout::Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Percentage(ThemeLayout::SYSTEM_PANEL_HEIGHT), // System section
|
|
Constraint::Percentage(ThemeLayout::BACKUP_PANEL_HEIGHT), // Backup section
|
|
])
|
|
.split(content_chunks[0])
|
|
} else {
|
|
// Show only system panel (full height)
|
|
ratatui::layout::Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([Constraint::Percentage(100)]) // System section takes full height
|
|
.split(content_chunks[0])
|
|
};
|
|
|
|
// Render title bar
|
|
self.render_btop_title(frame, main_chunks[0], metric_store);
|
|
|
|
// Render new panel layout
|
|
self.render_system_panel(frame, left_chunks[0], metric_store);
|
|
if show_backup && left_chunks.len() > 1 {
|
|
self.render_backup_panel(frame, left_chunks[1]);
|
|
}
|
|
|
|
// Render services widget for current host
|
|
if let Some(hostname) = self.current_host.clone() {
|
|
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
|
host_widgets
|
|
.services_widget
|
|
.render(frame, content_chunks[1]); // Services takes full right side
|
|
}
|
|
}
|
|
|
|
/// Render btop-style minimal title with host status colors
|
|
fn render_btop_title(&self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) {
|
|
use ratatui::style::Modifier;
|
|
use ratatui::text::{Line, Span};
|
|
use theme::StatusIcons;
|
|
|
|
if self.available_hosts.is_empty() {
|
|
let title_text = "cm-dashboard • no hosts discovered";
|
|
let title = Paragraph::new(title_text).style(Typography::title());
|
|
frame.render_widget(title, area);
|
|
return;
|
|
}
|
|
|
|
// Create spans for each host with status indicators
|
|
let mut spans = vec![Span::styled("cm-dashboard • ", Typography::title())];
|
|
|
|
for (i, host) in self.available_hosts.iter().enumerate() {
|
|
if i > 0 {
|
|
spans.push(Span::styled(" ", Typography::title()));
|
|
}
|
|
|
|
// Calculate overall host status from metrics
|
|
let host_status = self.calculate_host_status(host, metric_store);
|
|
let status_icon = StatusIcons::get_icon(host_status);
|
|
let status_color = Theme::status_color(host_status);
|
|
|
|
// Add status icon
|
|
spans.push(Span::styled(
|
|
format!("{} ", status_icon),
|
|
Style::default().fg(status_color),
|
|
));
|
|
|
|
if Some(host) == self.current_host.as_ref() {
|
|
// Selected host in bold bright white
|
|
spans.push(Span::styled(
|
|
host.clone(),
|
|
Typography::title().add_modifier(Modifier::BOLD),
|
|
));
|
|
} else {
|
|
// Other hosts in normal style with status color
|
|
spans.push(Span::styled(
|
|
host.clone(),
|
|
Style::default().fg(status_color),
|
|
));
|
|
}
|
|
}
|
|
|
|
let title_line = Line::from(spans);
|
|
let title = Paragraph::new(vec![title_line]);
|
|
|
|
frame.render_widget(title, area);
|
|
}
|
|
|
|
/// Calculate overall status for a host based on its metrics
|
|
fn calculate_host_status(&self, hostname: &str, metric_store: &MetricStore) -> Status {
|
|
let metrics = metric_store.get_metrics_for_host(hostname);
|
|
|
|
if metrics.is_empty() {
|
|
return Status::Unknown;
|
|
}
|
|
|
|
// Check if any metric is critical
|
|
if metrics.iter().any(|m| m.status == Status::Critical) {
|
|
return Status::Critical;
|
|
}
|
|
|
|
// Check if any metric is warning
|
|
if metrics.iter().any(|m| m.status == Status::Warning) {
|
|
return Status::Warning;
|
|
}
|
|
|
|
// Check if all metrics are ok
|
|
if metrics.iter().all(|m| m.status == Status::Ok) {
|
|
return Status::Ok;
|
|
}
|
|
|
|
// Default to unknown if mixed statuses
|
|
Status::Unknown
|
|
}
|
|
|
|
fn render_system_panel(&mut self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) {
|
|
let system_block = Components::widget_block("system");
|
|
let inner_area = system_block.inner(area);
|
|
frame.render_widget(system_block, area);
|
|
let content_chunks = ratatui::layout::Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(ThemeLayout::CPU_SECTION_HEIGHT), // CPU section (title, load)
|
|
Constraint::Length(ThemeLayout::MEMORY_SECTION_HEIGHT), // Memory section (title, used, /tmp)
|
|
Constraint::Min(0), // Storage section
|
|
])
|
|
.split(inner_area);
|
|
|
|
// Get current host widgets, create if none exist
|
|
if let Some(hostname) = self.current_host.clone() {
|
|
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
|
host_widgets.cpu_widget.render(frame, content_chunks[0]);
|
|
host_widgets.memory_widget.render(frame, content_chunks[1]);
|
|
}
|
|
self.render_storage_section(frame, content_chunks[2], metric_store);
|
|
}
|
|
|
|
fn render_backup_panel(&mut self, frame: &mut Frame, area: Rect) {
|
|
let backup_block = Components::widget_block("backup");
|
|
let inner_area = backup_block.inner(area);
|
|
frame.render_widget(backup_block, area);
|
|
|
|
// Get current host widgets for backup widget
|
|
if let Some(hostname) = self.current_host.clone() {
|
|
let host_widgets = self.get_or_create_host_widgets(&hostname);
|
|
host_widgets.backup_widget.render(frame, inner_area);
|
|
}
|
|
}
|
|
|
|
fn render_storage_section(&self, frame: &mut Frame, area: Rect, metric_store: &MetricStore) {
|
|
if area.height < 2 {
|
|
return;
|
|
}
|
|
|
|
if let Some(ref hostname) = self.current_host {
|
|
// Get disk count to determine how many disks to display
|
|
let disk_count =
|
|
if let Some(count_metric) = metric_store.get_metric(hostname, "disk_count") {
|
|
count_metric.value.as_i64().unwrap_or(0) as usize
|
|
} else {
|
|
0
|
|
};
|
|
|
|
if disk_count == 0 {
|
|
// No disks found - show error/waiting message
|
|
let content_chunks = ratatui::layout::Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([Constraint::Length(1), Constraint::Min(0)])
|
|
.split(area);
|
|
|
|
let disk_title = Paragraph::new("Storage:").style(Typography::widget_title());
|
|
frame.render_widget(disk_title, content_chunks[0]);
|
|
|
|
let no_disks_spans =
|
|
StatusIcons::create_status_spans(Status::Unknown, "No mounted disks detected");
|
|
let no_disks_para = Paragraph::new(ratatui::text::Line::from(no_disks_spans));
|
|
frame.render_widget(no_disks_para, content_chunks[1]);
|
|
return;
|
|
}
|
|
|
|
// Group disks by physical device
|
|
let mut physical_devices: std::collections::HashMap<String, Vec<usize>> =
|
|
std::collections::HashMap::new();
|
|
|
|
for disk_index in 0..disk_count {
|
|
if let Some(physical_device_metric) = metric_store
|
|
.get_metric(hostname, &format!("disk_{}_physical_device", disk_index))
|
|
{
|
|
let physical_device = physical_device_metric.value.as_string();
|
|
physical_devices
|
|
.entry(physical_device)
|
|
.or_insert_with(Vec::new)
|
|
.push(disk_index);
|
|
}
|
|
}
|
|
|
|
// Calculate how many lines we need
|
|
let mut _total_lines_needed = 0;
|
|
for partitions in physical_devices.values() {
|
|
_total_lines_needed += 2 + partitions.len(); // title + health + usage_per_partition
|
|
}
|
|
|
|
let available_lines = area.height as usize;
|
|
|
|
// Create constraints dynamically based on physical devices
|
|
let mut constraints = Vec::new();
|
|
let mut devices_to_show = Vec::new();
|
|
let mut current_line = 0;
|
|
|
|
// Sort physical devices by name for consistent ordering
|
|
let mut sorted_devices: Vec<_> = physical_devices.iter().collect();
|
|
sorted_devices.sort_by_key(|(device_name, _)| device_name.as_str());
|
|
|
|
for (physical_device, partitions) in sorted_devices {
|
|
let lines_for_this_device = 2 + partitions.len();
|
|
if current_line + lines_for_this_device <= available_lines {
|
|
devices_to_show.push((physical_device.clone(), partitions.clone()));
|
|
|
|
// Add constraints for this device
|
|
constraints.push(Constraint::Length(1)); // Device title
|
|
constraints.push(Constraint::Length(1)); // Health line
|
|
for _ in 0..partitions.len() {
|
|
constraints.push(Constraint::Length(1)); // Usage line per partition
|
|
}
|
|
|
|
current_line += lines_for_this_device;
|
|
} else {
|
|
break; // Can't fit more devices
|
|
}
|
|
}
|
|
|
|
// Add remaining space if any
|
|
if constraints.len() < available_lines {
|
|
constraints.push(Constraint::Min(0));
|
|
}
|
|
|
|
let content_chunks = ratatui::layout::Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints(constraints)
|
|
.split(area);
|
|
|
|
let mut chunk_index = 0;
|
|
|
|
// Display each physical device
|
|
for (physical_device, partitions) in &devices_to_show {
|
|
// Device title
|
|
let disk_title_text = format!("Disk {}:", physical_device);
|
|
let disk_title_para =
|
|
Paragraph::new(disk_title_text).style(Typography::widget_title());
|
|
frame.render_widget(disk_title_para, content_chunks[chunk_index]);
|
|
chunk_index += 1;
|
|
|
|
// Health status (one per physical device)
|
|
let smart_health = metric_store
|
|
.get_metric(hostname, &format!("disk_smart_{}_health", physical_device))
|
|
.map(|m| (m.value.as_string(), m.status))
|
|
.unwrap_or_else(|| ("Unknown".to_string(), Status::Unknown));
|
|
|
|
let smart_temp = metric_store
|
|
.get_metric(
|
|
hostname,
|
|
&format!("disk_smart_{}_temperature", physical_device),
|
|
)
|
|
.and_then(|m| m.value.as_f32());
|
|
|
|
let temp_text = if let Some(temp) = smart_temp {
|
|
format!(" {}°C", temp as i32)
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
let health_spans = StatusIcons::create_status_spans(
|
|
smart_health.1,
|
|
&format!("Health: {}{}", smart_health.0, temp_text),
|
|
);
|
|
let health_para = Paragraph::new(ratatui::text::Line::from(health_spans));
|
|
frame.render_widget(health_para, content_chunks[chunk_index]);
|
|
chunk_index += 1;
|
|
|
|
// Usage lines (one per partition/mount point)
|
|
// Sort partitions by disk index for consistent ordering
|
|
let mut sorted_partitions = partitions.clone();
|
|
sorted_partitions.sort();
|
|
for &disk_index in &sorted_partitions {
|
|
let mount_point = metric_store
|
|
.get_metric(hostname, &format!("disk_{}_mount_point", disk_index))
|
|
.map(|m| m.value.as_string())
|
|
.unwrap_or("?".to_string());
|
|
|
|
let usage_percent = metric_store
|
|
.get_metric(hostname, &format!("disk_{}_usage_percent", disk_index))
|
|
.and_then(|m| m.value.as_f32())
|
|
.unwrap_or(0.0);
|
|
|
|
let used_gb = metric_store
|
|
.get_metric(hostname, &format!("disk_{}_used_gb", disk_index))
|
|
.and_then(|m| m.value.as_f32())
|
|
.unwrap_or(0.0);
|
|
|
|
let total_gb = metric_store
|
|
.get_metric(hostname, &format!("disk_{}_total_gb", disk_index))
|
|
.and_then(|m| m.value.as_f32())
|
|
.unwrap_or(0.0);
|
|
|
|
let usage_status = metric_store
|
|
.get_metric(hostname, &format!("disk_{}_usage_percent", disk_index))
|
|
.map(|m| m.status)
|
|
.unwrap_or(Status::Unknown);
|
|
|
|
// Format mount point for usage line
|
|
let mount_display = if mount_point == "/" {
|
|
"root".to_string()
|
|
} else if mount_point == "/boot" {
|
|
"boot".to_string()
|
|
} else if mount_point.starts_with("/") {
|
|
mount_point[1..].to_string() // Remove leading slash
|
|
} else {
|
|
mount_point.clone()
|
|
};
|
|
|
|
let usage_spans = StatusIcons::create_status_spans(
|
|
usage_status,
|
|
&format!(
|
|
"Usage @{}: {:.1}% • {:.1}/{:.1} GB",
|
|
mount_display, usage_percent, used_gb, total_gb
|
|
),
|
|
);
|
|
let usage_para = Paragraph::new(ratatui::text::Line::from(usage_spans));
|
|
frame.render_widget(usage_para, content_chunks[chunk_index]);
|
|
chunk_index += 1;
|
|
}
|
|
}
|
|
|
|
// Show truncation indicator if we couldn't display all devices
|
|
if devices_to_show.len() < physical_devices.len() {
|
|
if let Some(last_chunk) = content_chunks.last() {
|
|
let truncated_count = physical_devices.len() - devices_to_show.len();
|
|
let truncated_text = format!(
|
|
"... and {} more disk{}",
|
|
truncated_count,
|
|
if truncated_count == 1 { "" } else { "s" }
|
|
);
|
|
let truncated_para = Paragraph::new(truncated_text).style(Typography::muted());
|
|
frame.render_widget(truncated_para, *last_chunk);
|
|
}
|
|
}
|
|
} else {
|
|
// No host connected
|
|
let content_chunks = ratatui::layout::Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([Constraint::Length(1), Constraint::Min(0)])
|
|
.split(area);
|
|
|
|
let disk_title = Paragraph::new("Storage:").style(Typography::widget_title());
|
|
frame.render_widget(disk_title, content_chunks[0]);
|
|
|
|
let no_host_spans =
|
|
StatusIcons::create_status_spans(Status::Unknown, "No host connected");
|
|
let no_host_para = Paragraph::new(ratatui::text::Line::from(no_host_spans));
|
|
frame.render_widget(no_host_para, content_chunks[1]);
|
|
}
|
|
}
|
|
}
|