baobab/core/actor/cmd/terminal_ui.rs
2025-08-07 10:26:11 +02:00

1076 lines
39 KiB
Rust

//! Baobab Actor UI - Self-contained WASM UI with automatic Webdis installation
//!
//! This binary provides a complete actor monitoring and job dispatch interface
//! with automatic Webdis installation and management.
use anyhow::Result;
use clap::Parser;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use hero_job::{Job, JobStatus, ScriptType};
use log::{error, info};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap},
Frame, Terminal,
};
use redis::AsyncCommands;
use std::fs;
use std::io;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use uuid::Uuid;
#[derive(Parser)]
#[command(name = "baobab-actor-ui")]
#[command(about = "Terminal UI for Baobab Actor - Monitor and dispatch jobs to a single actor")]
struct Args {
/// Actor ID to monitor
#[arg(short, long)]
id: String,
/// Path to actor binary
#[arg(short, long)]
path: PathBuf,
/// Directory containing example .rhai scripts
#[arg(short, long)]
example_dir: Option<PathBuf>,
/// Redis URL for job queue
#[arg(short, long, default_value = "redis://localhost:6379")]
redis_url: String,
/// Enable verbose logging
#[arg(short, long)]
verbose: bool,
}
#[derive(Debug, Clone, PartialEq)]
enum AppState {
Main,
JobCreation,
JobDetails,
ExampleSelection,
Confirmation,
}
#[derive(Debug, Clone, PartialEq)]
enum TabState {
Dashboard,
Jobs,
NewJob,
Examples,
}
#[derive(Debug, Clone)]
struct JobCreationForm {
caller_id: String,
context_id: String,
script_content: String,
timeout: String,
current_field: usize, // 0=caller_id, 1=context_id, 2=timeout, 3=script
}
impl Default for JobCreationForm {
fn default() -> Self {
Self {
caller_id: "tui_user".to_string(),
context_id: "default_context".to_string(),
script_content: String::new(),
timeout: "300".to_string(),
current_field: 0,
}
}
}
#[derive(Debug, Clone)]
struct ExampleScript {
name: String,
path: PathBuf,
content: String,
}
#[derive(Debug, Clone)]
struct JobInfo {
job: Job,
status: JobStatus,
output: Option<String>,
error: Option<String>,
created_at: Instant,
}
#[derive(Debug)]
struct App {
// Core state
actor_id: String,
actor_path: PathBuf,
redis_client: redis::Client,
// UI state
current_tab: TabState,
app_state: AppState,
should_quit: bool,
// Jobs
jobs: Vec<JobInfo>,
job_list_state: ListState,
selected_job: Option<Job>,
// Job creation
job_form: JobCreationForm,
last_executed_job: Option<JobInfo>,
// Examples
examples: Vec<ExampleScript>,
example_list_state: ListState,
selected_example: Option<ExampleScript>,
// Status
status_message: Option<String>,
last_refresh: Instant,
}
pub struct ActorTui;
impl ActorTui {
pub fn new() -> Self {
Self
}
}
impl App {
fn new(actor_id: String, actor_path: PathBuf, redis_url: String, example_dir: Option<PathBuf>) -> Result<Self> {
let redis_client = redis::Client::open(redis_url)?;
let mut app = Self {
actor_id,
actor_path,
redis_client,
current_tab: TabState::Dashboard,
app_state: AppState::Main,
should_quit: false,
jobs: Vec::new(),
job_list_state: ListState::default(),
selected_job: None,
job_form: JobCreationForm::default(),
last_executed_job: None,
examples: Vec::new(),
example_list_state: ListState::default(),
selected_example: None,
status_message: None,
last_refresh: Instant::now(),
};
// Load example scripts if directory provided
if let Some(dir) = example_dir {
app.load_examples(dir)?;
}
Ok(app)
}
fn load_examples(&mut self, dir: PathBuf) -> Result<()> {
if !dir.exists() {
return Ok(());
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("rhai") {
let name = path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let content = fs::read_to_string(&path)
.unwrap_or_else(|_| "// Failed to read script".to_string());
self.examples.push(ExampleScript {
name,
path,
content,
});
}
}
Ok(())
}
async fn refresh_jobs(&mut self) -> Result<()> {
let mut conn = self.redis_client.get_multiplexed_async_connection().await?;
// Get all job keys for this actor
let pattern = format!("hero:job:*");
let keys: Vec<String> = conn.keys(pattern).await?;
self.jobs.clear();
for key in keys {
if let Ok(job_data) = conn.hgetall::<String, std::collections::HashMap<String, String>>(key).await {
// Filter jobs by script_type field (e.g., "OSIS" for OSIS actor)
if let Some(script_type) = job_data.get("script_type") {
if script_type.to_uppercase() == self.actor_id.to_uppercase() {
if let Ok(job) = self.parse_job_from_redis(&job_data) {
let status = match job_data.get("status").map(|s| s.as_str()).unwrap_or("dispatched") {
"started" => JobStatus::Started,
"finished" => JobStatus::Finished,
"error" => JobStatus::Error,
"waiting_for_prerequisites" => JobStatus::WaitingForPrerequisites,
_ => JobStatus::Dispatched,
};
let output = job_data.get("output").cloned();
let error = job_data.get("error").cloned();
let job_info = JobInfo {
job,
status,
output,
error,
created_at: Instant::now(),
};
self.jobs.push(job_info);
}
}
}
}
}
// Sort jobs by creation time (newest first)
self.jobs.sort_by(|a, b| b.created_at.cmp(&a.created_at));
self.last_refresh = Instant::now();
Ok(())
}
fn parse_job_from_redis(&self, data: &std::collections::HashMap<String, String>) -> Result<Job> {
// Use actual camelCase field names from Redis
let id = data.get("jobId").unwrap_or(&String::new()).clone();
let caller_id = data.get("callerId").unwrap_or(&String::new()).clone();
let context_id = data.get("contextId").unwrap_or(&String::new()).clone();
let script = data.get("script").unwrap_or(&String::new()).clone();
let timeout_secs = data.get("timeout").unwrap_or(&"300".to_string()).parse().unwrap_or(300u64);
let timeout = Duration::from_secs(timeout_secs);
let retries = data.get("retries").map_or(0u8, |v| v.parse().unwrap_or(0));
let status = match data.get("status").map(|s| s.as_str()).unwrap_or("dispatched") {
"started" => JobStatus::Started,
"finished" => JobStatus::Finished,
"error" => JobStatus::Error,
"waiting_for_prerequisites" => JobStatus::WaitingForPrerequisites,
_ => JobStatus::Dispatched,
};
let output = data.get("output").cloned();
let error = data.get("error").cloned();
// Parse script_type from Redis data
let script_type = match data.get("script_type").map(|s| s.as_str()).unwrap_or("OSIS") {
"OSIS" => ScriptType::OSIS,
"SAL" => ScriptType::SAL,
"V" => ScriptType::V,
"Python" => ScriptType::Python,
_ => ScriptType::OSIS, // Default
};
let job = Job {
id,
caller_id,
context_id,
script,
script_type,
timeout,
retries,
concurrent: false,
log_path: None,
env_vars: std::collections::HashMap::new(),
prerequisites: Vec::new(),
dependents: Vec::new(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
Ok(job)
}
async fn create_and_dispatch_job(&mut self) -> Result<()> {
let job_id = format!("job_{}", chrono::Utc::now().timestamp_millis());
let timeout_secs = self.job_form.timeout.parse().unwrap_or(300u64);
let timeout = Duration::from_secs(timeout_secs);
let job = Job {
id: Uuid::new_v4().to_string(),
caller_id: self.job_form.caller_id.clone(),
context_id: self.job_form.context_id.clone(),
script: self.job_form.script_content.clone(),
script_type: ScriptType::OSIS, // Use OSIS for this actor
timeout,
retries: 0,
concurrent: false,
log_path: None,
env_vars: std::collections::HashMap::new(),
prerequisites: Vec::new(),
dependents: Vec::new(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let mut conn = self.redis_client.get_multiplexed_async_connection().await?;
// Store job in Redis
let job_key = format!("hero:job:{}", job.id);
let _: () = conn.hset_multiple(&job_key, &[
("id", &job.id),
("caller_id", &job.caller_id),
("context_id", &job.context_id),
("script", &job.script),
("timeout", &job.timeout.as_secs().to_string()),
("retries", &job.retries.to_string()),
("status", &"dispatched".to_string()),
("actor_id", &self.actor_id), // Store actor_id separately for filtering
]).await?;
// Add to work queue
let queue_key = format!("hero:work_queue:{}", self.actor_id);
let _: () = conn.lpush(&queue_key, &job_id).await?;
// Track this job for display in the right pane
let job_info = JobInfo {
job: job.clone(),
status: JobStatus::Dispatched,
output: None,
error: None,
created_at: Instant::now(),
};
self.last_executed_job = Some(job_info);
self.status_message = Some(format!("Job {} dispatched successfully!", job_id));
self.refresh_jobs().await?;
Ok(())
}
async fn dispatch_example(&mut self, example: &ExampleScript) -> Result<()> {
self.job_form.script_content = example.content.clone();
self.create_and_dispatch_job().await
}
fn next_job(&mut self) {
if self.jobs.is_empty() {
return;
}
let i = match self.job_list_state.selected() {
Some(i) => {
if i >= self.jobs.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.job_list_state.select(Some(i));
if let Some(job_info) = self.jobs.get(i) {
self.selected_job = Some(job_info.job.clone());
}
}
fn previous_job(&mut self) {
if self.jobs.is_empty() {
return;
}
let i = match self.job_list_state.selected() {
Some(i) => {
if i == 0 {
self.jobs.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.job_list_state.select(Some(i));
if let Some(job_info) = self.jobs.get(i) {
self.selected_job = Some(job_info.job.clone());
}
}
fn next_example(&mut self) {
if self.examples.is_empty() {
return;
}
let i = match self.example_list_state.selected() {
Some(i) => {
if i >= self.examples.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.example_list_state.select(Some(i));
if let Some(example) = self.examples.get(i) {
self.selected_example = Some(example.clone());
}
}
fn previous_example(&mut self) {
if self.examples.is_empty() {
return;
}
let i = match self.example_list_state.selected() {
Some(i) => {
if i == 0 {
self.examples.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.example_list_state.select(Some(i));
if let Some(example) = self.examples.get(i) {
self.selected_example = Some(example.clone());
}
}
fn handle_char_input(&mut self, c: char) {
match self.job_form.current_field {
0 => self.job_form.caller_id.push(c),
1 => self.job_form.context_id.push(c),
2 => self.job_form.timeout.push(c),
3 => self.job_form.script_content.push(c),
_ => {}
}
}
fn handle_backspace(&mut self) {
match self.job_form.current_field {
0 => { self.job_form.caller_id.pop(); }
1 => { self.job_form.context_id.pop(); }
2 => { self.job_form.timeout.pop(); }
3 => { self.job_form.script_content.pop(); }
_ => {}
}
}
fn next_field(&mut self) {
self.job_form.current_field = (self.job_form.current_field + 1) % 4;
}
fn previous_field(&mut self) {
self.job_form.current_field = if self.job_form.current_field == 0 { 3 } else { self.job_form.current_field - 1 };
}
}
async fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut app: App) -> Result<()> {
let mut last_tick = Instant::now();
let tick_rate = Duration::from_millis(250);
loop {
terminal.draw(|f| ui(f, &mut app))?;
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or_else(|| Duration::from_secs(0));
if crossterm::event::poll(timeout)? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
match app.app_state {
AppState::Main => {
match key.code {
KeyCode::Char('q') => {
app.should_quit = true;
}
KeyCode::Char('1') => app.current_tab = TabState::Dashboard,
KeyCode::Char('2') => app.current_tab = TabState::Jobs,
KeyCode::Char('3') => app.current_tab = TabState::NewJob,
KeyCode::Char('4') => app.current_tab = TabState::Examples,
KeyCode::Char('[') => {
app.current_tab = match app.current_tab {
TabState::Dashboard => TabState::Examples,
TabState::Jobs => TabState::Dashboard,
TabState::NewJob => TabState::Jobs,
TabState::Examples => TabState::NewJob,
};
}
KeyCode::Char(']') => {
app.current_tab = match app.current_tab {
TabState::Dashboard => TabState::Jobs,
TabState::Jobs => TabState::NewJob,
TabState::NewJob => TabState::Examples,
TabState::Examples => TabState::Dashboard,
};
}
KeyCode::Tab => {
// Tab key now handles field navigation within tabs
if app.current_tab == TabState::NewJob {
app.next_field();
}
}
KeyCode::BackTab => {
// Shift+Tab for reverse field navigation
if app.current_tab == TabState::NewJob {
app.previous_field();
}
}
KeyCode::Char('r') => {
if let Err(e) = app.refresh_jobs().await {
app.status_message = Some(format!("Error refreshing jobs: {}", e));
}
}
KeyCode::Char('n') => {
app.app_state = AppState::JobCreation;
}
KeyCode::Down => {
match app.current_tab {
TabState::Jobs => app.next_job(),
TabState::Examples => app.next_example(),
_ => {}
}
}
KeyCode::Up => {
match app.current_tab {
TabState::Jobs => app.previous_job(),
TabState::Examples => app.previous_example(),
_ => {}
}
}
KeyCode::Enter => {
if app.current_tab == TabState::Examples {
if let Some(example) = app.selected_example.clone() {
if let Err(e) = app.dispatch_example(&example).await {
app.status_message = Some(format!("Error dispatching example: {}", e));
}
}
} else if app.current_tab == TabState::NewJob {
if let Err(e) = app.create_and_dispatch_job().await {
app.status_message = Some(format!("Error creating job: {}", e));
}
}
}
_ => {}
}
}
AppState::JobCreation => {
match key.code {
KeyCode::Esc => {
app.app_state = AppState::Main;
}
KeyCode::Enter => {
if let Err(e) = app.create_and_dispatch_job().await {
app.status_message = Some(format!("Error creating job: {}", e));
}
}
KeyCode::Char(c) => {
app.handle_char_input(c);
}
KeyCode::Backspace => {
app.handle_backspace();
}
_ => {}
}
}
_ => {
if key.code == KeyCode::Esc {
app.app_state = AppState::Main;
}
}
}
}
}
}
if last_tick.elapsed() >= tick_rate {
// Auto-refresh jobs every few seconds
if app.last_refresh.elapsed() >= Duration::from_secs(5) {
if let Err(e) = app.refresh_jobs().await {
app.status_message = Some(format!("Auto-refresh error: {}", e));
}
}
last_tick = Instant::now();
}
if app.should_quit {
break;
}
}
Ok(())
}
fn ui(f: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)])
.split(f.area());
// Title
let title = Paragraph::new(format!("Baobab Actor UI - Actor: {}", app.actor_id))
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
f.render_widget(title, chunks[0]);
// Main content area
match app.app_state {
AppState::Main => render_main_view(f, app, chunks[1]),
AppState::JobCreation => render_job_creation(f, app, chunks[1]),
_ => render_main_view(f, app, chunks[1]),
}
// Status bar
let status_text = if let Some(ref msg) = app.status_message {
msg.clone()
} else {
format!("Jobs: {} | Press 'q' to quit, Tab/1-4 to switch tabs, 'r' to refresh, 'n' for new job | Last refresh: {:.1}s ago",
app.jobs.len(), app.last_refresh.elapsed().as_secs_f32())
};
let status = Paragraph::new(status_text)
.style(Style::default().fg(Color::White))
.alignment(Alignment::Left)
.block(Block::default().borders(Borders::ALL));
f.render_widget(status, chunks[2]);
}
fn render_main_view(f: &mut Frame, app: &mut App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
// Tabs
let tab_titles: Vec<Line> = vec!["Dashboard", "Jobs", "New Job", "Examples"]
.iter()
.cloned()
.map(Line::from)
.collect();
let selected_tab = match app.current_tab {
TabState::Dashboard => 0,
TabState::Jobs => 1,
TabState::NewJob => 2,
TabState::Examples => 3,
};
let tabs = Tabs::new(tab_titles)
.block(Block::default().borders(Borders::ALL).title("Tabs (Use [ ] to navigate, Tab for fields, q to quit)"))
.style(Style::default().fg(Color::White))
.highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
.select(selected_tab);
f.render_widget(tabs, chunks[0]);
// Tab content
match app.current_tab {
TabState::Dashboard => render_dashboard(f, app, chunks[1]),
TabState::Jobs => render_jobs(f, app, chunks[1]),
TabState::NewJob => render_new_job(f, app, chunks[1]),
TabState::Examples => render_examples(f, app, chunks[1]),
}
}
fn render_dashboard(f: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
// Actor info
let actor_info = vec![
Line::from(vec![Span::raw(format!("Actor ID: {}", app.actor_id))]),
Line::from(vec![Span::raw(format!("Actor Path: {}", app.actor_path.display()))]),
Line::from(vec![Span::raw(format!("Total Jobs: {}", app.jobs.len()))]),
Line::from(vec![Span::raw(format!("Examples Loaded: {}", app.examples.len()))]),
];
let actor_block = Paragraph::new(actor_info)
.block(Block::default().borders(Borders::ALL).title("Actor Information"))
.wrap(Wrap { trim: true });
f.render_widget(actor_block, chunks[0]);
// Job status summary
let mut dispatched = 0;
let mut waiting = 0;
let mut started = 0;
let mut finished = 0;
let mut error = 0;
for job_info in &app.jobs {
match job_info.status {
JobStatus::Dispatched => dispatched += 1,
JobStatus::WaitingForPrerequisites => waiting += 1,
JobStatus::Started => started += 1,
JobStatus::Finished => finished += 1,
JobStatus::Error => error += 1,
}
}
let status_info = vec![
Line::from(vec![Span::styled(format!("Dispatched: {}", dispatched), Style::default().fg(Color::Yellow))]),
Line::from(vec![Span::styled(format!("Waiting: {}", waiting), Style::default().fg(Color::Cyan))]),
Line::from(vec![Span::styled(format!("Started: {}", started), Style::default().fg(Color::Blue))]),
Line::from(vec![Span::styled(format!("Finished: {}", finished), Style::default().fg(Color::Green))]),
Line::from(vec![Span::styled(format!("Error: {}", error), Style::default().fg(Color::Red))]),
];
let status_block = Paragraph::new(status_info)
.block(Block::default().borders(Borders::ALL).title("Job Status Summary"))
.wrap(Wrap { trim: true });
f.render_widget(status_block, chunks[1]);
}
fn render_jobs(f: &mut Frame, app: &mut App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(area);
// Job list
let jobs: Vec<ListItem> = app.jobs
.iter()
.map(|job_info| {
let status_color = match job_info.status {
JobStatus::Dispatched => Color::Yellow,
JobStatus::WaitingForPrerequisites => Color::Cyan,
JobStatus::Started => Color::Blue,
JobStatus::Finished => Color::Green,
JobStatus::Error => Color::Red,
};
let content = format!("{} - {:?} - {:.1}s ago",
job_info.job.id,
job_info.status,
job_info.created_at.elapsed().as_secs_f32());
ListItem::new(Line::from(Span::styled(content, Style::default().fg(status_color))))
})
.collect();
let jobs_list = List::new(jobs)
.block(Block::default().borders(Borders::ALL).title("Jobs"))
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.highlight_symbol("> ");
f.render_stateful_widget(jobs_list, chunks[0], &mut app.job_list_state);
// Job details
if let Some(selected_idx) = app.job_list_state.selected() {
if let Some(job_info) = app.jobs.get(selected_idx) {
let mut details = vec![
Line::from(vec![Span::raw(format!("ID: {}", job_info.job.id))]),
Line::from(vec![Span::raw(format!("Status: {:?}", job_info.status))]),
Line::from(vec![Span::raw(format!("Timeout: {:?}", job_info.job.timeout))]),
Line::from(vec![Span::raw("Script:")]),
Line::from(vec![Span::raw(job_info.job.script.clone())]),
];
if let Some(ref output) = job_info.output {
details.push(Line::from(vec![Span::raw("Output:")]));
details.push(Line::from(vec![Span::raw(output.clone())]));
}
if let Some(ref error) = job_info.error {
details.push(Line::from(vec![Span::raw("Error:")]));
details.push(Line::from(vec![Span::raw(error.clone())]));
}
let details_block = Paragraph::new(details)
.block(Block::default().borders(Borders::ALL).title("Job Details"))
.wrap(Wrap { trim: true });
f.render_widget(details_block, chunks[1]);
}
} else {
let placeholder = Paragraph::new("Select a job to view details")
.block(Block::default().borders(Borders::ALL).title("Job Details"))
.alignment(Alignment::Center);
f.render_widget(placeholder, chunks[1]);
}
}
fn render_new_job(f: &mut Frame, app: &App, area: Rect) {
// Split into left (job config) and right (job output) panes
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
// Left pane: Job configuration form
render_job_config_form(f, app, chunks[0]);
// Right pane: Job execution results
render_job_output(f, app, chunks[1]);
}
fn render_job_config_form(f: &mut Frame, app: &App, area: Rect) {
// Split form into 4 sections for the 4 fields
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // caller_id
Constraint::Length(3), // context_id
Constraint::Length(3), // timeout
Constraint::Min(4), // script (takes remaining space)
])
.split(area);
// Field styles based on current selection
let caller_id_style = if app.job_form.current_field == 0 {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let context_id_style = if app.job_form.current_field == 1 {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let timeout_style = if app.job_form.current_field == 2 {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let script_style = if app.job_form.current_field == 3 {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
// Render form fields
let caller_id_input = Paragraph::new(app.job_form.caller_id.clone())
.block(Block::default().borders(Borders::ALL).title("Caller ID").border_style(caller_id_style))
.wrap(Wrap { trim: true });
f.render_widget(caller_id_input, chunks[0]);
let context_id_input = Paragraph::new(app.job_form.context_id.clone())
.block(Block::default().borders(Borders::ALL).title("Context ID").border_style(context_id_style))
.wrap(Wrap { trim: true });
f.render_widget(context_id_input, chunks[1]);
let timeout_input = Paragraph::new(app.job_form.timeout.clone())
.block(Block::default().borders(Borders::ALL).title("Timeout (seconds)").border_style(timeout_style))
.wrap(Wrap { trim: true });
f.render_widget(timeout_input, chunks[2]);
let script_input = Paragraph::new(app.job_form.script_content.clone())
.block(Block::default().borders(Borders::ALL).title("Script Content (Press Enter to execute)").border_style(script_style))
.wrap(Wrap { trim: true });
f.render_widget(script_input, chunks[3]);
}
fn render_job_output(f: &mut Frame, app: &App, area: Rect) {
if let Some(ref job_info) = app.last_executed_job {
// Split into header and output sections
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(6), // Job header
Constraint::Min(4), // Output content
])
.split(area);
// Job header with ID, status, created_at
let header_lines = vec![
Line::from(vec![Span::raw(format!("Job ID: {}", job_info.job.id))]),
Line::from(vec![Span::raw(format!("Status: {:?}", job_info.status))]),
Line::from(vec![Span::raw(format!("Caller ID: {}", job_info.job.caller_id))]),
Line::from(vec![Span::raw(format!("Context ID: {}", job_info.job.context_id))]),
Line::from(vec![Span::raw(format!("Created: {:.1}s ago", job_info.created_at.elapsed().as_secs_f32()))]),
];
let header_block = Paragraph::new(header_lines)
.block(Block::default().borders(Borders::ALL).title("Job Details"))
.wrap(Wrap { trim: true });
f.render_widget(header_block, chunks[0]);
// Output content
let mut output_lines = Vec::new();
if let Some(ref output) = job_info.output {
output_lines.push(Line::from(vec![Span::styled("Output:", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD))]));
for line in output.lines() {
output_lines.push(Line::from(vec![Span::raw(line)]));
}
}
if let Some(ref error) = job_info.error {
if !output_lines.is_empty() {
output_lines.push(Line::from(""));
}
output_lines.push(Line::from(vec![Span::styled("Error:", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD))]));
for line in error.lines() {
output_lines.push(Line::from(vec![Span::styled(line, Style::default().fg(Color::Red))]));
}
}
if output_lines.is_empty() {
output_lines.push(Line::from(vec![Span::styled("Job is running... No output yet.", Style::default().fg(Color::Yellow))]));
}
let output_block = Paragraph::new(output_lines)
.block(Block::default().borders(Borders::ALL).title("Job Output"))
.wrap(Wrap { trim: true });
f.render_widget(output_block, chunks[1]);
} else {
// No job executed yet
let placeholder_text = vec![
Line::from("No job executed yet."),
Line::from(""),
Line::from("Configure a job in the left pane and press Enter to execute."),
Line::from(""),
Line::from("Use Tab to navigate between fields:"),
Line::from("• Caller ID"),
Line::from("• Context ID"),
Line::from("• Timeout (seconds)"),
Line::from("• Script Content"),
];
let placeholder_block = Paragraph::new(placeholder_text)
.block(Block::default().borders(Borders::ALL).title("Job Output"))
.alignment(Alignment::Center);
f.render_widget(placeholder_block, area);
}
}
fn render_examples(f: &mut Frame, app: &mut App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(area);
// Example list
let examples: Vec<ListItem> = app.examples
.iter()
.map(|example| {
ListItem::new(Line::from(Span::raw(&example.name)))
})
.collect();
let examples_list = List::new(examples)
.block(Block::default().borders(Borders::ALL).title("Example Scripts"))
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.highlight_symbol("> ");
f.render_stateful_widget(examples_list, chunks[0], &mut app.example_list_state);
// Example content
if let Some(ref example) = app.selected_example {
let content = Paragraph::new(example.content.clone())
.block(Block::default().borders(Borders::ALL).title(format!("Script: {}", example.name)))
.wrap(Wrap { trim: true });
f.render_widget(content, chunks[1]);
} else {
let placeholder = Paragraph::new("Select an example to view content\nPress Enter to dispatch selected example")
.block(Block::default().borders(Borders::ALL).title("Script Content"))
.alignment(Alignment::Center);
f.render_widget(placeholder, chunks[1]);
}
}
fn render_job_creation(f: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(10), Constraint::Min(0), Constraint::Length(3)])
.split(area);
// Form fields
let script_style = if app.job_form.current_field == 0 {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
let timeout_style = if app.job_form.current_field == 2 {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
let script_input = Paragraph::new(app.job_form.script_content.clone())
.style(script_style)
.block(Block::default().borders(Borders::ALL).title("Script Content"))
.wrap(Wrap { trim: true });
f.render_widget(script_input, chunks[0]);
let timeout_input = Paragraph::new(app.job_form.timeout.clone())
.style(timeout_style)
.block(Block::default().borders(Borders::ALL).title("Timeout (seconds)"));
f.render_widget(timeout_input, chunks[2]);
// Instructions
let instructions = Paragraph::new("Tab: Next field | Enter: Create job | Esc: Cancel")
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title("Instructions"));
f.render_widget(instructions, chunks[2]);
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
// Initialize logging
if args.verbose {
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Debug)
.init();
} else {
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Info)
.init();
}
info!("🚀 Starting Baobab Actor TUI...");
info!("Actor ID: {}", args.id);
info!("Actor Path: {}", args.path.display());
info!("Redis URL: {}", args.redis_url);
if let Some(ref example_dir) = args.example_dir {
info!("Example Directory: {}", example_dir.display());
}
// Create app
let mut app = App::new(args.id, args.path, args.redis_url, args.example_dir)?;
// Initial job refresh
if let Err(e) = app.refresh_jobs().await {
error!("Failed to refresh jobs: {}", e);
}
// Setup terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Run the app
let res = run_app(&mut terminal, app).await;
// Restore terminal
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err);
}
Ok(())
}