baobab/core/actor/src/terminal_ui.rs

1375 lines
52 KiB
Rust

//! Terminal UI module for Baobab Actor
//!
//! This module contains all the TUI logic, structures, and rendering functions
//! for the actor monitoring and job dispatch interface.
use anyhow::Result;
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind},
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
execute,
};
use hero_job::{Job, JobStatus, ScriptType};
use log::error;
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;
// Public exports for the cmd binary
#[derive(Debug, Clone, PartialEq)]
pub enum AppState {
Main,
JobCreation,
JobDetails,
ExampleSelection,
Confirmation,
}
#[derive(Debug, Clone, PartialEq)]
pub enum TabState {
Dashboard,
Jobs,
NewJob,
Examples,
}
#[derive(Debug, Clone, PartialEq)]
pub enum JobFormField {
CallerId,
ContextId,
Timeout,
ScriptContent,
}
#[derive(Debug, Clone)]
pub struct JobCreationForm {
pub caller_id: String,
pub context_id: String,
pub timeout: String,
pub script_content: String,
pub script_type: ScriptType,
pub current_field: JobFormField,
}
impl Default for JobCreationForm {
fn default() -> Self {
Self {
caller_id: String::new(),
context_id: String::new(),
timeout: "300".to_string(),
script_content: String::new(),
script_type: ScriptType::OSIS,
current_field: JobFormField::CallerId,
}
}
}
#[derive(Debug, Clone)]
pub struct ExampleScript {
pub name: String,
pub path: PathBuf,
}
#[derive(Debug, Clone)]
pub enum ExampleTreeNode {
File {
name: String,
path: PathBuf,
},
Folder {
name: String,
path: PathBuf,
children: Vec<ExampleTreeNode>,
expanded: bool,
},
}
impl ExampleTreeNode {
pub fn name(&self) -> &str {
match self {
ExampleTreeNode::File { name, .. } => name,
ExampleTreeNode::Folder { name, .. } => name,
}
}
pub fn path(&self) -> &PathBuf {
match self {
ExampleTreeNode::File { path, .. } => path,
ExampleTreeNode::Folder { path, .. } => path,
}
}
pub fn is_file(&self) -> bool {
matches!(self, ExampleTreeNode::File { .. })
}
pub fn is_folder(&self) -> bool {
matches!(self, ExampleTreeNode::Folder { .. })
}
pub fn is_expanded(&self) -> bool {
match self {
ExampleTreeNode::File { .. } => false,
ExampleTreeNode::Folder { expanded, .. } => *expanded,
}
}
pub fn toggle_expanded(&mut self) {
if let ExampleTreeNode::Folder { expanded, .. } = self {
*expanded = !*expanded;
}
}
pub fn set_expanded(&mut self, expand: bool) {
if let ExampleTreeNode::Folder { expanded, .. } = self {
*expanded = expand;
}
}
}
#[derive(Debug, Clone)]
pub struct ExampleTreeItem {
pub node: ExampleTreeNode,
pub depth: usize,
pub index: usize,
}
#[derive(Debug, Clone)]
pub struct JobInfo {
pub job: Job,
pub status: JobStatus,
pub output: Option<String>,
pub error: Option<String>,
pub created_at: Instant,
}
#[derive(Debug)]
pub struct App {
pub actor_id: String,
pub actor_path: PathBuf,
pub redis_client: redis::Client,
pub current_tab: TabState,
pub app_state: AppState,
pub should_quit: bool,
pub jobs: Vec<JobInfo>,
pub job_list_state: ListState,
pub job_form: JobCreationForm,
pub examples: Vec<ExampleScript>,
pub example_list_state: ListState,
pub selected_example: Option<ExampleScript>,
pub status_message: Option<String>,
pub last_executed_job: Option<JobInfo>,
pub last_refresh: Instant,
// New hierarchical examples system
pub example_tree: Vec<ExampleTreeNode>,
pub example_tree_items: Vec<ExampleTreeItem>,
pub example_tree_state: ListState,
pub selected_tree_item: Option<ExampleTreeItem>,
}
pub struct ActorTui;
impl ActorTui {
pub fn new() -> Self {
Self
}
}
impl App {
pub 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(),
job_form: JobCreationForm::default(),
examples: Vec::new(),
example_list_state: ListState::default(),
selected_example: None,
status_message: None,
last_executed_job: None,
last_refresh: Instant::now(),
// Initialize new hierarchical examples system
example_tree: Vec::new(),
example_tree_items: Vec::new(),
example_tree_state: ListState::default(),
selected_tree_item: None,
};
if let Some(dir) = example_dir {
if let Err(e) = app.load_examples_tree(dir) {
error!("Failed to load examples tree: {}", e);
}
}
Ok(app)
}
pub fn load_examples_tree(&mut self, dir: PathBuf) -> Result<()> {
if !dir.exists() {
return Ok(());
}
// Load hierarchical tree structure
self.example_tree = self.load_example_tree(&dir)?;
self.rebuild_tree_items();
if !self.example_tree_items.is_empty() {
self.example_tree_state.select(Some(0));
self.selected_tree_item = Some(self.example_tree_items[0].clone());
// Update selected_example for backward compatibility
if let Some(first_file) = self.find_first_file_in_tree() {
self.selected_example = Some(ExampleScript {
name: first_file.name().to_string(),
path: first_file.path().clone(),
});
}
}
Ok(())
}
fn load_example_tree(&self, dir: &PathBuf) -> Result<Vec<ExampleTreeNode>> {
let mut nodes = Vec::new();
let mut entries: Vec<_> = fs::read_dir(dir)?
.collect::<Result<Vec<_>, _>>()?;
// Sort entries: directories first, then files, both alphabetically
entries.sort_by(|a, b| {
let a_is_dir = a.path().is_dir();
let b_is_dir = b.path().is_dir();
match (a_is_dir, b_is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.file_name().cmp(&b.file_name()),
}
});
for entry in entries {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if path.is_dir() {
let children = self.load_example_tree(&path)?;
nodes.push(ExampleTreeNode::Folder {
name,
path,
children,
expanded: false, // Folders collapsed by default
});
} else if path.extension().map_or(false, |ext| ext == "rhai") {
nodes.push(ExampleTreeNode::File {
name: path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(&name)
.to_string(),
path,
});
}
}
Ok(nodes)
}
fn load_flat_examples(&mut self, dir: &PathBuf) -> Result<()> {
let mut examples = Vec::new();
self.collect_all_rhai_files(&mut examples, dir)?;
self.examples = examples;
if !self.examples.is_empty() {
self.example_list_state.select(Some(0));
if self.selected_example.is_none() {
self.selected_example = Some(self.examples[0].clone());
}
}
Ok(())
}
fn collect_all_rhai_files(&self, examples: &mut Vec<ExampleScript>, dir: &PathBuf) -> Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
self.collect_all_rhai_files(examples, &path)?;
} else if path.extension().map_or(false, |ext| ext == "rhai") {
if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
examples.push(ExampleScript {
name: name.to_string(),
path,
});
}
}
}
Ok(())
}
fn rebuild_tree_items(&mut self) {
self.example_tree_items.clear();
let mut index = 0;
let tree_clone = self.example_tree.clone();
for node in &tree_clone {
self.add_tree_items_recursive(node, 0, &mut index);
}
}
fn add_tree_items_recursive(&mut self, node: &ExampleTreeNode, depth: usize, index: &mut usize) {
// Always add the current node to the flattened list
self.example_tree_items.push(ExampleTreeItem {
node: node.clone(),
depth,
index: *index,
});
*index += 1;
// For folders, add children only if the folder is expanded
if let ExampleTreeNode::Folder { children, expanded, .. } = node {
if *expanded {
for child in children {
self.add_tree_items_recursive(child, depth + 1, index);
}
}
}
}
fn find_first_file_in_tree(&self) -> Option<&ExampleTreeNode> {
for item in &self.example_tree_items {
if item.node.is_file() {
return Some(&item.node);
}
}
None
}
pub 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> {
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") {
"Dispatched" => JobStatus::Dispatched,
"WaitingForPrerequisites" => JobStatus::WaitingForPrerequisites,
"Started" => JobStatus::Started,
"Error" => JobStatus::Error,
"Finished" => JobStatus::Finished,
_ => JobStatus::Dispatched,
};
let _output = data.get("output").cloned();
let _error = data.get("error").cloned();
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,
};
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)
}
pub async fn create_and_dispatch_job(&mut self) -> Result<()> {
let job_id = Uuid::new_v4().to_string();
let timeout_secs: u64 = self.job_form.timeout.parse().unwrap_or(300);
let job = Job {
id: job_id.clone(),
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: self.job_form.script_type.clone(),
timeout: Duration::from_secs(timeout_secs),
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(),
};
// Store job info for tracking
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);
// Store job in Redis using the proper Job::store_in_redis method
let mut conn = self.redis_client.get_multiplexed_async_connection().await?;
job.store_in_redis(&mut conn).await?;
// Add to work queue
let queue_name = format!("hero:job:actor_queue:{}", self.actor_id.to_lowercase());
let _: () = conn.lpush(&queue_name, &job_id).await?;
self.status_message = Some(format!("Job {} dispatched successfully", job_id));
// Refresh jobs to show the new job
self.refresh_jobs().await?;
Ok(())
}
pub async fn dispatch_example(&mut self, example: &ExampleScript) -> Result<()> {
let script_content = fs::read_to_string(&example.path)?;
self.job_form.script_content = script_content;
self.create_and_dispatch_job().await
}
pub fn load_example_to_new_job(&mut self, example: &ExampleScript) -> Result<()> {
let script_content = fs::read_to_string(&example.path)?;
self.job_form.script_content = script_content;
Ok(())
}
pub fn handle_char_input(&mut self, c: char) {
match self.job_form.current_field {
JobFormField::CallerId => {
self.job_form.caller_id.push(c);
}
JobFormField::ContextId => {
self.job_form.context_id.push(c);
}
JobFormField::Timeout => {
if c.is_ascii_digit() {
self.job_form.timeout.push(c);
}
}
JobFormField::ScriptContent => {
self.job_form.script_content.push(c);
}
}
}
pub fn handle_backspace(&mut self) {
match self.job_form.current_field {
JobFormField::CallerId => {
self.job_form.caller_id.pop();
}
JobFormField::ContextId => {
self.job_form.context_id.pop();
}
JobFormField::Timeout => {
self.job_form.timeout.pop();
}
JobFormField::ScriptContent => {
self.job_form.script_content.pop();
}
}
}
pub fn next_job(&mut self) {
if self.jobs.is_empty() {
return;
}
let selected = 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(selected));
}
pub fn previous_job(&mut self) {
if self.jobs.is_empty() {
return;
}
let selected = 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(selected));
}
pub fn next_example(&mut self) {
if self.example_tree_items.is_empty() {
return;
}
let i = match self.example_tree_state.selected() {
Some(i) => {
if i >= self.example_tree_items.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.example_tree_state.select(Some(i));
self.selected_tree_item = Some(self.example_tree_items[i].clone());
// Update selected_example for backward compatibility
if self.example_tree_items[i].node.is_file() {
self.selected_example = Some(ExampleScript {
name: self.example_tree_items[i].node.name().to_string(),
path: self.example_tree_items[i].node.path().clone(),
});
}
}
pub fn previous_example(&mut self) {
if self.example_tree_items.is_empty() {
return;
}
let i = match self.example_tree_state.selected() {
Some(i) => {
if i == 0 {
self.example_tree_items.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.example_tree_state.select(Some(i));
self.selected_tree_item = Some(self.example_tree_items[i].clone());
// Update selected_example for backward compatibility
if self.example_tree_items[i].node.is_file() {
self.selected_example = Some(ExampleScript {
name: self.example_tree_items[i].node.name().to_string(),
path: self.example_tree_items[i].node.path().clone(),
});
}
}
pub fn expand_selected_folder(&mut self) {
if let Some(selected_index) = self.example_tree_state.selected() {
if selected_index < self.example_tree_items.len() {
let item = &self.example_tree_items[selected_index];
if item.node.is_folder() {
let path = item.node.path().clone();
self.expand_folder_by_path(&path);
}
}
}
}
pub fn collapse_selected_folder(&mut self) {
if let Some(selected_index) = self.example_tree_state.selected() {
if selected_index < self.example_tree_items.len() {
let item = &self.example_tree_items[selected_index];
if item.node.is_folder() {
let path = item.node.path().clone();
self.collapse_folder_by_path(&path);
}
}
}
}
pub fn toggle_selected_folder(&mut self) {
if let Some(selected_index) = self.example_tree_state.selected() {
if selected_index < self.example_tree_items.len() {
let item = &self.example_tree_items[selected_index];
if item.node.is_folder() {
let path = item.node.path().clone();
let is_expanded = item.node.is_expanded();
if is_expanded {
self.collapse_folder_by_path(&path);
} else {
self.expand_folder_by_path(&path);
}
}
}
}
}
fn expand_folder_by_path(&mut self, target_path: &PathBuf) {
Self::set_folder_expanded_by_path_static(&mut self.example_tree, target_path, true);
self.rebuild_tree_items();
}
fn collapse_folder_by_path(&mut self, target_path: &PathBuf) {
Self::set_folder_expanded_by_path_static(&mut self.example_tree, target_path, false);
self.rebuild_tree_items();
}
fn set_folder_expanded_by_path_static(nodes: &mut Vec<ExampleTreeNode>, target_path: &PathBuf, expanded: bool) {
for node in nodes {
match node {
ExampleTreeNode::Folder { path, children, expanded: node_expanded, .. } => {
if path == target_path {
*node_expanded = expanded;
return;
}
Self::set_folder_expanded_by_path_static(children, target_path, expanded);
}
_ => {}
}
}
}
pub fn next_field(&mut self) {
self.job_form.current_field = match self.job_form.current_field {
JobFormField::CallerId => JobFormField::ContextId,
JobFormField::ContextId => JobFormField::Timeout,
JobFormField::Timeout => JobFormField::ScriptContent,
JobFormField::ScriptContent => JobFormField::CallerId,
};
}
pub fn previous_field(&mut self) {
self.job_form.current_field = match self.job_form.current_field {
JobFormField::CallerId => JobFormField::ScriptContent,
JobFormField::ContextId => JobFormField::CallerId,
JobFormField::Timeout => JobFormField::ContextId,
JobFormField::ScriptContent => JobFormField::Timeout,
};
}
}
/// Setup terminal and run the TUI application
pub async fn setup_and_run_tui(mut app: App) -> Result<()> {
// 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)?;
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,
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err);
}
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 Esc to quit, [ ] to switch tabs, Tab for fields, 'r' to refresh | 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 dispatched = app.jobs.len(); // Placeholder - all jobs shown as dispatched
let job_status = vec![
Line::from(vec![Span::raw(format!("Dispatched: {}", dispatched))]),
Line::from(vec![Span::raw("Waiting: 0")]),
Line::from(vec![Span::raw("Started: 0")]),
Line::from(vec![Span::raw("Finished: 0")]),
Line::from(vec![Span::raw("Error: 0")]),
];
let status_block = Paragraph::new(job_status)
.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 main_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(area);
// Job list (left side)
let jobs: Vec<ListItem> = app.jobs
.iter()
.map(|job_info| {
// Safely truncate job ID to avoid out-of-bounds panic
let job_id_display = if job_info.job.id.len() >= 8 {
&job_info.job.id[..8]
} else {
&job_info.job.id
};
let content = vec![
Line::from(vec![
Span::styled(format!("[{}] ", job_id_display), Style::default().fg(Color::Yellow)),
Span::raw(format!("Caller: {} | Context: {}", job_info.job.caller_id, job_info.job.context_id)),
]),
];
ListItem::new(content)
})
.collect();
let jobs_list = List::new(jobs)
.block(Block::default().borders(Borders::ALL).title("Jobs"))
.highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
.highlight_symbol("> ");
f.render_stateful_widget(jobs_list, main_chunks[0], &mut app.job_list_state);
// Right side: split into job details (top) and results (bottom)
let right_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
.split(main_chunks[1]);
// Job details (top right)
if let Some(selected) = app.job_list_state.selected() {
if let Some(job_info) = app.jobs.get(selected) {
let 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!("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!("Script Type: {:?}", job_info.job.script_type))]),
Line::from(vec![Span::raw(format!("Timeout: {}s", job_info.job.timeout.as_secs()))]),
Line::from(vec![Span::raw(format!("Retries: {}", job_info.job.retries))]),
Line::from(""),
Line::from(vec![Span::styled("Script:", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD))]),
Line::from(vec![Span::raw(job_info.job.script.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, right_chunks[0]);
// Job results (bottom right)
let result_content = if let Some(ref output) = job_info.output {
format!("Output:\n{}", output)
} else if let Some(ref error) = job_info.error {
format!("Error:\n{}", error)
} else {
match job_info.status {
JobStatus::Dispatched => "Job dispatched, waiting for execution...".to_string(),
JobStatus::Started => "Job is currently running...".to_string(),
JobStatus::WaitingForPrerequisites => "Waiting for prerequisites to complete...".to_string(),
_ => "No output available yet.".to_string(),
}
};
let result_color = match job_info.status {
JobStatus::Finished => Color::Green,
JobStatus::Error => Color::Red,
JobStatus::Started => Color::Yellow,
_ => Color::Gray,
};
let result_block = Paragraph::new(result_content)
.block(Block::default().borders(Borders::ALL).title("Job Results"))
.style(Style::default().fg(result_color))
.wrap(Wrap { trim: true });
f.render_widget(result_block, right_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, right_chunks[0]);
let result_placeholder = Paragraph::new("Select a job to view results")
.block(Block::default().borders(Borders::ALL).title("Job Results"))
.alignment(Alignment::Center);
f.render_widget(result_placeholder, right_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 == JobFormField::CallerId {
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 == JobFormField::ContextId {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let timeout_style = if app.job_form.current_field == JobFormField::Timeout {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let script_style = if app.job_form.current_field == JobFormField::ScriptContent {
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 last_job) = app.last_executed_job {
// Find the latest version of this job from the jobs list for real-time updates
let current_job_info = app.jobs
.iter()
.find(|job| job.job.id == last_job.job.id)
.unwrap_or(last_job); // Fallback to last_executed_job if not found in list
// 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: {}", current_job_info.job.id))]),
Line::from(vec![Span::raw(format!("Status: {:?}", current_job_info.status))]),
Line::from(vec![Span::raw(format!("Caller ID: {}", current_job_info.job.caller_id))]),
Line::from(vec![Span::raw(format!("Context ID: {}", current_job_info.job.context_id))]),
Line::from(vec![Span::raw(format!("Created: {:.1}s ago", current_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 - use current job info for real-time updates
let mut output_lines = Vec::new();
if let Some(ref output) = current_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) = current_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() {
// Show status-appropriate message based on current job status
let status_message = match current_job_info.status {
JobStatus::Dispatched => "Job dispatched, waiting for execution...",
JobStatus::Started => "Job is currently running...",
JobStatus::WaitingForPrerequisites => "Waiting for prerequisites to complete...",
JobStatus::Finished => "Job completed successfully (no output)",
JobStatus::Error => "Job failed (check error details above)",
};
output_lines.push(Line::from(vec![Span::styled(status_message, 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);
// Hierarchical example tree list
let tree_items: Vec<ListItem> = app.example_tree_items
.iter()
.map(|item| {
let indent = " ".repeat(item.depth);
let (icon, name) = match &item.node {
ExampleTreeNode::File { name, .. } => ("📄", name.as_str()),
ExampleTreeNode::Folder { name, expanded, .. } => {
if *expanded {
("📂", name.as_str())
} else {
("📁", name.as_str())
}
}
};
let display_text = format!("{}{} {}", indent, icon, name);
ListItem::new(Line::from(vec![Span::raw(display_text)]))
})
.collect();
let examples_list = List::new(tree_items)
.block(Block::default().borders(Borders::ALL).title("Example Scripts (→ expand, ← collapse)"))
.highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
.highlight_symbol("> ");
f.render_stateful_widget(examples_list, chunks[0], &mut app.example_tree_state);
// Example content
if let Some(ref example) = app.selected_example {
if let Ok(content) = fs::read_to_string(&example.path) {
let lines: Vec<Line> = content.lines().map(|line| Line::from(vec![Span::raw(line)])).collect();
let example_content = Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL).title(format!("Script: {}", example.name)))
.wrap(Wrap { trim: true });
f.render_widget(example_content, chunks[1]);
} else {
let error_msg = Paragraph::new("Failed to read example file")
.block(Block::default().borders(Borders::ALL).title("Script Content"))
.alignment(Alignment::Center);
f.render_widget(error_msg, chunks[1]);
}
} else {
let placeholder = Paragraph::new("Select an example to view its content")
.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 instructions_text = vec![
Line::from("Job Creation Mode"),
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"),
Line::from(""),
Line::from("Press Enter to create and dispatch the job."),
Line::from("Press Esc to return to main view."),
];
let instructions = Paragraph::new(instructions_text)
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title("Instructions"));
f.render_widget(instructions, area);
}
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::Esc => {
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::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::Left => {
if app.current_tab == TabState::Examples {
app.collapse_selected_folder();
}
}
KeyCode::Right => {
if app.current_tab == TabState::Examples {
app.expand_selected_folder();
}
}
KeyCode::Enter => {
if app.current_tab == TabState::Examples {
// Handle hierarchical tree navigation
if let Some(selected_index) = app.example_tree_state.selected() {
if selected_index < app.example_tree_items.len() {
let item = app.example_tree_items[selected_index].clone();
match &item.node {
ExampleTreeNode::Folder { .. } => {
// Toggle folder expand/collapse
app.toggle_selected_folder();
}
ExampleTreeNode::File { path, name, .. } => {
// Load file into New Job tab
let example = ExampleScript {
name: name.clone(),
path: path.clone(),
};
if let Err(e) = app.load_example_to_new_job(&example) {
app.status_message = Some(format!("Error loading example: {}", e));
} else {
app.current_tab = TabState::NewJob;
app.status_message = Some(format!("Loaded example '{}' into New Job tab", name));
}
}
}
}
}
} 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));
}
}
}
KeyCode::Char(c) => {
// Handle character input in New Job tab
if app.current_tab == TabState::NewJob {
app.handle_char_input(c);
}
}
KeyCode::Backspace => {
// Handle backspace in New Job tab
if app.current_tab == TabState::NewJob {
app.handle_backspace();
}
}
_ => {}
}
}
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 {
last_tick = Instant::now();
}
if app.should_quit {
break;
}
}
Ok(())
}