Alingment

This commit is contained in:
Marco De Araujo 2025-12-25 08:24:08 -04:00
parent 2819df185e
commit 3a87c7a011
9 changed files with 325 additions and 78 deletions

View file

@ -1,6 +1,7 @@
use crate::api::{UptimeKumaClient, UptimeKumaEndpoints};
use crate::core;
use crate::i18n::{t, t_with_args};
use crate::ui::components::render_footer;
use crate::ui::{
components::{render_header, render_monitor_list},
dashboard::model::DashboardViewState,
@ -110,7 +111,11 @@ impl App {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.constraints([
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(1),
])
.split(area);
render_header(frame, chunks[0], &self.state);
@ -119,11 +124,12 @@ impl App {
Self::render_loading(frame, chunks[1]);
} else if let Some(error) = &self.state.error_message {
Self::render_error(frame, chunks[1], error);
} else if self.state.monitors.is_empty() {
} else if self.state.groups.is_empty() || self.state.get_all_monitors().is_empty() {
Self::render_no_data(frame, chunks[1]);
} else {
render_monitor_list(frame, chunks[1], &self.state);
}
render_footer(frame, chunks[2]);
});
}

View file

@ -0,0 +1,27 @@
use ratatui::{
Frame,
layout::{Alignment, Rect},
style::{Color, Modifier, Style},
text::Span,
widgets::{Block, Borders, Paragraph},
};
use chrono::Local;
pub fn render_footer(frame: &mut Frame, area: Rect) {
let now = Local::now();
let datatime_str = now.format("%Y-%m-%d %H:%M:%S").to_string();
let footer = Paragraph::new(Span::styled(
datatime_str,
Style::default().fg(Color::Gray).add_modifier(Modifier::DIM),
))
.block(
Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(Color::Blue)),
)
.alignment(Alignment::Center);
frame.render_widget(footer, area);
}

View file

@ -1,5 +1,7 @@
pub mod footer;
pub mod header;
pub mod monitor_list;
pub use footer::render_footer;
pub use header::render_header;
pub use monitor_list::render_monitor_list;

View file

@ -1,9 +1,12 @@
use std::cmp::min;
use crate::i18n::t;
use crate::ui::dashboard::model::DashboardViewState;
use crate::ui::dashboard::model::{DashboardViewState, GroupViewState};
use crate::ui::dashboard::{MonitorStatus, MonitorViewState};
use ratatui::layout::Layout;
use ratatui::{
Frame,
layout::{Constraint, Rect},
layout::{Alignment, Constraint, Direction, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Row, Table},
@ -13,42 +16,110 @@ const STATUS_LINE_LENGTH: usize = 100;
const MAX_NAME_LENGTH: usize = 30;
pub fn render_monitor_list(frame: &mut Frame, area: Rect, state: &DashboardViewState) {
let block = Block::default()
.title(t("monitors"))
.borders(Borders::ALL)
.style(Style::default().fg(Color::Blue));
let group_areas = layout_groups(area, state.groups.len());
let header = vec![
for (i, group) in state.groups.iter().enumerate() {
if i < group_areas.len() {
render_group(frame, group_areas[i], group, i == 0);
}
}
}
fn layout_groups(area: Rect, group_count: usize) -> Vec<Rect> {
if group_count == 0 {
return vec![];
}
let height_per_group = (area.height as usize / group_count).max(3);
let mut current_y = area.y;
let mut areas = Vec::new();
for _ in 0..group_count {
if current_y + height_per_group as u16 > area.y + area.height {
break;
}
areas.push(Rect {
x: area.x,
y: current_y,
width: area.width,
height: height_per_group as u16,
});
current_y += height_per_group as u16;
}
areas
}
fn render_group(frame: &mut Frame, area: Rect, group: &GroupViewState, is_first: bool) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(0)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(area);
let group_title = Line::from(vec![
Span::styled(format!("{} ", group.name), title_style()),
Span::styled(
format!("({})", group.monitors.len()),
Style::default().fg(Color::Gray),
),
]);
let title_block = Block::default()
.borders(if is_first {
Borders::TOP | Borders::LEFT | Borders::RIGHT
} else {
Borders::ALL
})
.border_style(Style::default().fg(Color::Blue))
.title(group_title)
.title_alignment(Alignment::Left);
frame.render_widget(title_block, chunks[0]);
if !group.monitors.is_empty() {
render_monitor_table(frame, chunks[1], &group.monitors);
}
}
fn render_monitor_table(frame: &mut Frame, area: Rect, monitors: &Vec<MonitorViewState>) {
let max_items = area.height as usize;
let items_to_show = min(monitors.len(), max_items);
let header_cells = vec![
"".to_string(),
t("monitor"),
t("Response"),
t("uptime"),
t("history"),
];
let constraints = vec![
let header = Row::new(header_cells).style(title_style()).height(1);
let rows: Vec<Row> = monitors
.iter()
.take(items_to_show)
.map(|monitor| create_monitor_item(monitor))
.collect();
let widths = vec![
Constraint::Length(3),
Constraint::Length(MAX_NAME_LENGTH as u16),
Constraint::Length(10),
Constraint::Length(10),
Constraint::Length(STATUS_LINE_LENGTH as u16 + 8),
Constraint::Length(STATUS_LINE_LENGTH as u16),
];
let available_height = area.height.saturating_sub(2);
let visible_items = state.monitors.len().min(available_height as usize);
let rows: Vec<Row> = state
.monitors
.iter()
.take(visible_items)
.map(|m| create_monitor_item(m))
.collect();
let table = Table::new(rows, constraints)
.header(Row::new(header))
.block(block)
let table = Table::new(rows, widths)
.header(header)
.block(
Block::default()
.borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM)
.border_style(Style::default().fg(Color::Blue)),
)
.column_spacing(1)
.highlight_symbol(">> ")
.row_highlight_style(Style::default().add_modifier(Modifier::REVERSED));
.style(Style::default());
frame.render_widget(table, area);
}
@ -126,3 +197,9 @@ fn create_status_line_spans(status_history: &[MonitorStatus]) -> Line<'_> {
}
Line::from(spans)
}
fn title_style() -> Style {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
}

