use std::time::{SystemTime, UNIX_EPOCH};
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Padding, Paragraph};
use super::theme;
use crate::app::{App, PingStatus};
use crate::history::ConnectionHistory;
use crate::ssh_config::model::ConfigElement;
const LABEL_WIDTH: usize = 14;
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
let host = match app.selected_host() {
Some(h) => h,
None => {
let block = Block::bordered()
.border_type(BorderType::Rounded)
.padding(Padding::horizontal(1))
.border_style(theme::border());
let empty = Paragraph::new(" Select a host to see details.")
.style(theme::muted())
.block(block);
frame.render_widget(empty, area);
return;
}
};
let title = format!(" {} ", host.alias);
let block = Block::bordered()
.border_type(BorderType::Rounded)
.padding(Padding::horizontal(1))
.title(Span::styled(title, theme::brand()))
.border_style(theme::border());
let inner_width = (area.width as usize).saturating_sub(4); let max_value_width = inner_width.saturating_sub(LABEL_WIDTH);
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(""));
lines.push(section_header("Connection"));
push_field(&mut lines, "Host", &host.hostname, max_value_width);
if !host.user.is_empty() {
push_field(&mut lines, "User", &host.user, max_value_width);
}
if host.port != 22 {
push_field(&mut lines, "Port", &host.port.to_string(), max_value_width);
}
if !host.proxy_jump.is_empty() {
push_field(&mut lines, "ProxyJump", &host.proxy_jump, max_value_width);
}
if !host.identity_file.is_empty() {
let key_display = host
.identity_file
.rsplit('/')
.next()
.unwrap_or(&host.identity_file);
push_field(&mut lines, "Key", key_display, max_value_width);
}
if let Some(ref askpass) = host.askpass {
push_field(&mut lines, "Password", askpass, max_value_width);
}
let history_entry = app.history.entries.get(&host.alias);
let ping = app.ping_status.get(&host.alias);
if history_entry.is_some() || ping.is_some() {
lines.push(Line::from(""));
lines.push(section_header("Activity"));
if let Some(entry) = history_entry {
let ago = ConnectionHistory::format_time_ago(entry.last_connected);
if !ago.is_empty() {
push_field(&mut lines, "Last SSH", &ago, max_value_width);
}
push_field(
&mut lines,
"Connections",
&entry.count.to_string(),
max_value_width,
);
if !entry.timestamps.is_empty() && inner_width >= 10 {
let chart_lines = activity_sparkline(&entry.timestamps, inner_width);
if !chart_lines.is_empty() {
lines.push(Line::from(""));
lines.extend(chart_lines);
}
}
}
if let Some(status) = ping {
let (text, style) = match status {
PingStatus::Checking => ("checking...", theme::muted()),
PingStatus::Reachable => ("reachable", theme::success()),
PingStatus::Unreachable => ("unreachable", theme::error()),
PingStatus::Skipped => ("skipped", theme::muted()),
};
lines.push(Line::from(vec![
Span::styled(
format!("{:<width$}", "Status", width = LABEL_WIDTH),
theme::muted(),
),
Span::styled(text, style),
]));
}
}
if !host.tags.is_empty() || host.provider.is_some() {
lines.push(Line::from(""));
lines.push(section_header("Tags"));
let mut tag_spans = Vec::new();
for tag in &host.tags {
tag_spans.push(Span::styled(format!("#{}", tag), theme::accent()));
tag_spans.push(Span::raw(" "));
}
if let Some(ref provider) = host.provider {
tag_spans.push(Span::styled(format!("#{}", provider), theme::accent()));
}
lines.push(Line::from(tag_spans));
}
if !host.provider_meta.is_empty() {
lines.push(Line::from(""));
let header = match host.provider.as_deref() {
Some(name) => crate::providers::provider_display_name(name).to_string(),
None => "Provider".to_string(),
};
lines.push(section_header(&header));
for (key, value) in &host.provider_meta {
let label = meta_label(key);
push_field(&mut lines, &label, value, max_value_width);
}
}
let tunnel_active = app.active_tunnels.contains_key(&host.alias);
if host.tunnel_count > 0 {
lines.push(Line::from(""));
let tunnel_label = if tunnel_active {
"Tunnels (active)"
} else {
"Tunnels"
};
lines.push(section_header(tunnel_label));
let rules = find_tunnel_rules(&app.config.elements, &host.alias);
let style = if tunnel_active {
theme::bold()
} else {
theme::muted()
};
for rule in rules.iter().take(5) {
lines.push(Line::from(Span::styled(rule.to_string(), style)));
}
if rules.len() > 5 {
lines.push(Line::from(Span::styled(
format!("(and {} more...)", rules.len() - 5),
theme::muted(),
)));
}
}
let snippet_count = app.snippet_store.snippets.len();
if snippet_count > 0 {
lines.push(Line::from(""));
lines.push(section_header("Snippets"));
lines.push(Line::from(Span::styled(
format!("{} available (r to run)", snippet_count),
theme::muted(),
)));
}
if let Some(ref source) = host.source_file {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(
format!("{:<width$}", "Source", width = LABEL_WIDTH),
theme::muted(),
),
Span::styled(source.display().to_string(), theme::muted()),
]));
}
lines.push(Line::from(""));
let paragraph = Paragraph::new(lines)
.block(block)
.scroll((app.ui.detail_scroll, 0));
frame.render_widget(paragraph, area);
}
fn push_field(lines: &mut Vec<Line<'static>>, label: &str, value: &str, max_value_width: usize) {
let display = if max_value_width > 0 {
super::truncate(value, max_value_width)
} else {
value.to_string()
};
lines.push(Line::from(vec![
Span::styled(
format!("{:<width$}", label, width = LABEL_WIDTH),
theme::muted(),
),
Span::styled(display, theme::bold()),
]));
}
fn meta_label(key: &str) -> String {
match key {
"region" => "Region".to_string(),
"plan" => "Plan".to_string(),
"os" => "OS".to_string(),
"node" => "Node".to_string(),
"type" => "Type".to_string(),
"status" => "State".to_string(),
other => {
let mut chars = other.chars();
match chars.next() {
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
None => String::new(),
}
}
}
}
fn section_header(label: &str) -> Line<'static> {
Line::from(Span::styled(label.to_string(), theme::section_header()))
}
const BLOCKS: [char; 9] = [' ', '\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}', '\u{2587}', '\u{2588}'];
const CHART_DAYS: u64 = 84;
fn activity_sparkline(timestamps: &[u64], chart_width: usize) -> Vec<Line<'static>> {
if chart_width == 0 {
return Vec::new();
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let range_secs = CHART_DAYS * 86400;
let bucket_secs = range_secs as f64 / chart_width as f64;
let cutoff = now.saturating_sub(range_secs);
let mut buckets = vec![0u64; chart_width];
for &ts in timestamps {
if ts < cutoff || ts > now {
continue;
}
let age = now.saturating_sub(ts);
let idx = chart_width
- 1
- ((age as f64 / bucket_secs).floor() as usize).min(chart_width - 1);
buckets[idx] += 1;
}
if buckets.iter().all(|&v| v == 0) {
return Vec::new();
}
let max_val = buckets.iter().copied().max().unwrap_or(1).max(1);
let total_levels = 16usize;
let heights: Vec<usize> = buckets
.iter()
.map(|&v| {
if v == 0 {
0
} else {
((v as f64 / max_val as f64) * total_levels as f64).ceil() as usize
}
})
.collect();
let mut chart_lines = Vec::new();
if heights.iter().any(|&h| h > 8) {
let mut top = String::with_capacity(chart_width * 3);
for &h in &heights {
if h > 8 {
top.push(BLOCKS[(h - 8).min(8)]);
} else {
top.push(' ');
}
}
chart_lines.push(Line::from(Span::styled(top, theme::bold())));
}
let mut bottom = String::with_capacity(chart_width * 3);
for &h in &heights {
if h == 0 {
bottom.push(' ');
} else if h >= 8 {
bottom.push(BLOCKS[8]);
} else {
bottom.push(BLOCKS[h]);
}
}
chart_lines.push(Line::from(Span::styled(bottom, theme::bold())));
let left_label = format!("{}w", CHART_DAYS / 7);
let right_label = "now";
let gap = chart_width.saturating_sub(left_label.len() + right_label.len());
chart_lines.push(Line::from(vec![
Span::styled(left_label, theme::muted()),
Span::raw(" ".repeat(gap)),
Span::styled(right_label.to_string(), theme::muted()),
]));
chart_lines
}
fn find_tunnel_rules(elements: &[ConfigElement], alias: &str) -> Vec<String> {
for element in elements {
match element {
ConfigElement::HostBlock(block) if block.host_pattern == alias => {
return block
.directives
.iter()
.filter(|d| !d.is_non_directive)
.filter_map(|d| {
let prefix = match d.key.to_lowercase().as_str() {
"localforward" => "L",
"remoteforward" => "R",
"dynamicforward" => "D",
_ => return None,
};
Some(format!("{} {}", prefix, d.value))
})
.collect();
}
ConfigElement::Include(include) => {
for file in &include.resolved_files {
let result = find_tunnel_rules(&file.elements, alias);
if !result.is_empty() {
return result;
}
}
}
_ => {}
}
}
Vec::new()
}
#[cfg(test)]
mod tests {
use super::*;
fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
#[test]
fn sparkline_empty_timestamps() {
let result = activity_sparkline(&[], 40);
assert!(result.is_empty());
}
#[test]
fn sparkline_all_outside_range() {
let old = now() - 100 * 86400;
let result = activity_sparkline(&[old], 40);
assert!(result.is_empty());
}
#[test]
fn sparkline_single_timestamp() {
let ts = now() - 86400;
let lines = activity_sparkline(&[ts], 40);
assert!(!lines.is_empty());
assert!(lines.len() >= 2);
}
#[test]
fn sparkline_multiple_buckets() {
let n = now();
let timestamps: Vec<u64> = (0..84).map(|day| n - day * 86400).collect();
let lines = activity_sparkline(×tamps, 40);
assert!(lines.len() >= 2);
}
#[test]
fn sparkline_all_in_one_bucket() {
let n = now();
let timestamps: Vec<u64> = (0..10).map(|i| n - i * 60).collect();
let lines = activity_sparkline(×tamps, 20);
assert!(lines.len() >= 2);
}
#[test]
fn sparkline_axis_labels() {
let ts = now() - 86400;
let lines = activity_sparkline(&[ts], 30);
let axis = lines.last().unwrap();
let text: String = axis.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("12w"));
assert!(text.contains("now"));
}
#[test]
fn sparkline_narrow_width() {
let ts = now() - 86400;
let lines = activity_sparkline(&[ts], 10);
assert!(lines.len() >= 2);
}
#[test]
fn sparkline_two_rows_for_high_variance() {
let n = now();
let mut timestamps: Vec<u64> = vec![n; 100];
timestamps.push(n - 40 * 86400);
let lines = activity_sparkline(×tamps, 20);
assert_eq!(lines.len(), 3);
}
}