initial commit

This commit is contained in:
Timur Gordon
2025-08-26 14:49:21 +02:00
commit 767c66fb6a
66 changed files with 22035 additions and 0 deletions

2
clients/admin-ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
dist
target

3347
clients/admin-ui/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View 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" }

View 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..."]

View 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
View 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 &current_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 == &current_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>
}
}

File diff suppressed because it is too large Load Diff

View 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>
}
}

View 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>
}
}

View 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;

View 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>
}
}

View 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>
}
}

View 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>
}
}

View 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>
}
}

View 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>
}
}

View 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>
}
}

View 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();
}

View 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(
&register_form.secret,
&register_form.name,
&register_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>
}
}

View 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())
}

View 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>
}
}

View 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,
}

View 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

File diff suppressed because it is too large Load Diff