initial commit
This commit is contained in:
2
clients/admin-ui/.gitignore
vendored
Normal file
2
clients/admin-ui/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
dist
|
||||
target
|
3347
clients/admin-ui/Cargo.lock
generated
Normal file
3347
clients/admin-ui/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
clients/admin-ui/Cargo.toml
Normal file
29
clients/admin-ui/Cargo.toml
Normal file
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "supervisor-admin-ui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
yew = { version = "0.21", features = ["csr"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = [
|
||||
"console",
|
||||
"Document",
|
||||
"Element",
|
||||
"HtmlElement",
|
||||
"Window",
|
||||
] }
|
||||
js-sys = "0.3"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
gloo = { version = "0.11", features = ["console", "timers", "futures"] }
|
||||
log = "0.4"
|
||||
wasm-logger = "0.2"
|
||||
uuid = { version = "1.0", features = ["v4", "js"] }
|
||||
|
||||
# Use our new WASM OpenRPC client
|
||||
hero-supervisor-openrpc-client = { path = "../clients/openrpc" }
|
16
clients/admin-ui/Trunk.toml
Normal file
16
clients/admin-ui/Trunk.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[build]
|
||||
target = "index.html"
|
||||
dist = "dist"
|
||||
|
||||
[watch]
|
||||
watch = ["src", "index.html", "styles.css"]
|
||||
|
||||
[serve]
|
||||
address = "127.0.0.1"
|
||||
port = 8080
|
||||
open = false
|
||||
|
||||
[[hooks]]
|
||||
stage = "pre_build"
|
||||
command = "echo"
|
||||
command_arguments = ["Building Supervisor Admin UI..."]
|
13
clients/admin-ui/index.html
Normal file
13
clients/admin-ui/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Hero Supervisor</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link data-trunk rel="css" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
630
clients/admin-ui/src/app.rs
Normal file
630
clients/admin-ui/src/app.rs
Normal file
@@ -0,0 +1,630 @@
|
||||
use yew::prelude::*;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use gloo::console;
|
||||
use hero_supervisor_openrpc_client::wasm::{WasmSupervisorClient, WasmJob};
|
||||
use gloo::timers::callback::Interval;
|
||||
|
||||
|
||||
use crate::sidebar::{Sidebar, SupervisorInfo};
|
||||
use crate::runners::{Runners, RegisterForm};
|
||||
use crate::jobs::Jobs;
|
||||
|
||||
/// Generate a unique job ID client-side using UUID v4
|
||||
fn generate_job_id() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct JobForm {
|
||||
pub payload: String,
|
||||
pub runner_name: String,
|
||||
pub executor: String,
|
||||
pub secret: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PingState {
|
||||
Idle,
|
||||
Waiting,
|
||||
Success(String), // Result message
|
||||
Error(String), // Error message
|
||||
}
|
||||
|
||||
impl Default for PingState {
|
||||
fn default() -> Self {
|
||||
PingState::Idle
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub server_url: String,
|
||||
pub runners: Vec<(String, String)>, // (name, status)
|
||||
pub jobs: Vec<WasmJob>,
|
||||
pub ongoing_jobs: Vec<String>, // Job IDs being polled
|
||||
pub loading: bool,
|
||||
pub register_form: RegisterForm,
|
||||
pub job_form: JobForm,
|
||||
pub supervisor_info: Option<SupervisorInfo>,
|
||||
pub admin_secret: String,
|
||||
pub ping_states: std::collections::HashMap<String, PingState>, // runner_name -> ping_state
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
#[function_component(App)]
|
||||
pub fn app() -> Html {
|
||||
let state = use_state(|| AppState {
|
||||
server_url: "http://localhost:3030".to_string(),
|
||||
runners: vec![],
|
||||
jobs: vec![],
|
||||
ongoing_jobs: vec![],
|
||||
loading: false,
|
||||
register_form: RegisterForm {
|
||||
name: String::new(),
|
||||
secret: String::new(),
|
||||
},
|
||||
job_form: JobForm {
|
||||
payload: String::new(),
|
||||
runner_name: String::new(),
|
||||
executor: String::new(),
|
||||
secret: String::new(),
|
||||
},
|
||||
supervisor_info: None,
|
||||
admin_secret: String::new(),
|
||||
ping_states: std::collections::HashMap::new(),
|
||||
});
|
||||
|
||||
// Set up polling for ongoing jobs every 2 seconds
|
||||
{
|
||||
let state = state.clone();
|
||||
use_effect_with((), move |_| {
|
||||
let state = state.clone();
|
||||
|
||||
let poll_jobs = {
|
||||
let state = state.clone();
|
||||
Callback::from(move |_| {
|
||||
let current_state = (*state).clone();
|
||||
if !current_state.ongoing_jobs.is_empty() {
|
||||
let client = WasmSupervisorClient::new(current_state.server_url.clone());
|
||||
let state_clone = state.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
console::log!("Polling ongoing jobs:", format!("{:?}", current_state.ongoing_jobs));
|
||||
let mut updated_state = (*state_clone).clone();
|
||||
let mut jobs_to_remove = Vec::new();
|
||||
|
||||
// Poll each ongoing job
|
||||
for job_id in ¤t_state.ongoing_jobs {
|
||||
match client.get_job(job_id).await {
|
||||
Ok(updated_job) => {
|
||||
// Find and update the job in the jobs list
|
||||
if let Some(job_index) = updated_state.jobs.iter().position(|j| j.id() == *job_id) {
|
||||
updated_state.jobs[job_index] = updated_job.clone();
|
||||
console::log!("Updated job status for:", job_id);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to poll job:", job_id, format!("{:?}", e));
|
||||
// Remove failed jobs from ongoing list
|
||||
jobs_to_remove.push(job_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove completed/failed jobs from ongoing list
|
||||
for job_id in jobs_to_remove {
|
||||
updated_state.ongoing_jobs.retain(|id| id != &job_id);
|
||||
}
|
||||
|
||||
state_clone.set(updated_state);
|
||||
});
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let interval = Interval::new(2000, move || {
|
||||
poll_jobs.emit(());
|
||||
});
|
||||
|
||||
move || drop(interval)
|
||||
});
|
||||
}
|
||||
|
||||
// Load initial data when component mounts
|
||||
let load_initial_data = {
|
||||
let state = state.clone();
|
||||
let client_url = state.server_url.clone();
|
||||
Callback::from(move |_: ()| {
|
||||
let state = state.clone();
|
||||
let client = WasmSupervisorClient::new(client_url.clone());
|
||||
spawn_local(async move {
|
||||
console::log!("Loading initial data...");
|
||||
let mut current_state = (*state).clone();
|
||||
current_state.loading = true;
|
||||
state.set(current_state.clone());
|
||||
|
||||
// Load runners and jobs in parallel
|
||||
let runners_result = client.list_runners().await;
|
||||
let jobs_result = client.list_jobs().await;
|
||||
|
||||
match (runners_result, jobs_result) {
|
||||
(Ok(runner_names), Ok(job_ids)) => {
|
||||
console::log!("Successfully loaded runners:", format!("{:?}", runner_names));
|
||||
console::log!("Successfully loaded jobs:", format!("{:?}", job_ids));
|
||||
|
||||
let runners_with_status: Vec<(String, String)> = runner_names
|
||||
.into_iter()
|
||||
.map(|name| (name, "Running".to_string()))
|
||||
.collect();
|
||||
|
||||
// Fetch full job details for each job ID and identify unfinished jobs
|
||||
let mut jobs = Vec::new();
|
||||
let mut ongoing_jobs = Vec::new();
|
||||
|
||||
for job_id in job_ids {
|
||||
match client.get_job(&job_id).await {
|
||||
Ok(job) => {
|
||||
// Check if job is unfinished (you may need to adjust this logic based on your job status field)
|
||||
// For now, we'll assume all jobs are ongoing until we have proper status checking
|
||||
ongoing_jobs.push(job_id.clone());
|
||||
jobs.push(job);
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to fetch job details for:", &job_id, format!("{:?}", e));
|
||||
// Create placeholder job if fetch fails
|
||||
jobs.push(WasmJob::new(job_id.clone(), "Loading...".to_string(), "Unknown".to_string(), "Unknown".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut updated_state = (*state).clone();
|
||||
updated_state.runners = runners_with_status;
|
||||
updated_state.jobs = jobs;
|
||||
updated_state.ongoing_jobs = ongoing_jobs.clone();
|
||||
updated_state.loading = false;
|
||||
console::log!("Added ongoing jobs to polling:", format!("{:?}", ongoing_jobs));
|
||||
state.set(updated_state);
|
||||
}
|
||||
(Ok(runner_names), Err(jobs_err)) => {
|
||||
console::log!("Successfully loaded runners:", format!("{:?}", runner_names));
|
||||
console::error!("Failed to load jobs:", format!("{:?}", jobs_err));
|
||||
|
||||
let runners_with_status: Vec<(String, String)> = runner_names
|
||||
.into_iter()
|
||||
.map(|name| (name, "Running".to_string()))
|
||||
.collect();
|
||||
|
||||
let mut updated_state = (*state).clone();
|
||||
updated_state.runners = runners_with_status;
|
||||
updated_state.loading = false;
|
||||
state.set(updated_state);
|
||||
}
|
||||
(Err(runners_err), Ok(job_ids)) => {
|
||||
console::error!("Failed to load runners:", format!("{:?}", runners_err));
|
||||
console::log!("Successfully loaded jobs:", format!("{:?}", job_ids));
|
||||
|
||||
// Convert job IDs to WasmJob objects
|
||||
let jobs: Vec<WasmJob> = job_ids
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
WasmJob::new(id.clone(), "Loading...".to_string(), "Unknown".to_string(), "Unknown".to_string())
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut updated_state = (*state).clone();
|
||||
updated_state.jobs = jobs;
|
||||
updated_state.loading = false;
|
||||
state.set(updated_state);
|
||||
}
|
||||
(Err(runners_err), Err(jobs_err)) => {
|
||||
console::error!("Failed to load runners:", format!("{:?}", runners_err));
|
||||
console::error!("Failed to load jobs:", format!("{:?}", jobs_err));
|
||||
let mut updated_state = (*state).clone();
|
||||
updated_state.loading = false;
|
||||
state.set(updated_state);
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
use_effect_with((), move |_| {
|
||||
load_initial_data.emit(());
|
||||
|| ()
|
||||
});
|
||||
|
||||
let on_load_runners = {
|
||||
let state = state.clone();
|
||||
let client_url = state.server_url.clone();
|
||||
Callback::from(move |_: ()| {
|
||||
let state = state.clone();
|
||||
let client = WasmSupervisorClient::new(client_url.clone());
|
||||
spawn_local(async move {
|
||||
console::log!("Loading runners...");
|
||||
let mut current_state = (*state).clone();
|
||||
current_state.loading = true;
|
||||
state.set(current_state.clone());
|
||||
|
||||
match client.list_runners().await {
|
||||
Ok(runner_names) => {
|
||||
console::log!("Successfully loaded runners:", format!("{:?}", runner_names));
|
||||
// For now, assume all runners are "Running" - we'd need a separate status call
|
||||
let runners_with_status: Vec<(String, String)> = runner_names
|
||||
.into_iter()
|
||||
.map(|name| (name, "Running".to_string()))
|
||||
.collect();
|
||||
|
||||
let mut updated_state = (*state).clone();
|
||||
updated_state.runners = runners_with_status;
|
||||
updated_state.loading = false;
|
||||
state.set(updated_state);
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to load runners:", format!("{:?}", e));
|
||||
let mut updated_state = (*state).clone();
|
||||
updated_state.loading = false;
|
||||
state.set(updated_state);
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
let on_register_form_change = {
|
||||
let state = state.clone();
|
||||
Callback::from(move |(field, value): (String, String)| {
|
||||
let mut new_form = state.register_form.clone();
|
||||
match field.as_str() {
|
||||
"name" => new_form.name = value,
|
||||
"secret" => new_form.secret = value,
|
||||
_ => {}
|
||||
}
|
||||
let new_state = AppState {
|
||||
server_url: state.server_url.clone(),
|
||||
runners: state.runners.clone(),
|
||||
jobs: state.jobs.clone(),
|
||||
ongoing_jobs: state.ongoing_jobs.clone(),
|
||||
loading: state.loading,
|
||||
register_form: new_form,
|
||||
job_form: state.job_form.clone(),
|
||||
supervisor_info: state.supervisor_info.clone(),
|
||||
admin_secret: state.admin_secret.clone(),
|
||||
ping_states: state.ping_states.clone(),
|
||||
};
|
||||
state.set(new_state);
|
||||
})
|
||||
};
|
||||
|
||||
let on_register_runner = {
|
||||
let state = state.clone();
|
||||
Callback::from(move |_: ()| {
|
||||
let current_state = (*state).clone();
|
||||
|
||||
// Add runner to UI immediately with "Registering" status
|
||||
let new_runner = (
|
||||
current_state.register_form.name.clone(),
|
||||
"Registering".to_string(),
|
||||
);
|
||||
|
||||
let mut updated_runners = current_state.runners.clone();
|
||||
updated_runners.push(new_runner);
|
||||
|
||||
let mut temp_state = current_state.clone();
|
||||
temp_state.runners = updated_runners;
|
||||
|
||||
// Clear form and update status to "Running"
|
||||
temp_state.register_form = RegisterForm {
|
||||
name: String::new(),
|
||||
secret: String::new(),
|
||||
};
|
||||
|
||||
// Update the newly added runner status to "Running"
|
||||
if let Some(runner) = temp_state.runners.iter_mut()
|
||||
.find(|(name, _)| name == ¤t_state.register_form.name) {
|
||||
runner.1 = "Running".to_string();
|
||||
}
|
||||
|
||||
state.set(temp_state);
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Admin secret change callback
|
||||
let on_admin_secret_change = {
|
||||
let state = state.clone();
|
||||
Callback::from(move |admin_secret: String| {
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.admin_secret = admin_secret;
|
||||
state.set(new_state);
|
||||
})
|
||||
};
|
||||
|
||||
// Job form change callback
|
||||
let on_job_form_change = {
|
||||
let state = state.clone();
|
||||
Callback::from(move |(field, value): (String, String)| {
|
||||
let mut new_form = state.job_form.clone();
|
||||
match field.as_str() {
|
||||
"payload" => new_form.payload = value,
|
||||
"runner_name" => new_form.runner_name = value,
|
||||
"executor" => new_form.executor = value,
|
||||
"secret" => new_form.secret = value,
|
||||
_ => {}
|
||||
}
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.job_form = new_form;
|
||||
state.set(new_state);
|
||||
})
|
||||
};
|
||||
|
||||
// Run job callback - now uses create_job for immediate display and polling
|
||||
let on_run_job = {
|
||||
let state = state.clone();
|
||||
Callback::from(move |_| {
|
||||
let current_state = (*state).clone();
|
||||
let client = WasmSupervisorClient::new(current_state.server_url.clone());
|
||||
let job_form = current_state.job_form.clone();
|
||||
let state_clone = state.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
console::log!("Creating job...");
|
||||
|
||||
// Generate unique job ID client-side
|
||||
let job_id = generate_job_id();
|
||||
|
||||
// Create WasmJob from form data with client-generated ID
|
||||
let job = WasmJob::new(
|
||||
job_id.clone(),
|
||||
job_form.payload.clone(),
|
||||
job_form.executor.clone(),
|
||||
job_form.runner_name.clone(),
|
||||
);
|
||||
|
||||
// Immediately add job to the list with "pending" status
|
||||
let mut updated_state = (*state_clone).clone();
|
||||
updated_state.jobs.push(job.clone());
|
||||
updated_state.ongoing_jobs.push(job_id.clone());
|
||||
// Clear the job form
|
||||
updated_state.job_form = JobForm::default();
|
||||
state_clone.set(updated_state);
|
||||
console::log!("Job added to list immediately with ID:", &job_id);
|
||||
|
||||
// Create the job using fire-and-forget create_job method
|
||||
match client.create_job(job_form.secret.clone(), job).await {
|
||||
Ok(returned_job_id) => {
|
||||
console::log!("Job created successfully with ID:", &returned_job_id);
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to create job:", format!("{:?}", e));
|
||||
// Remove job from ongoing jobs if creation failed
|
||||
let mut error_state = (*state_clone).clone();
|
||||
error_state.ongoing_jobs.retain(|id| id != &job_id);
|
||||
state_clone.set(error_state);
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
// Supervisor info loaded callback
|
||||
let on_supervisor_info_loaded = {
|
||||
let state = state.clone();
|
||||
Callback::from(move |supervisor_info: SupervisorInfo| {
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.supervisor_info = Some(supervisor_info);
|
||||
state.set(new_state);
|
||||
})
|
||||
};
|
||||
|
||||
// Remove runner callback
|
||||
let on_remove_runner = {
|
||||
let state = state.clone();
|
||||
Callback::from(move |runner_id: String| {
|
||||
let current_state = (*state).clone();
|
||||
let client = WasmSupervisorClient::new(current_state.server_url.clone());
|
||||
let state_clone = state.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
console::log!("Removing runner:", &runner_id);
|
||||
|
||||
match client.remove_runner(&runner_id).await {
|
||||
Ok(_) => {
|
||||
console::log!("Runner removed successfully");
|
||||
// Remove runner from the list
|
||||
let mut updated_state = (*state_clone).clone();
|
||||
updated_state.runners.retain(|(name, _)| name != &runner_id);
|
||||
state_clone.set(updated_state);
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to remove runner:", format!("{:?}", e));
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
// Stop job callback
|
||||
let on_stop_job = {
|
||||
let state = state.clone();
|
||||
Callback::from(move |job_id: String| {
|
||||
let current_state = (*state).clone();
|
||||
let client = WasmSupervisorClient::new(current_state.server_url.clone());
|
||||
let state_clone = state.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
console::log!("Stopping job:", &job_id);
|
||||
|
||||
match client.stop_job(&job_id).await {
|
||||
Ok(_) => {
|
||||
console::log!("Job stopped successfully");
|
||||
// Remove job from ongoing jobs list
|
||||
let mut updated_state = (*state_clone).clone();
|
||||
updated_state.ongoing_jobs.retain(|id| id != &job_id);
|
||||
state_clone.set(updated_state);
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to stop job:", format!("{:?}", e));
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
// Delete job callback
|
||||
let on_delete_job = {
|
||||
let state = state.clone();
|
||||
Callback::from(move |job_id: String| {
|
||||
let current_state = (*state).clone();
|
||||
let client = WasmSupervisorClient::new(current_state.server_url.clone());
|
||||
let state_clone = state.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
console::log!("Deleting job:", &job_id);
|
||||
|
||||
match client.delete_job(&job_id).await {
|
||||
Ok(_) => {
|
||||
console::log!("Job deleted successfully");
|
||||
// Remove job from both jobs list and ongoing jobs list
|
||||
let mut updated_state = (*state_clone).clone();
|
||||
updated_state.jobs.retain(|job| job.id() != job_id);
|
||||
updated_state.ongoing_jobs.retain(|id| id != &job_id);
|
||||
state_clone.set(updated_state);
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to delete job:", format!("{:?}", e));
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
// Ping runner callback - uses run_job for immediate result with proper state management
|
||||
let on_ping_runner = {
|
||||
let state = state.clone();
|
||||
Callback::from(move |(runner_id, secret): (String, String)| {
|
||||
let current_state = (*state).clone();
|
||||
let client = WasmSupervisorClient::new(current_state.server_url.clone());
|
||||
let state_clone = state.clone();
|
||||
|
||||
// Set ping state to waiting
|
||||
{
|
||||
let mut updated_state = (*state_clone).clone();
|
||||
updated_state.ping_states.insert(runner_id.clone(), PingState::Waiting);
|
||||
state_clone.set(updated_state);
|
||||
}
|
||||
|
||||
spawn_local(async move {
|
||||
console::log!("Pinging runner:", &runner_id);
|
||||
|
||||
// Generate unique job ID client-side
|
||||
let job_id = generate_job_id();
|
||||
|
||||
// Create ping job with client-generated ID
|
||||
let ping_job = WasmJob::new(
|
||||
job_id.clone(),
|
||||
"ping".to_string(),
|
||||
"ping".to_string(),
|
||||
runner_id.clone(),
|
||||
);
|
||||
|
||||
// Use run_job for immediate result instead of create_job
|
||||
match client.run_job(secret, ping_job).await {
|
||||
Ok(result) => {
|
||||
console::log!("Ping successful, result:", &result);
|
||||
// Set ping state to success with result
|
||||
let mut success_state = (*state_clone).clone();
|
||||
success_state.ping_states.insert(runner_id.clone(), PingState::Success(result));
|
||||
state_clone.set(success_state);
|
||||
|
||||
// Reset to idle after 3 seconds
|
||||
let state_reset = state_clone.clone();
|
||||
let runner_id_reset = runner_id.clone();
|
||||
spawn_local(async move {
|
||||
gloo::timers::future::TimeoutFuture::new(3000).await;
|
||||
let mut reset_state = (*state_reset).clone();
|
||||
reset_state.ping_states.insert(runner_id_reset, PingState::Idle);
|
||||
state_reset.set(reset_state);
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to ping runner:", format!("{:?}", e));
|
||||
// Set ping state to error
|
||||
let mut error_state = (*state_clone).clone();
|
||||
let error_msg = format!("Error: {:?}", e);
|
||||
error_state.ping_states.insert(runner_id.clone(), PingState::Error(error_msg));
|
||||
state_clone.set(error_state);
|
||||
|
||||
// Reset to idle after 3 seconds
|
||||
let state_reset = state_clone.clone();
|
||||
let runner_id_reset = runner_id.clone();
|
||||
spawn_local(async move {
|
||||
gloo::timers::future::TimeoutFuture::new(3000).await;
|
||||
let mut reset_state = (*state_reset).clone();
|
||||
reset_state.ping_states.insert(runner_id_reset, PingState::Idle);
|
||||
state_reset.set(reset_state);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
// Load initial data
|
||||
use_effect_with((), {
|
||||
let on_load_runners = on_load_runners.clone();
|
||||
move |_| {
|
||||
on_load_runners.emit(());
|
||||
|| ()
|
||||
}
|
||||
});
|
||||
|
||||
html! {
|
||||
<div class="app-container">
|
||||
<Sidebar
|
||||
server_url={state.server_url.clone()}
|
||||
supervisor_info={state.supervisor_info.clone()}
|
||||
admin_secret={state.admin_secret.clone()}
|
||||
on_admin_secret_change={on_admin_secret_change}
|
||||
on_supervisor_info_loaded={on_supervisor_info_loaded}
|
||||
/>
|
||||
|
||||
<div class="main-content">
|
||||
<Runners
|
||||
server_url={state.server_url.clone()}
|
||||
runners={state.runners.clone()}
|
||||
register_form={state.register_form.clone()}
|
||||
ping_states={state.ping_states.clone()}
|
||||
on_register_form_change={on_register_form_change}
|
||||
on_register_runner={on_register_runner}
|
||||
on_load_runners={on_load_runners.clone()}
|
||||
on_remove_runner={on_remove_runner}
|
||||
on_ping_runner={on_ping_runner}
|
||||
/>
|
||||
|
||||
<Jobs
|
||||
jobs={state.jobs.clone()}
|
||||
server_url={state.server_url.clone()}
|
||||
job_form={state.job_form.clone()}
|
||||
runners={state.runners.clone()}
|
||||
on_job_form_change={on_job_form_change}
|
||||
on_run_job={on_run_job}
|
||||
on_stop_job={on_stop_job}
|
||||
on_delete_job={on_delete_job}
|
||||
/>
|
||||
|
||||
// Floating refresh button
|
||||
<button class="refresh-btn" onclick={on_load_runners.reform(|_| ())}>
|
||||
{"↻"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
1099
clients/admin-ui/src/app.rs.backup
Normal file
1099
clients/admin-ui/src/app.rs.backup
Normal file
File diff suppressed because it is too large
Load Diff
296
clients/admin-ui/src/components/add_runner.rs
Normal file
296
clients/admin-ui/src/components/add_runner.rs
Normal file
@@ -0,0 +1,296 @@
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
use gloo::console;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use web_sys::HtmlInputElement;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::types::{AddRunnerForm, RunnerType, ProcessManagerType};
|
||||
use crate::services::{SupervisorService, use_supervisor_service};
|
||||
|
||||
#[function_component(AddRunner)]
|
||||
pub fn add_runner() -> Html {
|
||||
let navigator = use_navigator().unwrap();
|
||||
let server_url = "http://localhost:8081";
|
||||
let (service, service_error) = use_supervisor_service(server_url);
|
||||
|
||||
let form = use_state(|| AddRunnerForm::default());
|
||||
let loading = use_state(|| false);
|
||||
let error = use_state(|| None::<String>);
|
||||
let success = use_state(|| false);
|
||||
|
||||
let on_actor_id_change = {
|
||||
let form = form.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
let mut new_form = (*form).clone();
|
||||
new_form.actor_id = input.value();
|
||||
form.set(new_form);
|
||||
})
|
||||
};
|
||||
|
||||
let on_runner_type_change = {
|
||||
let form = form.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let select: web_sys::HtmlSelectElement = e.target_unchecked_into();
|
||||
let mut new_form = (*form).clone();
|
||||
new_form.runner_type = match select.value().as_str() {
|
||||
"SALRunner" => RunnerType::SALRunner,
|
||||
"OSISRunner" => RunnerType::OSISRunner,
|
||||
"VRunner" => RunnerType::VRunner,
|
||||
_ => RunnerType::SALRunner,
|
||||
};
|
||||
form.set(new_form);
|
||||
})
|
||||
};
|
||||
|
||||
let on_binary_path_change = {
|
||||
let form = form.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
let mut new_form = (*form).clone();
|
||||
new_form.binary_path = input.value();
|
||||
form.set(new_form);
|
||||
})
|
||||
};
|
||||
|
||||
let on_script_type_change = {
|
||||
let form = form.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
let mut new_form = (*form).clone();
|
||||
new_form.script_type = input.value();
|
||||
form.set(new_form);
|
||||
})
|
||||
};
|
||||
|
||||
let on_process_manager_change = {
|
||||
let form = form.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let select: web_sys::HtmlSelectElement = e.target_unchecked_into();
|
||||
let mut new_form = (*form).clone();
|
||||
new_form.process_manager_type = match select.value().as_str() {
|
||||
"Tmux" => ProcessManagerType::Tmux,
|
||||
"Simple" => ProcessManagerType::Simple,
|
||||
_ => ProcessManagerType::Simple,
|
||||
};
|
||||
form.set(new_form);
|
||||
})
|
||||
};
|
||||
|
||||
let on_submit = {
|
||||
let form = form.clone();
|
||||
let service = service.clone();
|
||||
let loading = loading.clone();
|
||||
let error = error.clone();
|
||||
let success = success.clone();
|
||||
let navigator = navigator.clone();
|
||||
|
||||
Callback::from(move |e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
|
||||
if let Some(service) = &service {
|
||||
let form = form.clone();
|
||||
let service = service.clone();
|
||||
let loading = loading.clone();
|
||||
let error = error.clone();
|
||||
let success = success.clone();
|
||||
let navigator = navigator.clone();
|
||||
|
||||
loading.set(true);
|
||||
error.set(None);
|
||||
success.set(false);
|
||||
|
||||
spawn_local(async move {
|
||||
let config = form.to_runner_config();
|
||||
match service.add_runner(config, form.process_manager_type.clone()).await {
|
||||
Ok(_) => {
|
||||
console::log!("Runner added successfully");
|
||||
success.set(true);
|
||||
// Navigate back to runners list after a short delay
|
||||
gloo::timers::callback::Timeout::new(1500, move || {
|
||||
navigator.push(&Route::Runners);
|
||||
}).forget();
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to add runner:", e.to_string());
|
||||
error.set(Some(e.to_string()));
|
||||
}
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_cancel = {
|
||||
let navigator = navigator.clone();
|
||||
Callback::from(move |_| navigator.push(&Route::Runners))
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="bi bi-plus-circle me-2"></i>
|
||||
{"Add New Runner"}
|
||||
</h1>
|
||||
<button class="btn btn-outline-secondary" onclick={on_cancel.clone()}>
|
||||
<i class="bi bi-arrow-left me-1"></i>
|
||||
{"Back to Runners"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Error display
|
||||
if let Some(err) = service_error {
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="alert alert-danger">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
{"Service Error: "}{err}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
if let Some(err) = error.as_ref() {
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="alert alert-danger">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
{"Error: "}{err}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
if *success {
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="alert alert-success">
|
||||
<i class="bi bi-check-circle-fill me-2"></i>
|
||||
{"Runner added successfully! Redirecting..."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8 mx-auto">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{"Runner Configuration"}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form onsubmit={on_submit}>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="actor_id" class="form-label">{"Actor ID"}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="actor_id"
|
||||
value={form.actor_id.clone()}
|
||||
onchange={on_actor_id_change}
|
||||
required=true
|
||||
placeholder="e.g., sal_runner_1"
|
||||
/>
|
||||
<div class="form-text">{"Unique identifier for this runner"}</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="runner_type" class="form-label">{"Runner Type"}</label>
|
||||
<select
|
||||
class="form-select"
|
||||
id="runner_type"
|
||||
onchange={on_runner_type_change}
|
||||
>
|
||||
<option value="SALRunner" selected={matches!(form.runner_type, RunnerType::SALRunner)}>
|
||||
{"SAL Runner"}
|
||||
</option>
|
||||
<option value="OSISRunner" selected={matches!(form.runner_type, RunnerType::OSISRunner)}>
|
||||
{"OSIS Runner"}
|
||||
</option>
|
||||
<option value="VRunner" selected={matches!(form.runner_type, RunnerType::VRunner)}>
|
||||
{"V Runner"}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="binary_path" class="form-label">{"Binary Path"}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="binary_path"
|
||||
value={form.binary_path.clone()}
|
||||
onchange={on_binary_path_change}
|
||||
required=true
|
||||
placeholder="/path/to/runner/binary"
|
||||
/>
|
||||
<div class="form-text">{"Full path to the runner executable"}</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="script_type" class="form-label">{"Script Type"}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="script_type"
|
||||
value={form.script_type.clone()}
|
||||
onchange={on_script_type_change}
|
||||
required=true
|
||||
placeholder="e.g., rhai, bash, python"
|
||||
/>
|
||||
<div class="form-text">{"Type of scripts this runner will execute"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="process_manager" class="form-label">{"Process Manager"}</label>
|
||||
<select
|
||||
class="form-select"
|
||||
id="process_manager"
|
||||
onchange={on_process_manager_change}
|
||||
>
|
||||
<option value="Simple" selected={matches!(form.process_manager_type, ProcessManagerType::Simple)}>
|
||||
{"Simple"}
|
||||
</option>
|
||||
<option value="Tmux" selected={matches!(form.process_manager_type, ProcessManagerType::Tmux)}>
|
||||
{"Tmux"}
|
||||
</option>
|
||||
</select>
|
||||
<div class="form-text">{"Process management system to use"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary" onclick={on_cancel.clone()}>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={*loading}>
|
||||
if *loading {
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status">
|
||||
<span class="visually-hidden">{"Loading..."}</span>
|
||||
</div>
|
||||
{"Adding Runner..."}
|
||||
} else {
|
||||
<i class="bi bi-plus-circle me-1"></i>
|
||||
{"Add Runner"}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
294
clients/admin-ui/src/components/dashboard.rs
Normal file
294
clients/admin-ui/src/components/dashboard.rs
Normal file
@@ -0,0 +1,294 @@
|
||||
use yew::prelude::*;
|
||||
use gloo::console;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
use crate::types::{RunnerInfo, ProcessStatus};
|
||||
use crate::components::{status_badge::StatusBadge, runner_card::RunnerCard};
|
||||
use crate::services::{SupervisorService, use_supervisor_service};
|
||||
|
||||
#[function_component(Dashboard)]
|
||||
pub fn dashboard() -> Html {
|
||||
let server_url = "http://localhost:8081"; // Default supervisor server URL
|
||||
let (service, service_error) = use_supervisor_service(server_url);
|
||||
let runners = use_state(|| Vec::<RunnerInfo>::new());
|
||||
let loading = use_state(|| false);
|
||||
let error = use_state(|| None::<String>);
|
||||
|
||||
// Load runners on component mount and when service is available
|
||||
{
|
||||
let runners = runners.clone();
|
||||
let loading = loading.clone();
|
||||
let error = error.clone();
|
||||
let service = service.clone();
|
||||
|
||||
use_effect_with(service.clone(), move |service| {
|
||||
if let Some(service) = service {
|
||||
let runners = runners.clone();
|
||||
let loading = loading.clone();
|
||||
let error = error.clone();
|
||||
let service = service.clone();
|
||||
|
||||
loading.set(true);
|
||||
spawn_local(async move {
|
||||
match service.get_all_runners().await {
|
||||
Ok(runner_list) => {
|
||||
runners.set(runner_list);
|
||||
error.set(None);
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to load runners:", e.to_string());
|
||||
error.set(Some(e.to_string()));
|
||||
}
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let on_refresh = {
|
||||
let runners = runners.clone();
|
||||
let loading = loading.clone();
|
||||
let error = error.clone();
|
||||
let service = service.clone();
|
||||
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
if let Some(service) = &service {
|
||||
let runners = runners.clone();
|
||||
let loading = loading.clone();
|
||||
let error = error.clone();
|
||||
let service = service.clone();
|
||||
|
||||
loading.set(true);
|
||||
spawn_local(async move {
|
||||
match service.get_all_runners().await {
|
||||
Ok(runner_list) => {
|
||||
runners.set(runner_list);
|
||||
error.set(None);
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to refresh runners:", e.to_string());
|
||||
error.set(Some(e.to_string()));
|
||||
}
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_start_all = {
|
||||
let service = service.clone();
|
||||
let on_refresh = on_refresh.clone();
|
||||
let loading = loading.clone();
|
||||
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
if let Some(service) = &service {
|
||||
let service = service.clone();
|
||||
let on_refresh = on_refresh.clone();
|
||||
let loading = loading.clone();
|
||||
|
||||
loading.set(true);
|
||||
spawn_local(async move {
|
||||
match service.start_all().await {
|
||||
Ok(results) => {
|
||||
console::log!("Start all results:", format!("{:?}", results));
|
||||
on_refresh.emit(web_sys::MouseEvent::new("click").unwrap());
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to start all runners:", e.to_string());
|
||||
}
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_stop_all = {
|
||||
let service = service.clone();
|
||||
let on_refresh = on_refresh.clone();
|
||||
let loading = loading.clone();
|
||||
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
if let Some(service) = &service {
|
||||
if gloo::dialogs::confirm("Are you sure you want to stop all runners?") {
|
||||
let service = service.clone();
|
||||
let on_refresh = on_refresh.clone();
|
||||
let loading = loading.clone();
|
||||
|
||||
loading.set(true);
|
||||
spawn_local(async move {
|
||||
match service.stop_all(false).await {
|
||||
Ok(results) => {
|
||||
console::log!("Stop all results:", format!("{:?}", results));
|
||||
on_refresh.emit(web_sys::MouseEvent::new("click").unwrap());
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to stop all runners:", e.to_string());
|
||||
}
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// Create a proper on_update callback for RunnerCard
|
||||
let on_runner_update = {
|
||||
let on_refresh = on_refresh.clone();
|
||||
Callback::from(move |_: ()| {
|
||||
on_refresh.emit(web_sys::MouseEvent::new("click").unwrap());
|
||||
})
|
||||
};
|
||||
|
||||
// Calculate statistics
|
||||
let total_runners = runners.len();
|
||||
let running_count = runners.iter().filter(|r| r.status == ProcessStatus::Running).count();
|
||||
let stopped_count = runners.iter().filter(|r| r.status == ProcessStatus::Stopped).count();
|
||||
let failed_count = runners.iter().filter(|r| r.status == ProcessStatus::Failed).count();
|
||||
|
||||
html! {
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="bi bi-speedometer2 me-2"></i>
|
||||
{"Dashboard"}
|
||||
</h1>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-outline-primary" onclick={on_refresh} disabled={*loading}>
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>
|
||||
{"Refresh"}
|
||||
</button>
|
||||
<button class="btn btn-outline-success" onclick={on_start_all} disabled={*loading || total_runners == 0}>
|
||||
<i class="bi bi-play-fill me-1"></i>
|
||||
{"Start All"}
|
||||
</button>
|
||||
<button class="btn btn-outline-warning" onclick={on_stop_all} disabled={*loading || total_runners == 0}>
|
||||
<i class="bi bi-stop-fill me-1"></i>
|
||||
{"Stop All"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Error display
|
||||
if let Some(err) = service_error {
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="alert alert-danger">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
{"Service Error: "}{err}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
if let Some(err) = error.as_ref() {
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
{"Error: "}{err}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// Statistics cards
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-primary">{total_runners}</h2>
|
||||
<p class="card-text">{"Total Runners"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-success">{running_count}</h2>
|
||||
<p class="card-text">{"Running"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-warning">{stopped_count}</h2>
|
||||
<p class="card-text">{"Stopped"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-center">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-danger">{failed_count}</h2>
|
||||
<p class="card-text">{"Failed"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Loading state
|
||||
if *loading {
|
||||
<div class="row">
|
||||
<div class="col-12 text-center">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">{"Loading..."}</span>
|
||||
</div>
|
||||
<p class="mt-2">{"Loading runners..."}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// Runners grid
|
||||
if !*loading && total_runners > 0 {
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h4>{"Active Runners"}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
{for runners.iter().map(|runner| {
|
||||
if let Some(service) = &service {
|
||||
html! {
|
||||
<RunnerCard
|
||||
runner={runner.clone()}
|
||||
service={service.clone()}
|
||||
on_update={on_runner_update.clone()}
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if !*loading && total_runners == 0 && service.is_some() {
|
||||
<div class="row">
|
||||
<div class="col-12 text-center">
|
||||
<div class="card">
|
||||
<div class="card-body py-5">
|
||||
<i class="bi bi-cpu display-1 text-muted mb-3"></i>
|
||||
<h4 class="text-muted">{"No Runners Found"}</h4>
|
||||
<p class="text-muted">{"Get started by adding your first runner."}</p>
|
||||
<a href="/runners/add" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-1"></i>
|
||||
{"Add Runner"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
7
clients/admin-ui/src/components/mod.rs
Normal file
7
clients/admin-ui/src/components/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod navbar;
|
||||
pub mod dashboard;
|
||||
pub mod runners;
|
||||
pub mod runner_detail;
|
||||
pub mod add_runner;
|
||||
pub mod runner_card;
|
||||
pub mod status_badge;
|
67
clients/admin-ui/src/components/navbar.rs
Normal file
67
clients/admin-ui/src/components/navbar.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
use crate::app::Route;
|
||||
|
||||
#[function_component(Navbar)]
|
||||
pub fn navbar() -> Html {
|
||||
let navigator = use_navigator().unwrap();
|
||||
|
||||
let on_dashboard_click = {
|
||||
let navigator = navigator.clone();
|
||||
Callback::from(move |_| navigator.push(&Route::Dashboard))
|
||||
};
|
||||
|
||||
let on_runners_click = {
|
||||
let navigator = navigator.clone();
|
||||
Callback::from(move |_| navigator.push(&Route::Runners))
|
||||
};
|
||||
|
||||
let on_add_runner_click = {
|
||||
let navigator = navigator.clone();
|
||||
Callback::from(move |_| navigator.push(&Route::AddRunner))
|
||||
};
|
||||
|
||||
html! {
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="#">
|
||||
<i class="bi bi-gear-fill me-2"></i>
|
||||
{"Hero Supervisor Admin"}
|
||||
</a>
|
||||
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#navbarNav" aria-controls="navbarNav"
|
||||
aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item">
|
||||
<button class="nav-link btn btn-link" onclick={on_dashboard_click}>
|
||||
<i class="bi bi-speedometer2 me-1"></i>
|
||||
{"Dashboard"}
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link btn btn-link" onclick={on_runners_click}>
|
||||
<i class="bi bi-cpu me-1"></i>
|
||||
{"Runners"}
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link btn btn-link" onclick={on_add_runner_click}>
|
||||
<i class="bi bi-plus-circle me-1"></i>
|
||||
{"Add Runner"}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="navbar-text">
|
||||
<small class="text-muted">{"Connected to Supervisor"}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
}
|
||||
}
|
191
clients/admin-ui/src/components/runner_card.rs
Normal file
191
clients/admin-ui/src/components/runner_card.rs
Normal file
@@ -0,0 +1,191 @@
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
use gloo::console;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::types::{RunnerInfo, ProcessStatus};
|
||||
use crate::components::status_badge::StatusBadge;
|
||||
use crate::services::SupervisorService;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct RunnerCardProps {
|
||||
pub runner: RunnerInfo,
|
||||
pub service: SupervisorService,
|
||||
pub on_update: Callback<()>,
|
||||
}
|
||||
|
||||
#[function_component(RunnerCard)]
|
||||
pub fn runner_card(props: &RunnerCardProps) -> Html {
|
||||
let navigator = use_navigator().unwrap();
|
||||
let loading = use_state(|| false);
|
||||
|
||||
let runner_id = props.runner.id.clone();
|
||||
let on_view_details = {
|
||||
let navigator = navigator.clone();
|
||||
let runner_id = runner_id.clone();
|
||||
Callback::from(move |_| {
|
||||
navigator.push(&Route::RunnerDetail { id: runner_id.clone() });
|
||||
})
|
||||
};
|
||||
|
||||
let on_start = {
|
||||
let service = props.service.clone();
|
||||
let runner_id = runner_id.clone();
|
||||
let loading = loading.clone();
|
||||
let on_update = props.on_update.clone();
|
||||
Callback::from(move |_| {
|
||||
let service = service.clone();
|
||||
let runner_id = runner_id.clone();
|
||||
let loading = loading.clone();
|
||||
let on_update = on_update.clone();
|
||||
|
||||
loading.set(true);
|
||||
spawn_local(async move {
|
||||
match service.start_runner(&runner_id).await {
|
||||
Ok(_) => {
|
||||
console::log!("Runner started successfully");
|
||||
on_update.emit(());
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to start runner:", e.to_string());
|
||||
}
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
let on_stop = {
|
||||
let service = props.service.clone();
|
||||
let runner_id = runner_id.clone();
|
||||
let loading = loading.clone();
|
||||
let on_update = props.on_update.clone();
|
||||
Callback::from(move |_| {
|
||||
let service = service.clone();
|
||||
let runner_id = runner_id.clone();
|
||||
let loading = loading.clone();
|
||||
let on_update = on_update.clone();
|
||||
|
||||
loading.set(true);
|
||||
spawn_local(async move {
|
||||
match service.stop_runner(&runner_id, false).await {
|
||||
Ok(_) => {
|
||||
console::log!("Runner stopped successfully");
|
||||
on_update.emit(());
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to stop runner:", e.to_string());
|
||||
}
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
let on_remove = {
|
||||
let service = props.service.clone();
|
||||
let runner_id = runner_id.clone();
|
||||
let loading = loading.clone();
|
||||
let on_update = props.on_update.clone();
|
||||
Callback::from(move |_| {
|
||||
if gloo::dialogs::confirm("Are you sure you want to remove this runner?") {
|
||||
let service = service.clone();
|
||||
let runner_id = runner_id.clone();
|
||||
let loading = loading.clone();
|
||||
let on_update = on_update.clone();
|
||||
|
||||
loading.set(true);
|
||||
spawn_local(async move {
|
||||
match service.remove_runner(&runner_id).await {
|
||||
Ok(_) => {
|
||||
console::log!("Runner removed successfully");
|
||||
on_update.emit(());
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to remove runner:", e.to_string());
|
||||
}
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let is_loading = *loading;
|
||||
let can_start = matches!(props.runner.status, ProcessStatus::Stopped | ProcessStatus::Failed);
|
||||
let can_stop = matches!(props.runner.status, ProcessStatus::Running);
|
||||
|
||||
html! {
|
||||
<div class="col-md-6 col-lg-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-cpu me-2"></i>
|
||||
{&props.runner.id}
|
||||
</h6>
|
||||
<StatusBadge status={props.runner.status.clone()} />
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">{"Type: "}</small>
|
||||
<span class="badge bg-info">
|
||||
{format!("{:?}", props.runner.config.runner_type)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">{"Script: "}</small>
|
||||
<code class="small">{&props.runner.config.script_type}</code>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">{"Binary: "}</small>
|
||||
<code class="small">{props.runner.config.binary_path.to_string_lossy()}</code>
|
||||
</div>
|
||||
|
||||
if !props.runner.logs.is_empty() {
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">{"Recent logs: "}</small>
|
||||
<div class="log-container p-2 rounded small">
|
||||
{for props.runner.logs.iter().take(3).map(|log| html! {
|
||||
<div>{&log.message}</div>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="btn-group w-100" role="group">
|
||||
if can_start && !is_loading {
|
||||
<button class="btn btn-outline-success btn-sm" onclick={on_start}>
|
||||
<i class="bi bi-play-fill me-1"></i>
|
||||
{"Start"}
|
||||
</button>
|
||||
}
|
||||
if can_stop && !is_loading {
|
||||
<button class="btn btn-outline-warning btn-sm" onclick={on_stop}>
|
||||
<i class="bi bi-stop-fill me-1"></i>
|
||||
{"Stop"}
|
||||
</button>
|
||||
}
|
||||
if is_loading {
|
||||
<button class="btn btn-outline-secondary btn-sm" disabled=true>
|
||||
<div class="spinner-border spinner-border-sm me-1" role="status">
|
||||
<span class="visually-hidden">{"Loading..."}</span>
|
||||
</div>
|
||||
{"Working..."}
|
||||
</button>
|
||||
}
|
||||
<button class="btn btn-outline-primary btn-sm" onclick={on_view_details}>
|
||||
<i class="bi bi-eye me-1"></i>
|
||||
{"Details"}
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm" onclick={on_remove} disabled={is_loading}>
|
||||
<i class="bi bi-trash me-1"></i>
|
||||
{"Remove"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
437
clients/admin-ui/src/components/runner_detail.rs
Normal file
437
clients/admin-ui/src/components/runner_detail.rs
Normal file
@@ -0,0 +1,437 @@
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
use gloo::console;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use web_sys::HtmlTextAreaElement;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::types::{RunnerInfo, ProcessStatus, JobBuilder, JobType};
|
||||
use crate::components::status_badge::StatusBadge;
|
||||
use crate::services::{SupervisorService, use_supervisor_service};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct RunnerDetailProps {
|
||||
pub runner_id: String,
|
||||
}
|
||||
|
||||
#[function_component(RunnerDetail)]
|
||||
pub fn runner_detail(props: &RunnerDetailProps) -> Html {
|
||||
let navigator = use_navigator().unwrap();
|
||||
let server_url = "http://localhost:8081";
|
||||
let (service, service_error) = use_supervisor_service(server_url);
|
||||
|
||||
let runner = use_state(|| None::<RunnerInfo>);
|
||||
let loading = use_state(|| false);
|
||||
let error = use_state(|| None::<String>);
|
||||
let logs_loading = use_state(|| false);
|
||||
let job_script = use_state(|| String::new());
|
||||
let job_loading = use_state(|| false);
|
||||
let job_result = use_state(|| None::<String>);
|
||||
|
||||
// Load runner details
|
||||
{
|
||||
let runner = runner.clone();
|
||||
let loading = loading.clone();
|
||||
let error = error.clone();
|
||||
let service = service.clone();
|
||||
let runner_id = props.runner_id.clone();
|
||||
|
||||
use_effect_with((service.clone(), runner_id.clone()), move |(service, runner_id)| {
|
||||
if let Some(service) = service {
|
||||
let runner = runner.clone();
|
||||
let loading = loading.clone();
|
||||
let error = error.clone();
|
||||
let service = service.clone();
|
||||
let runner_id = runner_id.clone();
|
||||
|
||||
loading.set(true);
|
||||
spawn_local(async move {
|
||||
match service.get_all_runners().await {
|
||||
Ok(runners) => {
|
||||
if let Some(found_runner) = runners.into_iter().find(|r| r.id == runner_id) {
|
||||
runner.set(Some(found_runner));
|
||||
error.set(None);
|
||||
} else {
|
||||
error.set(Some("Runner not found".to_string()));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to load runner:", e.to_string());
|
||||
error.set(Some(e.to_string()));
|
||||
}
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let on_back = {
|
||||
let navigator = navigator.clone();
|
||||
Callback::from(move |_| navigator.push(&Route::Runners))
|
||||
};
|
||||
|
||||
let on_start = {
|
||||
let service = service.clone();
|
||||
let runner_id = props.runner_id.clone();
|
||||
let runner = runner.clone();
|
||||
let loading = loading.clone();
|
||||
|
||||
Callback::from(move |_| {
|
||||
if let Some(service) = &service {
|
||||
let service = service.clone();
|
||||
let runner_id = runner_id.clone();
|
||||
let runner = runner.clone();
|
||||
let loading = loading.clone();
|
||||
|
||||
loading.set(true);
|
||||
spawn_local(async move {
|
||||
match service.start_runner(&runner_id).await {
|
||||
Ok(_) => {
|
||||
console::log!("Runner started successfully");
|
||||
// Refresh runner status
|
||||
if let Ok(status) = service.get_runner_status(&runner_id).await {
|
||||
if let Some(mut current_runner) = (*runner).clone() {
|
||||
current_runner.status = status;
|
||||
runner.set(Some(current_runner));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to start runner:", e.to_string());
|
||||
}
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_stop = {
|
||||
let service = service.clone();
|
||||
let runner_id = props.runner_id.clone();
|
||||
let runner = runner.clone();
|
||||
let loading = loading.clone();
|
||||
|
||||
Callback::from(move |_| {
|
||||
if let Some(service) = &service {
|
||||
let service = service.clone();
|
||||
let runner_id = runner_id.clone();
|
||||
let runner = runner.clone();
|
||||
let loading = loading.clone();
|
||||
|
||||
loading.set(true);
|
||||
spawn_local(async move {
|
||||
match service.stop_runner(&runner_id, false).await {
|
||||
Ok(_) => {
|
||||
console::log!("Runner stopped successfully");
|
||||
// Refresh runner status
|
||||
if let Ok(status) = service.get_runner_status(&runner_id).await {
|
||||
if let Some(mut current_runner) = (*runner).clone() {
|
||||
current_runner.status = status;
|
||||
runner.set(Some(current_runner));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to stop runner:", e.to_string());
|
||||
}
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_refresh_logs = {
|
||||
let service = service.clone();
|
||||
let runner_id = props.runner_id.clone();
|
||||
let runner = runner.clone();
|
||||
let logs_loading = logs_loading.clone();
|
||||
|
||||
Callback::from(move |_| {
|
||||
if let Some(service) = &service {
|
||||
let service = service.clone();
|
||||
let runner_id = runner_id.clone();
|
||||
let runner = runner.clone();
|
||||
let logs_loading = logs_loading.clone();
|
||||
|
||||
logs_loading.set(true);
|
||||
spawn_local(async move {
|
||||
match service.get_runner_logs(&runner_id, Some(100), false).await {
|
||||
Ok(logs) => {
|
||||
if let Some(mut current_runner) = (*runner).clone() {
|
||||
current_runner.logs = logs;
|
||||
runner.set(Some(current_runner));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to refresh logs:", e.to_string());
|
||||
}
|
||||
}
|
||||
logs_loading.set(false);
|
||||
});
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_script_change = {
|
||||
let job_script = job_script.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let textarea: HtmlTextAreaElement = e.target_unchecked_into();
|
||||
job_script.set(textarea.value());
|
||||
})
|
||||
};
|
||||
|
||||
let on_run_job = {
|
||||
let service = service.clone();
|
||||
let runner_id = props.runner_id.clone();
|
||||
let job_script = job_script.clone();
|
||||
let job_loading = job_loading.clone();
|
||||
let job_result = job_result.clone();
|
||||
|
||||
Callback::from(move |_| {
|
||||
if let Some(service) = &service {
|
||||
let script = (*job_script).clone();
|
||||
if !script.trim().is_empty() {
|
||||
let service = service.clone();
|
||||
let runner_id = runner_id.clone();
|
||||
let job_loading = job_loading.clone();
|
||||
let job_result = job_result.clone();
|
||||
|
||||
job_loading.set(true);
|
||||
job_result.set(None);
|
||||
spawn_local(async move {
|
||||
let job = JobBuilder::new()
|
||||
.caller_id("admin-ui")
|
||||
.context_id("test-job")
|
||||
.payload(script)
|
||||
.job_type(JobType::SAL)
|
||||
.runner_name(&runner_id)
|
||||
.build();
|
||||
|
||||
match job {
|
||||
Ok(job) => {
|
||||
match service.queue_and_wait(&runner_id, job, 30).await {
|
||||
Ok(result) => {
|
||||
job_result.set(result);
|
||||
}
|
||||
Err(e) => {
|
||||
job_result.set(Some(format!("Error: {}", e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
job_result.set(Some(format!("Job creation error: {}", e)));
|
||||
}
|
||||
}
|
||||
job_loading.set(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="bi bi-cpu me-2"></i>
|
||||
{"Runner Details: "}{&props.runner_id}
|
||||
</h1>
|
||||
<button class="btn btn-outline-secondary" onclick={on_back}>
|
||||
<i class="bi bi-arrow-left me-1"></i>
|
||||
{"Back to Runners"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Error display
|
||||
if let Some(err) = service_error {
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="alert alert-danger">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
{"Service Error: "}{err}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
if let Some(err) = error.as_ref() {
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
{"Error: "}{err}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if *loading {
|
||||
<div class="row">
|
||||
<div class="col-12 text-center">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">{"Loading..."}</span>
|
||||
</div>
|
||||
<p class="mt-2">{"Loading runner details..."}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// Runner details
|
||||
if let Some(runner_info) = runner.as_ref() {
|
||||
<div class="row">
|
||||
// Left column - Runner info and controls
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">{"Runner Information"}</h5>
|
||||
<StatusBadge status={runner_info.status.clone()} />
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4"><strong>{"ID:"}</strong></div>
|
||||
<div class="col-sm-8"><code>{&runner_info.id}</code></div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4"><strong>{"Type:"}</strong></div>
|
||||
<div class="col-sm-8">
|
||||
<span class="badge bg-info">
|
||||
{format!("{:?}", runner_info.config.runner_type)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4"><strong>{"Script Type:"}</strong></div>
|
||||
<div class="col-sm-8"><code>{&runner_info.config.script_type}</code></div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4"><strong>{"Binary Path:"}</strong></div>
|
||||
<div class="col-sm-8"><code class="small">{runner_info.config.binary_path.to_string_lossy()}</code></div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4"><strong>{"Restart Policy:"}</strong></div>
|
||||
<div class="col-sm-8">{&runner_info.config.restart_policy}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="btn-group w-100">
|
||||
if matches!(runner_info.status, ProcessStatus::Stopped | ProcessStatus::Failed) && !*loading {
|
||||
<button class="btn btn-outline-success" onclick={on_start}>
|
||||
<i class="bi bi-play-fill me-1"></i>
|
||||
{"Start"}
|
||||
</button>
|
||||
}
|
||||
if matches!(runner_info.status, ProcessStatus::Running) && !*loading {
|
||||
<button class="btn btn-outline-warning" onclick={on_stop}>
|
||||
<i class="bi bi-stop-fill me-1"></i>
|
||||
{"Stop"}
|
||||
</button>
|
||||
}
|
||||
if *loading {
|
||||
<button class="btn btn-outline-secondary" disabled=true>
|
||||
<div class="spinner-border spinner-border-sm me-1" role="status">
|
||||
<span class="visually-hidden">{"Loading..."}</span>
|
||||
</div>
|
||||
{"Working..."}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Right column - Job execution
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{"Test Job Execution"}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="job_script" class="form-label">{"Script Content"}</label>
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="job_script"
|
||||
rows="6"
|
||||
value={(*job_script).clone()}
|
||||
onchange={on_script_change}
|
||||
placeholder="Enter script content to execute..."
|
||||
></textarea>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary w-100 mb-3"
|
||||
onclick={on_run_job}
|
||||
disabled={*job_loading || job_script.trim().is_empty()}
|
||||
>
|
||||
if *job_loading {
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status">
|
||||
<span class="visually-hidden">{"Loading..."}</span>
|
||||
</div>
|
||||
{"Running Job..."}
|
||||
} else {
|
||||
<i class="bi bi-play-circle me-1"></i>
|
||||
{"Run Job"}
|
||||
}
|
||||
</button>
|
||||
|
||||
if let Some(result) = job_result.as_ref() {
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{"Job Result"}</label>
|
||||
<div class="log-container p-3 rounded">
|
||||
<pre class="mb-0">{result}</pre>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Logs section
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">{"Logs"}</h5>
|
||||
<button class="btn btn-outline-primary btn-sm" onclick={on_refresh_logs} disabled={*logs_loading}>
|
||||
if *logs_loading {
|
||||
<div class="spinner-border spinner-border-sm me-1" role="status">
|
||||
<span class="visually-hidden">{"Loading..."}</span>
|
||||
</div>
|
||||
{"Refreshing..."}
|
||||
} else {
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>
|
||||
{"Refresh Logs"}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
if runner_info.logs.is_empty() {
|
||||
<div class="p-4 text-center text-muted">
|
||||
{"No logs available"}
|
||||
</div>
|
||||
} else {
|
||||
<div class="log-container p-3" style="max-height: 400px; overflow-y: auto;">
|
||||
{for runner_info.logs.iter().map(|log| html! {
|
||||
<div class="mb-1">
|
||||
<small class="text-muted me-2">{&log.timestamp}</small>
|
||||
{&log.message}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
278
clients/admin-ui/src/components/runners.rs
Normal file
278
clients/admin-ui/src/components/runners.rs
Normal file
@@ -0,0 +1,278 @@
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
use gloo::console;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::types::{RunnerInfo, ProcessStatus};
|
||||
use crate::components::{status_badge::StatusBadge, runner_card::RunnerCard};
|
||||
use crate::services::{SupervisorService, use_supervisor_service};
|
||||
|
||||
#[function_component(RunnersList)]
|
||||
pub fn runners_list() -> Html {
|
||||
let navigator = use_navigator().unwrap();
|
||||
let server_url = "http://localhost:8081"; // Default supervisor server URL
|
||||
let (service, service_error) = use_supervisor_service(server_url);
|
||||
let runners = use_state(|| Vec::<RunnerInfo>::new());
|
||||
let loading = use_state(|| false);
|
||||
let error = use_state(|| None::<String>);
|
||||
let view_mode = use_state(|| "grid"); // "grid" or "table"
|
||||
|
||||
// Load runners on component mount and when service is available
|
||||
{
|
||||
let runners = runners.clone();
|
||||
let loading = loading.clone();
|
||||
let error = error.clone();
|
||||
let service = service.clone();
|
||||
|
||||
use_effect_with(service.clone(), move |service| {
|
||||
if let Some(service) = service {
|
||||
let runners = runners.clone();
|
||||
let loading = loading.clone();
|
||||
let error = error.clone();
|
||||
let service = service.clone();
|
||||
|
||||
loading.set(true);
|
||||
spawn_local(async move {
|
||||
match service.get_all_runners().await {
|
||||
Ok(runner_list) => {
|
||||
runners.set(runner_list);
|
||||
error.set(None);
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to load runners:", e.to_string());
|
||||
error.set(Some(e.to_string()));
|
||||
}
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let on_refresh = {
|
||||
let runners = runners.clone();
|
||||
let loading = loading.clone();
|
||||
let error = error.clone();
|
||||
let service = service.clone();
|
||||
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
if let Some(service) = &service {
|
||||
let runners = runners.clone();
|
||||
let loading = loading.clone();
|
||||
let error = error.clone();
|
||||
let service = service.clone();
|
||||
|
||||
loading.set(true);
|
||||
spawn_local(async move {
|
||||
match service.get_all_runners().await {
|
||||
Ok(runner_list) => {
|
||||
runners.set(runner_list);
|
||||
error.set(None);
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to refresh runners:", e.to_string());
|
||||
error.set(Some(e.to_string()));
|
||||
}
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let on_add_runner = {
|
||||
let navigator = navigator.clone();
|
||||
Callback::from(move |_: MouseEvent| navigator.push(&Route::AddRunner))
|
||||
};
|
||||
|
||||
let on_toggle_view = {
|
||||
let view_mode = view_mode.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
let current: &str = view_mode.as_ref();
|
||||
view_mode.set(if current == "grid" { "table" } else { "grid" });
|
||||
})
|
||||
};
|
||||
|
||||
// Create a separate callback for runner updates that matches the expected signature
|
||||
let on_runner_update = {
|
||||
let on_refresh = on_refresh.clone();
|
||||
Callback::from(move |_: ()| {
|
||||
on_refresh.emit(web_sys::MouseEvent::new("click").unwrap());
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h1 class="h3 mb-0">
|
||||
<i class="bi bi-cpu me-2"></i>
|
||||
{"Runners"}
|
||||
</h1>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-outline-secondary" onclick={on_toggle_view}>
|
||||
if *view_mode == "grid" {
|
||||
<i class="bi bi-table me-1"></i>
|
||||
{"Table View"}
|
||||
} else {
|
||||
<i class="bi bi-grid-3x3-gap me-1"></i>
|
||||
{"Grid View"}
|
||||
}
|
||||
</button>
|
||||
<button class="btn btn-outline-primary" onclick={on_refresh} disabled={*loading}>
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>
|
||||
{"Refresh"}
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick={on_add_runner.clone()}>
|
||||
<i class="bi bi-plus-circle me-1"></i>
|
||||
{"Add Runner"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Error display
|
||||
if let Some(err) = service_error {
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="alert alert-danger">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
{"Service Error: "}{err}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
if let Some(err) = error.as_ref() {
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
{"Error: "}{err}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if *loading {
|
||||
<div class="row">
|
||||
<div class="col-12 text-center">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">{"Loading..."}</span>
|
||||
</div>
|
||||
<p class="mt-2">{"Loading runners..."}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// Content based on view mode
|
||||
if !*loading && !runners.is_empty() {
|
||||
if *view_mode == "grid" {
|
||||
// Grid view
|
||||
<div class="row">
|
||||
{for runners.iter().map(|runner| {
|
||||
if let Some(service) = &service {
|
||||
html! {
|
||||
<RunnerCard
|
||||
runner={runner.clone()}
|
||||
service={service.clone()}
|
||||
on_update={on_runner_update.clone()}
|
||||
/>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
} else {
|
||||
// Table view
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"ID"}</th>
|
||||
<th>{"Type"}</th>
|
||||
<th>{"Status"}</th>
|
||||
<th>{"Script Type"}</th>
|
||||
<th>{"Binary Path"}</th>
|
||||
<th>{"Actions"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{for runners.iter().map(|runner| {
|
||||
let runner_id = runner.id.clone();
|
||||
let on_view_details = {
|
||||
let navigator = navigator.clone();
|
||||
let runner_id = runner_id.clone();
|
||||
Callback::from(move |_| {
|
||||
navigator.push(&Route::RunnerDetail { id: runner_id.clone() });
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<tr>
|
||||
<td>
|
||||
<code>{&runner.id}</code>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info">
|
||||
{format!("{:?}", runner.config.runner_type)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<StatusBadge status={runner.status.clone()} />
|
||||
</td>
|
||||
<td>
|
||||
<code class="small">{&runner.config.script_type}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code class="small">{runner.config.binary_path.to_string_lossy()}</code>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-outline-primary btn-sm" onclick={on_view_details}>
|
||||
<i class="bi bi-eye me-1"></i>
|
||||
{"Details"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if !*loading && runners.is_empty() && service.is_some() {
|
||||
<div class="row">
|
||||
<div class="col-12 text-center">
|
||||
<div class="card">
|
||||
<div class="card-body py-5">
|
||||
<i class="bi bi-cpu display-1 text-muted mb-3"></i>
|
||||
<h4 class="text-muted">{"No Runners Found"}</h4>
|
||||
<p class="text-muted">{"Get started by adding your first runner."}</p>
|
||||
<button class="btn btn-primary" onclick={on_add_runner.clone()}>
|
||||
<i class="bi bi-plus-circle me-1"></i>
|
||||
{"Add Runner"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
30
clients/admin-ui/src/components/status_badge.rs
Normal file
30
clients/admin-ui/src/components/status_badge.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use yew::prelude::*;
|
||||
use crate::types::ProcessStatus;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct StatusBadgeProps {
|
||||
pub status: ProcessStatus,
|
||||
#[prop_or_default]
|
||||
pub size: Option<String>,
|
||||
}
|
||||
|
||||
#[function_component(StatusBadge)]
|
||||
pub fn status_badge(props: &StatusBadgeProps) -> Html {
|
||||
let (badge_class, icon, text) = match props.status {
|
||||
ProcessStatus::Running => ("badge bg-success", "bi-play-circle-fill", "Running"),
|
||||
ProcessStatus::Stopped => ("badge bg-danger", "bi-stop-circle-fill", "Stopped"),
|
||||
ProcessStatus::Starting => ("badge bg-warning", "bi-hourglass-split", "Starting"),
|
||||
ProcessStatus::Stopping => ("badge bg-warning", "bi-hourglass-split", "Stopping"),
|
||||
ProcessStatus::Failed => ("badge bg-danger", "bi-exclamation-triangle-fill", "Failed"),
|
||||
ProcessStatus::Unknown => ("badge bg-secondary", "bi-question-circle-fill", "Unknown"),
|
||||
};
|
||||
|
||||
let size_class = props.size.as_deref().unwrap_or("");
|
||||
|
||||
html! {
|
||||
<span class={format!("{} {}", badge_class, size_class)}>
|
||||
<i class={format!("{} me-1", icon)}></i>
|
||||
{text}
|
||||
</span>
|
||||
}
|
||||
}
|
185
clients/admin-ui/src/jobs.rs
Normal file
185
clients/admin-ui/src/jobs.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
use yew::prelude::*;
|
||||
use hero_supervisor_openrpc_client::wasm::WasmJob;
|
||||
use crate::app::JobForm;
|
||||
use web_sys::{Event, HtmlInputElement, MouseEvent};
|
||||
|
||||
|
||||
#[derive(Properties)]
|
||||
pub struct JobsProps {
|
||||
pub jobs: Vec<WasmJob>,
|
||||
pub server_url: String,
|
||||
pub job_form: JobForm,
|
||||
pub runners: Vec<(String, String)>, // (name, status) - list of registered runners
|
||||
pub on_job_form_change: Callback<(String, String)>,
|
||||
pub on_run_job: Callback<()>,
|
||||
pub on_stop_job: Callback<String>,
|
||||
pub on_delete_job: Callback<String>,
|
||||
}
|
||||
|
||||
impl PartialEq for JobsProps {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
// Since WasmJob doesn't implement PartialEq, we'll compare by length
|
||||
// This is a simple comparison that will trigger re-renders when the job list changes
|
||||
self.jobs.len() == other.jobs.len() &&
|
||||
self.server_url == other.server_url &&
|
||||
self.job_form.payload == other.job_form.payload &&
|
||||
self.job_form.runner_name == other.job_form.runner_name &&
|
||||
self.job_form.executor == other.job_form.executor &&
|
||||
self.job_form.secret == other.job_form.secret &&
|
||||
self.runners.len() == other.runners.len()
|
||||
// Note: Callbacks don't implement PartialEq, so we skip them
|
||||
}
|
||||
}
|
||||
|
||||
#[function_component(Jobs)]
|
||||
pub fn jobs(props: &JobsProps) -> Html {
|
||||
let on_payload_change = {
|
||||
let on_change = props.on_job_form_change.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
on_change.emit(("payload".to_string(), input.value()));
|
||||
})
|
||||
};
|
||||
|
||||
let on_runner_name_change = {
|
||||
let on_change = props.on_job_form_change.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
on_change.emit(("runner_name".to_string(), input.value()));
|
||||
})
|
||||
};
|
||||
|
||||
let on_executor_change = {
|
||||
let on_change = props.on_job_form_change.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
on_change.emit(("executor".to_string(), input.value()));
|
||||
})
|
||||
};
|
||||
|
||||
let on_secret_change = {
|
||||
let on_change = props.on_job_form_change.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
on_change.emit(("secret".to_string(), input.value()));
|
||||
})
|
||||
};
|
||||
|
||||
let on_run_click = {
|
||||
let on_run = props.on_run_job.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
on_run.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="jobs-section">
|
||||
<h2>{"Jobs"}</h2>
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"Job ID"}</th>
|
||||
<th>{"Payload"}</th>
|
||||
<th>{"Runner"}</th>
|
||||
<th>{"Executor"}</th>
|
||||
<th>{"Status"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
// Job creation form as first row
|
||||
<tr class="job-form-row">
|
||||
<td>
|
||||
<span class="text-muted">{"New Job"}</span>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control table-input"
|
||||
placeholder="Script content"
|
||||
value={props.job_form.payload.clone()}
|
||||
onchange={on_payload_change}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
class="form-control table-input"
|
||||
value={props.job_form.runner_name.clone()}
|
||||
onchange={on_runner_name_change}
|
||||
>
|
||||
<option value="" disabled=true>{"-Select Runner-"}</option>
|
||||
{ for props.runners.iter().map(|(name, _status)| {
|
||||
html! {
|
||||
<option value={name.clone()} selected={name == &props.job_form.runner_name}>
|
||||
{name}
|
||||
</option>
|
||||
}
|
||||
})}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control table-input"
|
||||
placeholder="Executor"
|
||||
value={props.job_form.executor.clone()}
|
||||
onchange={on_executor_change}
|
||||
/>
|
||||
</td>
|
||||
<td class="action-cell">
|
||||
<input
|
||||
type="password"
|
||||
class="form-control table-input secret-input"
|
||||
placeholder="Secret"
|
||||
value={props.job_form.secret.clone()}
|
||||
onchange={on_secret_change}
|
||||
/>
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
onclick={on_run_click}
|
||||
>
|
||||
{"Run"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
// Existing jobs
|
||||
{for props.jobs.iter().map(|job| {
|
||||
let job_id = job.id();
|
||||
let on_stop = props.on_stop_job.clone();
|
||||
let on_delete = props.on_delete_job.clone();
|
||||
let job_id_stop = job_id.clone();
|
||||
let job_id_delete = job_id.clone();
|
||||
|
||||
html! {
|
||||
<tr>
|
||||
<td><small class="text-muted">{job_id}</small></td>
|
||||
<td><code class="code">{job.payload()}</code></td>
|
||||
<td>{job.runner_name()}</td>
|
||||
<td>{job.executor()}</td>
|
||||
<td class="action-cell">
|
||||
<span class="status-badge">{"Queued"}</span>
|
||||
<button
|
||||
class="btn-icon btn-stop"
|
||||
title="Stop Job"
|
||||
onclick={Callback::from(move |_| on_stop.emit(job_id_stop.clone()))}
|
||||
>
|
||||
{"⏹"}
|
||||
</button>
|
||||
<button
|
||||
class="btn-icon btn-delete"
|
||||
title="Delete Job"
|
||||
onclick={Callback::from(move |_| on_delete.emit(job_id_delete.clone()))}
|
||||
>
|
||||
{"🗑"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
12
clients/admin-ui/src/lib.rs
Normal file
12
clients/admin-ui/src/lib.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
mod app;
|
||||
mod sidebar;
|
||||
mod runners;
|
||||
mod jobs;
|
||||
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn main() {
|
||||
wasm_logger::init(wasm_logger::Config::default());
|
||||
yew::Renderer::<app::App>::new().render();
|
||||
}
|
219
clients/admin-ui/src/runners.rs
Normal file
219
clients/admin-ui/src/runners.rs
Normal file
@@ -0,0 +1,219 @@
|
||||
use yew::prelude::*;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use gloo::console;
|
||||
use hero_supervisor_openrpc_client::wasm::WasmSupervisorClient;
|
||||
use wasm_bindgen::JsCast;
|
||||
use crate::app::PingState;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct RegisterForm {
|
||||
pub name: String,
|
||||
pub secret: String,
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct RunnersProps {
|
||||
pub server_url: String,
|
||||
pub runners: Vec<(String, String)>, // (name, status)
|
||||
pub register_form: RegisterForm,
|
||||
pub ping_states: HashMap<String, PingState>, // runner_name -> ping_state
|
||||
pub on_register_form_change: Callback<(String, String)>,
|
||||
pub on_register_runner: Callback<()>,
|
||||
pub on_load_runners: Callback<()>,
|
||||
pub on_remove_runner: Callback<String>,
|
||||
pub on_ping_runner: Callback<(String, String)>, // (runner_name, secret)
|
||||
}
|
||||
|
||||
#[function_component(Runners)]
|
||||
pub fn runners(props: &RunnersProps) -> Html {
|
||||
let on_register_runner = {
|
||||
let server_url = props.server_url.clone();
|
||||
let register_form = props.register_form.clone();
|
||||
let on_register_runner = props.on_register_runner.clone();
|
||||
Callback::from(move |_: ()| {
|
||||
let server_url = server_url.clone();
|
||||
let register_form = register_form.clone();
|
||||
let on_register_runner = on_register_runner.clone();
|
||||
let client = WasmSupervisorClient::new(server_url);
|
||||
spawn_local(async move {
|
||||
console::log!("Registering runner...");
|
||||
|
||||
// Validate form data
|
||||
if register_form.name.is_empty() {
|
||||
console::error!("Runner name is required");
|
||||
return;
|
||||
}
|
||||
if register_form.secret.is_empty() {
|
||||
console::error!("Secret is required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Make actual registration call (use name as queue)
|
||||
match client.register_runner(
|
||||
®ister_form.secret,
|
||||
®ister_form.name,
|
||||
®ister_form.name, // queue = name
|
||||
).await {
|
||||
Ok(runner_name) => {
|
||||
console::log!("Runner registered successfully:", runner_name);
|
||||
on_register_runner.emit(());
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to register runner:", format!("{:?}", e));
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="runners-grid">
|
||||
// Registration card (first card)
|
||||
<div class="card register-card">
|
||||
<div class="card-title">{"+ Register Runner"}</div>
|
||||
<form onsubmit={on_register_runner.reform(|e: web_sys::SubmitEvent| {
|
||||
e.prevent_default();
|
||||
()
|
||||
})}>
|
||||
<div class="form-group">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Runner name"
|
||||
value={props.register_form.name.clone()}
|
||||
onchange={props.on_register_form_change.reform(|e: web_sys::Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target().unwrap().dyn_into().unwrap();
|
||||
("name".to_string(), input.value())
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group form-row">
|
||||
<input
|
||||
type="password"
|
||||
class="form-control form-control-inline"
|
||||
placeholder="Secret"
|
||||
value={props.register_form.secret.clone()}
|
||||
onchange={props.on_register_form_change.reform(|e: web_sys::Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target().unwrap().dyn_into().unwrap();
|
||||
("secret".to_string(), input.value())
|
||||
})}
|
||||
/>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{"Register"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
// Existing runner cards
|
||||
{for props.runners.iter().map(|(name, status)| {
|
||||
let status_class = match status.as_str() {
|
||||
"Running" => "status-running",
|
||||
"Stopped" => "status-stopped",
|
||||
"Starting" => "status-starting",
|
||||
"Stopping" => "status-starting",
|
||||
"Registering" => "status-registering",
|
||||
_ => "status-stopped",
|
||||
};
|
||||
|
||||
let name_clone = name.clone();
|
||||
let name_clone2 = name.clone();
|
||||
let on_remove = props.on_remove_runner.clone();
|
||||
let on_ping = props.on_ping_runner.clone();
|
||||
|
||||
html! {
|
||||
<div class="card runner-card">
|
||||
<div class="card-header">
|
||||
<div class="runner-title-section">
|
||||
<div class="runner-title-with-dot">
|
||||
<span class={format!("connection-dot {}", status_class)} title={status.clone()}>
|
||||
{"●"}
|
||||
</span>
|
||||
<div class="card-title">{name}</div>
|
||||
</div>
|
||||
<small class="queue-info">
|
||||
{"redis://localhost:6379/runner:"}{name}
|
||||
</small>
|
||||
</div>
|
||||
<div class="runner-actions-top">
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary btn-remove"
|
||||
title="Remove Runner"
|
||||
onclick={Callback::from(move |_| on_remove.emit(name_clone2.clone()))}
|
||||
>
|
||||
<svg class="trash-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3,6 5,6 21,6"></polyline>
|
||||
<path d="m5,6 1,14 c0,1.1 0.9,2 2,2 h8 c1.1,0 2,-0.9 2,-2 l1,-14"></path>
|
||||
<path d="m10,11 v6"></path>
|
||||
<path d="m14,11 v6"></path>
|
||||
<path d="M7,6V4c0-1.1,0.9-2,2-2h6c0-1.1,0.9-2,2-2v2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="runner-chart">
|
||||
<div class="chart-placeholder">
|
||||
{"📊 Live job count chart (5s updates)"}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ping-section">
|
||||
{
|
||||
match props.ping_states.get(name).cloned().unwrap_or(PingState::Idle) {
|
||||
PingState::Idle => html! {
|
||||
<div class="input-group input-group-sm">
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
placeholder="Secret"
|
||||
id={format!("ping-secret-{}", name)}
|
||||
/>
|
||||
<button
|
||||
class="btn btn-outline-primary"
|
||||
title="Ping Runner"
|
||||
onclick={Callback::from(move |_| {
|
||||
let window = web_sys::window().unwrap();
|
||||
let document = window.document().unwrap();
|
||||
let input_id = format!("ping-secret-{}", name_clone.clone());
|
||||
if let Some(input) = document.get_element_by_id(&input_id) {
|
||||
let input: web_sys::HtmlInputElement = input.dyn_into().unwrap();
|
||||
let secret = input.value();
|
||||
if !secret.is_empty() {
|
||||
on_ping.emit((name_clone.clone(), secret));
|
||||
input.set_value("");
|
||||
}
|
||||
}
|
||||
})}
|
||||
>
|
||||
{"Ping"}
|
||||
</button>
|
||||
</div>
|
||||
},
|
||||
PingState::Waiting => html! {
|
||||
<div class="ping-status ping-waiting">
|
||||
<span class="ping-spinner">{"⏳"}</span>
|
||||
<span>{"Waiting for response..."}</span>
|
||||
</div>
|
||||
},
|
||||
PingState::Success(result) => html! {
|
||||
<div class="ping-status ping-success">
|
||||
<span class="ping-icon">{"✅"}</span>
|
||||
<span>{format!("Success: {}", result)}</span>
|
||||
</div>
|
||||
},
|
||||
PingState::Error(error) => html! {
|
||||
<div class="ping-status ping-error">
|
||||
<span class="ping-icon">{"❌"}</span>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
},
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
}
|
145
clients/admin-ui/src/services.rs
Normal file
145
clients/admin-ui/src/services.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
use gloo::console;
|
||||
use std::rc::Rc;
|
||||
use std::cell::RefCell;
|
||||
use crate::wasm_client::{WasmSupervisorClient, WasmClientResult as ClientResult, RunnerConfig, ProcessManagerType, ProcessStatus, LogInfo, Job, RunnerType};
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use yew::prelude::*;
|
||||
|
||||
use crate::types::{RunnerInfo, AppState};
|
||||
|
||||
/// Service for managing supervisor client operations
|
||||
#[derive(Clone)]
|
||||
pub struct SupervisorService {
|
||||
client: Rc<RefCell<WasmSupervisorClient>>,
|
||||
}
|
||||
|
||||
impl PartialEq for SupervisorService {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
// Compare by server URL since that's the main identifier
|
||||
self.client.borrow().server_url() == other.client.borrow().server_url()
|
||||
}
|
||||
}
|
||||
|
||||
impl SupervisorService {
|
||||
pub fn new(server_url: &str) -> ClientResult<Self> {
|
||||
let client = WasmSupervisorClient::new(server_url);
|
||||
Ok(Self {
|
||||
client: Rc::new(RefCell::new(client)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get all runners with their status and basic info
|
||||
pub async fn get_all_runners(&self) -> ClientResult<Vec<RunnerInfo>> {
|
||||
let runner_ids = self.client.borrow_mut().list_runners().await?;
|
||||
let mut runners = Vec::new();
|
||||
|
||||
for id in runner_ids {
|
||||
let status = self.client.borrow_mut().get_runner_status(&id).await.unwrap_or(ProcessStatus::Unknown);
|
||||
let logs = self.client.borrow_mut().get_runner_logs(&id, Some(50), false).await.unwrap_or_default();
|
||||
|
||||
// Create a basic runner config since we don't have a get_runner_config method
|
||||
let config = RunnerConfig {
|
||||
actor_id: id.clone(),
|
||||
runner_type: RunnerType::SALRunner, // Default
|
||||
binary_path: std::path::PathBuf::from("unknown"),
|
||||
script_type: "unknown".to_string(),
|
||||
args: vec![],
|
||||
env_vars: std::collections::HashMap::new(),
|
||||
working_dir: None,
|
||||
restart_policy: "always".to_string(),
|
||||
health_check_command: None,
|
||||
dependencies: vec![],
|
||||
};
|
||||
|
||||
runners.push(RunnerInfo {
|
||||
id,
|
||||
config,
|
||||
status,
|
||||
logs,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(runners)
|
||||
}
|
||||
|
||||
/// Add a new runner
|
||||
pub async fn add_runner(&self, config: RunnerConfig, process_manager_type: ProcessManagerType) -> ClientResult<()> {
|
||||
self.client.borrow_mut().add_runner(config, process_manager_type).await
|
||||
}
|
||||
|
||||
/// Remove a runner
|
||||
pub async fn remove_runner(&self, actor_id: &str) -> ClientResult<()> {
|
||||
self.client.borrow_mut().remove_runner(actor_id).await
|
||||
}
|
||||
|
||||
/// Start a runner
|
||||
pub async fn start_runner(&self, actor_id: &str) -> ClientResult<()> {
|
||||
self.client.borrow_mut().start_runner(actor_id).await
|
||||
}
|
||||
|
||||
/// Stop a runner
|
||||
pub async fn stop_runner(&self, actor_id: &str, force: bool) -> ClientResult<()> {
|
||||
self.client.borrow_mut().stop_runner(actor_id, force).await
|
||||
}
|
||||
|
||||
/// Get runner status
|
||||
pub async fn get_runner_status(&self, actor_id: &str) -> ClientResult<ProcessStatus> {
|
||||
self.client.borrow_mut().get_runner_status(actor_id).await
|
||||
}
|
||||
|
||||
/// Get runner logs
|
||||
pub async fn get_runner_logs(&self, actor_id: &str, lines: Option<usize>, follow: bool) -> ClientResult<Vec<LogInfo>> {
|
||||
self.client.borrow_mut().get_runner_logs(actor_id, lines, follow).await
|
||||
}
|
||||
|
||||
/// Start all runners
|
||||
pub async fn start_all(&self) -> ClientResult<Vec<(String, bool)>> {
|
||||
self.client.borrow_mut().start_all().await
|
||||
}
|
||||
|
||||
/// Stop all runners
|
||||
pub async fn stop_all(&self, force: bool) -> ClientResult<Vec<(String, bool)>> {
|
||||
self.client.borrow_mut().stop_all(force).await
|
||||
}
|
||||
|
||||
/// Queue a job to a runner
|
||||
pub async fn queue_job(&self, runner_name: &str, job: Job) -> ClientResult<()> {
|
||||
self.client.borrow_mut().queue_job_to_runner(runner_name, job).await
|
||||
}
|
||||
|
||||
/// Queue a job and wait for result
|
||||
pub async fn queue_and_wait(&self, runner_name: &str, job: Job, timeout_secs: u64) -> ClientResult<Option<String>> {
|
||||
self.client.borrow_mut().queue_and_wait(runner_name, job, timeout_secs).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Hook for managing supervisor service state
|
||||
#[hook]
|
||||
pub fn use_supervisor_service(server_url: &str) -> (Option<SupervisorService>, Option<String>) {
|
||||
let server_url = server_url.to_string();
|
||||
let service_state = use_state(|| None);
|
||||
let error_state = use_state(|| None);
|
||||
|
||||
{
|
||||
let service_state = service_state.clone();
|
||||
let error_state = error_state.clone();
|
||||
let server_url = server_url.clone();
|
||||
|
||||
use_effect_with(server_url.clone(), move |_| {
|
||||
spawn_local(async move {
|
||||
match SupervisorService::new(&server_url) {
|
||||
Ok(service) => {
|
||||
service_state.set(Some(service));
|
||||
error_state.set(None);
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to create supervisor service:", e.to_string());
|
||||
error_state.set(Some(e.to_string()));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
((*service_state).clone(), (*error_state).clone())
|
||||
}
|
292
clients/admin-ui/src/sidebar.rs
Normal file
292
clients/admin-ui/src/sidebar.rs
Normal file
@@ -0,0 +1,292 @@
|
||||
use yew::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use gloo::console;
|
||||
use hero_supervisor_openrpc_client::wasm::WasmSupervisorClient;
|
||||
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct SupervisorInfo {
|
||||
pub server_url: String,
|
||||
pub admin_secrets_count: usize,
|
||||
pub user_secrets_count: usize,
|
||||
pub register_secrets_count: usize,
|
||||
pub runners_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct SidebarProps {
|
||||
pub server_url: String,
|
||||
pub supervisor_info: Option<SupervisorInfo>,
|
||||
pub admin_secret: String,
|
||||
pub on_admin_secret_change: Callback<String>,
|
||||
pub on_supervisor_info_loaded: Callback<SupervisorInfo>,
|
||||
}
|
||||
|
||||
#[function_component(Sidebar)]
|
||||
pub fn sidebar(props: &SidebarProps) -> Html {
|
||||
let is_unlocked = use_state(|| false);
|
||||
let unlock_secret = use_state(|| String::new());
|
||||
let admin_secrets = use_state(|| Vec::<String>::new());
|
||||
let user_secrets = use_state(|| Vec::<String>::new());
|
||||
let register_secrets = use_state(|| Vec::<String>::new());
|
||||
let is_loading = use_state(|| false);
|
||||
|
||||
|
||||
let on_unlock_secret_change = {
|
||||
let unlock_secret = unlock_secret.clone();
|
||||
Callback::from(move |e: web_sys::Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target().unwrap().dyn_into().unwrap();
|
||||
unlock_secret.set(input.value());
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
|
||||
let on_unlock_submit = {
|
||||
let unlock_secret = unlock_secret.clone();
|
||||
let is_unlocked = is_unlocked.clone();
|
||||
let is_loading = is_loading.clone();
|
||||
let admin_secrets = admin_secrets.clone();
|
||||
let user_secrets = user_secrets.clone();
|
||||
let register_secrets = register_secrets.clone();
|
||||
let server_url = props.server_url.clone();
|
||||
|
||||
Callback::from(move |_: web_sys::MouseEvent| {
|
||||
let unlock_secret = unlock_secret.clone();
|
||||
let is_unlocked = is_unlocked.clone();
|
||||
let is_loading = is_loading.clone();
|
||||
let admin_secrets = admin_secrets.clone();
|
||||
let user_secrets = user_secrets.clone();
|
||||
let register_secrets = register_secrets.clone();
|
||||
let server_url = server_url.clone();
|
||||
let secret_value = (*unlock_secret).clone();
|
||||
|
||||
if secret_value.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
is_loading.set(true);
|
||||
|
||||
spawn_local(async move {
|
||||
let client = WasmSupervisorClient::new(server_url);
|
||||
|
||||
// Try to load all secrets
|
||||
match client.list_admin_secrets(&secret_value).await {
|
||||
Ok(secrets) => {
|
||||
admin_secrets.set(secrets);
|
||||
|
||||
// Load user secrets
|
||||
if let Ok(user_secs) = client.list_user_secrets(&secret_value).await {
|
||||
user_secrets.set(user_secs);
|
||||
}
|
||||
|
||||
// Load register secrets
|
||||
if let Ok(reg_secs) = client.list_register_secrets(&secret_value).await {
|
||||
register_secrets.set(reg_secs);
|
||||
}
|
||||
|
||||
is_unlocked.set(true);
|
||||
unlock_secret.set(String::new());
|
||||
console::log!("Secrets unlocked successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to unlock secrets:", format!("{:?}", e));
|
||||
}
|
||||
}
|
||||
|
||||
is_loading.set(false);
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
let on_lock_click = {
|
||||
let is_unlocked = is_unlocked.clone();
|
||||
let admin_secrets = admin_secrets.clone();
|
||||
let user_secrets = user_secrets.clone();
|
||||
let register_secrets = register_secrets.clone();
|
||||
|
||||
Callback::from(move |_: web_sys::MouseEvent| {
|
||||
is_unlocked.set(false);
|
||||
admin_secrets.set(Vec::new());
|
||||
user_secrets.set(Vec::new());
|
||||
register_secrets.set(Vec::new());
|
||||
console::log!("Secrets locked");
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>{"Supervisor"}</h2>
|
||||
</div>
|
||||
<div class="sidebar-content">
|
||||
<div class="sidebar-sections">
|
||||
// Server Info Section
|
||||
<div class="server-info">
|
||||
<div class="server-header">
|
||||
<h3 class="supervisor-title">{"Hero Supervisor"}</h3>
|
||||
</div>
|
||||
<div class="server-url">
|
||||
<span class="connection-indicator connected"></span>
|
||||
<span class="url-text">{props.server_url.clone()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Secrets Management Section
|
||||
<div class="secrets-section">
|
||||
<div class="secrets-header">
|
||||
<span class="secrets-title">{"Secrets"}</span>
|
||||
if !*is_unlocked {
|
||||
<button
|
||||
class="unlock-btn"
|
||||
onclick={on_unlock_submit}
|
||||
disabled={*is_loading || unlock_secret.is_empty()}
|
||||
>
|
||||
<i class={if *is_loading { "fas fa-spinner fa-spin" } else { "fas fa-unlock" }}></i>
|
||||
</button>
|
||||
} else {
|
||||
<button
|
||||
class="lock-btn"
|
||||
onclick={on_lock_click}
|
||||
>
|
||||
<i class="fas fa-lock"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
if !*is_unlocked {
|
||||
<div class="unlock-input-row">
|
||||
<input
|
||||
type="password"
|
||||
class="unlock-input"
|
||||
placeholder="Enter admin secret to unlock"
|
||||
value={(*unlock_secret).clone()}
|
||||
onchange={on_unlock_secret_change}
|
||||
disabled={*is_loading}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
if *is_unlocked {
|
||||
<div class="secrets-content">
|
||||
<div class="secret-group">
|
||||
<div class="secret-header">
|
||||
<span class="secret-title">{"Admin secrets"}</span>
|
||||
</div>
|
||||
<div class="secret-list">
|
||||
{ for admin_secrets.iter().enumerate().map(|(i, secret)| {
|
||||
html! {
|
||||
<div class="secret-item" key={i}>
|
||||
<div class="secret-value">{secret.clone()}</div>
|
||||
<button class="btn-icon btn-remove">
|
||||
<i class="fas fa-minus"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
<div class="secret-add-row">
|
||||
<input
|
||||
type="text"
|
||||
class="secret-add-input"
|
||||
placeholder="New admin secret"
|
||||
/>
|
||||
<button class="btn-icon btn-add">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="secret-group">
|
||||
<div class="secret-header">
|
||||
<span class="secret-title">{"User secrets"}</span>
|
||||
</div>
|
||||
<div class="secret-list">
|
||||
{ for user_secrets.iter().enumerate().map(|(i, secret)| {
|
||||
html! {
|
||||
<div class="secret-item" key={i}>
|
||||
<div class="secret-value">{secret.clone()}</div>
|
||||
<button class="btn-icon btn-remove">
|
||||
<i class="fas fa-minus"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
<div class="secret-add-row">
|
||||
<input
|
||||
type="text"
|
||||
class="secret-add-input"
|
||||
placeholder="New user secret"
|
||||
/>
|
||||
<button class="btn-icon btn-add">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="secret-group">
|
||||
<div class="secret-header">
|
||||
<span class="secret-title">{"Register secrets"}</span>
|
||||
</div>
|
||||
<div class="secret-list">
|
||||
{ for register_secrets.iter().enumerate().map(|(i, secret)| {
|
||||
html! {
|
||||
<div class="secret-item" key={i}>
|
||||
<div class="secret-value">{secret.clone()}</div>
|
||||
<button class="btn-icon btn-remove">
|
||||
<i class="fas fa-minus"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
<div class="secret-add-row">
|
||||
<input
|
||||
type="text"
|
||||
class="secret-add-input"
|
||||
placeholder="New register secret"
|
||||
/>
|
||||
<button class="btn-icon btn-add">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
if *is_unlocked {
|
||||
<div class="save-section">
|
||||
<button class="save-changes-btn">
|
||||
{"Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Documentation Links at Bottom
|
||||
<div class="sidebar-footer">
|
||||
<div class="docs-section">
|
||||
<h5>{"Documentation"}</h5>
|
||||
<div class="docs-links">
|
||||
<a href="https://github.com/herocode/supervisor" target="_blank" class="doc-link">
|
||||
{"📖 User Guide"}
|
||||
</a>
|
||||
<a href="https://github.com/herocode/supervisor/blob/main/README.md" target="_blank" class="doc-link">
|
||||
{"🚀 Getting Started"}
|
||||
</a>
|
||||
<a href="https://github.com/herocode/supervisor/issues" target="_blank" class="doc-link">
|
||||
{"🐛 Report Issues"}
|
||||
</a>
|
||||
<a href="https://github.com/herocode/supervisor/wiki" target="_blank" class="doc-link">
|
||||
{"📚 API Reference"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
61
clients/admin-ui/src/types.rs
Normal file
61
clients/admin-ui/src/types.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
// Re-export types from the WASM client
|
||||
pub use crate::wasm_client::{
|
||||
WasmClientError as ClientError, WasmClientResult as ClientResult, JobType, ProcessStatus,
|
||||
RunnerType, RunnerConfig, ProcessManagerType, LogInfo, Job, JobBuilder
|
||||
};
|
||||
|
||||
/// UI-specific runner information combining config and status
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RunnerInfo {
|
||||
pub id: String,
|
||||
pub config: RunnerConfig,
|
||||
pub status: ProcessStatus,
|
||||
pub logs: Vec<LogInfo>,
|
||||
}
|
||||
|
||||
/// Form data for adding a new runner
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AddRunnerForm {
|
||||
pub actor_id: String,
|
||||
pub runner_type: RunnerType,
|
||||
pub binary_path: String,
|
||||
pub script_type: String,
|
||||
pub args: Vec<String>,
|
||||
pub env_vars: HashMap<String, String>,
|
||||
pub working_dir: Option<PathBuf>,
|
||||
pub restart_policy: String,
|
||||
pub health_check_command: Option<String>,
|
||||
pub dependencies: Vec<String>,
|
||||
pub process_manager_type: ProcessManagerType,
|
||||
}
|
||||
|
||||
impl AddRunnerForm {
|
||||
pub fn to_runner_config(&self) -> RunnerConfig {
|
||||
RunnerConfig {
|
||||
actor_id: self.actor_id.clone(),
|
||||
runner_type: self.runner_type.clone(),
|
||||
binary_path: PathBuf::from(&self.binary_path),
|
||||
script_type: self.script_type.clone(),
|
||||
args: self.args.clone(),
|
||||
env_vars: self.env_vars.clone(),
|
||||
working_dir: self.working_dir.clone(),
|
||||
restart_policy: self.restart_policy.clone(),
|
||||
health_check_command: self.health_check_command.clone(),
|
||||
dependencies: self.dependencies.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Application state for managing runners
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AppState {
|
||||
pub runners: Vec<RunnerInfo>,
|
||||
pub loading: bool,
|
||||
pub error: Option<String>,
|
||||
pub server_url: String,
|
||||
}
|
378
clients/admin-ui/src/wasm_client.rs
Normal file
378
clients/admin-ui/src/wasm_client.rs
Normal file
@@ -0,0 +1,378 @@
|
||||
use gloo::net::http::Request;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// WASM-compatible client for Hero Supervisor OpenRPC server
|
||||
#[derive(Clone)]
|
||||
pub struct WasmSupervisorClient {
|
||||
server_url: String,
|
||||
request_id: u64,
|
||||
}
|
||||
|
||||
/// Error types for client operations
|
||||
#[derive(Error, Debug)]
|
||||
pub enum WasmClientError {
|
||||
#[error("HTTP request error: {0}")]
|
||||
Http(String),
|
||||
|
||||
#[error("JSON serialization error: {0}")]
|
||||
Serialization(#[from] serde_json::Error),
|
||||
|
||||
#[error("Server error: {message}")]
|
||||
Server { message: String },
|
||||
}
|
||||
|
||||
/// Result type for client operations
|
||||
pub type WasmClientResult<T> = Result<T, WasmClientError>;
|
||||
|
||||
/// Types of runners supported by the supervisor
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum RunnerType {
|
||||
SALRunner,
|
||||
OSISRunner,
|
||||
VRunner,
|
||||
}
|
||||
|
||||
impl Default for RunnerType {
|
||||
fn default() -> Self {
|
||||
RunnerType::SALRunner
|
||||
}
|
||||
}
|
||||
|
||||
/// Process manager types
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ProcessManagerType {
|
||||
Simple,
|
||||
Tmux,
|
||||
}
|
||||
|
||||
impl Default for ProcessManagerType {
|
||||
fn default() -> Self {
|
||||
ProcessManagerType::Simple
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for an actor runner
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RunnerConfig {
|
||||
pub actor_id: String,
|
||||
pub runner_type: RunnerType,
|
||||
pub binary_path: PathBuf,
|
||||
pub script_type: String,
|
||||
pub args: Vec<String>,
|
||||
pub env_vars: HashMap<String, String>,
|
||||
pub working_dir: Option<PathBuf>,
|
||||
pub restart_policy: String,
|
||||
pub health_check_command: Option<String>,
|
||||
pub dependencies: Vec<String>,
|
||||
}
|
||||
|
||||
/// Job type enumeration
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum JobType {
|
||||
SAL,
|
||||
OSIS,
|
||||
V,
|
||||
}
|
||||
|
||||
/// Job structure for creating and managing jobs
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Job {
|
||||
pub id: String,
|
||||
pub caller_id: String,
|
||||
pub context_id: String,
|
||||
pub payload: String,
|
||||
pub job_type: JobType,
|
||||
pub runner_name: String,
|
||||
pub timeout: Option<u64>,
|
||||
pub env_vars: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Process status information
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ProcessStatus {
|
||||
Running,
|
||||
Stopped,
|
||||
Starting,
|
||||
Stopping,
|
||||
Failed,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Log information structure
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LogInfo {
|
||||
pub timestamp: String,
|
||||
pub level: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl WasmSupervisorClient {
|
||||
/// Create a new supervisor client
|
||||
pub fn new(server_url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
server_url: server_url.into(),
|
||||
request_id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the server URL
|
||||
pub fn server_url(&self) -> &str {
|
||||
&self.server_url
|
||||
}
|
||||
|
||||
/// Make a JSON-RPC request
|
||||
async fn make_request<T>(&mut self, method: &str, params: Value) -> WasmClientResult<T>
|
||||
where
|
||||
T: for<'de> Deserialize<'de>,
|
||||
{
|
||||
self.request_id += 1;
|
||||
|
||||
let request_body = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"params": params,
|
||||
"id": self.request_id
|
||||
});
|
||||
|
||||
let response = Request::post(&self.server_url)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&request_body)
|
||||
.map_err(|e| WasmClientError::Http(e.to_string()))?
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| WasmClientError::Http(e.to_string()))?;
|
||||
|
||||
if !response.ok() {
|
||||
return Err(WasmClientError::Http(format!(
|
||||
"HTTP error: {} {}",
|
||||
response.status(),
|
||||
response.status_text()
|
||||
)));
|
||||
}
|
||||
|
||||
let response_text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| WasmClientError::Http(e.to_string()))?;
|
||||
|
||||
let response_json: Value = serde_json::from_str(&response_text)?;
|
||||
|
||||
if let Some(error) = response_json.get("error") {
|
||||
return Err(WasmClientError::Server {
|
||||
message: error.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("Unknown server error")
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let result = response_json
|
||||
.get("result")
|
||||
.ok_or_else(|| WasmClientError::Server {
|
||||
message: "No result in response".to_string(),
|
||||
})?;
|
||||
|
||||
serde_json::from_value(result.clone()).map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Add a new runner to the supervisor
|
||||
pub async fn add_runner(
|
||||
&mut self,
|
||||
config: RunnerConfig,
|
||||
process_manager_type: ProcessManagerType,
|
||||
) -> WasmClientResult<()> {
|
||||
let params = json!({
|
||||
"config": config,
|
||||
"process_manager_type": process_manager_type
|
||||
});
|
||||
|
||||
self.make_request("add_runner", params).await
|
||||
}
|
||||
|
||||
/// Remove a runner from the supervisor
|
||||
pub async fn remove_runner(&mut self, actor_id: &str) -> WasmClientResult<()> {
|
||||
let params = json!({ "actor_id": actor_id });
|
||||
self.make_request("remove_runner", params).await
|
||||
}
|
||||
|
||||
/// List all runner IDs
|
||||
pub async fn list_runners(&mut self) -> WasmClientResult<Vec<String>> {
|
||||
self.make_request("list_runners", json!({})).await
|
||||
}
|
||||
|
||||
/// Start a specific runner
|
||||
pub async fn start_runner(&mut self, actor_id: &str) -> WasmClientResult<()> {
|
||||
let params = json!({ "actor_id": actor_id });
|
||||
self.make_request("start_runner", params).await
|
||||
}
|
||||
|
||||
/// Stop a specific runner
|
||||
pub async fn stop_runner(&mut self, actor_id: &str, force: bool) -> WasmClientResult<()> {
|
||||
let params = json!({ "actor_id": actor_id, "force": force });
|
||||
self.make_request("stop_runner", params).await
|
||||
}
|
||||
|
||||
/// Get status of a specific runner
|
||||
pub async fn get_runner_status(&mut self, actor_id: &str) -> WasmClientResult<ProcessStatus> {
|
||||
let params = json!({ "actor_id": actor_id });
|
||||
self.make_request("get_runner_status", params).await
|
||||
}
|
||||
|
||||
/// Get logs for a specific runner
|
||||
pub async fn get_runner_logs(
|
||||
&mut self,
|
||||
actor_id: &str,
|
||||
lines: Option<usize>,
|
||||
follow: bool,
|
||||
) -> WasmClientResult<Vec<LogInfo>> {
|
||||
let params = json!({
|
||||
"actor_id": actor_id,
|
||||
"lines": lines,
|
||||
"follow": follow
|
||||
});
|
||||
self.make_request("get_runner_logs", params).await
|
||||
}
|
||||
|
||||
/// Queue a job to a specific runner
|
||||
pub async fn queue_job_to_runner(&mut self, runner_name: &str, job: Job) -> WasmClientResult<()> {
|
||||
let params = json!({
|
||||
"runner_name": runner_name,
|
||||
"job": job
|
||||
});
|
||||
self.make_request("queue_job_to_runner", params).await
|
||||
}
|
||||
|
||||
/// Queue a job to a specific runner and wait for the result
|
||||
pub async fn queue_and_wait(
|
||||
&mut self,
|
||||
runner_name: &str,
|
||||
job: Job,
|
||||
timeout_secs: u64,
|
||||
) -> WasmClientResult<Option<String>> {
|
||||
let params = json!({
|
||||
"runner_name": runner_name,
|
||||
"job": job,
|
||||
"timeout_secs": timeout_secs
|
||||
});
|
||||
self.make_request("queue_and_wait", params).await
|
||||
}
|
||||
|
||||
/// Get job result by job ID
|
||||
pub async fn get_job_result(&mut self, job_id: &str) -> WasmClientResult<Option<String>> {
|
||||
let params = json!({ "job_id": job_id });
|
||||
self.make_request("get_job_result", params).await
|
||||
}
|
||||
|
||||
/// Get status of all runners
|
||||
pub async fn get_all_runner_status(&mut self) -> WasmClientResult<Vec<(String, ProcessStatus)>> {
|
||||
self.make_request("get_all_runner_status", json!({})).await
|
||||
}
|
||||
|
||||
/// Start all runners
|
||||
pub async fn start_all(&mut self) -> WasmClientResult<Vec<(String, bool)>> {
|
||||
self.make_request("start_all", json!({})).await
|
||||
}
|
||||
|
||||
/// Stop all runners
|
||||
pub async fn stop_all(&mut self, force: bool) -> WasmClientResult<Vec<(String, bool)>> {
|
||||
let params = json!({ "force": force });
|
||||
self.make_request("stop_all", params).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for creating jobs with a fluent API
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct JobBuilder {
|
||||
id: Option<String>,
|
||||
caller_id: Option<String>,
|
||||
context_id: Option<String>,
|
||||
payload: Option<String>,
|
||||
job_type: Option<JobType>,
|
||||
runner_name: Option<String>,
|
||||
timeout: Option<u64>,
|
||||
env_vars: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl JobBuilder {
|
||||
/// Create a new job builder
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Set the caller ID for this job
|
||||
pub fn caller_id(mut self, caller_id: impl Into<String>) -> Self {
|
||||
self.caller_id = Some(caller_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the context ID for this job
|
||||
pub fn context_id(mut self, context_id: impl Into<String>) -> Self {
|
||||
self.context_id = Some(context_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the payload (script content) for this job
|
||||
pub fn payload(mut self, payload: impl Into<String>) -> Self {
|
||||
self.payload = Some(payload.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the job type
|
||||
pub fn job_type(mut self, job_type: JobType) -> Self {
|
||||
self.job_type = Some(job_type);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the runner name for this job
|
||||
pub fn runner_name(mut self, runner_name: impl Into<String>) -> Self {
|
||||
self.runner_name = Some(runner_name.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the timeout for job execution
|
||||
pub fn timeout(mut self, timeout_secs: u64) -> Self {
|
||||
self.timeout = Some(timeout_secs);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set a single environment variable
|
||||
pub fn env_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
self.env_vars.insert(key.into(), value.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set multiple environment variables from a HashMap
|
||||
pub fn env_vars(mut self, env_vars: HashMap<String, String>) -> Self {
|
||||
self.env_vars = env_vars;
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the job
|
||||
pub fn build(self) -> WasmClientResult<Job> {
|
||||
Ok(Job {
|
||||
id: self.id.unwrap_or_else(|| Uuid::new_v4().to_string()),
|
||||
caller_id: self.caller_id.ok_or_else(|| WasmClientError::Server {
|
||||
message: "caller_id is required".to_string(),
|
||||
})?,
|
||||
context_id: self.context_id.ok_or_else(|| WasmClientError::Server {
|
||||
message: "context_id is required".to_string(),
|
||||
})?,
|
||||
payload: self.payload.ok_or_else(|| WasmClientError::Server {
|
||||
message: "payload is required".to_string(),
|
||||
})?,
|
||||
job_type: self.job_type.ok_or_else(|| WasmClientError::Server {
|
||||
message: "job_type is required".to_string(),
|
||||
})?,
|
||||
runner_name: self.runner_name.ok_or_else(|| WasmClientError::Server {
|
||||
message: "runner_name is required".to_string(),
|
||||
})?,
|
||||
timeout: self.timeout,
|
||||
env_vars: self.env_vars,
|
||||
})
|
||||
}
|
||||
}
|
1129
clients/admin-ui/styles.css
Normal file
1129
clients/admin-ui/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
2
clients/openrpc/.gitignore
vendored
Normal file
2
clients/openrpc/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
pkg
|
||||
target
|
59
clients/openrpc/Cargo-wasm.toml
Normal file
59
clients/openrpc/Cargo-wasm.toml
Normal file
@@ -0,0 +1,59 @@
|
||||
[package]
|
||||
name = "hero-supervisor-openrpc-client-wasm"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "WASM-compatible OpenRPC client for Hero Supervisor"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
# WASM bindings
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
|
||||
# Web APIs
|
||||
web-sys = { version = "0.3", features = [
|
||||
"console",
|
||||
"Request",
|
||||
"RequestInit",
|
||||
"RequestMode",
|
||||
"Response",
|
||||
"Window",
|
||||
"Headers",
|
||||
"AbortController",
|
||||
"AbortSignal",
|
||||
] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde-wasm-bindgen = "0.6"
|
||||
|
||||
# Error handling
|
||||
thiserror = "1.0"
|
||||
|
||||
# UUID for job IDs
|
||||
uuid = { version = "1.0", features = ["v4", "serde", "js"] }
|
||||
|
||||
# Time handling
|
||||
chrono = { version = "0.4", features = ["serde", "wasmbind"] }
|
||||
|
||||
# Collections
|
||||
indexmap = "2.0"
|
||||
|
||||
# Logging for WASM
|
||||
log = "0.4"
|
||||
console_log = "1.0"
|
||||
|
||||
# Async utilities
|
||||
futures = "0.3"
|
||||
|
||||
[dependencies.getrandom]
|
||||
version = "0.2"
|
||||
features = ["js"]
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
2710
clients/openrpc/Cargo.lock
generated
Normal file
2710
clients/openrpc/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
82
clients/openrpc/Cargo.toml
Normal file
82
clients/openrpc/Cargo.toml
Normal file
@@ -0,0 +1,82 @@
|
||||
[package]
|
||||
name = "hero-supervisor-openrpc-client"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "OpenRPC client for Hero Supervisor"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
# Common dependencies for both native and WASM
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
thiserror = "1.0"
|
||||
log = "0.4"
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
|
||||
# Native JSON-RPC client (not WASM compatible)
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
jsonrpsee = { version = "0.24", features = ["http-client", "macros"] }
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
hero-supervisor = { path = "../.." }
|
||||
env_logger = "0.11"
|
||||
|
||||
# WASM-specific dependencies
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = [
|
||||
"console",
|
||||
"Request",
|
||||
"RequestInit",
|
||||
"RequestMode",
|
||||
"Response",
|
||||
"Headers",
|
||||
"Window",
|
||||
] }
|
||||
console_log = "1.0"
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
|
||||
# UUID for job IDs (native)
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.uuid]
|
||||
version = "1.0"
|
||||
features = ["v4", "serde"]
|
||||
|
||||
# Time handling (native)
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.chrono]
|
||||
version = "0.4"
|
||||
features = ["serde"]
|
||||
|
||||
# WASM-compatible dependencies (already defined above)
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies.chrono]
|
||||
version = "0.4"
|
||||
features = ["serde", "wasmbind"]
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies.uuid]
|
||||
version = "1.0"
|
||||
features = ["v4", "serde", "js"]
|
||||
|
||||
# Collections
|
||||
indexmap = "2.0"
|
||||
|
||||
# Interactive CLI
|
||||
crossterm = "0.27"
|
||||
ratatui = "0.28"
|
||||
|
||||
# Command line parsing
|
||||
clap = { version = "4.0", features = ["derive"] }
|
||||
|
||||
[[bin]]
|
||||
name = "openrpc-cli"
|
||||
path = "cmd/main.rs"
|
||||
|
||||
[dev-dependencies]
|
||||
# Testing utilities
|
||||
tokio-test = "0.4"
|
196
clients/openrpc/README.md
Normal file
196
clients/openrpc/README.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Hero Supervisor OpenRPC Client
|
||||
|
||||
A Rust client library for interacting with the Hero Supervisor OpenRPC server. This crate provides a simple, async interface for managing actors and jobs remotely.
|
||||
|
||||
## Features
|
||||
|
||||
- **Async API**: Built on `tokio` and `jsonrpsee` for high-performance async operations
|
||||
- **Type Safety**: Full Rust type safety with serde serialization/deserialization
|
||||
- **Job Builder**: Fluent API for creating jobs with validation
|
||||
- **Comprehensive Coverage**: All supervisor operations available via client
|
||||
- **Error Handling**: Detailed error types with proper error propagation
|
||||
|
||||
## Installation
|
||||
|
||||
Add this to your `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
hero-supervisor-openrpc-client = "0.1.0"
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```rust
|
||||
use hero_supervisor_openrpc_client::{
|
||||
SupervisorClient, RunnerConfig, RunnerType, ProcessManagerType, JobBuilder, JobType
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Create a client
|
||||
let client = SupervisorClient::new("http://127.0.0.1:3030")?;
|
||||
|
||||
// Add a runner
|
||||
let config = RunnerConfig {
|
||||
actor_id: "my_actor".to_string(),
|
||||
runner_type: RunnerType::OSISRunner,
|
||||
binary_path: PathBuf::from("/path/to/actor/binary"),
|
||||
db_path: "/path/to/db".to_string(),
|
||||
redis_url: "redis://localhost:6379".to_string(),
|
||||
};
|
||||
|
||||
client.add_runner(config, ProcessManagerType::Simple).await?;
|
||||
|
||||
// Start the runner
|
||||
client.start_runner("my_actor").await?;
|
||||
|
||||
// Create and queue a job
|
||||
let job = JobBuilder::new()
|
||||
.caller_id("my_client")
|
||||
.context_id("example_context")
|
||||
.payload("print('Hello from Hero Supervisor!');")
|
||||
.job_type(JobType::OSIS)
|
||||
.runner_name("my_actor")
|
||||
.timeout(Duration::from_secs(60))
|
||||
.build()?;
|
||||
|
||||
client.queue_job_to_runner("my_actor", job).await?;
|
||||
|
||||
// Check runner status
|
||||
let status = client.get_runner_status("my_actor").await?;
|
||||
println!("Runner status: {:?}", status);
|
||||
|
||||
// List all runners
|
||||
let runners = client.list_runners().await?;
|
||||
println!("Active runners: {:?}", runners);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Client Creation
|
||||
|
||||
```rust
|
||||
let client = SupervisorClient::new("http://127.0.0.1:3030")?;
|
||||
```
|
||||
|
||||
### Runner Management
|
||||
|
||||
```rust
|
||||
// Add a runner
|
||||
client.add_runner(config, ProcessManagerType::Simple).await?;
|
||||
|
||||
// Remove a runner
|
||||
client.remove_runner("actor_id").await?;
|
||||
|
||||
// List all runners
|
||||
let runners = client.list_runners().await?;
|
||||
|
||||
// Start/stop runners
|
||||
client.start_runner("actor_id").await?;
|
||||
client.stop_runner("actor_id", false).await?; // force = false
|
||||
|
||||
// Get runner status
|
||||
let status = client.get_runner_status("actor_id").await?;
|
||||
|
||||
// Get runner logs
|
||||
let logs = client.get_runner_logs("actor_id", Some(100), false).await?;
|
||||
```
|
||||
|
||||
### Job Management
|
||||
|
||||
```rust
|
||||
// Create a job using the builder
|
||||
let job = JobBuilder::new()
|
||||
.caller_id("client_id")
|
||||
.context_id("context_id")
|
||||
.payload("script_content")
|
||||
.job_type(JobType::OSIS)
|
||||
.runner_name("target_actor")
|
||||
.timeout(Duration::from_secs(300))
|
||||
.env_var("KEY", "value")
|
||||
.build()?;
|
||||
|
||||
// Queue the job
|
||||
client.queue_job_to_runner("actor_id", job).await?;
|
||||
```
|
||||
|
||||
### Bulk Operations
|
||||
|
||||
```rust
|
||||
// Start all runners
|
||||
let results = client.start_all().await?;
|
||||
|
||||
// Stop all runners
|
||||
let results = client.stop_all(false).await?; // force = false
|
||||
|
||||
// Get status of all runners
|
||||
let statuses = client.get_all_runner_status().await?;
|
||||
```
|
||||
|
||||
## Types
|
||||
|
||||
### RunnerType
|
||||
|
||||
- `SALRunner` - System abstraction layer operations
|
||||
- `OSISRunner` - Operating system interface operations
|
||||
- `VRunner` - Virtualization operations
|
||||
- `PyRunner` - Python-based actors
|
||||
|
||||
### JobType
|
||||
|
||||
- `SAL` - SAL job type
|
||||
- `OSIS` - OSIS job type
|
||||
- `V` - V job type
|
||||
- `Python` - Python job type
|
||||
|
||||
### ProcessManagerType
|
||||
|
||||
- `Simple` - Direct process spawning
|
||||
- `Tmux(String)` - Tmux session-based management
|
||||
|
||||
### ProcessStatus
|
||||
|
||||
- `Running` - Process is active
|
||||
- `Stopped` - Process is stopped
|
||||
- `Failed` - Process failed
|
||||
- `Unknown` - Status unknown
|
||||
|
||||
## Error Handling
|
||||
|
||||
The client uses the `ClientError` enum for error handling:
|
||||
|
||||
```rust
|
||||
use hero_supervisor_openrpc_client::ClientError;
|
||||
|
||||
match client.start_runner("actor_id").await {
|
||||
Ok(()) => println!("Runner started successfully"),
|
||||
Err(ClientError::JsonRpc(e)) => println!("JSON-RPC error: {}", e),
|
||||
Err(ClientError::Server { message }) => println!("Server error: {}", message),
|
||||
Err(e) => println!("Other error: {}", e),
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
See the `examples/` directory for complete usage examples:
|
||||
|
||||
- `basic_client.rs` - Basic client usage
|
||||
- `job_management.rs` - Job creation and management
|
||||
- `runner_lifecycle.rs` - Complete runner lifecycle management
|
||||
|
||||
## Requirements
|
||||
|
||||
- Rust 1.70+
|
||||
- Hero Supervisor server running with OpenRPC feature enabled
|
||||
- Network access to the supervisor server
|
||||
|
||||
## License
|
||||
|
||||
Licensed under either of Apache License, Version 2.0 or MIT license at your option.
|
29
clients/openrpc/build-wasm.sh
Executable file
29
clients/openrpc/build-wasm.sh
Executable file
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Build script for WASM-compatible OpenRPC client
|
||||
|
||||
set -e
|
||||
|
||||
echo "Building WASM OpenRPC client..."
|
||||
|
||||
# Check if wasm-pack is installed
|
||||
if ! command -v wasm-pack &> /dev/null; then
|
||||
echo "wasm-pack is not installed. Installing..."
|
||||
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
|
||||
fi
|
||||
|
||||
# Build the WASM package
|
||||
echo "Building WASM package..."
|
||||
wasm-pack build --target web --out-dir pkg-wasm
|
||||
|
||||
echo "WASM build complete! Package available in pkg-wasm/"
|
||||
echo ""
|
||||
echo "To use in a web project:"
|
||||
echo "1. Copy the pkg-wasm directory to your web project"
|
||||
echo "2. Import the module in your JavaScript:"
|
||||
echo " import init, { WasmSupervisorClient, create_client, create_job } from './pkg-wasm/hero_supervisor_openrpc_client_wasm.js';"
|
||||
echo "3. Initialize the WASM module:"
|
||||
echo " await init();"
|
||||
echo "4. Create and use the client:"
|
||||
echo " const client = create_client('http://localhost:3030');"
|
||||
echo " const runners = await client.list_runners();"
|
872
clients/openrpc/cmd/main.rs
Normal file
872
clients/openrpc/cmd/main.rs
Normal file
@@ -0,0 +1,872 @@
|
||||
//! 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_name".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_name" => params["runner_name"] = value,
|
||||
"payload" => params["payload"] = value,
|
||||
_ => {}
|
||||
}
|
||||
} else {
|
||||
params[¶m.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_name), Some(payload)) = (
|
||||
params.get("secret").and_then(|v| v.as_str()),
|
||||
params.get("job_id").and_then(|v| v.as_str()),
|
||||
params.get("runner_name").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_name": runner_name,
|
||||
"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_name, 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(¶m.name, label_style),
|
||||
Span::raw(if param.required { " (required)" } else { " (optional)" }),
|
||||
Span::raw(format!(" [{}]", param.param_type)),
|
||||
]),
|
||||
Line::from(Span::styled(¶m.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(())
|
||||
}
|
202
clients/openrpc/example-wasm.html
Normal file
202
clients/openrpc/example-wasm.html
Normal file
@@ -0,0 +1,202 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Hero Supervisor WASM OpenRPC Client Example</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
button {
|
||||
background: #007cba;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin: 5px;
|
||||
}
|
||||
button:hover {
|
||||
background: #005a87;
|
||||
}
|
||||
button:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
margin: 5px 0;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.output {
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
font-family: monospace;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.error {
|
||||
color: #d32f2f;
|
||||
}
|
||||
.success {
|
||||
color: #2e7d32;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hero Supervisor WASM OpenRPC Client</h1>
|
||||
|
||||
<div class="container">
|
||||
<h2>Connection</h2>
|
||||
<input type="text" id="serverUrl" placeholder="Server URL" value="http://localhost:3030">
|
||||
<button onclick="testConnection()">Test Connection</button>
|
||||
<div id="connectionOutput" class="output"></div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<h2>Runner Management</h2>
|
||||
<button onclick="listRunners()">List Runners</button>
|
||||
<div id="runnersOutput" class="output"></div>
|
||||
|
||||
<h3>Register Runner</h3>
|
||||
<input type="text" id="registerSecret" placeholder="Secret" value="admin123">
|
||||
<input type="text" id="runnerName" placeholder="Runner Name" value="wasm_runner">
|
||||
<input type="text" id="runnerQueue" placeholder="Queue Name" value="wasm_queue">
|
||||
<button onclick="registerRunner()">Register Runner</button>
|
||||
<div id="registerOutput" class="output"></div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<h2>Job Execution</h2>
|
||||
<input type="text" id="jobSecret" placeholder="Secret" value="admin123">
|
||||
<input type="text" id="jobId" placeholder="Job ID" value="">
|
||||
<input type="text" id="jobRunnerName" placeholder="Runner Name" value="wasm_runner">
|
||||
<textarea id="jobPayload" placeholder="Job Payload" rows="3">echo "Hello from WASM client!"</textarea>
|
||||
<button onclick="runJob()">Run Job</button>
|
||||
<div id="jobOutput" class="output"></div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import init, {
|
||||
WasmSupervisorClient,
|
||||
WasmJob,
|
||||
create_client,
|
||||
create_job
|
||||
} from './pkg-wasm/hero_supervisor_openrpc_client_wasm.js';
|
||||
|
||||
let client = null;
|
||||
|
||||
// Initialize WASM module
|
||||
async function initWasm() {
|
||||
try {
|
||||
await init();
|
||||
console.log('WASM module initialized');
|
||||
document.getElementById('connectionOutput').textContent = 'WASM module loaded successfully';
|
||||
document.getElementById('connectionOutput').className = 'output success';
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize WASM:', error);
|
||||
document.getElementById('connectionOutput').textContent = `Failed to initialize WASM: ${error}`;
|
||||
document.getElementById('connectionOutput').className = 'output error';
|
||||
}
|
||||
}
|
||||
|
||||
// Test connection to supervisor
|
||||
window.testConnection = async function() {
|
||||
try {
|
||||
const serverUrl = document.getElementById('serverUrl').value;
|
||||
client = create_client(serverUrl);
|
||||
|
||||
const result = await client.discover();
|
||||
document.getElementById('connectionOutput').textContent = `Connection successful!\n${JSON.stringify(result, null, 2)}`;
|
||||
document.getElementById('connectionOutput').className = 'output success';
|
||||
} catch (error) {
|
||||
document.getElementById('connectionOutput').textContent = `Connection failed: ${error}`;
|
||||
document.getElementById('connectionOutput').className = 'output error';
|
||||
}
|
||||
};
|
||||
|
||||
// List all runners
|
||||
window.listRunners = async function() {
|
||||
try {
|
||||
if (!client) {
|
||||
throw new Error('Client not initialized. Test connection first.');
|
||||
}
|
||||
|
||||
const runners = await client.list_runners();
|
||||
document.getElementById('runnersOutput').textContent = `Runners:\n${JSON.stringify(runners, null, 2)}`;
|
||||
document.getElementById('runnersOutput').className = 'output success';
|
||||
} catch (error) {
|
||||
document.getElementById('runnersOutput').textContent = `Failed to list runners: ${error}`;
|
||||
document.getElementById('runnersOutput').className = 'output error';
|
||||
}
|
||||
};
|
||||
|
||||
// Register a new runner
|
||||
window.registerRunner = async function() {
|
||||
try {
|
||||
if (!client) {
|
||||
throw new Error('Client not initialized. Test connection first.');
|
||||
}
|
||||
|
||||
const secret = document.getElementById('registerSecret').value;
|
||||
const name = document.getElementById('runnerName').value;
|
||||
const queue = document.getElementById('runnerQueue').value;
|
||||
|
||||
await client.register_runner(secret, name, queue);
|
||||
document.getElementById('registerOutput').textContent = `Runner '${name}' registered successfully!`;
|
||||
document.getElementById('registerOutput').className = 'output success';
|
||||
} catch (error) {
|
||||
document.getElementById('registerOutput').textContent = `Failed to register runner: ${error}`;
|
||||
document.getElementById('registerOutput').className = 'output error';
|
||||
}
|
||||
};
|
||||
|
||||
// Run a job
|
||||
window.runJob = async function() {
|
||||
try {
|
||||
if (!client) {
|
||||
throw new Error('Client not initialized. Test connection first.');
|
||||
}
|
||||
|
||||
const secret = document.getElementById('jobSecret').value;
|
||||
let jobId = document.getElementById('jobId').value;
|
||||
const runnerName = document.getElementById('jobRunnerName').value;
|
||||
const payload = document.getElementById('jobPayload').value;
|
||||
|
||||
// Generate job ID if not provided
|
||||
if (!jobId) {
|
||||
jobId = 'job_' + Math.random().toString(36).substr(2, 9);
|
||||
document.getElementById('jobId').value = jobId;
|
||||
}
|
||||
|
||||
const job = create_job(jobId, payload, "SAL", runnerName);
|
||||
const result = await client.run_job(secret, job);
|
||||
|
||||
document.getElementById('jobOutput').textContent = `Job executed successfully!\nJob ID: ${jobId}\nResult: ${result}`;
|
||||
document.getElementById('jobOutput').className = 'output success';
|
||||
} catch (error) {
|
||||
document.getElementById('jobOutput').textContent = `Failed to run job: ${error}`;
|
||||
document.getElementById('jobOutput').className = 'output error';
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize on page load
|
||||
initWasm();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
1037
clients/openrpc/src/lib.rs
Normal file
1037
clients/openrpc/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
668
clients/openrpc/src/wasm.rs
Normal file
668
clients/openrpc/src/wasm.rs
Normal file
@@ -0,0 +1,668 @@
|
||||
//! WASM-compatible OpenRPC client for Hero Supervisor
|
||||
//!
|
||||
//! This module provides a WASM-compatible client library for interacting with the Hero Supervisor
|
||||
//! OpenRPC server using browser-native fetch APIs.
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use web_sys::{Request, RequestInit, RequestMode, Response, Headers};
|
||||
use serde::{Deserialize, Serialize};
|
||||
// use std::collections::HashMap; // Unused
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
// use js_sys::Promise; // Unused
|
||||
|
||||
/// WASM-compatible client for communicating with Hero Supervisor OpenRPC server
|
||||
#[wasm_bindgen]
|
||||
pub struct WasmSupervisorClient {
|
||||
server_url: String,
|
||||
}
|
||||
|
||||
/// Error types for WASM client operations
|
||||
#[derive(Error, Debug)]
|
||||
pub enum WasmClientError {
|
||||
#[error("Network error: {0}")]
|
||||
Network(String),
|
||||
|
||||
#[error("Serialization error: {0}")]
|
||||
Serialization(#[from] serde_json::Error),
|
||||
|
||||
#[error("JavaScript error: {0}")]
|
||||
JavaScript(String),
|
||||
|
||||
#[error("Server error: {message}")]
|
||||
Server { message: String },
|
||||
|
||||
#[error("Invalid response format")]
|
||||
InvalidResponse,
|
||||
}
|
||||
|
||||
/// Result type for WASM client operations
|
||||
pub type WasmClientResult<T> = Result<T, WasmClientError>;
|
||||
|
||||
/// JSON-RPC request structure
|
||||
#[derive(Serialize)]
|
||||
struct JsonRpcRequest {
|
||||
jsonrpc: String,
|
||||
method: String,
|
||||
params: serde_json::Value,
|
||||
id: u32,
|
||||
}
|
||||
|
||||
/// JSON-RPC response structure
|
||||
#[derive(Deserialize)]
|
||||
struct JsonRpcResponse {
|
||||
jsonrpc: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<JsonRpcError>,
|
||||
id: u32,
|
||||
}
|
||||
|
||||
/// JSON-RPC error structure
|
||||
#[derive(Deserialize)]
|
||||
struct JsonRpcError {
|
||||
code: i32,
|
||||
message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Types of runners supported by the supervisor
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[wasm_bindgen]
|
||||
pub enum WasmRunnerType {
|
||||
SALRunner,
|
||||
OSISRunner,
|
||||
VRunner,
|
||||
}
|
||||
|
||||
/// Job type enumeration that maps to runner types
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[wasm_bindgen]
|
||||
pub enum WasmJobType {
|
||||
SAL,
|
||||
OSIS,
|
||||
V,
|
||||
}
|
||||
|
||||
/// Job structure for creating and managing jobs
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[wasm_bindgen]
|
||||
pub struct WasmJob {
|
||||
id: String,
|
||||
caller_id: String,
|
||||
context_id: String,
|
||||
payload: String,
|
||||
runner_name: String,
|
||||
executor: String,
|
||||
timeout_secs: u64,
|
||||
env_vars: String, // JSON string of HashMap<String, String>
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl WasmSupervisorClient {
|
||||
/// Create a new WASM supervisor client
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(server_url: String) -> Self {
|
||||
console_log::init_with_level(log::Level::Info).ok();
|
||||
Self { server_url }
|
||||
}
|
||||
|
||||
/// Get the server URL
|
||||
#[wasm_bindgen(getter)]
|
||||
pub fn server_url(&self) -> String {
|
||||
self.server_url.clone()
|
||||
}
|
||||
|
||||
/// Test connection using OpenRPC discovery method
|
||||
pub async fn discover(&self) -> Result<JsValue, JsValue> {
|
||||
let result = self.call_method("rpc.discover", serde_json::Value::Null).await;
|
||||
match result {
|
||||
Ok(value) => Ok(wasm_bindgen::JsValue::from_str(&value.to_string())),
|
||||
Err(e) => Err(JsValue::from_str(&e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a new runner to the supervisor with secret authentication
|
||||
pub async fn register_runner(&self, secret: &str, name: &str, queue: &str) -> Result<String, JsValue> {
|
||||
let params = serde_json::json!([{
|
||||
"secret": secret,
|
||||
"name": name,
|
||||
"queue": queue
|
||||
}]);
|
||||
|
||||
match self.call_method("register_runner", params).await {
|
||||
Ok(result) => {
|
||||
// Extract the runner name from the result
|
||||
if let Some(runner_name) = result.as_str() {
|
||||
Ok(runner_name.to_string())
|
||||
} else {
|
||||
Err(JsValue::from_str("Invalid response format: expected runner name"))
|
||||
}
|
||||
},
|
||||
Err(e) => Err(JsValue::from_str(&e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a job (fire-and-forget, non-blocking)
|
||||
#[wasm_bindgen]
|
||||
pub async fn create_job(&self, secret: String, job: WasmJob) -> Result<String, JsValue> {
|
||||
// Backend expects RunJobParams struct with secret and job fields - wrap in array like register_runner
|
||||
let params = serde_json::json!([{
|
||||
"secret": secret,
|
||||
"job": {
|
||||
"id": job.id,
|
||||
"caller_id": job.caller_id,
|
||||
"context_id": job.context_id,
|
||||
"payload": job.payload,
|
||||
"runner_name": job.runner_name,
|
||||
"executor": job.executor,
|
||||
"timeout": {
|
||||
"secs": job.timeout_secs,
|
||||
"nanos": 0
|
||||
},
|
||||
"env_vars": serde_json::from_str::<serde_json::Value>(&job.env_vars).unwrap_or(serde_json::json!({})),
|
||||
"created_at": job.created_at,
|
||||
"updated_at": job.updated_at
|
||||
}
|
||||
}]);
|
||||
|
||||
match self.call_method("create_job", params).await {
|
||||
Ok(result) => {
|
||||
if let Some(job_id) = result.as_str() {
|
||||
Ok(job_id.to_string())
|
||||
} else {
|
||||
Ok(result.to_string())
|
||||
}
|
||||
}
|
||||
Err(e) => Err(JsValue::from_str(&format!("Failed to create job: {:?}", e)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a job on a specific runner (blocking, returns result)
|
||||
#[wasm_bindgen]
|
||||
pub async fn run_job(&self, secret: String, job: WasmJob) -> Result<String, JsValue> {
|
||||
// Backend expects RunJobParams struct with secret and job fields - wrap in array like register_runner
|
||||
let params = serde_json::json!([{
|
||||
"secret": secret,
|
||||
"job": {
|
||||
"id": job.id,
|
||||
"caller_id": job.caller_id,
|
||||
"context_id": job.context_id,
|
||||
"payload": job.payload,
|
||||
"runner_name": job.runner_name,
|
||||
"executor": job.executor,
|
||||
"timeout": {
|
||||
"secs": job.timeout_secs,
|
||||
"nanos": 0
|
||||
},
|
||||
"env_vars": serde_json::from_str::<serde_json::Value>(&job.env_vars).unwrap_or(serde_json::json!({})),
|
||||
"created_at": job.created_at,
|
||||
"updated_at": job.updated_at
|
||||
}
|
||||
}]);
|
||||
|
||||
match self.call_method("run_job", params).await {
|
||||
Ok(result) => {
|
||||
if let Some(result_str) = result.as_str() {
|
||||
Ok(result_str.to_string())
|
||||
} else {
|
||||
Ok(result.to_string())
|
||||
}
|
||||
},
|
||||
Err(e) => Err(JsValue::from_str(&e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all runner IDs
|
||||
pub async fn list_runners(&self) -> Result<Vec<String>, JsValue> {
|
||||
match self.call_method("list_runners", serde_json::Value::Null).await {
|
||||
Ok(result) => {
|
||||
if let Ok(runners) = serde_json::from_value::<Vec<String>>(result) {
|
||||
Ok(runners)
|
||||
} else {
|
||||
Err(JsValue::from_str("Invalid response format for list_runners"))
|
||||
}
|
||||
},
|
||||
Err(e) => Err(JsValue::from_str(&e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all job IDs from Redis
|
||||
pub async fn list_jobs(&self) -> Result<Vec<String>, JsValue> {
|
||||
match self.call_method("list_jobs", serde_json::Value::Null).await {
|
||||
Ok(result) => {
|
||||
if let Ok(jobs) = serde_json::from_value::<Vec<String>>(result) {
|
||||
Ok(jobs)
|
||||
} else {
|
||||
Err(JsValue::from_str("Invalid response format for list_jobs"))
|
||||
}
|
||||
},
|
||||
Err(e) => Err(JsValue::from_str(&e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a job by job ID
|
||||
pub async fn get_job(&self, job_id: &str) -> Result<WasmJob, JsValue> {
|
||||
let params = serde_json::json!([job_id]);
|
||||
match self.call_method("get_job", params).await {
|
||||
Ok(result) => {
|
||||
// Convert the Job result to WasmJob
|
||||
if let Ok(job_value) = serde_json::from_value::<serde_json::Value>(result) {
|
||||
// Extract fields from the job
|
||||
let id = job_value.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let caller_id = job_value.get("caller_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let context_id = job_value.get("context_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let payload = job_value.get("payload").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let runner_name = job_value.get("runner_name").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let executor = job_value.get("executor").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let timeout_secs = job_value.get("timeout").and_then(|v| v.get("secs")).and_then(|v| v.as_u64()).unwrap_or(30);
|
||||
let env_vars = job_value.get("env_vars").map(|v| v.to_string()).unwrap_or_else(|| "{}".to_string());
|
||||
let created_at = job_value.get("created_at").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let updated_at = job_value.get("updated_at").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
|
||||
Ok(WasmJob {
|
||||
id,
|
||||
caller_id,
|
||||
context_id,
|
||||
payload,
|
||||
runner_name,
|
||||
executor,
|
||||
timeout_secs,
|
||||
env_vars,
|
||||
created_at,
|
||||
updated_at,
|
||||
})
|
||||
} else {
|
||||
Err(JsValue::from_str("Invalid response format for get_job"))
|
||||
}
|
||||
},
|
||||
Err(e) => Err(JsValue::from_str(&e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ping a runner by dispatching a ping job to its queue
|
||||
#[wasm_bindgen]
|
||||
pub async fn ping_runner(&self, runner_id: &str) -> Result<String, JsValue> {
|
||||
let params = serde_json::json!([runner_id]);
|
||||
|
||||
match self.call_method("ping_runner", params).await {
|
||||
Ok(result) => {
|
||||
if let Some(job_id) = result.as_str() {
|
||||
Ok(job_id.to_string())
|
||||
} else {
|
||||
Ok(result.to_string())
|
||||
}
|
||||
}
|
||||
Err(e) => Err(JsValue::from_str(&format!("Failed to ping runner: {:?}", e)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop a job by ID
|
||||
#[wasm_bindgen]
|
||||
pub async fn stop_job(&self, job_id: &str) -> Result<(), JsValue> {
|
||||
let params = serde_json::json!([job_id]);
|
||||
|
||||
match self.call_method("stop_job", params).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(JsValue::from_str(&format!("Failed to stop job: {:?}", e)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a job by ID
|
||||
#[wasm_bindgen]
|
||||
pub async fn delete_job(&self, job_id: &str) -> Result<(), JsValue> {
|
||||
let params = serde_json::json!([job_id]);
|
||||
|
||||
match self.call_method("delete_job", params).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(JsValue::from_str(&format!("Failed to delete job: {:?}", e)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a runner from the supervisor
|
||||
pub async fn remove_runner(&self, actor_id: &str) -> Result<(), JsValue> {
|
||||
let params = serde_json::json!([actor_id]);
|
||||
match self.call_method("remove_runner", params).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(JsValue::from_str(&e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a specific runner
|
||||
pub async fn start_runner(&self, actor_id: &str) -> Result<(), JsValue> {
|
||||
let params = serde_json::json!([actor_id]);
|
||||
match self.call_method("start_runner", params).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(JsValue::from_str(&e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop a specific runner
|
||||
pub async fn stop_runner(&self, actor_id: &str, force: bool) -> Result<(), JsValue> {
|
||||
let params = serde_json::json!([actor_id, force]);
|
||||
self.call_method("stop_runner", params)
|
||||
.await
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a specific runner by ID
|
||||
pub async fn get_runner(&self, actor_id: &str) -> Result<JsValue, JsValue> {
|
||||
let params = serde_json::json!([actor_id]);
|
||||
let result = self.call_method("get_runner", params)
|
||||
.await
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
// Convert the serde_json::Value to a JsValue via string serialization
|
||||
let json_string = serde_json::to_string(&result)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
Ok(js_sys::JSON::parse(&json_string)
|
||||
.map_err(|e| JsValue::from_str("Failed to parse JSON"))?)
|
||||
}
|
||||
|
||||
/// Add a secret to the supervisor
|
||||
pub async fn add_secret(&self, admin_secret: &str, secret_type: &str, secret_value: &str) -> Result<(), JsValue> {
|
||||
let params = serde_json::json!([{
|
||||
"admin_secret": admin_secret,
|
||||
"secret_type": secret_type,
|
||||
"secret_value": secret_value
|
||||
}]);
|
||||
match self.call_method("add_secret", params).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(JsValue::from_str(&e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a secret from the supervisor
|
||||
pub async fn remove_secret(&self, admin_secret: &str, secret_type: &str, secret_value: &str) -> Result<(), JsValue> {
|
||||
let params = serde_json::json!([{
|
||||
"admin_secret": admin_secret,
|
||||
"secret_type": secret_type,
|
||||
"secret_value": secret_value
|
||||
}]);
|
||||
match self.call_method("remove_secret", params).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(JsValue::from_str(&e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// List secrets (returns supervisor info including secret counts)
|
||||
pub async fn list_secrets(&self, admin_secret: &str) -> Result<JsValue, JsValue> {
|
||||
let params = serde_json::json!([{
|
||||
"admin_secret": admin_secret
|
||||
}]);
|
||||
match self.call_method("list_secrets", params).await {
|
||||
Ok(result) => {
|
||||
// Convert serde_json::Value to JsValue
|
||||
let result_str = serde_json::to_string(&result)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
Ok(js_sys::JSON::parse(&result_str)
|
||||
.map_err(|e| JsValue::from_str(&format!("JSON parse error: {:?}", e)))?)
|
||||
},
|
||||
Err(e) => Err(JsValue::from_str(&e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get supervisor information including secret counts
|
||||
pub async fn get_supervisor_info(&self, admin_secret: &str) -> Result<JsValue, JsValue> {
|
||||
let params = serde_json::json!({
|
||||
"admin_secret": admin_secret
|
||||
});
|
||||
|
||||
match self.call_method("get_supervisor_info", params).await {
|
||||
Ok(result) => {
|
||||
let result_str = serde_json::to_string(&result)
|
||||
.map_err(|e| JsValue::from_str(&format!("Serialization error: {:?}", e)))?;
|
||||
Ok(js_sys::JSON::parse(&result_str)
|
||||
.map_err(|e| JsValue::from_str(&format!("JSON parse error: {:?}", e)))?)
|
||||
},
|
||||
Err(e) => Err(JsValue::from_str(&format!("Failed to get supervisor info: {:?}", e))),
|
||||
}
|
||||
}
|
||||
|
||||
/// List admin secrets (returns actual secret values)
|
||||
pub async fn list_admin_secrets(&self, admin_secret: &str) -> Result<Vec<String>, JsValue> {
|
||||
let params = serde_json::json!({
|
||||
"admin_secret": admin_secret
|
||||
});
|
||||
|
||||
match self.call_method("list_admin_secrets", params).await {
|
||||
Ok(result) => {
|
||||
let secrets: Vec<String> = serde_json::from_value(result)
|
||||
.map_err(|e| JsValue::from_str(&format!("Failed to parse admin secrets: {:?}", e)))?;
|
||||
Ok(secrets)
|
||||
},
|
||||
Err(e) => Err(JsValue::from_str(&format!("Failed to list admin secrets: {:?}", e))),
|
||||
}
|
||||
}
|
||||
|
||||
/// List user secrets (returns actual secret values)
|
||||
pub async fn list_user_secrets(&self, admin_secret: &str) -> Result<Vec<String>, JsValue> {
|
||||
let params = serde_json::json!({
|
||||
"admin_secret": admin_secret
|
||||
});
|
||||
|
||||
match self.call_method("list_user_secrets", params).await {
|
||||
Ok(result) => {
|
||||
let secrets: Vec<String> = serde_json::from_value(result)
|
||||
.map_err(|e| JsValue::from_str(&format!("Failed to parse user secrets: {:?}", e)))?;
|
||||
Ok(secrets)
|
||||
},
|
||||
Err(e) => Err(JsValue::from_str(&format!("Failed to list user secrets: {:?}", e))),
|
||||
}
|
||||
}
|
||||
|
||||
/// List register secrets (returns actual secret values)
|
||||
pub async fn list_register_secrets(&self, admin_secret: &str) -> Result<Vec<String>, JsValue> {
|
||||
let params = serde_json::json!({
|
||||
"admin_secret": admin_secret
|
||||
});
|
||||
|
||||
match self.call_method("list_register_secrets", params).await {
|
||||
Ok(result) => {
|
||||
let secrets: Vec<String> = serde_json::from_value(result)
|
||||
.map_err(|e| JsValue::from_str(&format!("Failed to parse register secrets: {:?}", e)))?;
|
||||
Ok(secrets)
|
||||
},
|
||||
Err(e) => Err(JsValue::from_str(&format!("Failed to list register secrets: {:?}", e))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl WasmJob {
|
||||
/// Create a new job with default values
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(id: String, payload: String, executor: String, runner_name: String) -> Self {
|
||||
let now = js_sys::Date::new_0().to_iso_string().as_string().unwrap();
|
||||
Self {
|
||||
id,
|
||||
caller_id: "wasm_client".to_string(),
|
||||
context_id: "wasm_context".to_string(),
|
||||
payload,
|
||||
runner_name,
|
||||
executor,
|
||||
timeout_secs: 30,
|
||||
env_vars: "{}".to_string(),
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the caller ID
|
||||
#[wasm_bindgen(setter)]
|
||||
pub fn set_caller_id(&mut self, caller_id: String) {
|
||||
self.caller_id = caller_id;
|
||||
}
|
||||
|
||||
/// Set the context ID
|
||||
#[wasm_bindgen(setter)]
|
||||
pub fn set_context_id(&mut self, context_id: String) {
|
||||
self.context_id = context_id;
|
||||
}
|
||||
|
||||
/// Set the timeout in seconds
|
||||
#[wasm_bindgen(setter)]
|
||||
pub fn set_timeout_secs(&mut self, timeout_secs: u64) {
|
||||
self.timeout_secs = timeout_secs;
|
||||
}
|
||||
|
||||
/// Set environment variables as JSON string
|
||||
#[wasm_bindgen(setter)]
|
||||
pub fn set_env_vars(&mut self, env_vars: String) {
|
||||
self.env_vars = env_vars;
|
||||
}
|
||||
|
||||
/// Generate a new UUID for the job
|
||||
#[wasm_bindgen]
|
||||
pub fn generate_id(&mut self) {
|
||||
self.id = Uuid::new_v4().to_string();
|
||||
}
|
||||
|
||||
/// Get the job ID
|
||||
#[wasm_bindgen(getter)]
|
||||
pub fn id(&self) -> String {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
/// Get the caller ID
|
||||
#[wasm_bindgen(getter)]
|
||||
pub fn caller_id(&self) -> String {
|
||||
self.caller_id.clone()
|
||||
}
|
||||
|
||||
/// Get the context ID
|
||||
#[wasm_bindgen(getter)]
|
||||
pub fn context_id(&self) -> String {
|
||||
self.context_id.clone()
|
||||
}
|
||||
|
||||
/// Get the payload
|
||||
#[wasm_bindgen(getter)]
|
||||
pub fn payload(&self) -> String {
|
||||
self.payload.clone()
|
||||
}
|
||||
|
||||
/// Get the job type
|
||||
#[wasm_bindgen(getter)]
|
||||
pub fn executor(&self) -> String {
|
||||
self.executor.clone()
|
||||
}
|
||||
|
||||
/// Get the runner name
|
||||
#[wasm_bindgen(getter)]
|
||||
pub fn runner_name(&self) -> String {
|
||||
self.runner_name.clone()
|
||||
}
|
||||
|
||||
/// Get the timeout in seconds
|
||||
#[wasm_bindgen(getter)]
|
||||
pub fn timeout_secs(&self) -> u64 {
|
||||
self.timeout_secs
|
||||
}
|
||||
|
||||
/// Get the environment variables as JSON string
|
||||
#[wasm_bindgen(getter)]
|
||||
pub fn env_vars(&self) -> String {
|
||||
self.env_vars.clone()
|
||||
}
|
||||
|
||||
/// Get the created timestamp
|
||||
#[wasm_bindgen(getter)]
|
||||
pub fn created_at(&self) -> String {
|
||||
self.created_at.clone()
|
||||
}
|
||||
|
||||
/// Get the updated timestamp
|
||||
#[wasm_bindgen(getter)]
|
||||
pub fn updated_at(&self) -> String {
|
||||
self.updated_at.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl WasmSupervisorClient {
|
||||
/// Internal method to make JSON-RPC calls
|
||||
async fn call_method(&self, method: &str, params: serde_json::Value) -> WasmClientResult<serde_json::Value> {
|
||||
let request = JsonRpcRequest {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
method: method.to_string(),
|
||||
params,
|
||||
id: 1,
|
||||
};
|
||||
|
||||
let body = serde_json::to_string(&request)?;
|
||||
|
||||
// Create headers
|
||||
let headers = Headers::new().map_err(|e| WasmClientError::JavaScript(format!("{:?}", e)))?;
|
||||
headers.set("Content-Type", "application/json")
|
||||
.map_err(|e| WasmClientError::JavaScript(format!("{:?}", e)))?;
|
||||
|
||||
// Create request init
|
||||
let opts = RequestInit::new();
|
||||
opts.set_method("POST");
|
||||
opts.set_headers(&headers);
|
||||
opts.set_body(&JsValue::from_str(&body));
|
||||
opts.set_mode(RequestMode::Cors);
|
||||
|
||||
// Create request
|
||||
let request = Request::new_with_str_and_init(&self.server_url, &opts)
|
||||
.map_err(|e| WasmClientError::JavaScript(format!("{:?}", e)))?;
|
||||
|
||||
// Get window and fetch
|
||||
let window = web_sys::window().ok_or_else(|| WasmClientError::JavaScript("No window object".to_string()))?;
|
||||
let resp_value = JsFuture::from(window.fetch_with_request(&request)).await
|
||||
.map_err(|e| WasmClientError::Network(format!("{:?}", e)))?;
|
||||
|
||||
// Convert to Response
|
||||
let resp: Response = resp_value.dyn_into()
|
||||
.map_err(|e| WasmClientError::JavaScript(format!("{:?}", e)))?;
|
||||
|
||||
// Check if response is ok
|
||||
if !resp.ok() {
|
||||
return Err(WasmClientError::Network(format!("HTTP {}: {}", resp.status(), resp.status_text())));
|
||||
}
|
||||
|
||||
// Get response text
|
||||
let text_promise = resp.text()
|
||||
.map_err(|e| WasmClientError::JavaScript(format!("{:?}", e)))?;
|
||||
let text_value = JsFuture::from(text_promise).await
|
||||
.map_err(|e| WasmClientError::Network(format!("{:?}", e)))?;
|
||||
let text = text_value.as_string()
|
||||
.ok_or_else(|| WasmClientError::InvalidResponse)?;
|
||||
|
||||
// Parse JSON-RPC response
|
||||
let response: JsonRpcResponse = serde_json::from_str(&text)?;
|
||||
|
||||
if let Some(error) = response.error {
|
||||
return Err(WasmClientError::Server {
|
||||
message: format!("{}: {}", error.code, error.message),
|
||||
});
|
||||
}
|
||||
|
||||
// For void methods, null result is valid
|
||||
Ok(response.result.unwrap_or(serde_json::Value::Null))
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize the WASM client library (call manually if needed)
|
||||
pub fn init() {
|
||||
console_log::init_with_level(log::Level::Info).ok();
|
||||
log::info!("Hero Supervisor WASM OpenRPC Client initialized");
|
||||
}
|
||||
|
||||
/// Utility function to create a job from JavaScript
|
||||
/// Create a new job (convenience function for JavaScript)
|
||||
#[wasm_bindgen]
|
||||
pub fn create_job(id: String, payload: String, executor: String, runner_name: String) -> WasmJob {
|
||||
WasmJob::new(id, payload, executor, runner_name)
|
||||
}
|
||||
|
||||
/// Utility function to create a client from JavaScript
|
||||
#[wasm_bindgen]
|
||||
pub fn create_client(server_url: String) -> WasmSupervisorClient {
|
||||
WasmSupervisorClient::new(server_url)
|
||||
}
|
Reference in New Issue
Block a user