use std::io;
use anyhow::Result;
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, List, ListItem, ListState, Padding, Paragraph, Wrap, Sparkline},
Frame, Terminal,
};
use crate::api::client::ApiClient;
use crate::config::Credentials;
use super::theme::Theme;
const TABS: &[&str] = &[
"Customers",
"Plans",
"Subscriptions",
"Invoices",
"Meters",
"Wallets",
"Features",
];
const TAB_ENDPOINTS: &[&str] = &[
"/v1/customers",
"/v1/plans",
"/v1/subscriptions",
"/v1/invoices",
"/v1/meters",
"/v1/wallets",
"/v1/features",
];
pub struct App {
client: ApiClient,
creds: Credentials,
active_tab: usize,
list_state: ListState,
data_items: Vec<String>,
detail_text: String,
loading: bool,
error: Option<String>,
should_quit: bool,
sparkline_data: Vec<u64>,
}
impl App {
pub fn new(creds: Credentials) -> Result<Self> {
let client = ApiClient::new(creds.clone())?;
let mut s = Self {
client,
creds,
active_tab: 0,
list_state: ListState::default(),
data_items: vec![],
detail_text: String::new(),
loading: false,
error: None,
should_quit: false,
sparkline_data: vec![3, 7, 2, 9, 5, 12, 8, 4, 11, 6, 14, 3, 8, 10, 5],
};
s.list_state.select(Some(0));
Ok(s)
}
fn next_tab(&mut self) {
self.active_tab = (self.active_tab + 1) % TABS.len();
self.data_items.clear();
self.detail_text.clear();
self.error = None;
self.list_state.select(Some(0));
}
fn prev_tab(&mut self) {
self.active_tab = if self.active_tab == 0 { TABS.len() - 1 } else { self.active_tab - 1 };
self.data_items.clear();
self.detail_text.clear();
self.error = None;
self.list_state.select(Some(0));
}
fn next_item(&mut self) {
if self.data_items.is_empty() { return; }
let i = self.list_state.selected().unwrap_or(0);
self.list_state.select(Some((i + 1) % self.data_items.len()));
}
fn prev_item(&mut self) {
if self.data_items.is_empty() { return; }
let i = self.list_state.selected().unwrap_or(0);
self.list_state.select(Some(if i == 0 { self.data_items.len() - 1 } else { i - 1 }));
}
}
pub async fn run(creds: Credentials) -> Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = App::new(creds)?;
load_data(&mut app).await;
loop {
terminal.draw(|f| ui(f, &mut app))?;
if event::poll(std::time::Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Press { continue; }
match key.code {
KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
KeyCode::Tab | KeyCode::Char('l') => {
app.next_tab();
load_data(&mut app).await;
}
KeyCode::BackTab | KeyCode::Char('h') => {
app.prev_tab();
load_data(&mut app).await;
}
KeyCode::Down | KeyCode::Char('j') => {
app.next_item();
update_detail(&mut app);
}
KeyCode::Up | KeyCode::Char('k') => {
app.prev_item();
update_detail(&mut app);
}
KeyCode::Char('r') => {
load_data(&mut app).await;
}
_ => {}
}
}
}
if app.should_quit {
break;
}
}
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
Ok(())
}
async fn load_data(app: &mut App) {
app.loading = true;
app.error = None;
let endpoint = TAB_ENDPOINTS[app.active_tab];
match app.client.get_text(endpoint).await {
Ok(body) => {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&body) {
if let Some(items) = json.get("items").and_then(|v| v.as_array()) {
app.data_items = items.iter().map(|item| {
let id = item.get("id").and_then(|v| v.as_str()).unwrap_or("?");
let name = item.get("name")
.or_else(|| item.get("email"))
.or_else(|| item.get("event_name"))
.and_then(|v| v.as_str())
.unwrap_or("-");
let status = item.get("status")
.or_else(|| item.get("subscription_status"))
.or_else(|| item.get("invoice_status"))
.or_else(|| item.get("wallet_status"))
.and_then(|v| v.as_str())
.unwrap_or("");
if status.is_empty() {
format!("{} {}", id, name)
} else {
format!("{} {} [{}]", id, name, status)
}
}).collect();
app.detail_text = serde_json::to_string_pretty(&json).unwrap_or_default();
} else {
app.data_items = vec!["(no items)".to_string()];
app.detail_text = serde_json::to_string_pretty(&json).unwrap_or(body);
}
} else {
app.data_items = vec!["(raw response)".to_string()];
app.detail_text = body;
}
}
Err(e) => {
app.error = Some(format!("{}", e));
app.data_items.clear();
app.detail_text.clear();
}
}
app.loading = false;
app.list_state.select(Some(0));
update_detail(app);
}
fn update_detail(app: &mut App) {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&app.detail_text) {
if let Some(items) = json.get("items").and_then(|v| v.as_array()) {
let idx = app.list_state.selected().unwrap_or(0);
if let Some(item) = items.get(idx) {
app.detail_text = serde_json::to_string_pretty(item).unwrap_or_default();
}
}
}
}
fn ui(f: &mut Frame, app: &mut App) {
let size = f.area();
f.render_widget(Block::default().style(Style::default().bg(Theme::BG)), size);
let main_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5), Constraint::Min(10), Constraint::Length(3), ])
.split(size);
render_header(f, main_layout[0], app);
render_body(f, main_layout[1], app);
render_footer(f, main_layout[2], app);
}
fn render_header(f: &mut Frame, area: Rect, app: &App) {
let header_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(30), Constraint::Length(40)])
.split(area);
let logo_text = vec![
Line::from(vec![
Span::styled(" ⚡ ", Style::default().fg(Theme::WARNING)),
Span::styled("FlexPrice", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)),
Span::styled(" Dashboard", Style::default().fg(Theme::TEXT_DIM)),
]),
Line::from(vec![
Span::styled(" Usage-based billing, visualized.", Style::default().fg(Theme::TEXT_MUTED)),
]),
];
let logo = Paragraph::new(logo_text)
.block(Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(Theme::BORDER))
.padding(Padding::new(0, 0, 1, 0))
);
f.render_widget(logo, header_layout[0]);
let info_lines = vec![
Line::from(vec![
Span::styled(" API: ", Style::default().fg(Theme::TEXT_DIM)),
Span::styled(&app.creds.api_url, Style::default().fg(Theme::ACCENT)),
]),
Line::from(vec![
Span::styled(" Auth: ", Style::default().fg(Theme::TEXT_DIM)),
Span::styled("API Key", Style::default().fg(Theme::INFO)),
]),
];
let info = Paragraph::new(info_lines)
.block(Block::default()
.borders(Borders::BOTTOM | Borders::LEFT)
.border_style(Style::default().fg(Theme::BORDER))
.padding(Padding::new(0, 0, 1, 0))
);
f.render_widget(info, header_layout[1]);
}
fn render_body(f: &mut Frame, area: Rect, app: &mut App) {
let body_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(22), Constraint::Min(30), Constraint::Min(40), ])
.split(area);
let tab_items: Vec<ListItem> = TABS.iter().enumerate().map(|(i, name)| {
let style = if i == app.active_tab {
Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Theme::TEXT_DIM)
};
let prefix = if i == app.active_tab { " ▸ " } else { " " };
ListItem::new(Line::from(vec![
Span::styled(prefix, Style::default().fg(Theme::PRIMARY)),
Span::styled(*name, style),
]))
}).collect();
let sidebar = List::new(tab_items)
.block(Block::default()
.title(Span::styled(" Navigate ", Style::default().fg(Theme::TEXT_DIM)))
.borders(Borders::RIGHT)
.border_style(Style::default().fg(Theme::BORDER))
.padding(Padding::new(1, 1, 1, 0))
);
f.render_widget(sidebar, body_layout[0]);
if app.loading {
let loading = Paragraph::new(" ⏳ Loading...")
.style(Style::default().fg(Theme::WARNING))
.block(Block::default()
.title(Span::styled(format!(" {} ", TABS[app.active_tab]), Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)))
.borders(Borders::RIGHT)
.border_style(Style::default().fg(Theme::BORDER))
.padding(Padding::new(1, 1, 1, 0))
);
f.render_widget(loading, body_layout[1]);
} else if let Some(ref err) = app.error {
let error_text = Paragraph::new(format!(" ✗ {}", err))
.style(Style::default().fg(Theme::ERROR))
.wrap(Wrap { trim: true })
.block(Block::default()
.title(Span::styled(format!(" {} ", TABS[app.active_tab]), Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)))
.borders(Borders::RIGHT)
.border_style(Style::default().fg(Theme::BORDER))
.padding(Padding::new(1, 1, 1, 0))
);
f.render_widget(error_text, body_layout[1]);
} else {
let items: Vec<ListItem> = app.data_items.iter().map(|item| {
ListItem::new(Line::from(Span::styled(format!(" {}", item), Style::default().fg(Theme::TEXT))))
}).collect();
let list = List::new(items)
.highlight_style(Style::default().fg(Theme::PRIMARY).bg(Theme::SURFACE_HOVER).add_modifier(Modifier::BOLD))
.highlight_symbol("▸ ")
.block(Block::default()
.title(Span::styled(format!(" {} ({}) ", TABS[app.active_tab], app.data_items.len()), Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)))
.borders(Borders::RIGHT)
.border_style(Style::default().fg(Theme::BORDER))
.padding(Padding::new(0, 0, 0, 0))
);
f.render_stateful_widget(list, body_layout[1], &mut app.list_state);
}
let detail_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(5)])
.split(body_layout[2]);
let detail = Paragraph::new(Text::from(app.detail_text.clone()))
.style(Style::default().fg(Theme::TEXT_DIM))
.wrap(Wrap { trim: false })
.block(Block::default()
.title(Span::styled(" Detail ", Style::default().fg(Theme::ACCENT).add_modifier(Modifier::BOLD)))
.borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER))
.padding(Padding::new(1, 1, 0, 0))
);
f.render_widget(detail, detail_layout[0]);
let sparkline = Sparkline::default()
.block(Block::default()
.title(Span::styled(" Activity ", Style::default().fg(Theme::INFO)))
.borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER))
)
.data(&app.sparkline_data)
.style(Style::default().fg(Theme::ACCENT));
f.render_widget(sparkline, detail_layout[1]);
}
fn render_footer(f: &mut Frame, area: Rect, _app: &App) {
let shortcuts = vec![
Span::styled(" ←/→ Tab", Style::default().fg(Theme::PRIMARY)),
Span::styled(" │ ", Style::default().fg(Theme::BORDER)),
Span::styled("↑/↓ Navigate", Style::default().fg(Theme::TEXT_DIM)),
Span::styled(" │ ", Style::default().fg(Theme::BORDER)),
Span::styled("r Refresh", Style::default().fg(Theme::ACCENT)),
Span::styled(" │ ", Style::default().fg(Theme::BORDER)),
Span::styled("q Quit", Style::default().fg(Theme::ERROR)),
];
let footer = Paragraph::new(Line::from(shortcuts))
.block(Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(Theme::BORDER))
.padding(Padding::new(0, 0, 0, 0))
);
f.render_widget(footer, area);
}