From 2571c2bbb33af3626488426c6f3b13a8b75d6163 Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Wed, 4 Mar 2026 13:04:53 +0100 Subject: [PATCH] feat(fxt): add Pods cockpit with service-grouped table Co-Authored-By: Claude Opus 4.6 --- bin/fxt/src/tui/cockpits/mod.rs | 1 + bin/fxt/src/tui/cockpits/pods.rs | 150 +++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 bin/fxt/src/tui/cockpits/pods.rs diff --git a/bin/fxt/src/tui/cockpits/mod.rs b/bin/fxt/src/tui/cockpits/mod.rs index fe8312801..8ba8c7729 100644 --- a/bin/fxt/src/tui/cockpits/mod.rs +++ b/bin/fxt/src/tui/cockpits/mod.rs @@ -2,6 +2,7 @@ pub mod data; pub mod overview; +pub mod pods; pub mod risk; pub mod services; pub mod trading; diff --git a/bin/fxt/src/tui/cockpits/pods.rs b/bin/fxt/src/tui/cockpits/pods.rs new file mode 100644 index 000000000..d0e823330 --- /dev/null +++ b/bin/fxt/src/tui/cockpits/pods.rs @@ -0,0 +1,150 @@ +#![allow(clippy::indexing_slicing)] // ratatui Layout::split indices match constraints +//! Pods cockpit -- K8s pod table grouped by service. + +use ratatui::Frame; +use ratatui::layout::{Constraint, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Paragraph, Row, Table}; + +use crate::tui::cockpit::Cockpit; +use crate::tui::state::AppState; +use crate::tui::theme; + +pub struct PodsCockpit; + +impl Cockpit for PodsCockpit { + fn name(&self) -> &str { + "Pods" + } + + fn render(&self, frame: &mut Frame, area: Rect, state: &AppState) { + if state.pods.is_empty() { + let msg = Paragraph::new(Line::from(Span::styled( + "Waiting for pod data...", + theme::muted_style(), + ))) + .block(theme::block(" Pods ")); + frame.render_widget(msg, area); + return; + } + + render_pod_table(frame, area, state); + } +} + +/// Shorten a K8s pod name to its trailing random suffix. +/// +/// `api-gateway-7686d84bdd-zl4mn` becomes `zl4mn`. +/// Falls back to the full name if there is no `-` separator. +fn shorten_pod_name(name: &str) -> &str { + name.rsplit('-').next().unwrap_or(name) +} + +fn render_pod_table(frame: &mut Frame, area: Rect, state: &AppState) { + let header = Row::new(vec![ + "Service", "Ready", "Status", "Version", "Restarts", "Age", "Node", + ]) + .style(theme::header_style()); + + let mut rows: Vec> = Vec::new(); + + for group in &state.pods { + let ready_str = format!("{}/{}", group.ready_count, group.total_count); + + let (status_text, status_style) = if group.ready_count == group.total_count + && group.total_count > 0 + { + ("Running", Style::default().fg(theme::SUCCESS)) + } else if group.ready_count > 0 { + ("Degraded", Style::default().fg(theme::WARNING)) + } else { + ("Down", Style::default().fg(theme::ERROR)) + }; + + let total_restarts: i64 = group.pods.iter().map(|p| i64::from(p.restarts)).sum(); + let restart_style = if total_restarts > 0 { + Style::default().fg(theme::WARNING) + } else { + theme::muted_style() + }; + + // First pod provides age + node for the group header row. + let (age, node) = group + .pods + .first() + .map(|p| (p.age.as_str(), p.node.as_str())) + .unwrap_or(("-", "-")); + + let version = group + .pods + .first() + .map(|p| p.version.as_str()) + .unwrap_or("-"); + + // Group header row -- bold service name. + rows.push(Row::new(vec![ + Span::styled( + group.service.clone(), + Style::default() + .fg(theme::TEXT) + .add_modifier(Modifier::BOLD), + ), + Span::styled(ready_str, Style::default().fg(theme::TEXT)), + Span::styled(status_text.to_owned(), status_style), + Span::styled(version.to_owned(), theme::muted_style()), + Span::styled(total_restarts.to_string(), restart_style), + Span::styled(age.to_owned(), theme::muted_style()), + Span::styled(node.to_owned(), theme::muted_style()), + ])); + + // Sub-rows for groups with more than one pod. + if group.pods.len() > 1 { + for pod in &group.pods { + let pod_status_style = if pod.ready { + Style::default().fg(theme::SUCCESS) + } else { + Style::default().fg(theme::ERROR) + }; + + let pod_restart_style = if pod.restarts > 0 { + Style::default().fg(theme::WARNING) + } else { + theme::muted_style() + }; + + let suffix = shorten_pod_name(&pod.name); + + rows.push(Row::new(vec![ + Span::styled(format!(" {suffix}"), theme::muted_style()), + Span::styled( + if pod.ready { "1/1" } else { "0/1" }.to_owned(), + Style::default().fg(theme::TEXT), + ), + Span::styled(pod.status.clone(), pod_status_style), + Span::styled(pod.version.clone(), theme::muted_style()), + Span::styled(pod.restarts.to_string(), pod_restart_style), + Span::styled(pod.age.clone(), theme::muted_style()), + Span::styled(pod.node.clone(), theme::muted_style()), + ])); + } + } + } + + let table = Table::new( + rows, + [ + Constraint::Length(22), + Constraint::Length(7), + Constraint::Length(10), + Constraint::Length(12), + Constraint::Length(10), + Constraint::Length(8), + Constraint::Min(12), + ], + ) + .header(header) + .block(theme::block(" Pods ")); + + frame.render_widget(table, area); +}