Files
supervisor/clients/openrpc/cmd/main.rs
2025-08-27 10:07:53 +02:00

873 lines
34 KiB
Rust

//! Interactive CLI for Hero Supervisor OpenRPC Client
//!
//! This CLI provides an interactive interface to explore and test OpenRPC methods
//! with arrow key navigation, parameter input, and response display.
use clap::Parser;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
Frame, Terminal,
};
use serde_json::json;
use std::io;
use chrono;
use hero_supervisor_openrpc_client::{SupervisorClient, RunnerConfig, RunnerType, ProcessManagerType};
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "openrpc-cli")]
#[command(about = "Interactive CLI for Hero Supervisor OpenRPC")]
struct Cli {
/// OpenRPC server URL
#[arg(short, long, default_value = "http://127.0.0.1:3030")]
url: String,
}
#[derive(Debug, Clone)]
struct RpcMethod {
name: String,
description: String,
params: Vec<RpcParam>,
}
#[derive(Debug, Clone)]
struct RpcParam {
name: String,
param_type: String,
required: bool,
description: String,
}
struct App {
client: SupervisorClient,
methods: Vec<RpcMethod>,
list_state: ListState,
current_screen: Screen,
selected_method: Option<RpcMethod>,
param_inputs: Vec<String>,
current_param_index: usize,
response: Option<String>,
error_message: Option<String>,
}
#[derive(Debug, PartialEq)]
enum Screen {
MethodList,
ParamInput,
Response,
}
impl App {
async fn new(url: String) -> Result<Self, Box<dyn std::error::Error>> {
let client = SupervisorClient::new(&url)?;
// Test connection to OpenRPC server using the standard rpc.discover method
// This is the proper OpenRPC way to test server connectivity and discover available methods
let discovery_result = client.discover().await;
match discovery_result {
Ok(discovery_info) => {
println!("✓ Connected to OpenRPC server at {}", url);
if let Some(info) = discovery_info.get("info") {
if let Some(title) = info.get("title").and_then(|t| t.as_str()) {
println!(" Server: {}", title);
}
if let Some(version) = info.get("version").and_then(|v| v.as_str()) {
println!(" Version: {}", version);
}
}
}
Err(e) => {
return Err(format!("Failed to connect to OpenRPC server at {}: {}\nMake sure the supervisor is running with OpenRPC enabled.", url, e).into());
}
}
let methods = vec![
RpcMethod {
name: "list_runners".to_string(),
description: "List all registered runners".to_string(),
params: vec![],
},
RpcMethod {
name: "register_runner".to_string(),
description: "Register a new runner to the supervisor with secret authentication".to_string(),
params: vec![
RpcParam {
name: "secret".to_string(),
param_type: "String".to_string(),
required: true,
description: "Secret required for runner registration".to_string(),
},
RpcParam {
name: "name".to_string(),
param_type: "String".to_string(),
required: true,
description: "Name of the runner".to_string(),
},
RpcParam {
name: "queue".to_string(),
param_type: "String".to_string(),
required: true,
description: "Queue name for the runner to listen to".to_string(),
},
],
},
RpcMethod {
name: "run_job".to_string(),
description: "Run a job on the appropriate runner".to_string(),
params: vec![
RpcParam {
name: "secret".to_string(),
param_type: "String".to_string(),
required: true,
description: "Secret required for job execution".to_string(),
},
RpcParam {
name: "job_id".to_string(),
param_type: "String".to_string(),
required: true,
description: "Job ID".to_string(),
},
RpcParam {
name: "runner".to_string(),
param_type: "String".to_string(),
required: true,
description: "Name of the runner to execute the job".to_string(),
},
RpcParam {
name: "payload".to_string(),
param_type: "String".to_string(),
required: true,
description: "Job payload/script content".to_string(),
},
],
},
RpcMethod {
name: "remove_runner".to_string(),
description: "Remove a runner from the supervisor".to_string(),
params: vec![
RpcParam {
name: "actor_id".to_string(),
param_type: "String".to_string(),
required: true,
description: "ID of the runner to remove".to_string(),
},
],
},
RpcMethod {
name: "start_runner".to_string(),
description: "Start a specific runner".to_string(),
params: vec![
RpcParam {
name: "actor_id".to_string(),
param_type: "String".to_string(),
required: true,
description: "ID of the runner to start".to_string(),
},
],
},
RpcMethod {
name: "stop_runner".to_string(),
description: "Stop a specific runner".to_string(),
params: vec![
RpcParam {
name: "actor_id".to_string(),
param_type: "String".to_string(),
required: true,
description: "ID of the runner to stop".to_string(),
},
RpcParam {
name: "force".to_string(),
param_type: "bool".to_string(),
required: true,
description: "Whether to force stop the runner".to_string(),
},
],
},
RpcMethod {
name: "get_runner_status".to_string(),
description: "Get the status of a specific runner".to_string(),
params: vec![
RpcParam {
name: "actor_id".to_string(),
param_type: "String".to_string(),
required: true,
description: "ID of the runner".to_string(),
},
],
},
RpcMethod {
name: "get_all_runner_status".to_string(),
description: "Get status of all runners".to_string(),
params: vec![],
},
RpcMethod {
name: "start_all".to_string(),
description: "Start all runners".to_string(),
params: vec![],
},
RpcMethod {
name: "stop_all".to_string(),
description: "Stop all runners".to_string(),
params: vec![
RpcParam {
name: "force".to_string(),
param_type: "bool".to_string(),
required: true,
description: "Whether to force stop all runners".to_string(),
},
],
},
RpcMethod {
name: "get_all_status".to_string(),
description: "Get status of all components".to_string(),
params: vec![],
},
];
let mut list_state = ListState::default();
list_state.select(Some(0));
Ok(App {
client,
methods,
list_state,
current_screen: Screen::MethodList,
selected_method: None,
param_inputs: vec![],
current_param_index: 0,
response: None,
error_message: None,
})
}
fn next_method(&mut self) {
let i = match self.list_state.selected() {
Some(i) => {
if i >= self.methods.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.list_state.select(Some(i));
}
fn previous_method(&mut self) {
let i = match self.list_state.selected() {
Some(i) => {
if i == 0 {
self.methods.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.list_state.select(Some(i));
}
fn select_method(&mut self) {
if let Some(i) = self.list_state.selected() {
let method = self.methods[i].clone();
if method.params.is_empty() {
// No parameters needed, call directly
self.selected_method = Some(method);
self.current_screen = Screen::Response;
} else {
// Parameters needed, go to input screen
self.selected_method = Some(method.clone());
self.param_inputs = vec!["".to_string(); method.params.len()];
self.current_param_index = 0;
self.current_screen = Screen::ParamInput;
}
}
}
fn next_param(&mut self) {
if let Some(method) = &self.selected_method {
if self.current_param_index < method.params.len() - 1 {
self.current_param_index += 1;
}
}
}
fn previous_param(&mut self) {
if self.current_param_index > 0 {
self.current_param_index -= 1;
}
}
fn add_char_to_current_param(&mut self, c: char) {
if self.current_param_index < self.param_inputs.len() {
self.param_inputs[self.current_param_index].push(c);
}
}
fn remove_char_from_current_param(&mut self) {
if self.current_param_index < self.param_inputs.len() {
self.param_inputs[self.current_param_index].pop();
}
}
async fn execute_method(&mut self) {
if let Some(method) = &self.selected_method {
self.error_message = None;
self.response = None;
// Build parameters
let mut params = json!({});
if !method.params.is_empty() {
for (i, param) in method.params.iter().enumerate() {
let input = &self.param_inputs[i];
if input.is_empty() && param.required {
self.error_message = Some(format!("Required parameter '{}' is empty", param.name));
return;
}
if !input.is_empty() {
let value = match param.param_type.as_str() {
"bool" => {
match input.to_lowercase().as_str() {
"true" | "1" | "yes" => json!(true),
"false" | "0" | "no" => json!(false),
_ => {
self.error_message = Some(format!("Invalid boolean value for '{}': {}", param.name, input));
return;
}
}
}
"i32" | "i64" | "u32" | "u64" => {
match input.parse::<i64>() {
Ok(n) => json!(n),
Err(_) => {
self.error_message = Some(format!("Invalid number for '{}': {}", param.name, input));
return;
}
}
}
_ => json!(input),
};
if method.name == "register_runner" {
// Special handling for register_runner method
match param.name.as_str() {
"secret" => params["secret"] = value,
"name" => params["name"] = value,
"queue" => params["queue"] = value,
_ => {}
}
} else if method.name == "run_job" {
// Special handling for run_job method
match param.name.as_str() {
"secret" => params["secret"] = value,
"job_id" => params["job_id"] = value,
"runner" => params["runner"] = value,
"payload" => params["payload"] = value,
_ => {}
}
} else {
params[&param.name] = value;
}
}
}
}
// Execute the method
let result: Result<serde_json::Value, hero_supervisor_openrpc_client::ClientError> = match method.name.as_str() {
"list_runners" => {
match self.client.list_runners().await {
Ok(response) => {
match serde_json::to_value(response) {
Ok(value) => Ok(value),
Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)),
}
},
Err(e) => Err(e),
}
}
"get_all_runner_status" => {
match self.client.get_all_runner_status().await {
Ok(response) => {
match serde_json::to_value(response) {
Ok(value) => Ok(value),
Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)),
}
},
Err(e) => Err(e),
}
}
"start_all" => {
match self.client.start_all().await {
Ok(response) => {
match serde_json::to_value(response) {
Ok(value) => Ok(value),
Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)),
}
},
Err(e) => Err(e),
}
}
"get_all_status" => {
match self.client.get_all_status().await {
Ok(response) => {
match serde_json::to_value(response) {
Ok(value) => Ok(value),
Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)),
}
},
Err(e) => Err(e),
}
}
"stop_all" => {
let force = params.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
match self.client.stop_all(force).await {
Ok(response) => {
match serde_json::to_value(response) {
Ok(value) => Ok(value),
Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)),
}
},
Err(e) => Err(e),
}
}
"start_runner" => {
if let Some(actor_id) = params.get("actor_id").and_then(|v| v.as_str()) {
match self.client.start_runner(actor_id).await {
Ok(response) => {
match serde_json::to_value(response) {
Ok(value) => Ok(value),
Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)),
}
},
Err(e) => Err(e),
}
} else {
Err(hero_supervisor_openrpc_client::ClientError::from(
serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Missing actor_id parameter"))
))
}
}
"stop_runner" => {
if let (Some(actor_id), Some(force)) = (
params.get("actor_id").and_then(|v| v.as_str()),
params.get("force").and_then(|v| v.as_bool())
) {
match self.client.stop_runner(actor_id, force).await {
Ok(response) => {
match serde_json::to_value(response) {
Ok(value) => Ok(value),
Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)),
}
},
Err(e) => Err(e),
}
} else {
Err(hero_supervisor_openrpc_client::ClientError::from(
serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Missing parameters"))
))
}
}
"remove_runner" => {
if let Some(actor_id) = params.get("actor_id").and_then(|v| v.as_str()) {
match self.client.remove_runner(actor_id).await {
Ok(response) => {
match serde_json::to_value(response) {
Ok(value) => Ok(value),
Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)),
}
},
Err(e) => Err(e),
}
} else {
Err(hero_supervisor_openrpc_client::ClientError::from(
serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Missing actor_id parameter"))
))
}
}
"get_runner_status" => {
if let Some(actor_id) = params.get("actor_id").and_then(|v| v.as_str()) {
match self.client.get_runner_status(actor_id).await {
Ok(response) => {
match serde_json::to_value(response) {
Ok(value) => Ok(value),
Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)),
}
},
Err(e) => Err(e),
}
} else {
Err(hero_supervisor_openrpc_client::ClientError::from(
serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Missing actor_id parameter"))
))
}
}
"register_runner" => {
if let (Some(secret), Some(name), Some(queue)) = (
params.get("secret").and_then(|v| v.as_str()),
params.get("name").and_then(|v| v.as_str()),
params.get("queue").and_then(|v| v.as_str())
) {
match self.client.register_runner(secret, name, queue).await {
Ok(response) => {
match serde_json::to_value(response) {
Ok(value) => Ok(value),
Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)),
}
},
Err(e) => Err(e),
}
} else {
Err(hero_supervisor_openrpc_client::ClientError::from(
serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Missing required parameters: secret, name, queue"))
))
}
}
"run_job" => {
if let (Some(secret), Some(job_id), Some(runner), Some(payload)) = (
params.get("secret").and_then(|v| v.as_str()),
params.get("job_id").and_then(|v| v.as_str()),
params.get("runner").and_then(|v| v.as_str()),
params.get("payload").and_then(|v| v.as_str())
) {
// Create a job object
let job = serde_json::json!({
"id": job_id,
"caller_id": "cli_user",
"context_id": "cli_context",
"payload": payload,
"job_type": "SAL",
"runner": runner,
"timeout": 30000000000u64, // 30 seconds in nanoseconds
"env_vars": {},
"created_at": chrono::Utc::now().to_rfc3339(),
"updated_at": chrono::Utc::now().to_rfc3339()
});
match self.client.run_job(secret, job).await {
Ok(response) => {
match serde_json::to_value(response) {
Ok(value) => Ok(value),
Err(e) => Err(hero_supervisor_openrpc_client::ClientError::from(e)),
}
},
Err(e) => Err(e),
}
} else {
Err(hero_supervisor_openrpc_client::ClientError::from(
serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Missing required parameters: secret, job_id, runner, payload"))
))
}
}
_ => Err(hero_supervisor_openrpc_client::ClientError::from(
serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidInput, "Method not implemented in CLI"))
)),
};
match result {
Ok(response) => {
self.response = Some(format!("{:#}", response));
}
Err(e) => {
self.error_message = Some(format!("Error: {}", e));
}
}
self.current_screen = Screen::Response;
}
}
fn back_to_methods(&mut self) {
self.current_screen = Screen::MethodList;
self.selected_method = None;
self.param_inputs.clear();
self.current_param_index = 0;
self.response = None;
self.error_message = None;
}
}
fn ui(f: &mut Frame, app: &mut App) {
match app.current_screen {
Screen::MethodList => draw_method_list(f, app),
Screen::ParamInput => draw_param_input(f, app),
Screen::Response => draw_response(f, app),
}
}
fn draw_method_list(f: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([Constraint::Min(0)].as_ref())
.split(f.area());
let items: Vec<ListItem> = app
.methods
.iter()
.map(|method| {
let content = vec![Line::from(vec![
Span::styled(&method.name, Style::default().fg(Color::Yellow)),
Span::raw(" - "),
Span::raw(&method.description),
])];
ListItem::new(content)
})
.collect();
let items = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title("OpenRPC Methods (↑↓ to navigate, Enter to select, q to quit)"),
)
.highlight_style(
Style::default()
.bg(Color::LightGreen)
.fg(Color::Black)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(">> ");
f.render_stateful_widget(items, chunks[0], &mut app.list_state);
}
fn draw_param_input(f: &mut Frame, app: &mut App) {
if let Some(method) = &app.selected_method {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(f.area());
// Title
let title = Paragraph::new(format!("Parameters for: {}", method.name))
.block(Block::default().borders(Borders::ALL).title("Method"));
f.render_widget(title, chunks[0]);
// Parameters - create proper form layout with separate label and input areas
let param_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![Constraint::Length(5); method.params.len()])
.split(chunks[1]);
for (i, param) in method.params.iter().enumerate() {
let is_current = i == app.current_param_index;
// Split each parameter into label and input areas
let param_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Length(3)])
.split(param_chunks[i]);
// Parameter label and description
let label_style = if is_current {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let label_text = vec![
Line::from(vec![
Span::styled(&param.name, label_style),
Span::raw(if param.required { " (required)" } else { " (optional)" }),
Span::raw(format!(" [{}]", param.param_type)),
]),
Line::from(Span::styled(&param.description, Style::default().fg(Color::Gray))),
];
let label_widget = Paragraph::new(label_text)
.block(Block::default().borders(Borders::NONE));
f.render_widget(label_widget, param_layout[0]);
// Input field
let empty_string = String::new();
let input_value = app.param_inputs.get(i).unwrap_or(&empty_string);
let input_display = if is_current {
if input_value.is_empty() {
"".to_string() // Show cursor when active and empty
} else {
format!("{}", input_value) // Show cursor at end when active
}
} else {
if input_value.is_empty() {
" ".to_string() // Empty space for inactive empty fields
} else {
input_value.clone()
}
};
let input_style = if is_current {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else {
Style::default().fg(Color::White).bg(Color::DarkGray)
};
let border_style = if is_current {
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
};
let input_widget = Paragraph::new(Line::from(Span::styled(input_display, input_style)))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(if is_current { " INPUT " } else { "" }),
);
f.render_widget(input_widget, param_layout[1]);
}
// Instructions
let instructions = Paragraph::new("↑↓ to navigate params, type to edit, Enter to execute, Esc to go back")
.block(Block::default().borders(Borders::ALL).title("Instructions"));
f.render_widget(instructions, chunks[2]);
}
}
fn draw_response(f: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(f.area());
// Title
let method_name = app.selected_method.as_ref().map(|m| m.name.as_str()).unwrap_or("Unknown");
let title = Paragraph::new(format!("Response for: {}", method_name))
.block(Block::default().borders(Borders::ALL).title("Response"));
f.render_widget(title, chunks[0]);
// Response content
let content = if let Some(error) = &app.error_message {
Text::from(error.clone()).style(Style::default().fg(Color::Red))
} else if let Some(response) = &app.response {
Text::from(response.clone()).style(Style::default().fg(Color::Green))
} else {
Text::from("Executing...").style(Style::default().fg(Color::Yellow))
};
let response_widget = Paragraph::new(content)
.block(Block::default().borders(Borders::ALL))
.wrap(Wrap { trim: true });
f.render_widget(response_widget, chunks[1]);
// Instructions
let instructions = Paragraph::new("Esc to go back to methods")
.block(Block::default().borders(Borders::ALL).title("Instructions"));
f.render_widget(instructions, chunks[2]);
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
// Setup terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Create app
let mut app = match App::new(cli.url).await {
Ok(app) => app,
Err(e) => {
// Cleanup terminal before showing error
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
eprintln!("Failed to connect to OpenRPC server: {}", e);
eprintln!("Make sure the supervisor is running with OpenRPC enabled.");
std::process::exit(1);
}
};
// Main loop
loop {
terminal.draw(|f| ui(f, &mut app))?;
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
match app.current_screen {
Screen::MethodList => {
match key.code {
KeyCode::Char('q') => break,
KeyCode::Down => app.next_method(),
KeyCode::Up => app.previous_method(),
KeyCode::Enter => {
app.select_method();
// If the selected method has no parameters, execute it immediately
if let Some(method) = &app.selected_method {
if method.params.is_empty() {
app.execute_method().await;
}
}
},
_ => {}
}
}
Screen::ParamInput => {
match key.code {
KeyCode::Esc => app.back_to_methods(),
KeyCode::Up => app.previous_param(),
KeyCode::Down => app.next_param(),
KeyCode::Enter => {
app.execute_method().await;
}
KeyCode::Backspace => app.remove_char_from_current_param(),
KeyCode::Char(c) => app.add_char_to_current_param(c),
_ => {}
}
}
Screen::Response => {
match key.code {
KeyCode::Esc => app.back_to_methods(),
_ => {}
}
}
}
}
}
}
// Restore terminal
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
Ok(())
}