use std::collections::{HashSet, VecDeque};
use std::io;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style, Stylize},
symbols,
text::{Line, Span},
widgets::{
Block, BorderType, Borders, Cell, Paragraph, Row, Sparkline, Table, Wrap,
},
Frame, Terminal,
};
use super::stats::{format_credits, format_uptime, BrokerMetrics, StatsCollector, StatsResponse, TaskOfferRecord, TransactionRecord, TransactionStatus, WorkerStats};
use super::{SharedBrokerState, WorkerStatus};
mod colors {
use ratatui::style::Color;
pub const BG: Color = Color::Reset;
pub const FG: Color = Color::White;
pub const ACCENT: Color = Color::Cyan;
pub const SUCCESS: Color = Color::Green;
pub const WARNING: Color = Color::Yellow;
pub const ERROR: Color = Color::Red;
pub const MUTED: Color = Color::DarkGray;
pub const BORDER: Color = Color::DarkGray;
pub const HEADER_BG: Color = Color::Rgb(30, 30, 40);
pub const HIGHLIGHT: Color = Color::Rgb(60, 60, 80);
}
pub struct TuiApp {
state: SharedBrokerState,
stats: Arc<StatsCollector>,
tx_scroll: usize,
selected_panel: Panel,
show_help: bool,
running: Arc<AtomicBool>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum Panel {
Transactions,
Workers,
Metrics,
}
impl TuiApp {
pub fn new(state: SharedBrokerState, stats: Arc<StatsCollector>, running: Arc<AtomicBool>) -> Self {
Self {
state,
stats,
tx_scroll: 0,
selected_panel: Panel::Transactions,
show_help: false,
running,
}
}
fn handle_input(&mut self, key: KeyCode, modifiers: KeyModifiers) -> bool {
match key {
KeyCode::Char('q') | KeyCode::Esc => {
self.running.store(false, Ordering::Relaxed);
return false;
}
KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
self.running.store(false, Ordering::Relaxed);
return false;
}
KeyCode::Char('?') | KeyCode::F(1) => {
self.show_help = !self.show_help;
}
KeyCode::Tab => {
self.selected_panel = match self.selected_panel {
Panel::Transactions => Panel::Workers,
Panel::Workers => Panel::Metrics,
Panel::Metrics => Panel::Transactions,
};
}
KeyCode::Up | KeyCode::Char('k') => {
self.tx_scroll = self.tx_scroll.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
self.tx_scroll = self.tx_scroll.saturating_add(1);
}
KeyCode::Home | KeyCode::Char('g') => {
self.tx_scroll = 0;
}
KeyCode::End | KeyCode::Char('G') => {
self.tx_scroll = 0;
}
_ => {}
}
true
}
fn render(&self, frame: &mut Frame) {
let area = frame.size();
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(10), Constraint::Length(1), ])
.split(area);
self.render_header(frame, main_chunks[0]);
self.render_content(frame, main_chunks[1]);
self.render_footer(frame, main_chunks[2]);
if self.show_help {
self.render_help_overlay(frame, area);
}
}
fn render_header(&self, frame: &mut Frame, area: Rect) {
let metrics = self.get_metrics();
let mode_indicator = if metrics.local_mode {
Span::styled(" LOCAL ", Style::default().bg(colors::WARNING).fg(Color::Black).bold())
} else {
Span::styled(" P2P ", Style::default().bg(colors::ACCENT).fg(Color::Black).bold())
};
let ledger_indicator = if metrics.ledger_connected {
Span::styled(" ● ", Style::default().fg(colors::SUCCESS))
} else {
Span::styled(" ○ ", Style::default().fg(colors::WARNING))
};
let title = vec![
Span::styled(" ◆ ", Style::default().fg(colors::ACCENT).bold()),
Span::styled("Zakuro Compute Broker", Style::default().fg(colors::FG).bold()),
Span::raw(" "),
mode_indicator,
Span::raw(" "),
ledger_indicator,
Span::styled(
if metrics.ledger_connected { "PG" } else { "Local" },
Style::default().fg(colors::MUTED)
),
];
let uptime = format!(" ⏱ {} ", format_uptime(metrics.uptime_secs));
let host_port = format!("{}:{}", self.state.config.host, self.state.config.port);
let host_port_display = format!(" {}", host_port);
let tailscale_span = if let Some(ref ip) = metrics.tailscale_ip {
Span::styled(format!(" tailscale: {}", ip), Style::default().fg(colors::ACCENT))
} else {
Span::styled(" tailscale: offline", Style::default().fg(colors::MUTED))
};
let header_text = vec![
Line::from(title),
Line::from(vec![
Span::styled(host_port_display, Style::default().fg(colors::MUTED)),
tailscale_span,
Span::styled(uptime, Style::default().fg(colors::MUTED)),
]),
];
let header = Paragraph::new(header_text)
.block(Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(colors::BORDER))
.border_type(BorderType::Plain));
frame.render_widget(header, area);
}
fn render_content(&self, frame: &mut Frame, area: Rect) {
let content_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(60),
Constraint::Percentage(40),
])
.split(area);
let left_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(6),
Constraint::Min(8),
])
.split(content_chunks[0]);
self.render_task_pile(frame, left_chunks[0], &self.stats.recent_task_offers(30));
self.render_transactions(frame, left_chunks[1], &self.stats.recent_transactions(50));
let right_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(50),
Constraint::Percentage(50),
])
.split(content_chunks[1]);
self.render_workers(frame, right_chunks[0]);
self.render_metrics(frame, right_chunks[1]);
}
fn render_task_pile(&self, frame: &mut Frame, area: Rect, offers: &[TaskOfferRecord]) {
let block = Block::default()
.title(Line::from(vec![
Span::styled(" Task pile (broadcast) ", Style::default().fg(colors::FG).bold()),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(colors::BORDER))
.border_type(BorderType::Plain);
let inner = block.inner(area);
frame.render_widget(block, area);
if offers.is_empty() {
let msg = Paragraph::new("No task offers yet")
.style(Style::default().fg(colors::MUTED))
.alignment(Alignment::Center);
frame.render_widget(msg, inner);
return;
}
let header_cells = ["TIME", "TASK", "USER", "MAX ₡/hr", "BROKER"]
.iter()
.map(|h| Cell::from(*h).style(Style::default().fg(colors::MUTED).add_modifier(Modifier::BOLD)));
let header = Row::new(header_cells).height(1);
let rows: Vec<Row> = offers.iter().map(|o| {
let time = o.timestamp.format("%H:%M:%S").to_string();
let task_short = o.task_id.chars().take(10).collect::<String>();
let user_short = o.requester_user_id.chars().take(10).collect::<String>();
let price = format!("{:.2}", o.max_price_per_hour);
Row::new(vec![
Cell::from(time),
Cell::from(task_short).style(Style::default().fg(colors::ACCENT)),
Cell::from(user_short),
Cell::from(price),
Cell::from(o.source_broker.chars().take(12).collect::<String>()).style(Style::default().fg(colors::MUTED)),
])
}).collect();
let widths = [
Constraint::Length(8),
Constraint::Length(10),
Constraint::Length(10),
Constraint::Length(10),
Constraint::Min(8),
];
let table = Table::new(rows, widths).header(header);
frame.render_widget(table, inner);
}
fn render_transactions(&self, frame: &mut Frame, area: Rect, transactions: &[TransactionRecord]) {
let is_selected = self.selected_panel == Panel::Transactions;
let border_color = if is_selected { colors::ACCENT } else { colors::BORDER };
let block = Block::default()
.title(Line::from(vec![
Span::styled(" Transactions ", Style::default().fg(colors::FG).bold()),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.border_type(if is_selected { BorderType::Rounded } else { BorderType::Plain });
let inner_area = block.inner(area);
frame.render_widget(block, area);
let header_cells = ["TIME", "FROM", "WORKER", "OWNER", "PRICE", "COST", "MS"]
.iter()
.map(|h| Cell::from(*h).style(Style::default().fg(colors::MUTED).add_modifier(Modifier::BOLD)));
let header = Row::new(header_cells).height(1);
let rows: Vec<Row> = transactions.iter().enumerate().map(|(idx, tx)| {
let status_style = match tx.status {
TransactionStatus::Ok => Style::default().fg(colors::SUCCESS),
TransactionStatus::Fail => Style::default().fg(colors::ERROR),
TransactionStatus::Pending => Style::default().fg(colors::WARNING),
};
let status_icon = match tx.status {
TransactionStatus::Ok => "✓",
TransactionStatus::Fail => "✗",
TransactionStatus::Pending => "◌",
};
let time = tx.timestamp.format("%H:%M:%S").to_string();
let cost = format!("{:.6}", tx.cost);
let latency = if tx.duration_ms > 0.0 { format!("{:.0}", tx.duration_ms) } else { "-".to_string() };
let worker = tx.worker.as_deref().unwrap_or("-");
let worker_display = match (&tx.worker_pid, &tx.worker_ip) {
(Some(pid), Some(ip)) => format!("{} {}@{}", worker, pid, ip),
(Some(pid), None) => format!("{} {}", worker, pid),
(None, Some(ip)) => format!("{} @{}", worker, ip),
(None, None) => worker.to_string(),
};
let price_str = tx.price_per_hour.map(|p| format!("{:.2}", p)).unwrap_or_else(|| "-".to_string());
let owner_str = tx.owner_id.as_deref().unwrap_or("-");
let row_style = if idx % 2 == 0 {
Style::default()
} else {
Style::default().bg(Color::Rgb(25, 25, 30))
};
Row::new(vec![
Cell::from(format!("{} {}", status_icon, time)).style(status_style),
Cell::from(tx.user_id.chars().take(10).collect::<String>()).style(Style::default().fg(colors::ACCENT)),
Cell::from(worker_display.chars().take(18).collect::<String>()).style(Style::default().fg(colors::MUTED)),
Cell::from(owner_str.chars().take(10).collect::<String>()),
Cell::from(price_str),
Cell::from(cost),
Cell::from(latency),
]).style(row_style)
}).collect();
let widths = [
Constraint::Length(11),
Constraint::Length(10),
Constraint::Length(18),
Constraint::Length(10),
Constraint::Length(8),
Constraint::Length(10),
Constraint::Min(4),
];
let table = Table::new(rows, widths)
.header(header)
.highlight_style(Style::default().bg(colors::HIGHLIGHT));
frame.render_widget(table, inner_area);
}
fn render_workers(&self, frame: &mut Frame, area: Rect) {
let is_selected = self.selected_panel == Panel::Workers;
let border_color = if is_selected { colors::ACCENT } else { colors::BORDER };
let workers = self.state.workers.list();
let healthy_count = workers.iter().filter(|w| w.status == WorkerStatus::Healthy).count();
let block = Block::default()
.title(Line::from(vec![
Span::styled(" Workers ", Style::default().fg(colors::FG).bold()),
Span::styled(
format!(" {}/{} ", healthy_count, workers.len()),
Style::default().fg(colors::MUTED)
),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.border_type(if is_selected { BorderType::Rounded } else { BorderType::Plain });
let inner_area = block.inner(area);
frame.render_widget(block, area);
if workers.is_empty() {
let empty_msg = Paragraph::new("No workers connected")
.style(Style::default().fg(colors::MUTED))
.alignment(Alignment::Center);
frame.render_widget(empty_msg, inner_area);
return;
}
let rows: Vec<Row> = workers.iter().map(|w| {
let status_icon = match w.status {
WorkerStatus::Healthy => "●",
WorkerStatus::Busy => "◐",
WorkerStatus::Unhealthy => "○",
WorkerStatus::Draining => "◌",
};
let status_color = match w.status {
WorkerStatus::Healthy => colors::SUCCESS,
WorkerStatus::Busy => colors::ACCENT,
WorkerStatus::Unhealthy => colors::ERROR,
WorkerStatus::Draining => colors::WARNING,
};
let name = w.name.chars().take(15).collect::<String>();
let resources = format!(
"{}c {:.1}G",
w.resources.cpus_available as i32,
w.resources.memory_available as f64 / (1024.0 * 1024.0 * 1024.0)
);
let latency = format!("{:.0}ms", w.avg_latency_ms);
Row::new(vec![
Cell::from(status_icon).style(Style::default().fg(status_color)),
Cell::from(name),
Cell::from(resources).style(Style::default().fg(colors::MUTED)),
Cell::from(latency).style(Style::default().fg(colors::MUTED)),
])
}).collect();
let widths = [
Constraint::Length(2),
Constraint::Min(10),
Constraint::Length(10),
Constraint::Length(8),
];
let table = Table::new(rows, widths);
frame.render_widget(table, inner_area);
}
fn render_metrics(&self, frame: &mut Frame, area: Rect) {
let is_selected = self.selected_panel == Panel::Metrics;
let border_color = if is_selected { colors::ACCENT } else { colors::BORDER };
let block = Block::default()
.title(Line::from(vec![
Span::styled(" Metrics ", Style::default().fg(colors::FG).bold()),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.border_type(if is_selected { BorderType::Rounded } else { BorderType::Plain });
let inner_area = block.inner(area);
frame.render_widget(block, area);
let metrics = self.get_metrics();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(6),
Constraint::Length(3),
])
.split(inner_area);
let stats_text = vec![
Line::from(vec![
Span::styled(" Requests ", Style::default().fg(colors::MUTED)),
Span::styled(
format!("{}", metrics.total_requests),
Style::default().fg(colors::FG).bold()
),
Span::styled(
format!(" ({} OK / {} ERR)", metrics.successful_requests, metrics.failed_requests),
Style::default().fg(colors::MUTED)
),
]),
Line::from(vec![
Span::styled(" RPS ", Style::default().fg(colors::MUTED)),
Span::styled(
format!("{:.1}", metrics.requests_per_sec),
Style::default().fg(colors::ACCENT).bold()
),
]),
Line::from(vec![
Span::styled(" Latency ", Style::default().fg(colors::MUTED)),
Span::styled(
format!("{:.0}ms", metrics.avg_latency_ms),
Style::default().fg(colors::FG)
),
Span::styled(
format!(" (p95: {:.0}ms)", metrics.p95_latency_ms),
Style::default().fg(colors::MUTED)
),
]),
Line::from(vec![
Span::styled(" Credits ", Style::default().fg(colors::MUTED)),
Span::styled(
format_credits(metrics.total_credits_spent),
Style::default().fg(if metrics.local_mode { colors::MUTED } else { colors::SUCCESS })
),
Span::styled(
if metrics.local_mode { " (free mode)" } else { " spent" },
Style::default().fg(colors::MUTED)
),
]),
];
let stats_para = Paragraph::new(stats_text);
frame.render_widget(stats_para, chunks[0]);
let rps_data = self.stats.rps_history();
if !rps_data.is_empty() {
let data_u64: Vec<u64> = rps_data.iter().map(|v| (*v * 10.0) as u64).collect();
let sparkline = Sparkline::default()
.data(&data_u64)
.style(Style::default().fg(colors::ACCENT))
.bar_set(symbols::bar::NINE_LEVELS);
frame.render_widget(sparkline, chunks[1]);
}
}
fn render_footer(&self, frame: &mut Frame, area: Rect) {
let keys = vec![
("q", "Quit"),
("Tab", "Switch Panel"),
("j/k", "Scroll"),
("?", "Help"),
];
let spans: Vec<Span> = keys.iter().flat_map(|(key, desc)| {
vec![
Span::styled(format!(" {} ", key), Style::default().fg(Color::Black).bg(colors::MUTED)),
Span::styled(format!(" {} ", desc), Style::default().fg(colors::MUTED)),
Span::raw(" "),
]
}).collect();
let footer = Paragraph::new(Line::from(spans));
frame.render_widget(footer, area);
}
fn render_help_overlay(&self, frame: &mut Frame, area: Rect) {
let help_text = vec![
Line::from(""),
Line::from(vec![
Span::styled(" Keyboard Shortcuts", Style::default().fg(colors::FG).bold()),
]),
Line::from(""),
Line::from(vec![
Span::styled(" q, Esc ", Style::default().fg(colors::ACCENT)),
Span::raw("Quit"),
]),
Line::from(vec![
Span::styled(" Tab ", Style::default().fg(colors::ACCENT)),
Span::raw("Switch between panels"),
]),
Line::from(vec![
Span::styled(" j, ↓ ", Style::default().fg(colors::ACCENT)),
Span::raw("Scroll down"),
]),
Line::from(vec![
Span::styled(" k, ↑ ", Style::default().fg(colors::ACCENT)),
Span::raw("Scroll up"),
]),
Line::from(vec![
Span::styled(" g ", Style::default().fg(colors::ACCENT)),
Span::raw("Go to top"),
]),
Line::from(vec![
Span::styled(" G ", Style::default().fg(colors::ACCENT)),
Span::raw("Go to bottom"),
]),
Line::from(vec![
Span::styled(" ? ", Style::default().fg(colors::ACCENT)),
Span::raw("Toggle help"),
]),
Line::from(""),
];
let help_width = 40u16;
let help_height = help_text.len() as u16 + 2;
let x = area.width.saturating_sub(help_width) / 2;
let y = area.height.saturating_sub(help_height) / 2;
let help_area = Rect::new(x, y, help_width, help_height);
let help_block = Block::default()
.title(" Help ")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(colors::ACCENT))
.style(Style::default().bg(Color::Rgb(20, 20, 30)));
let help_para = Paragraph::new(help_text)
.block(help_block)
.wrap(Wrap { trim: false });
frame.render_widget(ratatui::widgets::Clear, help_area);
frame.render_widget(help_para, help_area);
}
fn get_metrics(&self) -> BrokerMetrics {
let workers = self.state.workers.list();
let active = workers.iter().filter(|w| w.status == WorkerStatus::Healthy).count();
self.stats.metrics(
active,
workers.len(),
self.state.is_local_mode(),
false,
self.state.own_tailscale_ip.clone(),
)
}
}
pub fn run_tui(
state: SharedBrokerState,
stats: Arc<StatsCollector>,
running: Arc<AtomicBool>,
) -> io::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 = TuiApp::new(state, stats, running.clone());
let tick_rate = Duration::from_millis(100);
let mut last_tick = Instant::now();
while running.load(Ordering::Relaxed) {
terminal.draw(|f| app.render(f))?;
let timeout = tick_rate.saturating_sub(last_tick.elapsed());
if event::poll(timeout)? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
if !app.handle_input(key.code, key.modifiers) {
break;
}
}
}
}
if last_tick.elapsed() >= tick_rate {
app.stats.tick_rps();
last_tick = Instant::now();
}
}
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
pub fn cleanup_terminal() {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
}
const MAX_STREAM_LINES: usize = 100;
struct RemoteTuiShared {
stats: Option<StatsResponse>,
last_error: Option<String>,
event_log: VecDeque<String>,
}
pub struct RemoteTuiApp {
broker_url: String,
api_key: Option<String>,
user_id: Option<String>,
shared: Arc<Mutex<RemoteTuiShared>>,
refresh_requested: Arc<AtomicBool>,
tx_scroll: usize,
selected_panel: Panel,
show_help: bool,
}
fn fetch_stats_once(
broker_url: &str,
api_key: Option<&str>,
user_id: Option<&str>,
) -> Result<StatsResponse, String> {
let base = broker_url.trim_end_matches('/');
let url = match user_id {
Some(uid) => format!("{}/stats?user={}", base, uid),
None => format!("{}/stats", base),
};
let mut req = ureq::get(&url)
.timeout(std::time::Duration::from_secs(60));
if let Some(key) = api_key {
req = req.set("Authorization", &format!("Bearer {}", key));
}
match req.call() {
Ok(response) => {
let body = response.into_string().map_err(|e| format!("Read error: {}", e))?;
serde_json::from_str(&body).map_err(|e| format!("Parse error: {}", e))
}
Err(e) => {
let msg = e.to_string();
let hint = if msg.contains("timed out") || msg.contains("timeout") {
" — retrying in background (broker may be busy with long tasks; press r to retry now)"
} else {
" — press r to retry"
};
Err(format!("{}{}", msg, hint))
}
}
}
impl RemoteTuiApp {
pub fn new(broker_url: String, api_key: Option<String>) -> Self {
let user_id = api_key.as_ref().and_then(|key| {
key.strip_prefix("zk_").and_then(|rest| {
rest.rfind('_').and_then(|pos| {
let uid = &rest[..pos];
if uid.is_empty() { None } else { Some(uid.to_string()) }
})
})
});
Self {
broker_url,
api_key,
user_id,
shared: Arc::new(Mutex::new(RemoteTuiShared {
stats: None,
last_error: None,
event_log: VecDeque::new(),
})),
refresh_requested: Arc::new(AtomicBool::new(false)),
tx_scroll: 0,
selected_panel: Panel::Transactions,
show_help: false,
}
}
fn get_state(&self) -> (Option<StatsResponse>, Option<String>, Vec<String>) {
let g = self.shared.lock().unwrap();
let log: Vec<String> = g.event_log.iter().cloned().collect();
(g.stats.clone(), g.last_error.clone(), log)
}
fn request_refresh(&self) {
self.refresh_requested.store(true, Ordering::Relaxed);
}
fn handle_input(&mut self, key: KeyCode, modifiers: KeyModifiers) -> bool {
match key {
KeyCode::Char('q') | KeyCode::Esc => {
return false;
}
KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
return false;
}
KeyCode::Char('?') | KeyCode::F(1) => {
self.show_help = !self.show_help;
}
KeyCode::Tab => {
self.selected_panel = match self.selected_panel {
Panel::Transactions => Panel::Workers,
Panel::Workers => Panel::Metrics,
Panel::Metrics => Panel::Transactions,
};
}
KeyCode::Up | KeyCode::Char('k') => {
self.tx_scroll = self.tx_scroll.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
self.tx_scroll = self.tx_scroll.saturating_add(1);
}
KeyCode::Char('r') => {
self.request_refresh();
}
_ => {}
}
true
}
fn render(&self, frame: &mut Frame) {
let area = frame.size();
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(10), Constraint::Length(1), ])
.split(area);
let (stats, last_error, event_log) = self.get_state();
self.render_header_remote(frame, main_chunks[0], &stats, &last_error);
if let Some(ref _stats) = stats {
self.render_content(frame, main_chunks[1], _stats, &event_log);
} else if let Some(ref error) = last_error {
let error_text = Paragraph::new(format!("Error: {}", error))
.style(Style::default().fg(colors::ERROR))
.alignment(Alignment::Center);
frame.render_widget(error_text, main_chunks[1]);
} else {
let loading = Paragraph::new("Connecting… (fetch runs in background, UI stays responsive)")
.style(Style::default().fg(colors::MUTED))
.alignment(Alignment::Center);
frame.render_widget(loading, main_chunks[1]);
}
self.render_footer(frame, main_chunks[2]);
if self.show_help {
self.render_help_overlay(frame, area);
}
}
fn render_header_remote(
&self,
frame: &mut Frame,
area: Rect,
stats: &Option<StatsResponse>,
last_error: &Option<String>,
) {
let (metrics, host_str) = if let Some(ref s) = *stats {
(Some(&s.metrics), format!("{}:{}", s.host, s.port))
} else {
(None, self.broker_url.clone())
};
let mode_indicator = Span::styled(
" REMOTE ",
Style::default().bg(Color::Magenta).fg(Color::Black).bold()
);
let connection_indicator = if last_error.is_some() {
Span::styled(" ● ", Style::default().fg(colors::ERROR))
} else if stats.is_some() {
Span::styled(" ● ", Style::default().fg(colors::SUCCESS))
} else {
Span::styled(" ○ ", Style::default().fg(colors::WARNING))
};
let title = vec![
Span::styled(" ◆ ", Style::default().fg(colors::ACCENT).bold()),
Span::styled("Zakuro Compute Broker", Style::default().fg(colors::FG).bold()),
Span::raw(" "),
mode_indicator,
Span::raw(" "),
connection_indicator,
Span::styled(&host_str, Style::default().fg(colors::MUTED)),
];
let uptime_str = if let Some(m) = metrics {
format!(" ⏱ {} ", format_uptime(m.uptime_secs))
} else {
String::new()
};
let tailscale_span = if let Some(ref s) = *stats {
if let Some(ref ip) = s.tailscale_ip {
Span::styled(format!(" tailscale: {}", ip), Style::default().fg(colors::ACCENT))
} else {
Span::styled(" tailscale: offline", Style::default().fg(colors::MUTED))
}
} else {
Span::styled("", Style::default())
};
let header_text = vec![
Line::from(title),
Line::from(vec![
Span::styled(
format!(" Connected to {}", host_str),
Style::default().fg(colors::MUTED)
),
tailscale_span,
Span::styled(uptime_str, Style::default().fg(colors::MUTED)),
]),
];
let header = Paragraph::new(header_text)
.block(Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(colors::BORDER))
.border_type(BorderType::Plain));
frame.render_widget(header, area);
}
fn render_content(&self, frame: &mut Frame, area: Rect, stats: &StatsResponse, event_log: &[String]) {
let content_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(60),
Constraint::Percentage(40),
])
.split(area);
let left_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(6),
Constraint::Min(6),
Constraint::Length(10),
])
.split(content_chunks[0]);
self.render_task_pile_remote(frame, left_chunks[0], &stats.task_offers, &stats.transactions);
self.render_transactions_remote(frame, left_chunks[1], &stats.transactions);
self.render_stream_remote(frame, left_chunks[2], event_log);
let right_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(50),
Constraint::Percentage(50),
])
.split(content_chunks[1]);
self.render_workers(frame, right_chunks[0], &stats.workers);
self.render_metrics(frame, right_chunks[1], &stats.metrics, &stats.rps_history);
}
fn render_task_pile_remote(&self, frame: &mut Frame, area: Rect, offers: &[TaskOfferRecord], transactions: &[TransactionRecord]) {
let block = Block::default()
.title(Line::from(vec![
Span::styled(" Task pile → lock → execute → finalized ", Style::default().fg(colors::FG).bold()),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(colors::BORDER))
.border_type(BorderType::Plain);
let inner = block.inner(area);
frame.render_widget(block, area);
if offers.is_empty() {
let msg = Paragraph::new("No offers yet — pull updates from API")
.style(Style::default().fg(colors::MUTED))
.alignment(Alignment::Center);
frame.render_widget(msg, inner);
return;
}
let header_cells = ["TIME", "TASK", "USER", "MAX ₡/hr", "STATUS", "WORKER / COST"]
.iter()
.map(|h| Cell::from(*h).style(Style::default().fg(colors::MUTED).add_modifier(Modifier::BOLD)));
let header = Row::new(header_cells).height(1);
let rows: Vec<Row> = offers.iter().map(|o| {
let time = o.timestamp.format("%H:%M:%S").to_string();
let task_short = o.task_id.chars().take(8).collect::<String>();
let user_short = o.requester_user_id.chars().take(8).collect::<String>();
let price = format!("{:.2}", o.max_price_per_hour);
let completed = transactions.iter().find(|tx| tx.request_id.as_deref() == Some(o.task_id.as_str()));
let (status_cell, trace_cell) = match completed {
Some(tx) => (
Cell::from("DONE").style(Style::default().fg(colors::SUCCESS)),
Cell::from(format!("{} {} ₡{:.4} {:.0}ms", tx.worker.as_deref().unwrap_or("-"), tx.owner_id.as_deref().unwrap_or(""), tx.cost, tx.duration_ms)).style(Style::default().fg(colors::MUTED)),
),
None => (
Cell::from("pending").style(Style::default().fg(colors::WARNING)),
Cell::from("— locked / reserve / execute…").style(Style::default().fg(colors::MUTED)),
),
};
Row::new(vec![
Cell::from(time),
Cell::from(task_short).style(Style::default().fg(colors::ACCENT)),
Cell::from(user_short),
Cell::from(price),
status_cell,
trace_cell,
])
}).collect();
let widths = [
Constraint::Length(8),
Constraint::Length(8),
Constraint::Length(8),
Constraint::Length(8),
Constraint::Length(6),
Constraint::Min(10),
];
let table = Table::new(rows, widths).header(header);
frame.render_widget(table, inner);
}
fn render_transactions_remote(&self, frame: &mut Frame, area: Rect, transactions: &[TransactionRecord]) {
let is_selected = self.selected_panel == Panel::Transactions;
let border_color = if is_selected { colors::ACCENT } else { colors::BORDER };
let block = Block::default()
.title(Line::from(vec![
Span::styled(" Transactions ", Style::default().fg(colors::FG).bold()),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.border_type(if is_selected { BorderType::Rounded } else { BorderType::Plain });
let inner_area = block.inner(area);
frame.render_widget(block, area);
let header_cells = ["TIME", "FROM", "WORKER", "OWNER", "PRICE", "COST", "MS"]
.iter()
.map(|h| Cell::from(*h).style(Style::default().fg(colors::MUTED).add_modifier(Modifier::BOLD)));
let header = Row::new(header_cells).height(1);
let rows: Vec<Row> = transactions.iter().enumerate().map(|(idx, tx)| {
let status_style = match tx.status {
TransactionStatus::Ok => Style::default().fg(colors::SUCCESS),
TransactionStatus::Fail => Style::default().fg(colors::ERROR),
TransactionStatus::Pending => Style::default().fg(colors::WARNING),
};
let status_icon = match tx.status {
TransactionStatus::Ok => "✓",
TransactionStatus::Fail => "✗",
TransactionStatus::Pending => "◌",
};
let time = tx.timestamp.format("%H:%M:%S").to_string();
let cost = format!("{:.6}", tx.cost);
let latency = if tx.duration_ms > 0.0 { format!("{:.0}", tx.duration_ms) } else { "-".to_string() };
let worker = tx.worker.as_deref().unwrap_or("-");
let worker_display = match (&tx.worker_pid, &tx.worker_ip) {
(Some(pid), Some(ip)) => format!("{} {}@{}", worker, pid, ip),
(Some(pid), None) => format!("{} {}", worker, pid),
(None, Some(ip)) => format!("{} @{}", worker, ip),
(None, None) => worker.to_string(),
};
let price_str = tx.price_per_hour.map(|p| format!("{:.2}", p)).unwrap_or_else(|| "-".to_string());
let owner_str = tx.owner_id.as_deref().unwrap_or("-");
let row_style = if idx % 2 == 0 {
Style::default()
} else {
Style::default().bg(Color::Rgb(25, 25, 30))
};
Row::new(vec![
Cell::from(format!("{} {}", status_icon, time)).style(status_style),
Cell::from(tx.user_id.chars().take(10).collect::<String>()).style(Style::default().fg(colors::ACCENT)),
Cell::from(worker_display.chars().take(18).collect::<String>()).style(Style::default().fg(colors::MUTED)),
Cell::from(owner_str.chars().take(10).collect::<String>()),
Cell::from(price_str),
Cell::from(cost),
Cell::from(latency),
]).style(row_style)
}).collect();
let widths = [
Constraint::Length(11),
Constraint::Length(10),
Constraint::Length(18),
Constraint::Length(10),
Constraint::Length(8),
Constraint::Length(10),
Constraint::Min(4),
];
let table = Table::new(rows, widths)
.header(header)
.highlight_style(Style::default().bg(colors::HIGHLIGHT));
frame.render_widget(table, inner_area);
}
fn render_stream_remote(&self, frame: &mut Frame, area: Rect, event_log: &[String]) {
let block = Block::default()
.title(Line::from(vec![
Span::styled(" Stream (results) ", Style::default().fg(colors::FG).bold()),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(colors::BORDER))
.border_type(BorderType::Plain);
let inner = block.inner(area);
frame.render_widget(block, area);
let lines: Vec<Line> = if event_log.is_empty() {
vec![Line::from(Span::styled(
"Task offers and execute results will appear here as they happen.",
Style::default().fg(colors::MUTED),
))]
} else {
let start = event_log.len().saturating_sub(inner.height as usize);
event_log[start..]
.iter()
.map(|s| Line::from(Span::raw(s.as_str())))
.collect()
};
let para = Paragraph::new(lines).wrap(Wrap { trim: false });
frame.render_widget(para, inner);
}
fn render_workers(&self, frame: &mut Frame, area: Rect, workers: &[WorkerStats]) {
let is_selected = self.selected_panel == Panel::Workers;
let border_color = if is_selected { colors::ACCENT } else { colors::BORDER };
let healthy_count = workers.iter().filter(|w| w.status == "healthy").count();
let block = Block::default()
.title(Line::from(vec![
Span::styled(" Workers ", Style::default().fg(colors::FG).bold()),
Span::styled(
format!(" {}/{} ", healthy_count, workers.len()),
Style::default().fg(colors::MUTED)
),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.border_type(if is_selected { BorderType::Rounded } else { BorderType::Plain });
let inner_area = block.inner(area);
frame.render_widget(block, area);
if workers.is_empty() {
let empty_msg = Paragraph::new("No workers connected")
.style(Style::default().fg(colors::MUTED))
.alignment(Alignment::Center);
frame.render_widget(empty_msg, inner_area);
return;
}
let rows: Vec<Row> = workers.iter().map(|w| {
let (status_icon, status_color) = match w.status.as_str() {
"healthy" => ("●", colors::SUCCESS),
"busy" => ("◐", colors::ACCENT),
"unhealthy" => ("○", colors::ERROR),
"draining" => ("◌", colors::WARNING),
_ => ("?", colors::MUTED),
};
let name = w.name.chars().take(15).collect::<String>();
let resources = format!(
"{}c {:.1}G",
w.cpus_available as i32,
w.memory_available_gib
);
let latency = format!("{:.0}ms", w.avg_latency_ms);
Row::new(vec![
Cell::from(status_icon).style(Style::default().fg(status_color)),
Cell::from(name),
Cell::from(resources).style(Style::default().fg(colors::MUTED)),
Cell::from(latency).style(Style::default().fg(colors::MUTED)),
])
}).collect();
let widths = [
Constraint::Length(2),
Constraint::Min(10),
Constraint::Length(10),
Constraint::Length(8),
];
let table = Table::new(rows, widths);
frame.render_widget(table, inner_area);
}
fn render_metrics(&self, frame: &mut Frame, area: Rect, metrics: &BrokerMetrics, rps_history: &[f64]) {
let is_selected = self.selected_panel == Panel::Metrics;
let border_color = if is_selected { colors::ACCENT } else { colors::BORDER };
let block = Block::default()
.title(Line::from(vec![
Span::styled(" Metrics ", Style::default().fg(colors::FG).bold()),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.border_type(if is_selected { BorderType::Rounded } else { BorderType::Plain });
let inner_area = block.inner(area);
frame.render_widget(block, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(6),
Constraint::Length(3),
])
.split(inner_area);
let stats_text = vec![
Line::from(vec![
Span::styled(" Requests ", Style::default().fg(colors::MUTED)),
Span::styled(
format!("{}", metrics.total_requests),
Style::default().fg(colors::FG).bold()
),
Span::styled(
format!(" ({} OK / {} ERR)", metrics.successful_requests, metrics.failed_requests),
Style::default().fg(colors::MUTED)
),
]),
Line::from(vec![
Span::styled(" RPS ", Style::default().fg(colors::MUTED)),
Span::styled(
format!("{:.1}", metrics.requests_per_sec),
Style::default().fg(colors::ACCENT).bold()
),
]),
Line::from(vec![
Span::styled(" Latency ", Style::default().fg(colors::MUTED)),
Span::styled(
format!("{:.0}ms", metrics.avg_latency_ms),
Style::default().fg(colors::FG)
),
Span::styled(
format!(" (p95: {:.0}ms)", metrics.p95_latency_ms),
Style::default().fg(colors::MUTED)
),
]),
Line::from(vec![
Span::styled(" Credits ", Style::default().fg(colors::MUTED)),
Span::styled(
format_credits(metrics.total_credits_spent),
Style::default().fg(if metrics.local_mode { colors::MUTED } else { colors::SUCCESS })
),
Span::styled(
if metrics.local_mode { " (free mode)" } else { " spent" },
Style::default().fg(colors::MUTED)
),
]),
];
let stats_para = Paragraph::new(stats_text);
frame.render_widget(stats_para, chunks[0]);
if !rps_history.is_empty() {
let data_u64: Vec<u64> = rps_history.iter().map(|v| (*v * 10.0) as u64).collect();
let sparkline = Sparkline::default()
.data(&data_u64)
.style(Style::default().fg(colors::ACCENT))
.bar_set(symbols::bar::NINE_LEVELS);
frame.render_widget(sparkline, chunks[1]);
}
}
fn render_footer(&self, frame: &mut Frame, area: Rect) {
let keys = vec![
("q", "Quit"),
("Tab", "Switch Panel"),
("j/k", "Scroll"),
("r", "Refresh"),
("?", "Help"),
];
let spans: Vec<Span> = keys.iter().flat_map(|(key, desc)| {
vec![
Span::styled(format!(" {} ", key), Style::default().fg(Color::Black).bg(colors::MUTED)),
Span::styled(format!(" {} ", desc), Style::default().fg(colors::MUTED)),
Span::raw(" "),
]
}).collect();
let footer = Paragraph::new(Line::from(spans));
frame.render_widget(footer, area);
}
fn render_help_overlay(&self, frame: &mut Frame, area: Rect) {
let help_text = vec![
Line::from(""),
Line::from(vec![
Span::styled(" Keyboard Shortcuts", Style::default().fg(colors::FG).bold()),
]),
Line::from(""),
Line::from(vec![
Span::styled(" q, Esc ", Style::default().fg(colors::ACCENT)),
Span::raw("Quit"),
]),
Line::from(vec![
Span::styled(" Tab ", Style::default().fg(colors::ACCENT)),
Span::raw("Switch between panels"),
]),
Line::from(vec![
Span::styled(" j, ↓ ", Style::default().fg(colors::ACCENT)),
Span::raw("Scroll down"),
]),
Line::from(vec![
Span::styled(" k, ↑ ", Style::default().fg(colors::ACCENT)),
Span::raw("Scroll up"),
]),
Line::from(vec![
Span::styled(" r ", Style::default().fg(colors::ACCENT)),
Span::raw("Force refresh"),
]),
Line::from(vec![
Span::styled(" ? ", Style::default().fg(colors::ACCENT)),
Span::raw("Toggle help"),
]),
Line::from(""),
];
let help_width = 40u16;
let help_height = help_text.len() as u16 + 2;
let x = area.width.saturating_sub(help_width) / 2;
let y = area.height.saturating_sub(help_height) / 2;
let help_area = Rect::new(x, y, help_width, help_height);
let help_block = Block::default()
.title(" Help ")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(colors::ACCENT))
.style(Style::default().bg(Color::Rgb(20, 20, 30)));
let help_para = Paragraph::new(help_text)
.block(help_block)
.wrap(Wrap { trim: false });
frame.render_widget(ratatui::widgets::Clear, help_area);
frame.render_widget(help_para, help_area);
}
}
pub fn run_remote_tui(broker_url: String, api_key: Option<String>) -> io::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 = RemoteTuiApp::new(broker_url.clone(), api_key.clone());
let shared = Arc::clone(&app.shared);
let refresh_requested = Arc::clone(&app.refresh_requested);
let fetch_broker_url = app.broker_url.clone();
let fetch_api_key = app.api_key.clone();
let fetch_user_id = app.user_id.clone();
crate::async_exec::spawn_detached(move || {
let fetch_interval = Duration::from_millis(400);
let poll_interval = Duration::from_millis(80);
let mut last_fetch = Instant::now();
let mut prev_stats: Option<StatsResponse> = None;
loop {
let now = Instant::now();
let should_fetch = refresh_requested.swap(false, Ordering::Relaxed)
|| now.duration_since(last_fetch) >= fetch_interval;
if should_fetch {
match fetch_stats_once(
&fetch_broker_url,
fetch_api_key.as_deref(),
fetch_user_id.as_deref(),
) {
Ok(stats) => {
let mut new_events: Vec<String> = Vec::new();
if let Some(ref prev) = prev_stats {
let prev_offer_ids: HashSet<_> =
prev.task_offers.iter().map(|o| o.task_id.as_str()).collect();
for o in &stats.task_offers {
if !prev_offer_ids.contains(o.task_id.as_str()) {
new_events.push(format!(
"{} TASK_OFFER task={} user={} max_hr={:.2}",
o.timestamp.format("%H:%M:%S"),
o.task_id.chars().take(8).collect::<String>(),
o.requester_user_id.chars().take(8).collect::<String>(),
o.max_price_per_hour,
));
}
}
let prev_tx_nums: HashSet<u64> =
prev.transactions.iter().map(|t| t.tx_num).collect();
for tx in &stats.transactions {
if !prev_tx_nums.contains(&tx.tx_num) {
let status = match tx.status {
TransactionStatus::Ok => "ok",
TransactionStatus::Fail => "fail",
TransactionStatus::Pending => "pending",
};
new_events.push(format!(
"{} EXECUTE {} {} worker={} cost={:.4} {}ms",
tx.timestamp.format("%H:%M:%S"),
status,
tx.request_id.as_deref().unwrap_or("-").chars().take(8).collect::<String>(),
tx.worker.as_deref().unwrap_or("-"),
tx.cost,
tx.duration_ms as u64,
));
}
}
}
if let Ok(mut g) = shared.lock() {
for line in new_events {
g.event_log.push_back(line);
while g.event_log.len() > MAX_STREAM_LINES {
g.event_log.pop_front();
}
}
g.stats = Some(stats.clone());
g.last_error = None;
}
prev_stats = Some(stats);
}
Err(e) => {
if let Ok(mut g) = shared.lock() {
g.last_error = Some(e);
}
}
}
last_fetch = Instant::now();
}
std::thread::sleep(poll_interval);
}
});
let tick_rate = Duration::from_millis(100);
let mut last_tick = Instant::now();
loop {
terminal.draw(|f| app.render(f))?;
let timeout = tick_rate.saturating_sub(last_tick.elapsed());
if event::poll(timeout)? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
if !app.handle_input(key.code, key.modifiers) {
break;
}
}
}
}
if last_tick.elapsed() >= tick_rate {
last_tick = Instant::now();
}
}
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}