uptime-kuma-dashboard/src/ui/components/monitor_list.rs

276 lines
8.3 KiB
Rust

use std::{
borrow::Cow,
cmp::min,
collections::{HashMap, hash_map::DefaultHasher},
hash::{Hash, Hasher},
sync::{OnceLock, RwLock},
};
use crate::{
i18n::t,
ui::dashboard::{
MonitorStatus, MonitorViewState,
model::{BORDER_LINES_VIEW, DashboardViewState, GroupViewState},
},
};
use once_cell::sync::Lazy;
use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Padding, Row, Table},
};
const STATUS_LINE_LENGTH: usize = 100;
const MAX_NAME_LENGTH: usize = 30;
static UP_SPAN: Lazy<Span<'static>> =
Lazy::new(|| Span::styled("", Style::default().fg(Color::Green)));
static DOWN_SPAN: Lazy<Span<'static>> =
Lazy::new(|| Span::styled("", Style::default().fg(Color::Red)));
static UNKNOWN_SPAN: Lazy<Span<'static>> =
Lazy::new(|| Span::styled("", Style::default().fg(Color::Yellow)));
static STATUS_LINE_CACHE: OnceLock<RwLock<HashMap<u64, Vec<&'static Span<'static>>>>> = OnceLock::new();
pub fn render_monitor_list(main_frame: &mut Frame, area: Rect, state: &mut DashboardViewState) {
let available_height = area.height as usize;
let max_scroll = state.get_total_lenght().saturating_sub(available_height);
if state.scroll_state.get_position() > max_scroll {
state.scroll_state = state.scroll_state.position(max_scroll);
}
let scroll_pos = state.scroll_state.get_position();
let mut current_y = area.y as usize;
let mut rendered_height = 0;
let mut lines_skipped = 0;
let half = state.get_total_lenght().saturating_div(2);
for group in state.groups.iter() {
let group_height = group.monitors.len() + BORDER_LINES_VIEW;
if lines_skipped + group_height <= scroll_pos {
lines_skipped += group_height;
continue;
}
let visible_height = if lines_skipped < scroll_pos {
group_height - (scroll_pos - lines_skipped)
} else {
group_height
}
.min(available_height - rendered_height);
let group_area = Rect {
x: area.x,
y: current_y as u16,
width: area.width,
height: visible_height as u16,
};
render_group(main_frame, group_area, group, half > rendered_height);
current_y += visible_height;
rendered_height += visible_height;
lines_skipped += group_height;
}
}
fn render_group(frame: &mut Frame, area: Rect, group: &GroupViewState, is_first_half: bool) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(0)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(area);
if chunks[0].height == 0 || chunks[1].height == 0 || group.monitors.is_empty() {
return;
}
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(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, is_first_half);
}
}
fn render_monitor_table(
frame: &mut Frame,
area: Rect,
monitors: &[MonitorViewState],
is_first_half: bool,
) {
let max_items = area.height as usize;
let items_to_show = min(monitors.len(), max_items);
let index = if is_first_half {
monitors.len().saturating_sub(max_items.saturating_sub(2)) // 2 = Table header + botton
} else {
0
};
let monitors = &monitors[index..index + items_to_show.min(monitors.len() - index)];
let header_cells = vec![
"".to_string(),
t("monitor"),
t("Response"),
t("uptime"),
t("history"),
];
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),
];
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))
.padding(Padding::horizontal(2)),
)
.column_spacing(1)
.style(Style::default());
frame.render_widget(table, area);
}
pub fn get_status_color(status: &MonitorStatus) -> Color {
match status {
MonitorStatus::Up => Color::Green,
MonitorStatus::Down => Color::Red,
MonitorStatus::Unknown => Color::Yellow,
}
}
pub fn get_status_emoji(status: &MonitorStatus) -> Cow<'static, str> {
static UP_ICON: &str = "";
static DOWN_ICON: &str = "";
static UNKNOWN_ICON: &str = "";
match status {
MonitorStatus::Up => Cow::Borrowed(UP_ICON),
MonitorStatus::Down => Cow::Borrowed(DOWN_ICON),
MonitorStatus::Unknown => Cow::Borrowed(UNKNOWN_ICON),
}
}
fn create_monitor_item(monitor: &MonitorViewState) -> Row<'_> {
let status_icon = get_status_emoji(&monitor.status);
let status_color = get_status_color(&monitor.status);
let display_name: Cow<str> = if monitor.name.len() > MAX_NAME_LENGTH {
Cow::Owned(format!(
"{:.width$}...",
monitor.name,
width = MAX_NAME_LENGTH - 3
))
} else {
Cow::Borrowed(&monitor.name)
};
let response_text = format!("{:>7}ms", monitor.response_time);
let uptime_text = format!("{:>7}%", monitor.uptime_24h);
let status_line_spans = get_cached_status_line(&monitor.status_history);
Row::new(vec![
get_formated_line(format!("{} ", status_icon), status_color, Modifier::empty()),
get_formated_line(display_name.to_string(), Color::White, Modifier::empty()),
get_formated_line(response_text, Color::Cyan, Modifier::empty()),
get_formated_line(uptime_text, Color::Magenta, Modifier::empty()),
status_line_spans,
])
.style(Style::default())
.height(1)
}
fn get_formated_line(text: String, color: Color, modifier: Modifier) -> Line<'static> {
Line::from(vec![Span::styled(
text,
Style::default().fg(color).add_modifier(modifier),
)])
}
fn get_status_span(status: &MonitorStatus) -> &'static Span<'static> {
match status {
MonitorStatus::Up => &UP_SPAN,
MonitorStatus::Down => &DOWN_SPAN,
MonitorStatus::Unknown => &UNKNOWN_SPAN,
}
}
fn title_style() -> Style {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
}
fn calculate_history_hash(status_history: &[MonitorStatus]) -> u64 {
let mut hasher = DefaultHasher::new();
status_history
.iter()
.take(STATUS_LINE_LENGTH)
.for_each(|status| {
status.hash(&mut hasher);
});
hasher.finish()
}
fn get_cached_status_line(status_history: &[MonitorStatus]) -> Line<'static> {
let hash = calculate_history_hash(status_history);
let cache = STATUS_LINE_CACHE.get_or_init(|| RwLock::new(HashMap::new()));
{
let read = cache.read().unwrap();
if let Some(spans) = read.get(&hash) {
return Line::from(spans.iter().map(|&span| span.clone()).collect::<Vec<_>>());
}
}
let spans: Vec<&'static Span<'static>> = status_history
.iter()
.rev()
.take(STATUS_LINE_LENGTH)
.map(|status| get_status_span(status))
.collect();
let mut write = cache.write().unwrap();
if write.len() > 1000 {
let keys_to_remove: Vec<_> = write.keys().take(250).copied().collect();
for key in keys_to_remove {
write.remove(&key);
}
}
write.insert(hash, spans.clone());
Line::from(spans.iter().map(|&span| span.clone()).collect::<Vec<_>>())
}