1076 lines
39 KiB
Rust
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(())
|
|
}
|