mod resources;
use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Paragraph}
};
use resources::{
render_ai_agent_details, render_app_details, render_balancer_details, render_database_details,
render_dedicated_details, render_domain_details, render_finances_details,
render_firewall_details, render_floating_ip_details, render_image_details, render_k8s_details,
render_knowledge_details, render_mail_details, render_network_drive_details,
render_project_details, render_registry_details, render_s3_details, render_server_details,
render_ssh_key_details, render_vpc_details
};
use rust_i18n::t;
use crate::tui::{
app::{App, ResourceTab},
themes::Palette
};
const KEY_WIDTH: usize = 13;
const RULE_WIDTH: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DetailAction {
Redeploy,
Kind(crate::tui::app::ActionKind)
}
pub struct DetailLine {
pub line: Line<'static>,
pub copy: Option<String>,
pub action: Option<DetailAction>
}
impl DetailLine {
#[must_use]
pub const fn is_interactive(&self) -> bool {
self.copy.is_some() || self.action.is_some()
}
}
impl From<Line<'static>> for DetailLine {
fn from(line: Line<'static>) -> Self {
Self {
line,
copy: None,
action: None
}
}
}
pub(super) fn blank() -> DetailLine {
Line::from("").into()
}
#[must_use]
pub fn build(app: &App) -> Vec<DetailLine> {
let palette = app.theme.palette();
let mut rows = action_rows(app, palette);
rows.extend(match app.active_tab {
ResourceTab::Servers => render_server_details(app, palette),
ResourceTab::Databases => render_database_details(app, palette),
ResourceTab::S3 => render_s3_details(app, palette),
ResourceTab::Kubernetes => render_k8s_details(app, palette),
ResourceTab::Projects => render_project_details(app, palette),
ResourceTab::Balancers => render_balancer_details(app, palette),
ResourceTab::Registry => render_registry_details(app, palette),
ResourceTab::Domains => render_domain_details(app, palette),
ResourceTab::Firewall => render_firewall_details(app, palette),
ResourceTab::FloatingIps => render_floating_ip_details(app, palette),
ResourceTab::Images => render_image_details(app, palette),
ResourceTab::NetworkDrives => render_network_drive_details(app, palette),
ResourceTab::Vpc => render_vpc_details(app, palette),
ResourceTab::DedicatedServers => render_dedicated_details(app, palette),
ResourceTab::Mail => render_mail_details(app, palette),
ResourceTab::Apps => render_app_details(app, palette),
ResourceTab::AiAgents => render_ai_agent_details(app, palette),
ResourceTab::KnowledgeBases => render_knowledge_details(app, palette),
ResourceTab::SshKeys => render_ssh_key_details(app, palette),
ResourceTab::Finances => render_finances_details(app, palette)
});
append_extra_sections(&mut rows, app, palette);
rows
}
fn action_rows(app: &App, palette: Palette) -> Vec<DetailLine> {
let mut actions: Vec<(String, DetailAction)> = Vec::new();
if app.active_tab == ResourceTab::Apps && !app.apps.is_empty() {
actions.push((t!("details.redeploy").into_owned(), DetailAction::Redeploy));
}
if app.selected_resource().is_some() {
for kind in app.active_tab.actions() {
actions.push((kind.display_label().into_owned(), DetailAction::Kind(*kind)));
}
}
if actions.is_empty() {
return Vec::new();
}
let mut rows = vec![section(&t!("details.actions"), palette)];
for (index, (label, action)) in actions.into_iter().enumerate() {
rows.push(action_row(
&label,
action,
app.detail_open && app.detail_selected == index,
palette
));
}
rows.push(blank());
rows
}
#[must_use]
pub fn initial_cursor(app: &App) -> usize {
let buttons = action_rows(app, app.theme.palette())
.iter()
.filter(|r| r.is_interactive())
.count();
if buttons < interactive_len(app) {
buttons
} else {
0
}
}
#[must_use]
pub fn interactive_at(app: &App, index: usize) -> Option<(Option<String>, Option<DetailAction>)> {
build(app)
.into_iter()
.filter(DetailLine::is_interactive)
.nth(index)
.map(|row| (row.copy, row.action))
}
#[must_use]
pub fn interactive_len(app: &App) -> usize {
build(app).iter().filter(|r| r.is_interactive()).count()
}
pub fn render(frame: &mut Frame, area: Rect, app: &App, border_color: Color) -> u16 {
let palette = app.theme.palette();
let rows = build(app);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color))
.title(Line::from(Span::styled(
format!(" {} ", breadcrumbs(app)),
Style::default()
.fg(palette.title)
.add_modifier(Modifier::BOLD)
)));
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 || inner.width == 0 {
return 0;
}
let charts_h = chart_rows(app, inner);
let text_area = Rect::new(
inner.x,
inner.y,
inner.width,
inner.height.saturating_sub(charts_h)
);
if charts_h > 0 {
render_charts(
frame,
Rect::new(inner.x, inner.y + text_area.height, inner.width, charts_h),
app,
palette
);
}
let mut selected_abs = None;
let mut interactive_seen = 0usize;
let lines: Vec<Line> = rows
.into_iter()
.enumerate()
.map(|(abs, row)| {
let interactive = row.is_interactive();
let mut line = row.line;
if interactive {
if interactive_seen == app.detail_selected {
selected_abs = Some(abs);
if row.action.is_some() {
line.spans.insert(0, Span::raw(" "));
} else {
line.spans.insert(
0,
Span::styled("\u{258E}", Style::default().fg(palette.accent))
);
}
} else {
line.spans.insert(0, Span::raw(" "));
}
interactive_seen += 1;
} else {
line.spans.insert(0, Span::raw(" "));
}
line
})
.collect();
render_columns(frame, text_area, lines, app.detail_scroll, selected_abs)
}
fn chart_rows(app: &App, inner: Rect) -> u16 {
let has_stats = matches!(app.active_tab, ResourceTab::Apps | ResourceTab::Servers)
&& (!app.cpu_history.is_empty() || !app.ram_history.is_empty());
if has_stats && inner.height >= 14 {
8
} else {
0
}
}
fn render_charts(frame: &mut Frame, area: Rect, app: &App, palette: Palette) {
use ratatui::widgets::Sparkline;
type ChartSeries = (String, Vec<f64>, Color, fn(f64) -> String);
let percent = |v: f64| format!("{v:.1}%");
let rate = crate::tui::humanize::bytes_rate;
let mut charts: Vec<ChartSeries> = Vec::new();
if !app.cpu_history.is_empty() {
let data = app.cpu_history.iter().copied().collect();
charts.push(("CPU".to_string(), data, palette.accent, percent));
}
if !app.ram_history.is_empty() {
let data = app.ram_history.iter().copied().collect();
charts.push(("RAM".to_string(), data, palette.success, percent));
}
if !app.net_in_history.is_empty() {
let data = app.net_in_history.iter().copied().collect();
charts.push((
t!("details.net_in").into_owned(),
data,
palette.warning,
rate
));
}
if !app.net_out_history.is_empty() {
let data = app.net_out_history.iter().copied().collect();
charts.push((
t!("details.net_out").into_owned(),
data,
palette.header,
rate
));
}
if charts.is_empty() {
return;
}
let cols = charts.len().min(usize::from(area.width / 24).max(1));
let rows = charts.len().div_ceil(cols);
let row_areas =
ratatui::layout::Layout::vertical(vec![ratatui::layout::Constraint::Fill(1); rows])
.split(area);
for (row_area, row_charts) in row_areas.iter().zip(charts.chunks(cols)) {
let cells = ratatui::layout::Layout::horizontal(vec![
ratatui::layout::Constraint::Fill(1);
row_charts.len()
])
.spacing(2)
.split(*row_area);
for ((title, data, color, label), cell) in row_charts.iter().zip(cells.iter()) {
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let points: Vec<u64> = data.iter().map(|v| (v.max(0.0) * 100.0) as u64).collect();
let last = data.last().copied().unwrap_or(0.0);
let spark = Sparkline::default()
.block(
Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(palette.border))
.title(Line::from(Span::styled(
format!(" {title} {} ", label(last)),
Style::default().fg(palette.header)
)))
)
.data(points)
.style(Style::default().fg(*color));
frame.render_widget(spark, *cell);
}
}
}
fn breadcrumbs(app: &App) -> String {
let name = app
.selected_resource()
.map_or_else(|| t!("details.title").into_owned(), |(_, name)| name);
app.drill_view().map_or_else(
|| format!("{} \u{2192} {name}", app.active_tab.display_name()),
|drill| format!("{} \u{2192} {name}", drill.title)
)
}
const COLUMN_GAP: u16 = 3;
fn append_extra_sections(text: &mut Vec<DetailLine>, app: &App, palette: Palette) {
let Some((id, _)) = app.selected_resource() else {
return;
};
let Some(sections) = app.detail_extra.get(&(app.active_tab, id)) else {
return;
};
for (title, rows) in sections {
text.push(blank());
text.push(heading(title, palette));
text.push(rule(palette));
for (key, value) in rows {
text.push(kv(key, value.clone(), name_style(palette), palette));
}
}
}
fn render_columns(
frame: &mut Frame,
inner: Rect,
text: Vec<Line<'static>>,
scroll: u16,
follow: Option<usize>
) -> u16 {
let height = usize::from(inner.height);
if height == 0 {
return 0;
}
let column_width = u16::try_from(text.iter().map(Line::width).max().unwrap_or(0))
.unwrap_or(u16::MAX)
.clamp(1, inner.width);
let max_cols = usize::from((inner.width + COLUMN_GAP) / (column_width + COLUMN_GAP)).max(1);
let needed = text.len().div_ceil(height);
let cols = needed.clamp(1, max_cols);
let capacity = cols * height;
let max_scroll = text.len().saturating_sub(capacity);
let mut offset = usize::from(scroll).min(max_scroll);
if let Some(target) = follow {
if target < offset {
offset = target;
} else if target >= offset + capacity {
offset = target + 1 - capacity;
}
}
let clamped = u16::try_from(offset).unwrap_or(u16::MAX);
let visible: Vec<Line> = text.into_iter().skip(offset).take(capacity).collect();
if cols == 1 {
frame.render_widget(Paragraph::new(visible), inner);
return clamped;
}
let mut constraints = Vec::with_capacity(cols);
for _ in 0..cols - 1 {
constraints.push(ratatui::layout::Constraint::Length(column_width));
}
constraints.push(ratatui::layout::Constraint::Min(10));
let areas = ratatui::layout::Layout::horizontal(constraints)
.spacing(COLUMN_GAP)
.split(inner);
for (chunk, column) in visible.chunks(height).zip(areas.iter()) {
frame.render_widget(Paragraph::new(chunk.to_vec()), *column);
}
clamped
}
pub(super) fn heading(name: &str, palette: Palette) -> DetailLine {
Line::from(Span::styled(
name.to_string(),
Style::default()
.fg(palette.title)
.add_modifier(Modifier::BOLD)
))
.into()
}
pub(super) fn rule(palette: Palette) -> DetailLine {
Line::from(Span::styled(
"\u{2500}".repeat(RULE_WIDTH),
Style::default().fg(palette.dim)
))
.into()
}
pub(super) fn section(label: &str, palette: Palette) -> DetailLine {
Line::from(Span::styled(
label.to_string(),
Style::default()
.fg(palette.header)
.add_modifier(Modifier::BOLD)
))
.into()
}
pub(super) fn kv(key: &str, value: String, value_style: Style, palette: Palette) -> DetailLine {
let padded = if key.chars().count() >= KEY_WIDTH {
format!("{key} ")
} else {
format!("{key:<KEY_WIDTH$}")
};
DetailLine {
line: Line::from(vec![
Span::styled(padded, Style::default().fg(palette.dim)),
Span::styled(value.clone(), value_style),
]),
copy: Some(value),
action: None
}
}
pub(super) fn kv_field(
key: &str,
value: &str,
value_style: Style,
palette: Palette
) -> DetailLine {
if value.is_empty() {
let mut row = kv(
key,
"\u{2014}".to_string(),
Style::default().fg(palette.dim),
palette
);
row.copy = None;
return row;
}
kv(key, value.to_string(), value_style, palette)
}
pub(super) fn chip(key: &str, label: &str, color: Color, palette: Palette) -> DetailLine {
DetailLine {
line: Line::from(vec![
Span::styled(
format!("{key:<KEY_WIDTH$}"),
Style::default().fg(palette.dim)
),
Span::styled(
format!("\u{25CF} {label}"),
Style::default().fg(color).add_modifier(Modifier::BOLD)
),
]),
copy: Some(label.to_string()),
action: None
}
}
pub(super) fn status_chip(key: &str, status: &str, palette: Palette) -> DetailLine {
let (color, label) = crate::tui::widgets::resource_list::status_view(status, &palette);
chip(key, &label, color, palette)
}
pub(super) fn action_row(
label: &str,
action: DetailAction,
focused: bool,
palette: Palette
) -> DetailLine {
DetailLine {
line: crate::tui::widgets::button::chip(label, focused, &palette),
copy: None,
action: Some(action)
}
}
pub(super) fn empty(message: &str, palette: Palette) -> Vec<DetailLine> {
vec![
blank(),
Line::from(Span::styled(
format!(" {message}"),
Style::default()
.fg(palette.dim)
.add_modifier(Modifier::ITALIC)
))
.into(),
]
}
pub(super) fn accent(palette: Palette) -> Style {
Style::default().fg(palette.accent)
}
pub(super) fn name_style(palette: Palette) -> Style {
Style::default().fg(palette.fg).add_modifier(Modifier::BOLD)
}
pub(super) fn warn(palette: Palette) -> Style {
Style::default().fg(palette.warning)
}
pub struct DetailsWidget {
enabled: bool
}
impl DetailsWidget {
#[must_use]
pub const fn new(enabled: bool) -> Self {
Self {
enabled
}
}
}
impl crate::tui::widgets::Widget for DetailsWidget {
fn id(&self) -> &'static str {
"details"
}
fn name(&self) -> &'static str {
"Details"
}
fn enabled(&self) -> bool {
self.enabled
}
fn toggle(&mut self) {
self.enabled = !self.enabled;
}
fn render(&self, frame: &mut Frame, area: Rect, app: &App) {
let border_color = if app.focus == crate::tui::app::Focus::Details {
app.theme.palette().accent
} else {
app.theme.palette().border
};
render(frame, area, app, border_color);
}
}
#[cfg(test)]
mod tests {
use ratatui::{Terminal, backend::TestBackend};
use super::*;
use crate::tui::app::{App, DatabaseSummary};
#[test]
fn scroll_state_clamps_to_content_each_frame() {
let mut app = App::new(5);
app.active_tab = crate::tui::app::ResourceTab::Databases;
app.databases = vec![DatabaseSummary {
id: 1,
name: "db".to_string(),
status: "started".to_string(),
engine: "postgres".to_string(),
size_mb: 100,
..Default::default()
}];
app.detail_scroll = 500;
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
app.detail_scroll = render(f, Rect::new(0, 0, 80, 24), &app, Color::Reset);
})
.unwrap();
assert_eq!(
app.detail_scroll, 0,
"short content must clamp runaway scroll back to zero"
);
}
#[test]
fn finances_details_show_the_balance() {
let mut app = App::new(5);
app.active_tab = crate::tui::app::ResourceTab::Finances;
app.finances = Some(crate::tui::app::FinancesSummary {
balance: 987.65,
currency: "RUB".to_string(),
monthly_cost: 300.0,
hourly_cost: 0.42,
..Default::default()
});
let rows = build(&app);
let copies: Vec<&str> = rows.iter().filter_map(|r| r.copy.as_deref()).collect();
assert!(copies.contains(&"987.65 RUB"), "copies: {copies:?}");
assert!(copies.contains(&"300.00 RUB"), "copies: {copies:?}");
assert!(copies.contains(&"0.42 RUB"), "copies: {copies:?}");
}
#[test]
fn ssh_key_details_expose_the_copyable_body_and_used_by() {
let mut app = App::new(5);
app.active_tab = crate::tui::app::ResourceTab::SshKeys;
app.ssh_keys = vec![crate::tui::app::SshKeySummary {
id: 7,
name: "laptop".to_string(),
body: "ssh-ed25519 AAAA laptop".to_string(),
created_at: "2026-06-08T02:33:05+00:00".to_string(),
used_by: vec!["web-1".to_string()],
is_default: true
}];
let rows = build(&app);
let copies: Vec<&str> = rows.iter().filter_map(|r| r.copy.as_deref()).collect();
assert!(copies.contains(&"ssh-ed25519 AAAA laptop"), "{copies:?}");
assert!(copies.contains(&"web-1"), "{copies:?}");
}
#[test]
fn long_details_flow_into_columns_on_wide_panels() {
let mut app = App::new(5);
app.active_tab = crate::tui::app::ResourceTab::Databases;
app.databases = vec![DatabaseSummary {
id: 1,
name: "db".to_string(),
status: "started".to_string(),
engine: "postgres".to_string(),
size_mb: 100,
config: (0..40)
.map(|i| (format!("param_{i}"), i.to_string()))
.collect(),
..Default::default()
}];
let (w, h) = (140u16, 14u16);
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
render(f, Rect::new(0, 0, w, h), &app, Color::Reset);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let mut second_column = String::new();
for y in 1..h - 1 {
for x in w / 3..w - 1 {
second_column.push_str(buf[(x, y)].symbol());
}
}
assert!(
second_column.contains("param_"),
"expected a second column of parameters on a wide panel"
);
}
}