1066 lines
41 KiB
Rust
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(())
|
|
}
|