Addresses CodeQL cleartext-logging alerts (#1, #2, #3) by implementing session ID redaction for CLI output. Changes: - Extract marathonctl into standalone crate (crates/marathonctl) - Add session ID redaction showing only first 8 characters by default - Add --show-sensitive/-s flag for full session IDs when debugging - Implement beautiful ratatui-based UI module with inline viewport - Add .envrc to .gitignore for secure token management - Document GitHub token setup in CONTRIBUTING.md The CLI now provides a secure-by-default experience while maintaining debugging capabilities through explicit opt-in flags. Session IDs are redacted to format "abc-def-..." unless --show-sensitive is specified. UI module provides easy-to-use builder APIs (ui::table, ui::grid, ui::list) that render beautiful terminal output without hijacking the terminal. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
225 lines
6.0 KiB
Rust
225 lines
6.0 KiB
Rust
//! Beautiful CLI UI module for marathonctl using ratatui
|
|
//!
|
|
//! Provides simple builder APIs for rendering beautiful terminal output
|
|
//! without taking over the terminal (inline mode).
|
|
//!
|
|
//! # Examples
|
|
//!
|
|
//! ```rust
|
|
//! // Render a status table
|
|
//! ui::table("Session Status")
|
|
//! .row("Node ID", node_id)
|
|
//! .row("Session", session_id)
|
|
//! .row("Queue Size", queue_size)
|
|
//! .render();
|
|
//!
|
|
//! // Render a list
|
|
//! ui::list("Connected Peers")
|
|
//! .item(peer1)
|
|
//! .item(peer2)
|
|
//! .render();
|
|
//!
|
|
//! // Render a data grid
|
|
//! ui::grid("Sessions")
|
|
//! .header(&["ID", "State", "Entities"])
|
|
//! .row(&[id1, state1, count1])
|
|
//! .row(&[id2, state2, count2])
|
|
//! .render();
|
|
//! ```
|
|
|
|
use ratatui::prelude::*;
|
|
use ratatui::widgets::*;
|
|
use ratatui::{TerminalOptions, Viewport};
|
|
|
|
/// Create a key-value table (like status output)
|
|
pub fn table(title: &str) -> TableBuilder {
|
|
TableBuilder::new(title)
|
|
}
|
|
|
|
/// Create a simple list
|
|
pub fn list(title: &str) -> ListBuilder {
|
|
ListBuilder::new(title)
|
|
}
|
|
|
|
/// Create a data grid with headers
|
|
pub fn grid(title: &str) -> GridBuilder {
|
|
GridBuilder::new(title)
|
|
}
|
|
|
|
/// Builder for key-value tables
|
|
pub struct TableBuilder {
|
|
title: String,
|
|
rows: Vec<(String, String)>,
|
|
}
|
|
|
|
impl TableBuilder {
|
|
fn new(title: &str) -> Self {
|
|
Self {
|
|
title: title.to_string(),
|
|
rows: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Add a key-value row
|
|
pub fn row(mut self, key: impl std::fmt::Display, value: impl std::fmt::Display) -> Self {
|
|
self.rows.push((key.to_string(), value.to_string()));
|
|
self
|
|
}
|
|
|
|
/// Render the table to the terminal
|
|
pub fn render(self) {
|
|
let height = self.rows.len() + 4; // rows + borders + header + padding
|
|
render_widget(height, |frame| {
|
|
let area = frame.area();
|
|
|
|
let rows: Vec<Row> = self
|
|
.rows
|
|
.iter()
|
|
.map(|(k, v)| Row::new(vec![k.clone(), v.clone()]))
|
|
.collect();
|
|
|
|
let table = Table::new(
|
|
rows,
|
|
[Constraint::Length(20), Constraint::Min(30)],
|
|
)
|
|
.header(
|
|
Row::new(vec!["Field", "Value"])
|
|
.bold()
|
|
.style(Style::default().fg(Color::Green)),
|
|
)
|
|
.block(
|
|
Block::default()
|
|
.title(format!(" {} ", self.title))
|
|
.borders(Borders::ALL)
|
|
.border_style(Style::default().fg(Color::Cyan)),
|
|
)
|
|
.column_spacing(2);
|
|
|
|
frame.render_widget(table, area);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Builder for simple lists
|
|
pub struct ListBuilder {
|
|
title: String,
|
|
items: Vec<String>,
|
|
}
|
|
|
|
impl ListBuilder {
|
|
fn new(title: &str) -> Self {
|
|
Self {
|
|
title: title.to_string(),
|
|
items: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Add an item to the list
|
|
pub fn item(mut self, item: impl std::fmt::Display) -> Self {
|
|
self.items.push(item.to_string());
|
|
self
|
|
}
|
|
|
|
/// Render the list to the terminal
|
|
pub fn render(self) {
|
|
let height = self.items.len() + 3; // items + borders + title
|
|
render_widget(height, |frame| {
|
|
let area = frame.area();
|
|
|
|
let items: Vec<ListItem> = self
|
|
.items
|
|
.iter()
|
|
.map(|i| ListItem::new(format!(" • {}", i)))
|
|
.collect();
|
|
|
|
let list = List::new(items)
|
|
.block(
|
|
Block::default()
|
|
.title(format!(" {} ", self.title))
|
|
.borders(Borders::ALL)
|
|
.border_style(Style::default().fg(Color::Cyan)),
|
|
);
|
|
|
|
frame.render_widget(list, area);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Builder for data grids with headers
|
|
pub struct GridBuilder {
|
|
title: String,
|
|
headers: Vec<String>,
|
|
rows: Vec<Vec<String>>,
|
|
}
|
|
|
|
impl GridBuilder {
|
|
fn new(title: &str) -> Self {
|
|
Self {
|
|
title: title.to_string(),
|
|
headers: Vec::new(),
|
|
rows: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Set the header row
|
|
pub fn header(mut self, headers: &[impl std::fmt::Display]) -> Self {
|
|
self.headers = headers.iter().map(|h| h.to_string()).collect();
|
|
self
|
|
}
|
|
|
|
/// Add a data row
|
|
pub fn row(mut self, cells: &[impl std::fmt::Display]) -> Self {
|
|
self.rows.push(cells.iter().map(|c| c.to_string()).collect());
|
|
self
|
|
}
|
|
|
|
/// Render the grid to the terminal
|
|
pub fn render(self) {
|
|
let height = self.rows.len() + 4; // rows + borders + header + padding
|
|
render_widget(height, |frame| {
|
|
let area = frame.area();
|
|
|
|
// Create constraints based on number of columns
|
|
let col_count = self.headers.len().max(
|
|
self.rows.iter().map(|r| r.len()).max().unwrap_or(0)
|
|
);
|
|
let constraints = vec![Constraint::Ratio(1, col_count as u32); col_count];
|
|
|
|
let header = Row::new(self.headers.clone())
|
|
.bold()
|
|
.style(Style::default().fg(Color::Green));
|
|
|
|
let rows: Vec<Row> = self
|
|
.rows
|
|
.iter()
|
|
.map(|r| Row::new(r.clone()))
|
|
.collect();
|
|
|
|
let table = Table::new(rows, constraints)
|
|
.header(header)
|
|
.block(
|
|
Block::default()
|
|
.title(format!(" {} ", self.title))
|
|
.borders(Borders::ALL)
|
|
.border_style(Style::default().fg(Color::Cyan)),
|
|
)
|
|
.column_spacing(2);
|
|
|
|
frame.render_widget(table, area);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Internal helper to render a widget using inline viewport
|
|
fn render_widget<F>(height: usize, render_fn: F)
|
|
where
|
|
F: FnOnce(&mut Frame),
|
|
{
|
|
let mut terminal = ratatui::init_with_options(TerminalOptions {
|
|
viewport: Viewport::Inline(height as u16),
|
|
});
|
|
|
|
terminal.draw(render_fn).unwrap();
|
|
ratatui::restore();
|
|
}
|