View file

@ -1,4 +1,4 @@
use crate::core::models::UnifiedData;
use crate::core::models::{UnifiedData, UnifiedGroupData};
use crate::data::heartbeat::model::HeartbeatEntry;
use crate::i18n::t;
@ -21,11 +21,17 @@ pub struct MonitorViewState {
pub status_history: Vec<MonitorStatus>,
}
#[derive(Debug, Clone)]
pub struct GroupViewState {
pub name: String,
pub monitors: Vec<MonitorViewState>,
}
#[derive(Debug, Clone)]
pub struct DashboardViewState {
pub title: String,
pub descriptions: Option<String>,
pub monitors: Vec<MonitorViewState>,
pub groups: Vec<GroupViewState>,
pub is_loading: bool,
pub error_message: Option<String>,
pub auto_refresh_interval: u32,
@ -36,7 +42,7 @@ impl DashboardViewState {
Self {
title: t("loading").to_string(),
descriptions: None,
monitors: Vec::new(),
groups: Vec::new(),
is_loading: true,
error_message: None,
auto_refresh_interval: 300,
@ -44,58 +50,28 @@ impl DashboardViewState {
}
pub fn from_unified_data(data: UnifiedData) -> Self {
let mut monitors = Vec::new();
let mut groups = Vec::new();
for monitor in data.monitors {
let status_history = get_status_history(&monitor.heartbeats);
let status = match monitor.heartbeats.last().map(|h| h.status) {
Some(1) => MonitorStatus::Up,
Some(0) => MonitorStatus::Down,
_ => MonitorStatus::Unknown,
};
let response_time = monitor
.heartbeats
.last()
.and_then(|h| h.ping)
.map(|ms| format!("{}", ms))
.unwrap_or_else(|| t("unknown").to_string() + " ");
let uptime_24h = monitor
.uptime_data
.map(|u| u.get_perc_formated())
.unwrap_or_else(|| t("unknown").to_string());
let last_check = monitor
.heartbeats
.last()
.map(|h| h.time.clone())
.unwrap_or_else(|| t("never").to_string());
monitors.push(MonitorViewState {
id: monitor.monitor_info.id,
name: monitor.monitor_info.name,
group_name: "Services".to_string(),
status,
response_time,
uptime_24h,
last_check,
status_history,
for group in data.groups {
groups.push(GroupViewState {
name: group.group_info.name.clone(),
monitors: add_monitor_view_state(group),
});
}
monitors.sort_by_key(|m| m.name.clone());
Self {
title: data.title,
descriptions: data.description,
monitors,
groups,
is_loading: false,
error_message: None,
auto_refresh_interval: data.audo_refresh_interval,
auto_refresh_interval: data.auto_refresh_interval.max(30),
}
}
pub fn get_all_monitors(&self) -> Vec<&MonitorViewState> {
self.groups.iter().flat_map(|g| g.monitors.iter()).collect()
}
}
fn get_status_history(heartbeats: &[HeartbeatEntry]) -> Vec<MonitorStatus> {
@ -117,3 +93,50 @@ fn get_status_history(heartbeats: &[HeartbeatEntry]) -> Vec<MonitorStatus> {
history.reverse();
history
}
fn add_monitor_view_state(group: UnifiedGroupData) -> Vec<MonitorViewState> {
let mut monitors = Vec::new();
for monitor in group.monitors {
let status_history = get_status_history(&monitor.heartbeats);
let status = match monitor.heartbeats.last().map(|h| h.status) {
Some(1) => MonitorStatus::Up,
Some(0) => MonitorStatus::Down,
_ => MonitorStatus::Unknown,
};
let response_time = monitor
.heartbeats
.last()
.and_then(|h| h.ping)
.map(|ms| format!("{}", ms))
.unwrap_or_else(|| t("unknown").to_string() + " ");
let uptime_24h = monitor
.uptime_data
.map(|u| u.get_perc_formated())
.unwrap_or_else(|| t("unknown").to_string());
let last_check = monitor
.heartbeats
.last()
.map(|h| h.time.clone())
.unwrap_or_else(|| t("never").to_string());
monitors.push(MonitorViewState {
id: monitor.monitor_info.id,
name: monitor.monitor_info.name,
group_name: group.group_info.name.clone(),
status,
response_time,
uptime_24h,
last_check,
status_history,
});
}
monitors.sort_by_key(|m| m.name.clone());
monitors
}