Implement comprehensive admin UI with job management and API key display

Admin UI Features:
- Complete job lifecycle: create, run, view status, view output, delete
- Job table with sorting, filtering, and real-time status updates
- Status polling with countdown timers for running jobs
- Job output modal with result/error display
- API keys management: create keys, list keys with secrets visible
- Sidebar toggle between runners and keys views
- Toast notifications for errors
- Modern dark theme UI with responsive design

Supervisor Improvements:
- Fixed job status persistence using client methods
- Refactored get_job_result to use client.get_status, get_result, get_error
- Changed runner_rust dependency from git to local path
- Authentication system with API key scopes (admin, user, register)
- Job listing with status fetching from Redis
- Services module for job and auth operations

OpenRPC Client:
- Added auth_list_keys method for fetching API keys
- WASM bindings for browser usage
- Proper error handling and type conversions

Build Status:  All components build successfully
This commit is contained in:
Timur Gordon
2025-10-28 03:32:25 +01:00
parent 5f5dd35dbc
commit f249c8b49b
36 changed files with 4811 additions and 6421 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,296 +0,0 @@
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

@@ -1,294 +0,0 @@
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

@@ -1,7 +0,0 @@
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

@@ -1,67 +0,0 @@
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

@@ -1,191 +0,0 @@
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

@@ -1,437 +0,0 @@
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(&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

@@ -1,278 +0,0 @@
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

@@ -1,30 +0,0 @@
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

@@ -1,177 +0,0 @@
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<(String, String)>, // (runner, payload)
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 == other.job_form.runner &&
self.job_form.executor == other.job_form.executor &&
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".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_run_click = {
let on_run = props.on_run_job.clone();
let job_form = props.job_form.clone();
Callback::from(move |_: MouseEvent| {
on_run.emit((job_form.runner.clone(), job_form.payload.clone()));
})
};
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>
<th>{"Actions"}</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.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}
</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>
<span class="status-badge status-not-started">{"Not Started"}</span>
</td>
<td class="action-cell">
<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()}</td>
<td>{job.executor()}</td>
<td>
<span class="status-badge status-pending">{"Pending"}</span>
</td>
<td class="action-cell">
<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

@@ -1,10 +1,6 @@
use wasm_bindgen::prelude::*;
pub mod app;
pub mod sidebar;
pub mod runners;
pub mod jobs;
pub mod toast;
#[wasm_bindgen(start)]
pub fn main() {

View File

@@ -1,205 +0,0 @@
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 -> ping_state
pub session_secret: String,
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_run_job: Callback<(String, String)>, // (runner, payload)
}
#[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) => {
console::log!("Runner registered successfully:", runner);
on_register_runner.emit(());
}
Err(e) => {
console::error!("Failed to register runner:", format!("{:?}", e));
}
}
});
})
};
html! {
<div class="mb-5">
<h2 class="mb-4">{"Runners"}</h2>
// All cards in same row - registration card first, then runner cards
<div class="d-flex flex-column gap-3">
// Registration card as first item
<div class="card bg-dark border-secondary">
<div class="card-header bg-transparent border-secondary">
<div class="d-flex align-items-center">
<i class="fas fa-plus-circle me-2 text-success"></i>
<h6 class="mb-0 text-white">{"Add New Runner"}</h6>
</div>
</div>
<div class="card-body">
<form onsubmit={on_register_runner.reform(|e: SubmitEvent| e.prevent_default())}>
<div class="mb-3">
<label class="form-label text-muted small">{"Runner Name"}</label>
<input
type="text"
class="form-control bg-secondary border-0 text-white"
placeholder="e.g., worker-01"
value={props.register_form.name.clone()}
oninput={props.on_register_form_change.reform(|e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target().unwrap().dyn_into().unwrap();
("name".to_string(), input.value())
})}
/>
</div>
<div class="mb-3">
<label class="form-label text-muted small">{"Registration Secret"}</label>
<input
type="password"
class="form-control bg-secondary border-0 text-white"
placeholder="Enter secret key"
value={props.register_form.secret.clone()}
oninput={props.on_register_form_change.reform(|e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target().unwrap().dyn_into().unwrap();
("secret".to_string(), input.value())
})}
/>
</div>
<button type="submit" class="btn btn-success w-100">
<i class="fas fa-plus me-1"></i>
{"Register Runner"}
</button>
</form>
</div>
</div>
{for props.runners.iter().map(|(name, status)| {
let badge_class = match status.as_str() {
"Running" => "status-running",
"Stopped" => "status-stopped",
"Starting" => "status-starting",
"Stopping" => "status-starting",
"Registering" => "status-registering",
_ => "bg-secondary",
};
let name_clone = name.clone();
let name_clone2 = name.clone();
let on_remove = props.on_remove_runner.clone();
let on_run = props.on_run_job.clone();
html! {
<div class="card bg-dark border-secondary mb-3">
<div class="card-body d-flex align-items-center justify-content-between p-3">
<div class="d-flex align-items-center flex-grow-1">
<div class="me-3">
<span class={classes!("badge", "rounded-pill", badge_class)}>{""}</span>
</div>
<div class="flex-grow-1">
<h6 class="text-white mb-1">{name}</h6>
<small class="text-muted">{format!("Queue: {}", name)}</small>
</div>
<div class="me-3">
<span class={classes!("badge", badge_class)}>
{status}
</span>
</div>
</div>
<div class="d-flex gap-2">
<button
class="btn btn-sm btn-outline-primary"
title="Run Job"
onclick={Callback::from(move |_| on_run.emit((name_clone.clone(), "test".to_string())))}
>
<i class="fas fa-play"></i>
</button>
<button
class="btn btn-sm btn-outline-danger"
title="Remove Runner"
onclick={Callback::from(move |_| on_remove.emit(name_clone2.clone()))}
>
<i class="fas fa-trash"></i>
</button>
</div>
<div class="ms-3">
{
match props.ping_states.get(name).cloned().unwrap_or(PingState::Idle) {
PingState::Idle => html! {
<small class="text-muted">{"Ready"}</small>
},
PingState::Waiting => html! {
<small class="text-info">
<i class="fas fa-spinner fa-spin me-1"></i>
{"Working..."}
</small>
},
PingState::Success(ref msg) => html! {
<small class="text-success">
<i class="fas fa-check me-1"></i>
{msg}
</small>
},
PingState::Error(ref msg) => html! {
<small class="text-danger">
<i class="fas fa-times me-1"></i>
{msg}
</small>
},
}
}
</div>
</div>
</div>
}
})}
</div>
</div>
}
}

