use crossterm::event::KeyCode;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, TableState},
Frame,
};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use crate::remote::server::{AgentInfo, AgentStatus};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum HeartbeatHealth {
Alive, Stale, Offline, }
impl HeartbeatHealth {
pub fn from_last(last: Option<Instant>) -> Self {
match last {
None => Self::Offline,
Some(t) => match t.elapsed().as_secs() {
0..=44 => Self::Alive,
45..=120 => Self::Stale,
_ => Self::Offline,
},
}
}
pub fn label(self) -> &'static str {
match self {
Self::Alive => "ALIVE",
Self::Stale => "STALE",
Self::Offline => "OFFLINE",
}
}
pub fn color(self) -> Color {
match self {
Self::Alive => Color::Green,
Self::Stale => Color::Yellow,
Self::Offline => Color::Red,
}
}
}
fn age_str(last: Option<Instant>) -> String {
match last {
None => "never".to_string(),
Some(t) => {
let s = t.elapsed().as_secs();
if s < 60 { format!("{}s ago", s) }
else if s < 3600 { format!("{}m{}s ago", s / 60, s % 60) }
else { format!("{}h ago", s / 3600) }
}
}
}
pub struct RemotePanel {
pub state: TableState,
pub agents: Arc<Mutex<HashMap<u32, AgentInfo>>>,
pub agent_ids: Vec<u32>,
pub server_running: bool,
pub server_addr: String,
pub server_token: String,
pub server_fingerprint: String,
pub last_event: String,
pub show_detail: bool,
}
impl RemotePanel {
pub fn new() -> Self {
Self {
state: TableState::default(),
agents: Arc::new(Mutex::new(HashMap::new())),
agent_ids: vec![],
server_running: false,
server_addr: String::new(),
server_token: String::new(),
server_fingerprint: String::new(),
last_event: String::new(),
show_detail: false,
}
}
pub fn sync_ids(&mut self) {
if let Ok(map) = self.agents.lock() {
let mut ids: Vec<u32> = map.keys().copied().collect();
ids.sort();
self.agent_ids = ids;
let len = self.agent_ids.len();
if len == 0 {
self.state.select(None);
} else if self.state.selected().map_or(true, |i| i >= len) {
self.state.select(Some(len - 1));
}
}
}
pub fn selected_agent_id(&self) -> Option<u32> {
self.state.selected().and_then(|i| self.agent_ids.get(i).copied())
}
fn selected_agent(&self) -> Option<AgentInfo> {
let id = self.selected_agent_id()?;
self.agents.lock().ok()?.get(&id).cloned()
}
pub fn handle_key(&mut self, code: KeyCode) {
match code {
KeyCode::Char('j') | KeyCode::Down => self.next(),
KeyCode::Char('k') | KeyCode::Up => self.prev(),
KeyCode::Enter => self.show_detail = !self.show_detail,
KeyCode::Esc => self.show_detail = false,
_ => {}
}
}
fn next(&mut self) {
let len = self.agent_ids.len();
if len == 0 { return; }
let i = self.state.selected().map_or(0, |i| (i + 1).min(len - 1));
self.state.select(Some(i));
self.show_detail = false;
}
fn prev(&mut self) {
let i = self.state.selected().map_or(0, |i| i.saturating_sub(1));
self.state.select(Some(i));
self.show_detail = false;
}
pub fn render(&mut self, f: &mut Frame, area: Rect) {
self.sync_ids();
let server_height = if self.server_running { 4 } else { 7 };
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(server_height),
Constraint::Min(5),
Constraint::Length(3),
])
.split(area);
self.render_server_bar(f, chunks[0]);
self.render_heartbeat_monitor(f, chunks[1]);
self.render_actions(f, chunks[2]);
if self.show_detail {
if let Some(agent) = self.selected_agent() {
self.render_detail_popup(f, area, &agent);
}
}
}
fn render_server_bar(&self, f: &mut Frame, area: Rect) {
let cyan = Style::default().fg(Color::Cyan);
let green = Style::default().fg(Color::Green);
let red = Style::default().fg(Color::Red);
let dim = Style::default().fg(Color::DarkGray);
let mut lines = vec![Line::from(vec![
Span::styled("Server: ", cyan),
if self.server_running {
Span::styled("RUNNING", green)
} else {
Span::styled("STOPPED", red)
},
])];
if self.server_running {
lines.push(Line::from(vec![
Span::styled("Listen: ", cyan),
Span::raw(self.server_addr.clone()),
]));
lines.push(Line::from(vec![
Span::styled("Token: ", cyan),
Span::styled(self.server_token.clone(), Style::default().fg(Color::Yellow)),
]));
lines.push(Line::from(vec![
Span::styled("TLS fp: ", cyan),
Span::styled(self.server_fingerprint.clone(), dim),
]));
} else {
lines.push(Line::from(Span::styled(
" [s] to start the remote server",
dim,
)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" On each host: cargo install --locked rustpm-agent",
dim,
)));
lines.push(Line::from(Span::styled(
" Then: rustpm-agent setup --server HOST:PORT --token TOKEN --fingerprint FP",
dim,
)));
lines.push(Line::from(Span::styled(
" Then: sudo systemctl enable --now rustpm-agent",
dim,
)));
}
f.render_widget(
Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL).title(" Remote Server ")),
area,
);
}
fn render_heartbeat_monitor(&mut self, f: &mut Frame, area: Rect) {
let agents_snapshot: Vec<AgentInfo> = if let Ok(map) = self.agents.lock() {
self.agent_ids.iter().filter_map(|id| map.get(id).cloned()).collect()
} else {
vec![]
};
let rows: Vec<Row> = agents_snapshot.iter().map(|a| {
let health = HeartbeatHealth::from_last(a.last_heartbeat);
let hb_style = Style::default().fg(health.color()).add_modifier(Modifier::BOLD);
let age = age_str(a.last_heartbeat);
let count = if a.heartbeat_count > 0 {
format!("#{}", a.heartbeat_count)
} else {
"—".to_string()
};
let updates = if a.pending.is_empty() {
"up to date".to_string()
} else {
format!("{} updates", a.pending.len())
};
let busy = match &a.status {
AgentStatus::Idle => String::new(),
AgentStatus::Busy(what) => format!("[{}]", what),
};
Row::new(vec![
Cell::from(Span::styled(
format!("● {}", health.label()),
hb_style,
)),
Cell::from(a.hostname.clone()),
Cell::from(age),
Cell::from(count),
Cell::from(updates),
Cell::from(busy),
])
}).collect();
let is_empty = agents_snapshot.is_empty();
let event_suffix = if !self.last_event.is_empty() {
format!(" — {}", self.last_event)
} else {
String::new()
};
let title = if is_empty {
format!(" Heartbeat Monitor — no agents{} ", event_suffix)
} else {
format!(" Heartbeat Monitor{} ", event_suffix)
};
let widths = [
Constraint::Length(10), Constraint::Length(20), Constraint::Length(12), Constraint::Length(6), Constraint::Length(14), Constraint::Min(10), ];
let table = Table::new(rows, widths)
.header(
Row::new(vec!["Status", "Hostname", "Last HB", "HBs", "Updates", "Activity"])
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
)
.block(Block::default().borders(Borders::ALL).title(title))
.row_highlight_style(
Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD),
)
.highlight_symbol("▶ ");
f.render_stateful_widget(table, area, &mut self.state);
}
fn render_actions(&self, f: &mut Frame, area: Rect) {
let text = if self.server_running {
" [s] stop [r] check updates [u] upgrade [Enter] detail [?] help"
} else {
" [s] start server [?] help"
};
f.render_widget(
Paragraph::new(text)
.block(Block::default().borders(Borders::ALL).title(" Actions ")),
area,
);
}
fn render_detail_popup(&self, f: &mut Frame, area: Rect, agent: &AgentInfo) {
let popup = centered_popup(area, 55, 60);
f.render_widget(Clear, popup);
let health = HeartbeatHealth::from_last(agent.last_heartbeat);
let cyan = Style::default().fg(Color::Cyan);
let bold = Style::default().add_modifier(Modifier::BOLD);
let mut lines = vec![
Line::from(vec![
Span::styled("Hostname: ", cyan),
Span::styled(agent.hostname.clone(), bold),
]),
Line::from(vec![
Span::styled("OS: ", cyan),
Span::raw(agent.os_info.clone()),
]),
Line::from(vec![
Span::styled("Status: ", cyan),
Span::styled(
format!("● {}", health.label()),
Style::default().fg(health.color()).add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled("Last HB: ", cyan),
Span::raw(age_str(agent.last_heartbeat)),
]),
Line::from(vec![
Span::styled("HB count: ", cyan),
Span::raw(agent.heartbeat_count.to_string()),
]),
Line::from(vec![
Span::styled("HB interval: ", cyan),
Span::raw("~30s"),
]),
Line::from(""),
Line::from(vec![
Span::styled("Activity: ", cyan),
Span::raw(match &agent.status {
AgentStatus::Idle => "idle".to_string(),
AgentStatus::Busy(what) => what.clone(),
}),
]),
Line::from(vec![
Span::styled("Updates: ", cyan),
Span::raw(if agent.pending.is_empty() {
"up to date".to_string()
} else {
format!("{} pending", agent.pending.len())
}),
]),
];
if !agent.pending.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("Pending packages:", cyan)));
for pkg in agent.pending.iter().take(5) {
lines.push(Line::from(format!(
" {} {} → {}",
pkg.name, pkg.current, pkg.new_ver
)));
}
if agent.pending.len() > 5 {
lines.push(Line::from(format!(
" … and {} more",
agent.pending.len() - 5
)));
}
}
if !agent.last_output.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("Last output:", cyan)));
for line in agent.last_output.lines().rev().take(3).collect::<Vec<_>>().iter().rev() {
lines.push(Line::from(format!(" {}", line)));
}
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Enter or Esc to close",
Style::default().fg(Color::DarkGray),
)));
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" {} ", agent.hostname))
.style(Style::default().bg(Color::Black));
f.render_widget(
Paragraph::new(lines).block(block).wrap(ratatui::widgets::Wrap { trim: false }),
popup,
);
}
}
fn centered_popup(area: Rect, pct_x: u16, pct_y: u16) -> Rect {
let w = area.width * pct_x / 100;
let h = area.height * pct_y / 100;
Rect {
x: area.x + (area.width - w) / 2,
y: area.y + (area.height - h) / 2,
width: w,
height: h,
}
}