- Remove unused fields from CommandStatus variants - Clean up unused methods and unused collector fields - Fix lifetime syntax warning in SystemWidget - Delete unused cache module completely - Remove redundant render methods from widgets All agent and dashboard warnings eliminated while preserving panel switching and scrolling functionality.
629 lines
27 KiB
Rust
629 lines
27 KiB
Rust
use cm_dashboard_shared::{Metric, Status};
|
|
use ratatui::{
|
|
layout::{Constraint, Direction, Layout, Rect},
|
|
widgets::Paragraph,
|
|
Frame,
|
|
};
|
|
use std::collections::HashMap;
|
|
use tracing::debug;
|
|
|
|
use super::Widget;
|
|
use crate::ui::theme::{Components, StatusIcons, Theme, Typography};
|
|
use crate::ui::{CommandStatus, CommandType};
|
|
use ratatui::style::Style;
|
|
|
|
/// Services widget displaying hierarchical systemd service statuses
|
|
#[derive(Clone)]
|
|
pub struct ServicesWidget {
|
|
/// Parent services (nginx, docker, etc.)
|
|
parent_services: HashMap<String, ServiceInfo>,
|
|
/// Sub-services grouped by parent (nginx -> [gitea, mariehall, ...], docker -> [container1, ...])
|
|
sub_services: HashMap<String, Vec<(String, ServiceInfo)>>,
|
|
/// Aggregated status
|
|
status: Status,
|
|
/// Last update indicator
|
|
has_data: bool,
|
|
/// Currently selected service index (for navigation cursor)
|
|
selected_index: usize,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct ServiceInfo {
|
|
status: String,
|
|
memory_mb: Option<f32>,
|
|
disk_gb: Option<f32>,
|
|
latency_ms: Option<f32>,
|
|
widget_status: Status,
|
|
}
|
|
|
|
impl ServicesWidget {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
parent_services: HashMap::new(),
|
|
sub_services: HashMap::new(),
|
|
status: Status::Unknown,
|
|
has_data: false,
|
|
selected_index: 0,
|
|
}
|
|
}
|
|
|
|
/// Extract service name and determine if it's a parent or sub-service
|
|
fn extract_service_info(metric_name: &str) -> Option<(String, Option<String>)> {
|
|
if metric_name.starts_with("service_") {
|
|
if let Some(end_pos) = metric_name
|
|
.rfind("_status")
|
|
.or_else(|| metric_name.rfind("_memory_mb"))
|
|
.or_else(|| metric_name.rfind("_disk_gb"))
|
|
.or_else(|| metric_name.rfind("_latency_ms"))
|
|
{
|
|
let service_part = &metric_name[8..end_pos]; // Remove "service_" prefix
|
|
|
|
// Check for sub-services patterns
|
|
if service_part.starts_with("nginx_") {
|
|
// nginx sub-services: service_nginx_gitea_latency_ms -> ("nginx", "gitea")
|
|
let sub_service = service_part.strip_prefix("nginx_").unwrap_or(service_part);
|
|
return Some(("nginx".to_string(), Some(sub_service.to_string())));
|
|
} else if service_part.starts_with("docker_") {
|
|
// docker sub-services: service_docker_container1_status -> ("docker", "container1")
|
|
let sub_service = service_part.strip_prefix("docker_").unwrap_or(service_part);
|
|
return Some(("docker".to_string(), Some(sub_service.to_string())));
|
|
} else {
|
|
// Regular parent service: service_nginx_status -> ("nginx", None)
|
|
return Some((service_part.to_string(), None));
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Format disk size with appropriate units (kB/MB/GB)
|
|
fn format_disk_size(size_gb: f32) -> String {
|
|
let size_mb = size_gb * 1024.0; // Convert GB to MB
|
|
|
|
if size_mb >= 1024.0 {
|
|
// Show as GB
|
|
format!("{:.1}GB", size_gb)
|
|
} else if size_mb >= 1.0 {
|
|
// Show as MB
|
|
format!("{:.0}MB", size_mb)
|
|
} else if size_mb >= 0.001 {
|
|
// Convert to kB
|
|
let size_kb = size_mb * 1024.0;
|
|
format!("{:.0}kB", size_kb)
|
|
} else {
|
|
// Show very small sizes as bytes
|
|
let size_bytes = size_mb * 1024.0 * 1024.0;
|
|
format!("{:.0}B", size_bytes)
|
|
}
|
|
}
|
|
|
|
/// Format parent service line - returns text without icon for span formatting
|
|
fn format_parent_service_line(&self, name: &str, info: &ServiceInfo) -> String {
|
|
let memory_str = info
|
|
.memory_mb
|
|
.map_or("0M".to_string(), |m| format!("{:.0}M", m));
|
|
let disk_str = info
|
|
.disk_gb
|
|
.map_or("0".to_string(), |d| Self::format_disk_size(d));
|
|
|
|
// Truncate long service names to fit layout (account for icon space)
|
|
let short_name = if name.len() > 22 {
|
|
format!("{}...", &name[..19])
|
|
} else {
|
|
name.to_string()
|
|
};
|
|
|
|
// Parent services always show active/inactive status
|
|
let status_str = match info.widget_status {
|
|
Status::Ok => "active".to_string(),
|
|
Status::Pending => "pending".to_string(),
|
|
Status::Warning => "inactive".to_string(),
|
|
Status::Critical => "failed".to_string(),
|
|
Status::Unknown => "unknown".to_string(),
|
|
};
|
|
|
|
format!(
|
|
"{:<23} {:<10} {:<8} {:<8}",
|
|
short_name, status_str, memory_str, disk_str
|
|
)
|
|
}
|
|
|
|
/// Get status icon for service, considering command status for visual feedback
|
|
fn get_service_icon_and_status(&self, service_name: &str, info: &ServiceInfo, command_status: Option<&CommandStatus>) -> (String, String, ratatui::prelude::Color) {
|
|
// Check if this service is currently being operated on
|
|
if let Some(status) = command_status {
|
|
match status {
|
|
CommandStatus::InProgress { command_type, target, .. } => {
|
|
if target == service_name {
|
|
// Only show special icons for service commands
|
|
if let Some((icon, status_text)) = match command_type {
|
|
CommandType::ServiceRestart => Some(("↻", "restarting")),
|
|
CommandType::ServiceStart => Some(("↑", "starting")),
|
|
CommandType::ServiceStop => Some(("↓", "stopping")),
|
|
_ => None, // Don't handle non-service commands here
|
|
} {
|
|
return (icon.to_string(), status_text.to_string(), Theme::highlight());
|
|
}
|
|
}
|
|
}
|
|
_ => {} // Success/Failed states will show normal status
|
|
}
|
|
}
|
|
|
|
// Normal status display
|
|
let icon = StatusIcons::get_icon(info.widget_status);
|
|
let status_color = match info.widget_status {
|
|
Status::Ok => Theme::success(),
|
|
Status::Pending => Theme::highlight(),
|
|
Status::Warning => Theme::warning(),
|
|
Status::Critical => Theme::error(),
|
|
Status::Unknown => Theme::muted_text(),
|
|
};
|
|
|
|
(icon.to_string(), info.status.clone(), status_color)
|
|
}
|
|
|
|
|
|
/// Create spans for sub-service with icon next to name, considering command status
|
|
fn create_sub_service_spans_with_status(
|
|
&self,
|
|
name: &str,
|
|
info: &ServiceInfo,
|
|
is_last: bool,
|
|
command_status: Option<&CommandStatus>,
|
|
) -> Vec<ratatui::text::Span<'static>> {
|
|
// Truncate long sub-service names to fit layout (accounting for indentation)
|
|
let short_name = if name.len() > 18 {
|
|
format!("{}...", &name[..15])
|
|
} else {
|
|
name.to_string()
|
|
};
|
|
|
|
// Get status icon and text, considering command status
|
|
let (icon, mut status_str, status_color) = self.get_service_icon_and_status(name, info, command_status);
|
|
|
|
// For sub-services, prefer latency if available (unless command is in progress)
|
|
if command_status.is_none() {
|
|
if let Some(latency) = info.latency_ms {
|
|
status_str = if latency < 0.0 {
|
|
"timeout".to_string()
|
|
} else {
|
|
format!("{:.0}ms", latency)
|
|
};
|
|
}
|
|
}
|
|
let tree_symbol = if is_last { "└─" } else { "├─" };
|
|
|
|
vec![
|
|
// Indentation and tree prefix
|
|
ratatui::text::Span::styled(
|
|
format!(" {} ", tree_symbol),
|
|
Typography::tree(),
|
|
),
|
|
// Status icon
|
|
ratatui::text::Span::styled(
|
|
format!("{} ", icon),
|
|
Style::default().fg(status_color).bg(Theme::background()),
|
|
),
|
|
// Service name
|
|
ratatui::text::Span::styled(
|
|
format!("{:<18} ", short_name),
|
|
Style::default()
|
|
.fg(Theme::secondary_text())
|
|
.bg(Theme::background()),
|
|
),
|
|
// Status/latency text
|
|
ratatui::text::Span::styled(
|
|
status_str,
|
|
Style::default()
|
|
.fg(Theme::secondary_text())
|
|
.bg(Theme::background()),
|
|
),
|
|
]
|
|
}
|
|
|
|
/// Move selection up
|
|
pub fn select_previous(&mut self) {
|
|
if self.selected_index > 0 {
|
|
self.selected_index -= 1;
|
|
}
|
|
debug!("Service selection moved up to: {}", self.selected_index);
|
|
}
|
|
|
|
/// Move selection down
|
|
pub fn select_next(&mut self, total_services: usize) {
|
|
if total_services > 0 && self.selected_index < total_services.saturating_sub(1) {
|
|
self.selected_index += 1;
|
|
}
|
|
debug!("Service selection: {}/{}", self.selected_index, total_services);
|
|
}
|
|
|
|
/// Get currently selected service name (for actions)
|
|
pub fn get_selected_service(&self) -> Option<String> {
|
|
// Build the same display list to find the selected service
|
|
let mut display_lines: Vec<(String, Status, bool, Option<(ServiceInfo, bool)>)> = Vec::new();
|
|
|
|
let mut parent_services: Vec<_> = self.parent_services.iter().collect();
|
|
parent_services.sort_by(|(a, _), (b, _)| a.cmp(b));
|
|
|
|
for (parent_name, parent_info) in parent_services {
|
|
display_lines.push((parent_name.clone(), parent_info.widget_status, false, None));
|
|
|
|
if let Some(sub_list) = self.sub_services.get(parent_name) {
|
|
let mut sorted_subs = sub_list.clone();
|
|
sorted_subs.sort_by(|(a, _), (b, _)| a.cmp(b));
|
|
|
|
for (i, (sub_name, sub_info)) in sorted_subs.iter().enumerate() {
|
|
let is_last_sub = i == sorted_subs.len() - 1;
|
|
display_lines.push((
|
|
format!("{}_{}", parent_name, sub_name), // Use parent_sub format for sub-services
|
|
sub_info.widget_status,
|
|
true,
|
|
Some((sub_info.clone(), is_last_sub)),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
display_lines.get(self.selected_index).map(|(name, _, _, _)| name.clone())
|
|
}
|
|
|
|
/// Get total count of selectable services (parent services only, not sub-services)
|
|
pub fn get_total_services_count(&self) -> usize {
|
|
// Only count parent services - sub-services are not selectable
|
|
self.parent_services.len()
|
|
}
|
|
|
|
/// Calculate which parent service index corresponds to a display line index
|
|
fn calculate_parent_service_index(&self, display_line_index: &usize) -> usize {
|
|
// Build the same display list to map line index to parent service index
|
|
let mut parent_index = 0;
|
|
let mut line_index = 0;
|
|
|
|
let mut parent_services: Vec<_> = self.parent_services.iter().collect();
|
|
parent_services.sort_by(|(a, _), (b, _)| a.cmp(b));
|
|
|
|
for (parent_name, _) in parent_services {
|
|
if line_index == *display_line_index {
|
|
return parent_index;
|
|
}
|
|
line_index += 1; // Parent service line
|
|
|
|
// Skip sub-services but count them in line_index
|
|
if let Some(sub_list) = self.sub_services.get(parent_name) {
|
|
line_index += sub_list.len();
|
|
}
|
|
|
|
parent_index += 1;
|
|
}
|
|
|
|
// If we get here, the display_line_index was probably for a sub-service
|
|
// Return the last valid parent index (should not happen with our logic)
|
|
parent_index.saturating_sub(1)
|
|
}
|
|
}
|
|
|
|
impl Widget for ServicesWidget {
|
|
fn update_from_metrics(&mut self, metrics: &[&Metric]) {
|
|
debug!("Services widget updating with {} metrics", metrics.len());
|
|
|
|
// Don't clear existing services - preserve data between metric batches
|
|
|
|
// Process individual service metrics
|
|
for metric in metrics {
|
|
if let Some((parent_service, sub_service)) = Self::extract_service_info(&metric.name) {
|
|
match sub_service {
|
|
None => {
|
|
// Parent service metric
|
|
let service_info =
|
|
self.parent_services
|
|
.entry(parent_service)
|
|
.or_insert(ServiceInfo {
|
|
status: "unknown".to_string(),
|
|
memory_mb: None,
|
|
disk_gb: None,
|
|
latency_ms: None,
|
|
widget_status: Status::Unknown,
|
|
});
|
|
|
|
if metric.name.ends_with("_status") {
|
|
service_info.status = metric.value.as_string();
|
|
service_info.widget_status = metric.status;
|
|
} else if metric.name.ends_with("_memory_mb") {
|
|
if let Some(memory) = metric.value.as_f32() {
|
|
service_info.memory_mb = Some(memory);
|
|
}
|
|
} else if metric.name.ends_with("_disk_gb") {
|
|
if let Some(disk) = metric.value.as_f32() {
|
|
service_info.disk_gb = Some(disk);
|
|
}
|
|
}
|
|
}
|
|
Some(sub_name) => {
|
|
// Sub-service metric
|
|
let sub_service_list = self
|
|
.sub_services
|
|
.entry(parent_service)
|
|
.or_insert_with(Vec::new);
|
|
|
|
// Find existing sub-service or create new one
|
|
let sub_service_info = if let Some(pos) = sub_service_list
|
|
.iter()
|
|
.position(|(name, _)| name == &sub_name)
|
|
{
|
|
&mut sub_service_list[pos].1
|
|
} else {
|
|
sub_service_list.push((
|
|
sub_name.clone(),
|
|
ServiceInfo {
|
|
status: "unknown".to_string(),
|
|
memory_mb: None,
|
|
disk_gb: None,
|
|
latency_ms: None,
|
|
widget_status: Status::Unknown,
|
|
},
|
|
));
|
|
&mut sub_service_list.last_mut().unwrap().1
|
|
};
|
|
|
|
if metric.name.ends_with("_status") {
|
|
sub_service_info.status = metric.value.as_string();
|
|
sub_service_info.widget_status = metric.status;
|
|
} else if metric.name.ends_with("_memory_mb") {
|
|
if let Some(memory) = metric.value.as_f32() {
|
|
sub_service_info.memory_mb = Some(memory);
|
|
}
|
|
} else if metric.name.ends_with("_disk_gb") {
|
|
if let Some(disk) = metric.value.as_f32() {
|
|
sub_service_info.disk_gb = Some(disk);
|
|
}
|
|
} else if metric.name.ends_with("_latency_ms") {
|
|
if let Some(latency) = metric.value.as_f32() {
|
|
sub_service_info.latency_ms = Some(latency);
|
|
sub_service_info.widget_status = metric.status;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Aggregate status from all parent and sub-services
|
|
let mut all_statuses = Vec::new();
|
|
|
|
// Add parent service statuses
|
|
all_statuses.extend(self.parent_services.values().map(|info| info.widget_status));
|
|
|
|
// Add sub-service statuses
|
|
for sub_list in self.sub_services.values() {
|
|
all_statuses.extend(sub_list.iter().map(|(_, info)| info.widget_status));
|
|
}
|
|
|
|
self.status = if all_statuses.is_empty() {
|
|
Status::Unknown
|
|
} else {
|
|
Status::aggregate(&all_statuses)
|
|
};
|
|
|
|
self.has_data = !self.parent_services.is_empty() || !self.sub_services.is_empty();
|
|
|
|
// Ensure selection index is within bounds after update
|
|
let total_count = self.get_total_services_count();
|
|
if self.selected_index >= total_count && total_count > 0 {
|
|
self.selected_index = total_count - 1;
|
|
}
|
|
|
|
debug!(
|
|
"Services widget updated: {} parent services, {} sub-service groups, total={}, selected={}, status={:?}",
|
|
self.parent_services.len(),
|
|
self.sub_services.len(),
|
|
total_count,
|
|
self.selected_index,
|
|
self.status
|
|
);
|
|
}
|
|
|
|
}
|
|
|
|
impl ServicesWidget {
|
|
|
|
/// Render with focus, scroll, and command status for visual feedback
|
|
pub fn render_with_command_status(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize, command_status: Option<&CommandStatus>) {
|
|
let services_block = if is_focused {
|
|
Components::focused_widget_block("services")
|
|
} else {
|
|
Components::widget_block("services")
|
|
};
|
|
let inner_area = services_block.inner(area);
|
|
frame.render_widget(services_block, area);
|
|
|
|
let content_chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([Constraint::Length(1), Constraint::Min(0)])
|
|
.split(inner_area);
|
|
|
|
// Header
|
|
let header = format!(
|
|
"{:<25} {:<10} {:<8} {:<8}",
|
|
"Service:", "Status:", "RAM:", "Disk:"
|
|
);
|
|
let header_para = Paragraph::new(header).style(Typography::muted());
|
|
frame.render_widget(header_para, content_chunks[0]);
|
|
|
|
// Check if we have any services to display
|
|
if self.parent_services.is_empty() && self.sub_services.is_empty() {
|
|
let empty_text = Paragraph::new("No process data").style(Typography::muted());
|
|
frame.render_widget(empty_text, content_chunks[1]);
|
|
return;
|
|
}
|
|
|
|
// Use the existing render logic but with command status
|
|
self.render_services_with_status(frame, content_chunks[1], is_focused, scroll_offset, command_status);
|
|
}
|
|
|
|
/// Render services list with command status awareness
|
|
fn render_services_with_status(&mut self, frame: &mut Frame, area: Rect, is_focused: bool, scroll_offset: usize, command_status: Option<&CommandStatus>) {
|
|
// Build hierarchical service list for display (same as existing logic)
|
|
let mut display_lines: Vec<(String, Status, bool, Option<(ServiceInfo, bool)>)> = Vec::new();
|
|
|
|
// Sort parent services alphabetically for consistent order
|
|
let mut parent_services: Vec<_> = self.parent_services.iter().collect();
|
|
parent_services.sort_by(|(a, _), (b, _)| a.cmp(b));
|
|
|
|
for (parent_name, parent_info) in parent_services {
|
|
// Add parent service line
|
|
let parent_line = self.format_parent_service_line(parent_name, parent_info);
|
|
display_lines.push((parent_line, parent_info.widget_status, false, None)); // false = not sub-service
|
|
|
|
// Add sub-services for this parent (if any)
|
|
if let Some(sub_list) = self.sub_services.get(parent_name) {
|
|
// Sort sub-services by name for consistent display
|
|
let mut sorted_subs = sub_list.clone();
|
|
sorted_subs.sort_by(|(a, _), (b, _)| a.cmp(b));
|
|
|
|
for (i, (sub_name, sub_info)) in sorted_subs.iter().enumerate() {
|
|
let is_last_sub = i == sorted_subs.len() - 1;
|
|
// Store sub-service info for custom span rendering
|
|
display_lines.push((
|
|
sub_name.clone(),
|
|
sub_info.widget_status,
|
|
true,
|
|
Some((sub_info.clone(), is_last_sub)),
|
|
)); // true = sub-service, with is_last info
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply scroll offset and render visible lines (same as existing logic)
|
|
let available_lines = area.height as usize;
|
|
let total_lines = display_lines.len();
|
|
|
|
// Calculate scroll boundaries
|
|
let max_scroll = if total_lines > available_lines {
|
|
total_lines - available_lines
|
|
} else {
|
|
total_lines.saturating_sub(1)
|
|
};
|
|
let effective_scroll = scroll_offset.min(max_scroll);
|
|
|
|
// Get visible lines after scrolling
|
|
let visible_lines: Vec<_> = display_lines
|
|
.iter()
|
|
.skip(effective_scroll)
|
|
.take(available_lines)
|
|
.collect();
|
|
|
|
let lines_to_show = visible_lines.len();
|
|
|
|
if lines_to_show > 0 {
|
|
let service_chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints(vec![Constraint::Length(1); lines_to_show])
|
|
.split(area);
|
|
|
|
for (i, (line_text, line_status, is_sub, sub_info)) in visible_lines.iter().enumerate()
|
|
{
|
|
let actual_index = effective_scroll + i; // Real index in the full list
|
|
|
|
// Only parent services can be selected - calculate parent service index
|
|
let is_selected = if !*is_sub {
|
|
// This is a parent service - count how many parent services came before this one
|
|
let parent_index = self.calculate_parent_service_index(&actual_index);
|
|
parent_index == self.selected_index
|
|
} else {
|
|
false // Sub-services are never selected
|
|
};
|
|
|
|
let mut spans = if *is_sub && sub_info.is_some() {
|
|
// Use custom sub-service span creation WITH command status
|
|
let (service_info, is_last) = sub_info.as_ref().unwrap();
|
|
self.create_sub_service_spans_with_status(line_text, service_info, *is_last, command_status)
|
|
} else {
|
|
// Parent services - check if this parent service has a command in progress
|
|
let service_spans = if let Some(status) = command_status {
|
|
match status {
|
|
CommandStatus::InProgress { target, .. } => {
|
|
if target == line_text {
|
|
// Create spans with progress status
|
|
let (icon, status_text, status_color) = self.get_service_icon_and_status(line_text, &ServiceInfo {
|
|
status: "".to_string(),
|
|
memory_mb: None,
|
|
disk_gb: None,
|
|
latency_ms: None,
|
|
widget_status: *line_status
|
|
}, command_status);
|
|
vec![
|
|
ratatui::text::Span::styled(format!("{} ", icon), Style::default().fg(status_color)),
|
|
ratatui::text::Span::styled(line_text.clone(), Style::default().fg(Theme::primary_text())),
|
|
ratatui::text::Span::styled(format!(" {}", status_text), Style::default().fg(status_color)),
|
|
]
|
|
} else {
|
|
StatusIcons::create_status_spans(*line_status, line_text)
|
|
}
|
|
}
|
|
_ => StatusIcons::create_status_spans(*line_status, line_text)
|
|
}
|
|
} else {
|
|
StatusIcons::create_status_spans(*line_status, line_text)
|
|
};
|
|
service_spans
|
|
};
|
|
|
|
// Apply selection highlighting to parent services only, preserving status icon color
|
|
// Only show selection when Services panel is focused
|
|
if is_selected && !*is_sub && is_focused {
|
|
for (i, span) in spans.iter_mut().enumerate() {
|
|
if i == 0 {
|
|
// First span is the status icon - preserve its color
|
|
span.style = span.style.bg(Theme::highlight());
|
|
} else {
|
|
// Other spans (text) get full selection highlighting
|
|
span.style = span.style
|
|
.bg(Theme::highlight())
|
|
.fg(Theme::background());
|
|
}
|
|
}
|
|
}
|
|
|
|
let service_para = Paragraph::new(ratatui::text::Line::from(spans));
|
|
|
|
frame.render_widget(service_para, service_chunks[i]);
|
|
}
|
|
}
|
|
|
|
// Show scroll indicator if there are more services than we can display (same as existing)
|
|
if total_lines > available_lines {
|
|
let hidden_above = effective_scroll;
|
|
let hidden_below = total_lines.saturating_sub(effective_scroll + available_lines);
|
|
|
|
if hidden_above > 0 || hidden_below > 0 {
|
|
let scroll_text = if hidden_above > 0 && hidden_below > 0 {
|
|
format!("... {} above, {} below", hidden_above, hidden_below)
|
|
} else if hidden_above > 0 {
|
|
format!("... {} more above", hidden_above)
|
|
} else {
|
|
format!("... {} more below", hidden_below)
|
|
};
|
|
|
|
if available_lines > 0 && lines_to_show > 0 {
|
|
let last_line_area = Rect {
|
|
x: area.x,
|
|
y: area.y + (lines_to_show - 1) as u16,
|
|
width: area.width,
|
|
height: 1,
|
|
};
|
|
|
|
let scroll_para = Paragraph::new(scroll_text).style(Typography::muted());
|
|
frame.render_widget(scroll_para, last_line_area);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for ServicesWidget {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|