View File

@@ -1,145 +0,0 @@
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: &str, job: Job) -> ClientResult<()> {
self.client.borrow_mut().queue_job_to_runner(runner, job).await
}
/// Queue a job and wait for result
pub async fn queue_and_wait(&self, runner: &str, job: Job, timeout_secs: u64) -> ClientResult<Option<String>> {
self.client.borrow_mut().queue_and_wait(runner, 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

@@ -1,525 +0,0 @@
use yew::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::spawn_local;
use gloo::console;
use gloo::storage::{LocalStorage, Storage};
use hero_supervisor_openrpc_client::wasm::WasmSupervisorClient;
use serde::{Deserialize, Serialize};
#[derive(Clone, PartialEq)]
pub struct SupervisorInfo {
pub server_url: String,
pub admin_secrets: Vec<String>,
pub user_secrets: Vec<String>,
pub register_secrets: Vec<String>,
pub runners_count: usize,
}
#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)]
pub enum SessionSecretType {
None,
User,
Admin,
Register,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct SessionData {
pub secret: String,
pub secret_type: SessionSecretType,
}
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct StoredSecrets {
pub admin_secrets: Vec<String>,
pub user_secrets: Vec<String>,
pub register_secrets: Vec<String>,
}
pub const STORED_SECRETS_KEY: &str = "supervisor_stored_secrets";
pub const SESSION_STORAGE_KEY: &str = "supervisor_session";
#[derive(Properties, PartialEq)]
pub struct SidebarProps {
pub server_url: String,
pub supervisor_info: Option<SupervisorInfo>,
pub session_secret: String,
pub session_secret_type: SessionSecretType,
pub on_session_secret_change: Callback<(String, SessionSecretType)>,
pub on_supervisor_info_loaded: Callback<SupervisorInfo>,
pub on_add_secret: Callback<(SessionSecretType, String)>,
pub on_remove_secret: Callback<(SessionSecretType, String)>,
}
#[function_component(Sidebar)]
pub fn sidebar(props: &SidebarProps) -> Html {
let session_secret_input = use_state(|| String::new());
let selected_secret_type = use_state(|| SessionSecretType::Admin);
let is_loading = use_state(|| false);
// Load session from localStorage on component mount
{
let on_session_secret_change = props.on_session_secret_change.clone();
use_effect_with((), move |_| {
if let Ok(session_data) = LocalStorage::get::<SessionData>(SESSION_STORAGE_KEY) {
on_session_secret_change.emit((session_data.secret, session_data.secret_type));
}
|| ()
});
}
let on_session_secret_change = {
let session_secret_input = session_secret_input.clone();
Callback::from(move |e: web_sys::Event| {
let input: web_sys::HtmlInputElement = e.target().unwrap().dyn_into().unwrap();
session_secret_input.set(input.value());
})
};
let on_session_secret_submit = {
let session_secret_input = session_secret_input.clone();
let selected_secret_type = selected_secret_type.clone();
let is_loading = is_loading.clone();
let on_session_secret_change = props.on_session_secret_change.clone();
let on_supervisor_info_loaded = props.on_supervisor_info_loaded.clone();
let server_url = props.server_url.clone();
Callback::from(move |_: web_sys::MouseEvent| {
let secret = (*session_secret_input).clone();
if secret.is_empty() {
return;
}
is_loading.set(true);
let is_loading = is_loading.clone();
let on_session_secret_change = on_session_secret_change.clone();
let on_supervisor_info_loaded = on_supervisor_info_loaded.clone();
let server_url = server_url.clone();
let session_secret_input = session_secret_input.clone();
let selected_secret_type = selected_secret_type.clone();
spawn_local(async move {
let client = WasmSupervisorClient::new(server_url.clone());
match client.discover().await {
Ok(_) => {
console::log!("Connected to supervisor successfully");
let secret_type = (*selected_secret_type).clone();
// Don't store secrets in localStorage - use API only
// Save to localStorage
let session_data = SessionData {
secret: secret.clone(),
secret_type: secret_type.clone(),
};
let _ = LocalStorage::set(SESSION_STORAGE_KEY, &session_data);
// Create supervisor info with empty secrets initially
let mut supervisor_info = SupervisorInfo {
server_url: server_url.clone(),
admin_secrets: vec![],
user_secrets: vec![],
register_secrets: vec![],
runners_count: 0,
};
// Only fetch secrets if this is an admin secret
if secret_type == SessionSecretType::Admin {
console::log!("Attempting to fetch secrets with admin secret");
// Try to fetch admin secrets
match client.list_admin_secrets(&secret).await {
Ok(admin_secrets) => {
console::log!("✅ Fetched admin secrets:", format!("{:?}", admin_secrets));
supervisor_info.admin_secrets = admin_secrets;
},
Err(e) => {
console::error!("❌ Failed to fetch admin secrets:", format!("{:?}", e));
// If admin secret fetch fails, this might not be a valid admin secret
supervisor_info.admin_secrets = vec![secret.clone()];
}
}
// Try to fetch user secrets
match client.list_user_secrets(&secret).await {
Ok(user_secrets) => {
console::log!("✅ Fetched user secrets:", format!("{:?}", user_secrets));
supervisor_info.user_secrets = user_secrets;
},
Err(e) => {
console::error!("❌ Failed to fetch user secrets:", format!("{:?}", e));
}
}
// Try to fetch register secrets
match client.list_register_secrets(&secret).await {
Ok(register_secrets) => {
console::log!("✅ Fetched register secrets:", format!("{:?}", register_secrets));
supervisor_info.register_secrets = register_secrets;
},
Err(e) => {
console::error!("❌ Failed to fetch register secrets:", format!("{:?}", e));
}
}
} else {
console::log!("Non-admin secret - showing only current secret");
// For non-admin secrets, only show the current secret
match secret_type {
SessionSecretType::User => {
supervisor_info.user_secrets = vec![secret.clone()];
},
SessionSecretType::Register => {
supervisor_info.register_secrets = vec![secret.clone()];
},
_ => {}
}
}
on_session_secret_change.emit((secret.clone(), secret_type.clone()));
on_supervisor_info_loaded.emit(supervisor_info);
session_secret_input.set(String::new());
}
Err(e) => {
console::error!("Failed to connect to supervisor:", format!("{:?}", e));
}
}
is_loading.set(false);
});
})
};
let on_logout = {
let on_session_secret_change = props.on_session_secret_change.clone();
Callback::from(move |_: web_sys::MouseEvent| {
// Clear localStorage
let _ = LocalStorage::delete(SESSION_STORAGE_KEY);
on_session_secret_change.emit((String::new(), SessionSecretType::None));
})
};
let on_add_admin_secret = {
let on_add_secret = props.on_add_secret.clone();
Callback::from(move |_| {
on_add_secret.emit((SessionSecretType::Admin, String::new()));
})
};
let on_add_user_secret = {
let on_add_secret = props.on_add_secret.clone();
Callback::from(move |_| {
on_add_secret.emit((SessionSecretType::User, String::new()));
})
};
let on_add_register_secret = {
let on_add_secret = props.on_add_secret.clone();
Callback::from(move |_| {
on_add_secret.emit((SessionSecretType::User, String::new()));
})
};
let on_session_input = {
let session_secret_input = session_secret_input.clone();
Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
session_secret_input.set(input.value());
})
};
let on_session_keypress = {
let on_session_secret_submit = on_session_secret_submit.clone();
Callback::from(move |e: KeyboardEvent| {
if e.key() == "Enter" {
e.prevent_default();
// Create a dummy MouseEvent to trigger the submit handler
let dummy_event = web_sys::MouseEvent::new("click").unwrap();
on_session_secret_submit.emit(dummy_event);
}
})
};
let on_session_toggle = {
let on_session_secret_submit = on_session_secret_submit.clone();
let on_session_secret_change = props.on_session_secret_change.clone();
let session_secret = props.session_secret.clone();
Callback::from(move |e: web_sys::MouseEvent| {
if session_secret.is_empty() {
// Try to login with current input
on_session_secret_submit.emit(e);
} else {
// Logout - clear localStorage and session
let _ = LocalStorage::delete(SESSION_STORAGE_KEY);
on_session_secret_change.emit((String::new(), SessionSecretType::None));
}
})
};
// Function to refresh secrets from API when switching to an admin secret
let refresh_secrets_from_api = {
let server_url = props.server_url.clone();
let on_supervisor_info_loaded = props.on_supervisor_info_loaded.clone();
Callback::from(move |(secret, secret_type): (String, SessionSecretType)| {
if secret_type == SessionSecretType::Admin {
let client = WasmSupervisorClient::new(server_url.clone());
let on_supervisor_info_loaded = on_supervisor_info_loaded.clone();
let server_url = server_url.clone();
spawn_local(async move {
let mut supervisor_info = SupervisorInfo {
server_url: server_url.clone(),
admin_secrets: vec![],
user_secrets: vec![],
register_secrets: vec![],
runners_count: 0,
};
// Fetch real secrets from the API
match client.list_admin_secrets(&secret).await {
Ok(admin_secrets) => {
console::log!("Refreshed admin secrets from API:", format!("{:?}", admin_secrets));
supervisor_info.admin_secrets = admin_secrets;
},
Err(e) => console::error!("Failed to refresh admin secrets:", format!("{:?}", e))
}
match client.list_user_secrets(&secret).await {
Ok(user_secrets) => {
console::log!("Refreshed user secrets from API:", format!("{:?}", user_secrets));
supervisor_info.user_secrets = user_secrets;
},
Err(e) => console::error!("Failed to refresh user secrets:", format!("{:?}", e))
}
match client.list_register_secrets(&secret).await {
Ok(register_secrets) => {
console::log!("Refreshed register secrets from API:", format!("{:?}", register_secrets));
supervisor_info.register_secrets = register_secrets;
},
Err(e) => console::error!("Failed to refresh register secrets:", format!("{:?}", e))
}
on_supervisor_info_loaded.emit(supervisor_info);
});
}
})
};
html! {
<div class="col-md-3 col-lg-2 d-md-block sidebar">
<div class="bg-dark rounded m-2 h-100 d-flex flex-column p-3 sidebar-island">
// Header section
<div class="pb-3 border-bottom border-secondary">
<h5 class="text-white mb-1">{"Supervisor"}</h5>
<small class="text-muted">{"Admin interface for managing jobs and secrets"}</small>
</div>
// Session login section
<div class="py-3 border-bottom border-secondary">
<div class="mb-2">
<select
class="form-select form-select-sm bg-secondary text-white border-0"
onchange={{
let selected_secret_type = selected_secret_type.clone();
Callback::from(move |e: Event| {
let select: web_sys::HtmlInputElement = e.target_unchecked_into();
let secret_type = match select.value().as_str() {
"Admin" => SessionSecretType::Admin,
"User" => SessionSecretType::User,
"Register" => SessionSecretType::Register,
_ => SessionSecretType::Admin,
};
selected_secret_type.set(secret_type);
})
}}
>
<option value="Admin" selected={*selected_secret_type == SessionSecretType::Admin}>{"Admin Secret"}</option>
<option value="User" selected={*selected_secret_type == SessionSecretType::User}>{"User Secret"}</option>
<option value="Register" selected={*selected_secret_type == SessionSecretType::Register}>{"Register Secret"}</option>
</select>
</div>
<div class="input-group input-group-sm">
<input
type="password"
class="form-control bg-secondary text-white border-0"
placeholder={format!("Enter {} secret...", match *selected_secret_type {
SessionSecretType::Admin => "admin",
SessionSecretType::User => "user",
SessionSecretType::Register => "register",
_ => "session"
})}
value={(*session_secret_input).clone()}
oninput={on_session_input}
onkeypress={on_session_keypress}
/>
<button
class={classes!("btn", if props.session_secret.is_empty() { "btn-outline-secondary" } else { "btn-outline-success" })}
onclick={on_session_toggle}
>
if props.session_secret.is_empty() {
{"🔒"}
} else {
{"🔓"}
}
</button>
</div>
</div>
// Secret management section (only show when logged in)
if !props.session_secret.is_empty() {
<div class="flex-grow-1 overflow-auto">
<div class="py-3">
<h6 class="text-white text-uppercase fw-bold mb-3">{"Secret Management"}</h6>
// Admin Secrets
<div class="mb-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<small class="text-muted text-uppercase fw-bold">{"Admin Secrets"}</small>
<button class="btn btn-sm btn-outline-secondary border-0" onclick={on_add_admin_secret.clone()}>
{""}
</button>
</div>
<div class="list-group list-group-flush">
{for props.supervisor_info.as_ref().map(|info| &info.admin_secrets).unwrap_or(&Vec::new()).iter().map(|secret| {
let is_current = secret == &props.session_secret && props.session_secret_type == SessionSecretType::Admin;
let on_select = {
let on_change = props.on_session_secret_change.clone();
let refresh_secrets = refresh_secrets_from_api.clone();
let secret = secret.clone();
Callback::from(move |_| {
on_change.emit((secret.clone(), SessionSecretType::Admin));
refresh_secrets.emit((secret.clone(), SessionSecretType::Admin));
})
};
let on_remove = {
let on_remove = props.on_remove_secret.clone();
let secret = secret.clone();
Callback::from(move |_| {
on_remove.emit((SessionSecretType::Admin, secret.clone()));
})
};
html! {
<div class={classes!("list-group-item", "d-flex", "justify-content-between", "align-items-center", "bg-secondary", "border-0", "mb-1", if is_current { "border-success" } else { "" })}>
<code class={classes!("text-white", "small", "cursor-pointer", if is_current { "text-success" } else { "" })} onclick={on_select}>
{format!("{}...{}", &secret[..4.min(secret.len())], if secret.len() > 8 { &secret[secret.len()-4..] } else { "" })}
</code>
<button class="btn btn-sm text-danger border-0 p-0" onclick={on_remove}>
{""}
</button>
</div>
}
})}
</div>
</div>
// User Secrets
<div class="mb-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<small class="text-muted text-uppercase fw-bold">{"User Secrets"}</small>
<button class="btn btn-sm btn-outline-secondary border-0" onclick={on_add_user_secret.clone()}>
{""}
</button>
</div>
<div class="list-group list-group-flush">
{for props.supervisor_info.as_ref().map(|info| &info.user_secrets).unwrap_or(&Vec::new()).iter().map(|secret| {
let is_current = secret == &props.session_secret && props.session_secret_type == SessionSecretType::User;
let on_select = {
let on_change = props.on_session_secret_change.clone();
let secret = secret.clone();
Callback::from(move |_| {
on_change.emit((secret.clone(), SessionSecretType::User));
})
};
let on_remove = {
let on_remove = props.on_remove_secret.clone();
let secret = secret.clone();
Callback::from(move |_| {
on_remove.emit((SessionSecretType::User, secret.clone()));
})
};
html! {
<div class={classes!("list-group-item", "d-flex", "justify-content-between", "align-items-center", "bg-secondary", "border-0", "mb-1", if is_current { "border-success" } else { "" })}>
<code class={classes!("text-white", "small", "cursor-pointer", if is_current { "text-success" } else { "" })} onclick={on_select}>
{format!("{}...{}", &secret[..4.min(secret.len())], if secret.len() > 8 { &secret[secret.len()-4..] } else { "" })}
</code>
<button class="btn btn-sm text-danger border-0 p-0" onclick={on_remove}>
{""}
</button>
</div>
}
})}
</div>
</div>
// Register Secrets
<div class="mb-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<small class="text-muted text-uppercase fw-bold">{"Register Secrets"}</small>
<button class="btn btn-sm btn-outline-secondary border-0" onclick={on_add_register_secret.clone()}>
{""}
</button>
</div>
<div class="list-group list-group-flush">
{for props.supervisor_info.as_ref().map(|info| &info.register_secrets).unwrap_or(&Vec::new()).iter().map(|secret| {
let is_current = secret == &props.session_secret;
let on_select = {
let on_change = props.on_session_secret_change.clone();
let secret = secret.clone();
Callback::from(move |_| {
on_change.emit((secret.clone(), SessionSecretType::Register));
})
};
let on_remove = {
let on_remove = props.on_remove_secret.clone();
let secret = secret.clone();
Callback::from(move |_| {
on_remove.emit((SessionSecretType::Register, secret.clone()));
})
};
html! {
<div class={classes!("list-group-item", "d-flex", "justify-content-between", "align-items-center", "bg-secondary", "border-0", "mb-1", if is_current { "border-success" } else { "" })}>
<code class={classes!("text-white", "small", "cursor-pointer", if is_current { "text-success" } else { "" })} onclick={on_select}>
{format!("{}...{}", &secret[..4.min(secret.len())], if secret.len() > 8 { &secret[secret.len()-4..] } else { "" })}
</code>
<button class="btn btn-sm text-danger border-0 p-0" onclick={on_remove}>
{""}
</button>
</div>
}
})}
</div>
</div>
</div>
</div>
}
// Navigation and status at bottom
<div class="mt-auto">
// Navigation links
<div class="py-2 border-top border-secondary">
<div class="nav nav-pills flex-column">
<a href="#runners" class="nav-link text-muted small">{"Runners"}</a>
<a href="#jobs" class="nav-link text-muted small">{"Jobs"}</a>
<a href="#logs" class="nav-link text-muted small">{"Logs"}</a>
</div>
</div>
// Server status
<div class="py-2 border-top border-secondary">
<div class="d-flex align-items-center">
<span class={classes!("badge", "me-2", if props.supervisor_info.is_some() { "bg-success" } else { "bg-danger" })}>
{""}
</span>
<small class="text-muted">
{if props.supervisor_info.is_some() { "Connected" } else { "Disconnected" }}
</small>
</div>
</div>
</div>
</div>
</div>
}
}

View File

@@ -1,639 +0,0 @@
use yew::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::spawn_local;
use gloo::console;
use gloo::storage::{LocalStorage, Storage};
use hero_supervisor_openrpc_client::wasm::{WasmSupervisorClient, WasmJob};
use serde::{Deserialize, Serialize};
#[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(Clone, PartialEq, Debug, Serialize, Deserialize)]
pub enum SessionSecretType {
None,
User,
Admin,
}
#[derive(Serialize, Deserialize)]
struct SessionData {
secret: String,
secret_type: SessionSecretType,
}
const SESSION_STORAGE_KEY: &str = "supervisor_session";
#[derive(Properties, PartialEq)]
pub struct SidebarProps {
pub server_url: String,
pub supervisor_info: Option<SupervisorInfo>,
pub session_secret: String,
pub session_secret_type: SessionSecretType,
pub on_session_secret_change: Callback<(String, SessionSecretType)>,
pub on_supervisor_info_loaded: Callback<SupervisorInfo>,
}
#[function_component(Sidebar)]
pub fn sidebar(props: &SidebarProps) -> Html {
let session_secret_input = use_state(|| String::new());
let is_loading = use_state(|| false);
// Load session from localStorage on component mount
{
let on_session_secret_change = props.on_session_secret_change.clone();
use_effect_with((), move |_| {
if let Ok(session_data) = LocalStorage::get::<SessionData>(SESSION_STORAGE_KEY) {
on_session_secret_change.emit((session_data.secret, session_data.secret_type));
}
|| ()
});
}
let on_session_secret_change = {
let session_secret_input = session_secret_input.clone();
Callback::from(move |e: web_sys::Event| {
let input: web_sys::HtmlInputElement = e.target().unwrap().dyn_into().unwrap();
session_secret_input.set(input.value());
})
};
let on_session_secret_submit = {
let session_secret_input = session_secret_input.clone();
let is_loading = is_loading.clone();
let on_session_secret_change = props.on_session_secret_change.clone();
let on_supervisor_info_loaded = props.on_supervisor_info_loaded.clone();
let server_url = props.server_url.clone();
Callback::from(move |_: web_sys::MouseEvent| {
let secret = (*session_secret_input).clone();
if secret.is_empty() {
return;
}
is_loading.set(true);
let is_loading = is_loading.clone();
let on_session_secret_change = on_session_secret_change.clone();
let on_supervisor_info_loaded = on_supervisor_info_loaded.clone();
let server_url = server_url.clone();
let session_secret_input = session_secret_input.clone();
spawn_local(async move {
let client = WasmSupervisorClient::new(server_url.clone());
match client.discover().await {
Ok(_) => {
console::log!("Connected to supervisor successfully");
let secret_type = if secret.starts_with("admin_") {
SessionSecretType::Admin
} else if secret.starts_with("user_") {
SessionSecretType::User
} else {
SessionSecretType::User
};
// Save to localStorage
let session_data = SessionData {
secret: secret.clone(),
secret_type: secret_type.clone(),
};
let _ = LocalStorage::set(SESSION_STORAGE_KEY, &session_data);
let supervisor_info = SupervisorInfo {
server_url: server_url.clone(),
admin_secrets_count: if secret_type == SessionSecretType::Admin { 1 } else { 0 },
user_secrets_count: 1,
register_secrets_count: 0,
runners_count: 0,
};
on_session_secret_change.emit((secret.clone(), secret_type.clone()));
on_supervisor_info_loaded.emit(supervisor_info);
session_secret_input.set(String::new());
}
Err(e) => {
console::error!("Failed to connect to supervisor:", format!("{:?}", e));
}
}
is_loading.set(false);
});
})
};
let on_logout = {
let on_session_secret_change = props.on_session_secret_change.clone();
Callback::from(move |_: web_sys::MouseEvent| {
// Clear localStorage
let _ = LocalStorage::delete(SESSION_STORAGE_KEY);
on_session_secret_change.emit((String::new(), SessionSecretType::None));
})
};
html! {
<div class="sidebar">
// Header with logo and title
<div class="sidebar-header">
<div class="logo-section">
<div class="logo">{""}</div>
<h1 class="title">{"Supervisor"}</h1>
</div>
</div>
// Main control island
<div class="control-island">
// Session Login Section
<div class="session-section">
<h3 class="section-title">{"Session Login"}</h3>
if props.session_secret.is_empty() {
<div class="login-form">
<div class="input-group">
<input
type="password"
class="secret-input"
placeholder="Enter session secret"
value={(*session_secret_input).clone()}
onchange={on_session_secret_change}
/>
<button
class="connect-btn"
onclick={on_session_secret_submit}
disabled={*is_loading}
>
if *is_loading {
<span class="loading-spinner"></span>
{"Connecting"}
} else {
{"🔐 Connect"}
}
</button>
</div>
<div class="login-hint">
{"Use admin_ or user_ prefixed secrets"}
</div>
</div>
} else {
<div class="session-active">
<div class="session-status">
<div class="status-indicator"></div>
<div class="status-info">
<span class="status-text">{"Connected"}</span>
<span class="session-badge">{
match props.session_secret_type {
SessionSecretType::Admin => "Admin",
SessionSecretType::User => "User",
SessionSecretType::None => "None",
}
}</span>
</div>
</div>
<button
class="logout-btn"
onclick={on_logout}
>
{"🚪 Logout"}
</button>
</div>
}
</div>
// Secret Management Section (Admin only)
if props.session_secret_type == SessionSecretType::Admin && !props.session_secret.is_empty() {
<div class="secrets-section">
<h3 class="section-title">{"Secret Management"}</h3>
<div class="secret-display">
<div class="secret-item">
<label class="secret-label">{"Current Session Secret"}</label>
<div class="secret-value">
<code>{&props.session_secret}</code>
<button class="copy-btn" title="Copy to clipboard">{"📋"}</button>
</div>
</div>
</div>
</div>
}
// Server Info Section
if let Some(info) = &props.supervisor_info {
<div class="server-info-section">
<h3 class="section-title">{"Server Status"}</h3>
<div class="info-cards">
<div class="info-card">
<div class="info-icon">{"🏃"}</div>
<div class="info-content">
<div class="info-number">{info.runners_count}</div>
<div class="info-label">{"Runners"}</div>
</div>
</div>
<div class="info-card">
<div class="info-icon">{"🔗"}</div>
<div class="info-content">
<div class="info-text">{&info.server_url.replace("http://", "").replace("https://", "")}</div>
<div class="info-label">{"Server"}</div>
</div>
</div>
</div>
</div>
}
</div>
// Footer with navigation links
<div class="sidebar-footer">
<div class="nav-links">
<a href="https://github.com/herocode/supervisor" target="_blank" class="nav-link">
<span class="nav-icon">{"📖"}</span>
<span class="nav-text">{"Documentation"}</span>
</a>
<a href="https://github.com/herocode/supervisor/issues" target="_blank" class="nav-link">
<span class="nav-icon">{"🐛"}</span>
<span class="nav-text">{"Report Issue"}</span>
</a>
<a href="#" class="nav-link">
<span class="nav-icon">{"⚙️"}</span>
<span class="nav-text">{"Settings"}</span>
</a>
</div>
<div class="version-info">
{"Hero Supervisor v0.1.0"}
}
is_loading.set(true);
let is_loading = is_loading.clone();
let on_session_secret_change = on_session_secret_change.clone();
let on_supervisor_info_loaded = on_supervisor_info_loaded.clone();
let server_url = server_url.clone();
let session_secret_input = session_secret_input.clone();
spawn_local(async move {
let client = WasmSupervisorClient::new(server_url.clone());
match client.discover().await {
Ok(_) => {
console::log!("Connected to supervisor successfully");
let secret_type = if secret.starts_with("admin_") {
SessionSecretType::Admin
} else if secret.starts_with("user_") {
SessionSecretType::User
} else {
SessionSecretType::User
};
// Save to localStorage
let session_data = SessionData {
secret: secret.clone(),
secret_type: secret_type.clone(),
};
let _ = LocalStorage::set(SESSION_STORAGE_KEY, &session_data);
let supervisor_info = SupervisorInfo {
server_url: server_url.clone(),
admin_secrets_count: if secret_type == SessionSecretType::Admin { 1 } else { 0 },
user_secrets_count: 1,
register_secrets_count: 0,
runners_count: 0,
};
on_session_secret_change.emit((secret.clone(), secret_type.clone()));
on_supervisor_info_loaded.emit(supervisor_info);
session_secret_input.set(String::new());
}
Err(e) => {
console::error!("Failed to connect to supervisor:", format!("{:?}", e));
}
}
is_loading.set(false);
});
})
};
let on_logout = {
let on_session_secret_change = props.on_session_secret_change.clone();
Callback::from(move |_: web_sys::MouseEvent| {
// Clear localStorage
let _ = LocalStorage::delete(SESSION_STORAGE_KEY);
on_session_secret_change.emit((String::new(), SessionSecretType::None));
})
};
html! {
<div class="sidebar">
// Header with logo and title
<div class="sidebar-header">
<div class="logo-section">
<div class="logo">{""}</div>
<h1 class="title">{"Supervisor"}</h1>
</div>
</div>
// Main control island
<div class="control-island">
// Session Login Section
<div class="session-section">
<h3 class="section-title">{"Session Login"}</h3>
if props.session_secret.is_empty() {
<div class="login-form">
<div class="input-group">
<input
type="password"
class="secret-input"
placeholder="Enter session secret"
value={(*session_secret_input).clone()}
onchange={on_session_secret_change}
/>
<button
class="connect-btn"
onclick={on_session_secret_submit}
disabled={*is_loading}
>
if *is_loading {
<span class="loading-spinner"></span>
{"Connecting"}
} else {
{"🔐 Connect"}
}
</button>
</div>
<div class="login-hint">
{"Use admin_ or user_ prefixed secrets"}
</div>
</div>
} else {
<div class="session-active">
<div class="session-status">
<div class="status-indicator"></div>
<div class="status-info">
<span class="status-text">{"Connected"}</span>
<span class="session-badge">{
match props.session_secret_type {
SessionSecretType::Admin => "Admin",
SessionSecretType::User => "User",
SessionSecretType::None => "None",
}
}</span>
</div>
</div>
<button
class="logout-btn"
onclick={on_logout}
>
{"🚪 Logout"}
</button>
</div>
}
</div>
// Secret Management Section (Admin only)
if props.session_secret_type == SessionSecretType::Admin && !props.session_secret.is_empty() {
<div class="secrets-section">
<h3 class="section-title">{"Secret Management"}</h3>
<div class="secret-display">
<div class="secret-item">
<label class="secret-label">{"Current Session Secret"}</label>
<div class="secret-value">
<code>{&props.session_secret}</code>
<button class="copy-btn" title="Copy to clipboard">{"📋"}</button>
</div>
</div>
</div>
</div>
}
// Server Info Section
if let Some(info) = &props.supervisor_info {
<div class="server-info-section">
<h3 class="section-title">{"Server Status"}</h3>
<div class="info-cards">
<div class="info-card">
<div class="info-icon">{"🏃"}</div>
<div class="info-content">
<div class="info-number">{info.runners_count}</div>
<div class="info-label">{"Runners"}</div>
</div>
</div>
<div class="info-card">
<div class="info-icon">{"🔗"}</div>
<div class="info-content">
<div class="info-text">{&info.server_url.replace("http://", "").replace("https://", "")}</div>
<div class="info-label">{"Server"}</div>
</div>
</div>
</div>
</div>
}
</div>
// Footer with navigation links
<div class="sidebar-footer">
<div class="nav-links">
<a href="https://github.com/herocode/supervisor" target="_blank" class="nav-link">
<span class="nav-icon">{"📖"}</span>
<span class="nav-text">{"Documentation"}</span>
</a>
<a href="https://github.com/herocode/supervisor/issues" target="_blank" class="nav-link">
<span class="nav-icon">{"🐛"}</span>
<span class="nav-text">{"Report Issue"}</span>
</a>
<a href="#" class="nav-link">
<span class="nav-icon">{"⚙️"}</span>
<span class="nav-text">{"Settings"}</span>
</a>
</div>
<div class="version-info">
{"Hero Supervisor v0.1.0"}
</div>
</div>
</div>
}
let on_session_secret_change = {
let session_secret_input = session_secret_input.clone();
Callback::from(move |e: web_sys::Event| {
let input: web_sys::HtmlInputElement = e.target().unwrap().dyn_into().unwrap();
session_secret_input.set(input.value());
})
};
let on_session_secret_submit = {
let session_secret_input = session_secret_input.clone();
let is_loading = is_loading.clone();
let on_session_secret_change = props.on_session_secret_change.clone();
let on_supervisor_info_loaded = props.on_supervisor_info_loaded.clone();
let server_url = props.server_url.clone();
Callback::from(move |_: web_sys::MouseEvent| {
let secret = (*session_secret_input).clone();
if secret.is_empty() {
return;
}
is_loading.set(true);
let is_loading = is_loading.clone();
let on_session_secret_change = on_session_secret_change.clone();
let on_supervisor_info_loaded = on_supervisor_info_loaded.clone();
let server_url = server_url.clone();
let session_secret_input = session_secret_input.clone();
spawn_local(async move {
let client = WasmSupervisorClient::new(server_url.clone());
match client.discover().await {
Ok(_) => {
console::log!("Connected to supervisor successfully");
let secret_type = if secret.starts_with("admin_") {
SessionSecretType::Admin
} else if secret.starts_with("user_") {
SessionSecretType::User
} else {
SessionSecretType::User
};
// Save to localStorage
let session_data = SessionData {
secret: secret.clone(),
secret_type: secret_type.clone(),
};
let _ = LocalStorage::set(SESSION_STORAGE_KEY, &session_data);
let supervisor_info = SupervisorInfo {
server_url: server_url.clone(),
admin_secrets_count: if secret_type == SessionSecretType::Admin { 1 } else { 0 },
user_secrets_count: 1,
register_secrets_count: 0,
runners_count: 0,
};
on_session_secret_change.emit((secret.clone(), secret_type.clone()));
on_supervisor_info_loaded.emit(supervisor_info);
session_secret_input.set(String::new());
}
Err(e) => {
console::error!("Failed to connect to supervisor:", format!("{:?}", e));
}
}
is_loading.set(false);
});
})
};
let on_logout = {
let on_session_secret_change = props.on_session_secret_change.clone();
Callback::from(move |_: web_sys::MouseEvent| {
// Clear localStorage
let _ = LocalStorage::delete(SESSION_STORAGE_KEY);
on_session_secret_change.emit((String::new(), SessionSecretType::None));
})
};
html! {
<div class="sidebar">
<div class="sidebar-header">
<div class="logo-section">
<div class="logo">{""}</div>
<h1 class="title">{"Supervisor"}</h1>
</div>
</div>
<div class="control-island">
<div class="session-section">
<h3 class="section-title">{"Session Login"}</h3>
if props.session_secret.is_empty() {
<div class="login-form">
<div class="input-group">
<input
type="password"
class="secret-input"
placeholder="Enter session secret"
value={(*session_secret_input).clone()}
onchange={on_session_secret_change}
/>
<button
class="connect-btn"
onclick={on_session_secret_submit}
disabled={*is_loading}
>
if *is_loading {
<span class="loading-spinner"></span>
{"Connecting"}
} else {
{"🔐 Connect"}
}
</button>
</div>
<div class="login-hint">
{"Use admin_ or user_ prefixed secrets"}
</div>
</div>
} else {
<div class="session-active">
<div class="session-status">
<div class="status-indicator"></div>
<div class="status-info">
<span class="status-text">{"Connected"}</span>
<span class="session-badge">{
match props.session_secret_type {
SessionSecretType::Admin => "Admin",
SessionSecretType::User => "User",
SessionSecretType::None => "None",
}
}</span>
</div>
</div>
<button
class="logout-btn"
onclick={on_logout}
>
{"🚪 Logout"}
</button>
</div>
}
</div>
// Supervisor Info Section
if let Some(info) = &props.supervisor_info {
<div class="supervisor-info">
<div class="supervisor-info-header">
<span class="supervisor-info-title">{"Supervisor Info"}</span>
</div>
<div class="supervisor-info-content">
<div class="info-item">
<span class="info-label">{"Admin secrets:"}</span>
<span class="info-value">{info.admin_secrets_count}</span>
</div>
<div class="info-item">
<span class="info-label">{"User secrets:"}</span>
<span class="info-value">{info.user_secrets_count}</span>
</div>
<div class="info-item">
<span class="info-label">{"Register secrets:"}</span>
<span class="info-value">{info.register_secrets_count}</span>
</div>
<div class="info-item">
<span class="info-label">{"Runners:"}</span>
<span class="info-value">{info.runners_count}</span>
</div>
</div>
</div>
}
</div>
</div>
{{ ... }}
</div>
</div>
</div>
}
}

