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

1066 lines
41 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, 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;
// 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 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,
}
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(),
};
if let Some(dir) = example_dir {
if let Err(e) = app.load_examples(dir) {
error!("Failed to load examples: {}", e);
}
}
Ok(app)
}
pub 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.is_file() && path.extension().map_or(false, |ext| ext == "rhai") {
if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
self.examples.push(ExampleScript {
name: name.to_string(),
path,
});
}
}
}
if !self.examples.is_empty() {
self.example_list_state.select(Some(0));
self.selected_example = Some(self.examples[0].clone());
}
Ok(())
}
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.examples.is_empty() {
return;
}
let selected = 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(selected));
self.selected_example = Some(self.examples[selected].clone());
}
pub fn previous_example(&mut self) {
if self.examples.is_empty() {
return;
}
let selected = 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(selected));
self.selected_example = Some(self.examples[selected].clone());
}
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);
// Example list
let examples: Vec<ListItem> = app.examples
.iter()
.map(|example| {
ListItem::new(Line::from(vec![Span::raw(example.name.clone())]))
})
.collect();
let examples_list = List::new(examples)
.block(Block::default().borders(Borders::ALL).title("Example Scripts"))
.highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
.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 {
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::Enter => {
if app.current_tab == TabState::Examples {
if let Some(example) = app.selected_example.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", example.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(())
}