View File

@@ -1,165 +0,0 @@
//! Toast notification component for displaying errors, warnings, and info messages
use yew::prelude::*;
use std::collections::HashMap;
use gloo::timers::callback::Timeout;
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq)]
pub enum ToastType {
Error,
Warning,
Info,
Success,
}
impl ToastType {
pub fn css_class(&self) -> &'static str {
match self {
ToastType::Error => "toast-error",
ToastType::Warning => "toast-warning",
ToastType::Info => "toast-info",
ToastType::Success => "toast-success",
}
}
pub fn icon(&self) -> &'static str {
match self {
ToastType::Error => "",
ToastType::Warning => "⚠️",
ToastType::Info => "",
ToastType::Success => "",
}
}
pub fn bg_class(&self) -> &'static str {
match self {
ToastType::Error => "bg-danger",
ToastType::Warning => "bg-warning",
ToastType::Info => "bg-info",
ToastType::Success => "bg-success",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Toast {
pub id: String,
pub message: String,
pub toast_type: ToastType,
pub timestamp: f64,
pub auto_dismiss: bool,
}
impl Toast {
pub fn new(message: String, toast_type: ToastType) -> Self {
Self {
id: Uuid::new_v4().to_string(),
message,
toast_type,
timestamp: js_sys::Date::now(),
auto_dismiss: true,
}
}
pub fn error(message: String) -> Self {
Self::new(message, ToastType::Error)
}
pub fn warning(message: String) -> Self {
Self::new(message, ToastType::Warning)
}
pub fn info(message: String) -> Self {
Self::new(message, ToastType::Info)
}
pub fn success(message: String) -> Self {
Self::new(message, ToastType::Success)
}
pub fn persistent(mut self) -> Self {
self.auto_dismiss = false;
self
}
}
#[derive(Properties, PartialEq)]
pub struct ToastContainerProps {
pub toasts: Vec<Toast>,
pub on_dismiss: Callback<String>,
}
#[function_component(ToastContainer)]
pub fn toast_container(props: &ToastContainerProps) -> Html {
let timeouts = use_mut_ref(HashMap::<String, Timeout>::new);
// Auto-dismiss toasts after 5 seconds
use_effect_with(props.toasts.clone(), {
let on_dismiss = props.on_dismiss.clone();
let timeouts = timeouts.clone();
move |toasts| {
for toast in toasts {
if toast.auto_dismiss {
let toast_id = toast.id.clone();
let on_dismiss = on_dismiss.clone();
let timeout = Timeout::new(5000, move || {
on_dismiss.emit(toast_id);
});
timeouts.borrow_mut().insert(toast.id.clone(), timeout);
}
}
move || {
timeouts.borrow_mut().clear();
}
}
});
html! {
<div class="toast-container position-fixed bottom-0 end-0 p-3" style="z-index: 1055;">
{for props.toasts.iter().map(|toast| {
let on_dismiss = {
let on_dismiss = props.on_dismiss.clone();
let toast_id = toast.id.clone();
Callback::from(move |_| {
on_dismiss.emit(toast_id.clone());
})
};
html! {
<div key={toast.id.clone()} class={classes!("toast", "show", "mb-2")} role="alert">
<div class={classes!("toast-header", toast.toast_type.bg_class(), "text-white")}>
<span class="me-2">{toast.toast_type.icon()}</span>
<strong class="me-auto">{
match toast.toast_type {
ToastType::Error => "Error",
ToastType::Warning => "Warning",
ToastType::Info => "Info",
ToastType::Success => "Success",
}
}</strong>
<small class="text-white-50">{format_timestamp(toast.timestamp)}</small>
<button type="button" class="btn-close btn-close-white ms-2" onclick={on_dismiss}></button>
</div>
<div class="toast-body bg-dark text-white">
{toast.message.clone()}
</div>
</div>
}
})}
</div>
}
}
fn format_timestamp(timestamp: f64) -> String {
let now = js_sys::Date::now();
let diff = (now - timestamp) / 1000.0; // seconds
if diff < 60.0 {
"now".to_string()
} else if diff < 3600.0 {
format!("{}m ago", (diff / 60.0) as u32)
} else {
format!("{}h ago", (diff / 3600.0) as u32)
}
}

View File

@@ -1,61 +0,0 @@
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

@@ -1,378 +0,0 @@
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: 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: &str, job: Job) -> WasmClientResult<()> {
let params = json!({
"runner": runner,
"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: &str,
job: Job,
timeout_secs: u64,
) -> WasmClientResult<Option<String>> {
let params = json!({
"runner": runner,
"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: 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(mut self, runner: impl Into<String>) -> Self {
self.runner = Some(runner.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: self.runner.ok_or_else(|| WasmClientError::Server {
message: "runner is required".to_string(),
})?,
timeout: self.timeout,
env_vars: self.env_vars,
})
}
}