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:
168
Cargo.lock
generated
168
Cargo.lock
generated
@@ -1377,12 +1377,14 @@ dependencies = [
|
||||
"env_logger 0.10.2",
|
||||
"escargot",
|
||||
"hero-supervisor-openrpc-client",
|
||||
"hyper 1.7.0",
|
||||
"hyper-util",
|
||||
"jsonrpsee",
|
||||
"log",
|
||||
"rand 0.8.5",
|
||||
"redis 0.25.4",
|
||||
"reqwest 0.12.24",
|
||||
"runner_rust 0.1.0 (git+https://git.ourworld.tf/herocode/runner_rust.git)",
|
||||
"runner_rust 0.1.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
@@ -1403,13 +1405,17 @@ dependencies = [
|
||||
"env_logger 0.11.8",
|
||||
"getrandom 0.2.16",
|
||||
"hero-supervisor",
|
||||
"hex",
|
||||
"indexmap",
|
||||
"js-sys",
|
||||
"jsonrpsee",
|
||||
"log",
|
||||
"runner_rust 0.1.0 (git+https://git.ourworld.tf/herocode/runner_rust.git?branch=main)",
|
||||
"secp256k1 0.29.1",
|
||||
"serde",
|
||||
"serde-wasm-bindgen",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"uuid",
|
||||
@@ -2639,6 +2645,26 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osiris"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"env_logger 0.10.2",
|
||||
"osiris_derive 0.1.0",
|
||||
"redis 0.24.0",
|
||||
"rhai",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"time",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osiris"
|
||||
version = "0.1.0"
|
||||
@@ -2647,7 +2673,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"env_logger 0.10.2",
|
||||
"osiris_derive",
|
||||
"osiris_derive 0.1.0 (git+https://git.ourworld.tf/herocode/osiris.git)",
|
||||
"redis 0.24.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2659,6 +2685,15 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osiris_derive"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.108",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osiris_derive"
|
||||
version = "0.1.0"
|
||||
@@ -3531,6 +3566,52 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "afab94fb28594581f62d981211a9a4d53cc8130bbcbbb89a0440d9b8e81a7746"
|
||||
|
||||
[[package]]
|
||||
name = "runner_rust"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"clap",
|
||||
"crossterm",
|
||||
"env_logger 0.10.2",
|
||||
"hero_logger",
|
||||
"heromodels",
|
||||
"heromodels-derive",
|
||||
"heromodels_core",
|
||||
"hex",
|
||||
"log",
|
||||
"osiris 0.1.0",
|
||||
"rand 0.8.5",
|
||||
"ratatui",
|
||||
"redis 0.25.4",
|
||||
"rhai",
|
||||
"rhailib_dsl",
|
||||
"sal-git",
|
||||
"sal-hetzner",
|
||||
"sal-kubernetes",
|
||||
"sal-mycelium",
|
||||
"sal-net",
|
||||
"sal-os",
|
||||
"sal-postgresclient",
|
||||
"sal-process",
|
||||
"sal-redisclient",
|
||||
"sal-text",
|
||||
"sal-vault",
|
||||
"sal-virt",
|
||||
"sal-zinit-client",
|
||||
"secp256k1 0.28.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "runner_rust"
|
||||
version = "0.1.0"
|
||||
@@ -3548,7 +3629,7 @@ dependencies = [
|
||||
"heromodels_core",
|
||||
"hex",
|
||||
"log",
|
||||
"osiris",
|
||||
"osiris 0.1.0 (git+https://git.ourworld.tf/herocode/osiris.git)",
|
||||
"rand 0.8.5",
|
||||
"ratatui",
|
||||
"redis 0.25.4",
|
||||
@@ -3567,54 +3648,7 @@ dependencies = [
|
||||
"sal-vault",
|
||||
"sal-virt",
|
||||
"sal-zinit-client",
|
||||
"secp256k1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "runner_rust"
|
||||
version = "0.1.0"
|
||||
source = "git+https://git.ourworld.tf/herocode/runner_rust.git#268128f7fd53e9586288efd95f9288595c4a74e9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"clap",
|
||||
"crossterm",
|
||||
"env_logger 0.10.2",
|
||||
"hero_logger",
|
||||
"heromodels",
|
||||
"heromodels-derive",
|
||||
"heromodels_core",
|
||||
"hex",
|
||||
"log",
|
||||
"osiris",
|
||||
"rand 0.8.5",
|
||||
"ratatui",
|
||||
"redis 0.25.4",
|
||||
"rhai",
|
||||
"rhailib_dsl",
|
||||
"sal-git",
|
||||
"sal-hetzner",
|
||||
"sal-kubernetes",
|
||||
"sal-mycelium",
|
||||
"sal-net",
|
||||
"sal-os",
|
||||
"sal-postgresclient",
|
||||
"sal-process",
|
||||
"sal-redisclient",
|
||||
"sal-text",
|
||||
"sal-vault",
|
||||
"sal-virt",
|
||||
"sal-zinit-client",
|
||||
"secp256k1",
|
||||
"secp256k1 0.28.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -4063,7 +4097,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d24b59d129cdadea20aea4fb2352fa053712e5d713eee47d700cd4b2bc002f10"
|
||||
dependencies = [
|
||||
"rand 0.8.5",
|
||||
"secp256k1-sys",
|
||||
"secp256k1-sys 0.9.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secp256k1"
|
||||
version = "0.29.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
|
||||
dependencies = [
|
||||
"rand 0.8.5",
|
||||
"secp256k1-sys 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4075,6 +4119,15 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secp256k1-sys"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secrecy"
|
||||
version = "0.8.0"
|
||||
@@ -4141,6 +4194,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde-wasm-bindgen"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
|
||||
@@ -5,7 +5,7 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
# Runner crate with integrated job module
|
||||
runner_rust = { git = "https://git.ourworld.tf/herocode/runner_rust.git" }
|
||||
runner_rust = { path = "../runner_rust" }
|
||||
# Async runtime
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
|
||||
@@ -37,6 +37,8 @@ anyhow = "1.0"
|
||||
# CORS support for OpenRPC server
|
||||
tower-http = { version = "0.5", features = ["cors"] }
|
||||
tower = "0.4"
|
||||
hyper = { version = "1.0", features = ["full"] }
|
||||
hyper-util = { version = "0.1", features = ["tokio"] }
|
||||
|
||||
# Mycelium integration (optional)
|
||||
base64 = { version = "0.22", optional = true }
|
||||
|
||||
159
clients/admin-ui/Cargo.lock
generated
159
clients/admin-ui/Cargo.lock
generated
@@ -1833,10 +1833,12 @@ dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"env_logger 0.10.2",
|
||||
"hyper 1.7.0",
|
||||
"hyper-util",
|
||||
"jsonrpsee",
|
||||
"log",
|
||||
"redis 0.25.4",
|
||||
"runner_rust 0.1.0 (git+https://git.ourworld.tf/herocode/runner_rust.git)",
|
||||
"runner_rust 0.1.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
@@ -1856,13 +1858,17 @@ dependencies = [
|
||||
"env_logger 0.11.8",
|
||||
"getrandom 0.2.16",
|
||||
"hero-supervisor",
|
||||
"hex",
|
||||
"indexmap",
|
||||
"js-sys",
|
||||
"jsonrpsee",
|
||||
"log",
|
||||
"runner_rust 0.1.0 (git+https://git.ourworld.tf/herocode/runner_rust.git?branch=main)",
|
||||
"secp256k1 0.29.1",
|
||||
"serde",
|
||||
"serde-wasm-bindgen 0.6.5",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"uuid",
|
||||
@@ -3149,6 +3155,26 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osiris"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"env_logger 0.10.2",
|
||||
"osiris_derive 0.1.0",
|
||||
"redis 0.24.0",
|
||||
"rhai",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"time",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osiris"
|
||||
version = "0.1.0"
|
||||
@@ -3157,7 +3183,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"env_logger 0.10.2",
|
||||
"osiris_derive",
|
||||
"osiris_derive 0.1.0 (git+https://git.ourworld.tf/herocode/osiris.git)",
|
||||
"redis 0.24.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3169,6 +3195,15 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osiris_derive"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osiris_derive"
|
||||
version = "0.1.0"
|
||||
@@ -4095,6 +4130,52 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "afab94fb28594581f62d981211a9a4d53cc8130bbcbbb89a0440d9b8e81a7746"
|
||||
|
||||
[[package]]
|
||||
name = "runner_rust"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"clap",
|
||||
"crossterm",
|
||||
"env_logger 0.10.2",
|
||||
"hero_logger",
|
||||
"heromodels",
|
||||
"heromodels-derive",
|
||||
"heromodels_core",
|
||||
"hex",
|
||||
"log",
|
||||
"osiris 0.1.0",
|
||||
"rand 0.8.5",
|
||||
"ratatui",
|
||||
"redis 0.25.4",
|
||||
"rhai",
|
||||
"rhailib_dsl",
|
||||
"sal-git",
|
||||
"sal-hetzner",
|
||||
"sal-kubernetes",
|
||||
"sal-mycelium",
|
||||
"sal-net",
|
||||
"sal-os",
|
||||
"sal-postgresclient",
|
||||
"sal-process",
|
||||
"sal-redisclient",
|
||||
"sal-text",
|
||||
"sal-vault",
|
||||
"sal-virt",
|
||||
"sal-zinit-client",
|
||||
"secp256k1 0.28.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "runner_rust"
|
||||
version = "0.1.0"
|
||||
@@ -4112,7 +4193,7 @@ dependencies = [
|
||||
"heromodels_core",
|
||||
"hex",
|
||||
"log",
|
||||
"osiris",
|
||||
"osiris 0.1.0 (git+https://git.ourworld.tf/herocode/osiris.git)",
|
||||
"rand 0.8.5",
|
||||
"ratatui",
|
||||
"redis 0.25.4",
|
||||
@@ -4131,54 +4212,7 @@ dependencies = [
|
||||
"sal-vault",
|
||||
"sal-virt",
|
||||
"sal-zinit-client",
|
||||
"secp256k1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "runner_rust"
|
||||
version = "0.1.0"
|
||||
source = "git+https://git.ourworld.tf/herocode/runner_rust.git#268128f7fd53e9586288efd95f9288595c4a74e9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"clap",
|
||||
"crossterm",
|
||||
"env_logger 0.10.2",
|
||||
"hero_logger",
|
||||
"heromodels",
|
||||
"heromodels-derive",
|
||||
"heromodels_core",
|
||||
"hex",
|
||||
"log",
|
||||
"osiris",
|
||||
"rand 0.8.5",
|
||||
"ratatui",
|
||||
"redis 0.25.4",
|
||||
"rhai",
|
||||
"rhailib_dsl",
|
||||
"sal-git",
|
||||
"sal-hetzner",
|
||||
"sal-kubernetes",
|
||||
"sal-mycelium",
|
||||
"sal-net",
|
||||
"sal-os",
|
||||
"sal-postgresclient",
|
||||
"sal-process",
|
||||
"sal-redisclient",
|
||||
"sal-text",
|
||||
"sal-vault",
|
||||
"sal-virt",
|
||||
"sal-zinit-client",
|
||||
"secp256k1",
|
||||
"secp256k1 0.28.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -4633,7 +4667,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d24b59d129cdadea20aea4fb2352fa053712e5d713eee47d700cd4b2bc002f10"
|
||||
dependencies = [
|
||||
"rand 0.8.5",
|
||||
"secp256k1-sys",
|
||||
"secp256k1-sys 0.9.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secp256k1"
|
||||
version = "0.29.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
|
||||
dependencies = [
|
||||
"rand 0.8.5",
|
||||
"secp256k1-sys 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4645,6 +4689,15 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secp256k1-sys"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secrecy"
|
||||
version = "0.8.0"
|
||||
@@ -5065,11 +5118,13 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
name = "supervisor-admin-ui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"gloo 0.11.0",
|
||||
"hero-supervisor-openrpc-client",
|
||||
"js-sys",
|
||||
"log",
|
||||
"serde",
|
||||
"serde-wasm-bindgen 0.6.5",
|
||||
"serde_json",
|
||||
"uuid",
|
||||
"wasm-bindgen",
|
||||
|
||||
@@ -15,11 +15,15 @@ web-sys = { version = "0.3", features = [
|
||||
"Document",
|
||||
"Element",
|
||||
"HtmlElement",
|
||||
"HtmlSelectElement",
|
||||
"HtmlTextAreaElement",
|
||||
"Window",
|
||||
] }
|
||||
js-sys = "0.3"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde-wasm-bindgen = "0.6"
|
||||
chrono = { version = "0.4", features = ["wasmbind"] }
|
||||
gloo = { version = "0.11", features = ["console", "timers", "futures"] }
|
||||
log = "0.4"
|
||||
wasm-logger = "0.2"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(
|
||||
®ister_form.secret,
|
||||
®ister_form.name,
|
||||
®ister_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>
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
267
clients/openrpc/Cargo.lock
generated
267
clients/openrpc/Cargo.lock
generated
@@ -666,22 +666,6 @@ version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df"
|
||||
dependencies = [
|
||||
"bitflags 2.9.3",
|
||||
"crossterm_winapi",
|
||||
"libc",
|
||||
"mio 0.8.11",
|
||||
"parking_lot",
|
||||
"signal-hook",
|
||||
"signal-hook-mio",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.28.1"
|
||||
@@ -690,7 +674,7 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||
dependencies = [
|
||||
"bitflags 2.9.3",
|
||||
"crossterm_winapi",
|
||||
"mio 1.0.4",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"rustix 0.38.44",
|
||||
"signal-hook",
|
||||
@@ -822,6 +806,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1405,16 +1390,15 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"clap",
|
||||
"env_logger 0.10.2",
|
||||
"hyper 1.7.0",
|
||||
"hyper-util",
|
||||
"jsonrpsee",
|
||||
"log",
|
||||
"rand 0.8.5",
|
||||
"redis 0.25.4",
|
||||
"reqwest 0.12.23",
|
||||
"runner_rust",
|
||||
"runner_rust 0.1.0 (git+https://git.ourworld.tf/herocode/runner_rust.git)",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
@@ -1430,19 +1414,21 @@ name = "hero-supervisor-openrpc-client"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"console_log",
|
||||
"crossterm 0.27.0",
|
||||
"env_logger 0.11.8",
|
||||
"getrandom 0.2.16",
|
||||
"hero-supervisor",
|
||||
"hex",
|
||||
"indexmap",
|
||||
"js-sys",
|
||||
"jsonrpsee",
|
||||
"log",
|
||||
"ratatui",
|
||||
"runner_rust",
|
||||
"runner_rust 0.1.0 (git+https://git.ourworld.tf/herocode/runner_rust.git?branch=main)",
|
||||
"secp256k1 0.29.1",
|
||||
"serde",
|
||||
"serde-wasm-bindgen",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
@@ -1514,6 +1500,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hkdf"
|
||||
version = "0.12.4"
|
||||
@@ -2499,18 +2491,6 @@ dependencies = [
|
||||
"adler2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.0.4"
|
||||
@@ -2717,6 +2697,36 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osiris"
|
||||
version = "0.1.0"
|
||||
source = "git+https://git.ourworld.tf/herocode/osiris.git#097360ad12d2ea73ac4d38552889d97702d9a889"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"env_logger 0.10.2",
|
||||
"osiris_derive",
|
||||
"redis 0.24.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"time",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osiris_derive"
|
||||
version = "0.1.0"
|
||||
source = "git+https://git.ourworld.tf/herocode/osiris.git#097360ad12d2ea73ac4d38552889d97702d9a889"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ourdb"
|
||||
version = "0.1.0"
|
||||
@@ -2922,19 +2932,6 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "poly1305"
|
||||
version = "0.8.0"
|
||||
@@ -3097,15 +3094,6 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.40"
|
||||
@@ -3216,7 +3204,7 @@ dependencies = [
|
||||
"bitflags 2.9.3",
|
||||
"cassowary",
|
||||
"compact_str",
|
||||
"crossterm 0.28.1",
|
||||
"crossterm",
|
||||
"instability",
|
||||
"itertools",
|
||||
"lru",
|
||||
@@ -3237,6 +3225,27 @@ dependencies = [
|
||||
"bitflags 2.9.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c580d9cbbe1d1b479e8d67cf9daf6a62c957e6846048408b80b43ac3f6af84cd"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"combine",
|
||||
"futures-util",
|
||||
"itoa",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"ryu",
|
||||
"sha1_smol",
|
||||
"socket2 0.4.10",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "0.25.4"
|
||||
@@ -3565,18 +3574,22 @@ checksum = "afab94fb28594581f62d981211a9a4d53cc8130bbcbbb89a0440d9b8e81a7746"
|
||||
[[package]]
|
||||
name = "runner_rust"
|
||||
version = "0.1.0"
|
||||
source = "git+https://git.ourworld.tf/herocode/runner_rust.git?branch=main#268128f7fd53e9586288efd95f9288595c4a74e9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"clap",
|
||||
"crossterm 0.28.1",
|
||||
"crossterm",
|
||||
"env_logger 0.10.2",
|
||||
"hero_logger",
|
||||
"heromodels",
|
||||
"heromodels-derive",
|
||||
"heromodels_core",
|
||||
"hex",
|
||||
"log",
|
||||
"osiris",
|
||||
"rand 0.8.5",
|
||||
"ratatui",
|
||||
"redis 0.25.4",
|
||||
"rhai",
|
||||
@@ -3590,13 +3603,61 @@ dependencies = [
|
||||
"sal-postgresclient",
|
||||
"sal-process",
|
||||
"sal-redisclient",
|
||||
"sal-service-manager",
|
||||
"sal-text",
|
||||
"sal-vault",
|
||||
"sal-virt",
|
||||
"sal-zinit-client",
|
||||
"secp256k1 0.28.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "runner_rust"
|
||||
version = "0.1.0"
|
||||
source = "git+https://git.ourworld.tf/herocode/runner_rust.git#268128f7fd53e9586288efd95f9288595c4a74e9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"clap",
|
||||
"crossterm",
|
||||
"env_logger 0.10.2",
|
||||
"hero_logger",
|
||||
"heromodels",
|
||||
"heromodels-derive",
|
||||
"heromodels_core",
|
||||
"hex",
|
||||
"log",
|
||||
"osiris",
|
||||
"rand 0.8.5",
|
||||
"ratatui",
|
||||
"redis 0.25.4",
|
||||
"rhai",
|
||||
"rhailib_dsl",
|
||||
"sal-git",
|
||||
"sal-hetzner",
|
||||
"sal-kubernetes",
|
||||
"sal-mycelium",
|
||||
"sal-net",
|
||||
"sal-os",
|
||||
"sal-postgresclient",
|
||||
"sal-process",
|
||||
"sal-redisclient",
|
||||
"sal-text",
|
||||
"sal-vault",
|
||||
"sal-virt",
|
||||
"sal-zinit-client",
|
||||
"secp256k1 0.28.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"toml",
|
||||
@@ -3906,24 +3967,6 @@ dependencies = [
|
||||
"rhai",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sal-service-manager"
|
||||
version = "0.1.0"
|
||||
source = "git+https://git.ourworld.tf/herocode/herolib_rust.git#7afa5ea1c0d9bb240fd2a96e5a0a01b04372fa1c"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"futures",
|
||||
"log",
|
||||
"once_cell",
|
||||
"plist",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"zinit-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sal-text"
|
||||
version = "0.1.0"
|
||||
@@ -4059,6 +4102,44 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secp256k1"
|
||||
version = "0.28.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d24b59d129cdadea20aea4fb2352fa053712e5d713eee47d700cd4b2bc002f10"
|
||||
dependencies = [
|
||||
"rand 0.8.5",
|
||||
"secp256k1-sys 0.9.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secp256k1"
|
||||
version = "0.29.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
|
||||
dependencies = [
|
||||
"rand 0.8.5",
|
||||
"secp256k1-sys 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secp256k1-sys"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5d1746aae42c19d583c3c1a8c646bfad910498e2051c551a7f2e3c0c9fbb7eb"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secp256k1-sys"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secrecy"
|
||||
version = "0.8.0"
|
||||
@@ -4124,6 +4205,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde-wasm-bindgen"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.219"
|
||||
@@ -4253,8 +4345,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio 0.8.11",
|
||||
"mio 1.0.4",
|
||||
"mio",
|
||||
"signal-hook",
|
||||
]
|
||||
|
||||
@@ -4326,6 +4417,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.4.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.5.10"
|
||||
@@ -4727,7 +4828,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"io-uring",
|
||||
"libc",
|
||||
"mio 1.0.4",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
|
||||
@@ -34,6 +34,7 @@ env_logger = "0.11"
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
serde-wasm-bindgen = "0.6"
|
||||
web-sys = { version = "0.3", features = [
|
||||
"console",
|
||||
"Request",
|
||||
@@ -45,6 +46,10 @@ web-sys = { version = "0.3", features = [
|
||||
] }
|
||||
console_log = "1.0"
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
# Crypto for signing
|
||||
secp256k1 = { version = "0.29", features = ["rand", "global-context"] }
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
|
||||
@@ -39,7 +39,7 @@ pub mod wasm;
|
||||
|
||||
// Re-export WASM types for convenience
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use wasm::{WasmSupervisorClient, WasmJob, WasmJobType, WasmRunnerType};
|
||||
pub use wasm::{WasmSupervisorClient, WasmJob, WasmJobType, WasmRunnerType, create_job_canonical_repr, sign_job_canonical};
|
||||
|
||||
// Native client dependencies
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -57,6 +57,7 @@ use std::path::PathBuf;
|
||||
pub struct SupervisorClient {
|
||||
client: HttpClient,
|
||||
server_url: String,
|
||||
secret: Option<String>,
|
||||
}
|
||||
|
||||
/// Error types for client operations
|
||||
@@ -225,6 +226,24 @@ impl SupervisorClient {
|
||||
Ok(Self {
|
||||
client,
|
||||
server_url,
|
||||
secret: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new supervisor client with authentication secret
|
||||
pub fn with_secret(server_url: impl Into<String>, secret: impl Into<String>) -> ClientResult<Self> {
|
||||
let server_url = server_url.into();
|
||||
let secret = secret.into();
|
||||
|
||||
let client = HttpClientBuilder::default()
|
||||
.request_timeout(std::time::Duration::from_secs(30))
|
||||
.build(&server_url)
|
||||
.map_err(|e| ClientError::Http(e.to_string()))?;
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
server_url,
|
||||
secret: Some(secret),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -5,15 +5,20 @@
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use web_sys::{Request, RequestInit, RequestMode, Response, Headers};
|
||||
use web_sys::{Headers, Request, RequestInit, RequestMode, Response};
|
||||
use serde_json::json;
|
||||
use secp256k1::{Message, PublicKey, Secp256k1, SecretKey, ecdsa::Signature};
|
||||
use sha2::{Sha256, Digest};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// WASM-compatible client for communicating with Hero Supervisor OpenRPC server
|
||||
#[wasm_bindgen]
|
||||
#[derive(Clone)]
|
||||
pub struct WasmSupervisorClient {
|
||||
server_url: String,
|
||||
secret: Option<String>,
|
||||
}
|
||||
|
||||
/// Error types for WASM client operations
|
||||
@@ -38,6 +43,14 @@ pub enum WasmClientError {
|
||||
/// Result type for WASM client operations
|
||||
pub type WasmClientResult<T> = Result<T, WasmClientError>;
|
||||
|
||||
/// Auth verification response
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthVerifyResponse {
|
||||
pub valid: bool,
|
||||
pub name: String,
|
||||
pub scope: String,
|
||||
}
|
||||
|
||||
/// JSON-RPC request structure
|
||||
#[derive(Serialize)]
|
||||
struct JsonRpcRequest {
|
||||
@@ -199,11 +212,24 @@ pub struct WasmJob {
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl WasmSupervisorClient {
|
||||
/// Create a new WASM supervisor client
|
||||
/// Create a new WASM supervisor client without authentication
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(server_url: String) -> Self {
|
||||
console_log::init_with_level(log::Level::Info).ok();
|
||||
Self { server_url }
|
||||
Self {
|
||||
server_url,
|
||||
secret: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new WASM supervisor client with authentication secret
|
||||
#[wasm_bindgen]
|
||||
pub fn with_secret(server_url: String, secret: String) -> Self {
|
||||
console_log::init_with_level(log::Level::Info).ok();
|
||||
Self {
|
||||
server_url,
|
||||
secret: Some(secret),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the server URL
|
||||
@@ -221,13 +247,88 @@ impl WasmSupervisorClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a new runner to the supervisor with secret authentication
|
||||
pub async fn register_runner(&self, secret: &str, name: &str, queue: &str) -> Result<String, JsValue> {
|
||||
let params = serde_json::json!([{
|
||||
"secret": secret,
|
||||
/// Verify an API key and return its metadata as JSON
|
||||
/// The key is sent via Authorization header (Bearer token)
|
||||
pub async fn auth_verify(&self, key: String) -> Result<JsValue, JsValue> {
|
||||
// Create a temporary client with the key to verify
|
||||
let temp_client = WasmSupervisorClient::with_secret(self.server_url.clone(), key);
|
||||
|
||||
// Send empty object as params - the key is in the Authorization header
|
||||
let params = serde_json::json!({});
|
||||
|
||||
match temp_client.call_method("auth.verify", params).await {
|
||||
Ok(result) => {
|
||||
// Parse to AuthVerifyResponse to validate, then convert to JsValue
|
||||
let auth_response: AuthVerifyResponse = serde_json::from_value(result)
|
||||
.map_err(|e| JsValue::from_str(&format!("Failed to parse auth response: {}", e)))?;
|
||||
|
||||
// Convert to JsValue
|
||||
serde_wasm_bindgen::to_value(&auth_response)
|
||||
.map_err(|e| JsValue::from_str(&format!("Failed to convert to JsValue: {}", e)))
|
||||
}
|
||||
Err(e) => Err(JsValue::from_str(&format!("Failed to verify auth: {}", e))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify the client's stored API key
|
||||
/// Uses the secret that was set when creating the client with with_secret()
|
||||
pub async fn auth_verify_self(&self) -> Result<JsValue, JsValue> {
|
||||
let key = self.secret.as_ref()
|
||||
.ok_or_else(|| JsValue::from_str("Client not authenticated - use with_secret() to create authenticated client"))?;
|
||||
|
||||
self.auth_verify(key.clone()).await
|
||||
}
|
||||
|
||||
/// Create a new API key (admin only)
|
||||
/// Returns the created API key with its key string
|
||||
pub async fn auth_create_key(&self, name: String, scope: String) -> Result<JsValue, JsValue> {
|
||||
let params = serde_json::json!({
|
||||
"name": name,
|
||||
"queue": queue
|
||||
}]);
|
||||
"scope": scope
|
||||
});
|
||||
|
||||
match self.call_method("auth.create_key", params).await {
|
||||
Ok(result) => Ok(serde_wasm_bindgen::to_value(&result)
|
||||
.map_err(|e| JsValue::from_str(&format!("Failed to convert result: {}", e)))?),
|
||||
Err(e) => Err(JsValue::from_str(&format!("Failed to create key: {}", e))),
|
||||
}
|
||||
}
|
||||
|
||||
/// List all API keys (admin only)
|
||||
pub async fn auth_list_keys(&self) -> Result<JsValue, JsValue> {
|
||||
match self.call_method("auth.list_keys", serde_json::Value::Null).await {
|
||||
Ok(result) => Ok(serde_wasm_bindgen::to_value(&result)
|
||||
.map_err(|e| JsValue::from_str(&format!("Failed to convert result: {}", e)))?),
|
||||
Err(e) => Err(JsValue::from_str(&format!("Failed to list keys: {}", e))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove an API key (admin only)
|
||||
pub async fn auth_remove_key(&self, key: String) -> Result<bool, JsValue> {
|
||||
let params = serde_json::json!({
|
||||
"key": key
|
||||
});
|
||||
|
||||
match self.call_method("auth.remove_key", params).await {
|
||||
Ok(result) => {
|
||||
if let Some(success) = result.as_bool() {
|
||||
Ok(success)
|
||||
} else {
|
||||
Err(JsValue::from_str("Invalid response format: expected boolean"))
|
||||
}
|
||||
},
|
||||
Err(e) => Err(JsValue::from_str(&format!("Failed to remove key: {}", e))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a new runner to the supervisor
|
||||
/// The queue name is automatically set to match the runner name
|
||||
/// Authentication uses the secret from Authorization header (set during client creation)
|
||||
pub async fn register_runner(&self, name: String) -> Result<String, JsValue> {
|
||||
// Secret is sent via Authorization header, not in params
|
||||
let params = serde_json::json!({
|
||||
"name": name
|
||||
});
|
||||
|
||||
match self.call_method("register_runner", params).await {
|
||||
Ok(result) => {
|
||||
@@ -238,13 +339,13 @@ impl WasmSupervisorClient {
|
||||
Err(JsValue::from_str("Invalid response format: expected runner name"))
|
||||
}
|
||||
},
|
||||
Err(e) => Err(JsValue::from_str(&e.to_string())),
|
||||
Err(e) => Err(JsValue::from_str(&format!("Failed to register runner: {}", e))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a job (fire-and-forget, non-blocking)
|
||||
/// Create a job (fire-and-forget, non-blocking) - DEPRECATED: Use create_job with API key auth
|
||||
#[wasm_bindgen]
|
||||
pub async fn create_job(&self, secret: String, job: WasmJob) -> Result<String, JsValue> {
|
||||
pub async fn create_job_with_secret(&self, secret: String, job: WasmJob) -> Result<String, JsValue> {
|
||||
// Backend expects RunJobParams struct with secret and job fields - wrap in array like register_runner
|
||||
let params = serde_json::json!([{
|
||||
"secret": secret,
|
||||
@@ -326,17 +427,65 @@ impl WasmSupervisorClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// List all job IDs from Redis
|
||||
pub async fn list_jobs(&self) -> Result<Vec<String>, JsValue> {
|
||||
match self.call_method("jobs.list", serde_json::Value::Null).await {
|
||||
/// Create a job from a JsValue (full Job object)
|
||||
pub async fn create_job(&self, job: JsValue) -> Result<String, JsValue> {
|
||||
// Convert JsValue to serde_json::Value
|
||||
let job_value: serde_json::Value = serde_wasm_bindgen::from_value(job)
|
||||
.map_err(|e| JsValue::from_str(&format!("Failed to parse job: {}", e)))?;
|
||||
|
||||
// Wrap in RunJobParams structure and pass as positional parameter
|
||||
let params = serde_json::json!([{
|
||||
"job": job_value
|
||||
}]);
|
||||
|
||||
match self.call_method("jobs.create", params).await {
|
||||
Ok(result) => {
|
||||
if let Ok(jobs) = serde_json::from_value::<Vec<String>>(result) {
|
||||
Ok(jobs)
|
||||
if let Some(job_id) = result.as_str() {
|
||||
Ok(job_id.to_string())
|
||||
} else {
|
||||
Err(JsValue::from_str("Invalid response format for list_jobs"))
|
||||
Err(JsValue::from_str("Invalid response format: expected job ID"))
|
||||
}
|
||||
},
|
||||
Err(e) => Err(JsValue::from_str(&e.to_string())),
|
||||
Err(e) => Err(JsValue::from_str(&format!("Failed to create job: {}", e))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a job with basic parameters (simplified version)
|
||||
pub async fn create_simple_job(
|
||||
&self,
|
||||
runner: String,
|
||||
caller_id: String,
|
||||
context_id: String,
|
||||
payload: String,
|
||||
executor: String,
|
||||
) -> Result<String, JsValue> {
|
||||
// Generate a unique job ID
|
||||
let job_id = format!("job-{}", uuid::Uuid::new_v4());
|
||||
|
||||
let job = serde_json::json!({
|
||||
"id": job_id,
|
||||
"runner": runner,
|
||||
"caller_id": caller_id,
|
||||
"context_id": context_id,
|
||||
"payload": payload,
|
||||
"executor": executor,
|
||||
"timeout": 30,
|
||||
"env": {}
|
||||
});
|
||||
|
||||
let params = serde_json::json!({
|
||||
"job": job
|
||||
});
|
||||
|
||||
match self.call_method("jobs.create", params).await {
|
||||
Ok(result) => {
|
||||
if let Some(job_id) = result.as_str() {
|
||||
Ok(job_id.to_string())
|
||||
} else {
|
||||
Err(JsValue::from_str("Invalid response format: expected job ID"))
|
||||
}
|
||||
},
|
||||
Err(e) => Err(JsValue::from_str(&format!("Failed to create job: {}", e))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,9 +559,11 @@ impl WasmSupervisorClient {
|
||||
/// Delete a job by ID
|
||||
#[wasm_bindgen]
|
||||
pub async fn delete_job(&self, job_id: &str) -> Result<(), JsValue> {
|
||||
let params = serde_json::json!([job_id]);
|
||||
let params = serde_json::json!([{
|
||||
"job_id": job_id
|
||||
}]);
|
||||
|
||||
match self.call_method("delete_job", params).await {
|
||||
match self.call_method("job.delete", params).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(JsValue::from_str(&format!("Failed to delete job: {:?}", e)))
|
||||
}
|
||||
@@ -679,6 +830,54 @@ impl WasmJob {
|
||||
}
|
||||
|
||||
impl WasmSupervisorClient {
|
||||
/// List all jobs (returns full job objects as Vec<serde_json::Value>)
|
||||
/// This is not exposed to WASM directly due to type limitations
|
||||
pub async fn list_jobs(&self) -> Result<Vec<serde_json::Value>, JsValue> {
|
||||
let params = serde_json::json!([]);
|
||||
match self.call_method("jobs.list", params).await {
|
||||
Ok(result) => {
|
||||
if let Ok(jobs) = serde_json::from_value::<Vec<serde_json::Value>>(result) {
|
||||
Ok(jobs)
|
||||
} else {
|
||||
Err(JsValue::from_str("Invalid response format for jobs.list"))
|
||||
}
|
||||
},
|
||||
Err(e) => Err(JsValue::from_str(&e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a previously created job by queuing it to its assigned runner
|
||||
pub async fn start_job(&self, job_id: &str) -> Result<(), JsValue> {
|
||||
let params = serde_json::json!([{
|
||||
"job_id": job_id
|
||||
}]);
|
||||
|
||||
match self.call_method("job.start", params).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(JsValue::from_str(&e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the status of a job
|
||||
pub async fn get_job_status(&self, job_id: &str) -> Result<serde_json::Value, JsValue> {
|
||||
let params = serde_json::json!([job_id]);
|
||||
|
||||
match self.call_method("job.status", params).await {
|
||||
Ok(result) => Ok(result),
|
||||
Err(e) => Err(JsValue::from_str(&e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the result of a completed job
|
||||
pub async fn get_job_result(&self, job_id: &str) -> Result<serde_json::Value, JsValue> {
|
||||
let params = serde_json::json!([job_id]);
|
||||
|
||||
match self.call_method("job.result", params).await {
|
||||
Ok(result) => Ok(result),
|
||||
Err(e) => Err(JsValue::from_str(&e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal method to make JSON-RPC calls
|
||||
async fn call_method(&self, method: &str, params: serde_json::Value) -> WasmClientResult<serde_json::Value> {
|
||||
let request = JsonRpcRequest {
|
||||
@@ -694,6 +893,12 @@ impl WasmSupervisorClient {
|
||||
let headers = Headers::new().map_err(|e| WasmClientError::JavaScript(format!("{:?}", e)))?;
|
||||
headers.set("Content-Type", "application/json")
|
||||
.map_err(|e| WasmClientError::JavaScript(format!("{:?}", e)))?;
|
||||
|
||||
// Add Authorization header if secret is present
|
||||
if let Some(secret) = &self.secret {
|
||||
headers.set("Authorization", &format!("Bearer {}", secret))
|
||||
.map_err(|e| WasmClientError::JavaScript(format!("{:?}", e)))?;
|
||||
}
|
||||
|
||||
// Create request init
|
||||
let opts = RequestInit::new();
|
||||
@@ -760,3 +965,82 @@ pub fn create_job(id: String, payload: String, executor: String, runner: String)
|
||||
pub fn create_client(server_url: String) -> WasmSupervisorClient {
|
||||
WasmSupervisorClient::new(server_url)
|
||||
}
|
||||
|
||||
/// Sign a job's canonical representation with a private key
|
||||
/// Returns a tuple of (public_key_hex, signature_hex)
|
||||
#[wasm_bindgen]
|
||||
pub fn sign_job_canonical(
|
||||
canonical_repr: String,
|
||||
private_key_hex: String,
|
||||
) -> Result<JsValue, JsValue> {
|
||||
// Decode private key from hex
|
||||
let secret_bytes = hex::decode(&private_key_hex)
|
||||
.map_err(|e| JsValue::from_str(&format!("Invalid private key hex: {}", e)))?;
|
||||
|
||||
let secret_key = SecretKey::from_slice(&secret_bytes)
|
||||
.map_err(|e| JsValue::from_str(&format!("Invalid private key: {}", e)))?;
|
||||
|
||||
// Get the public key
|
||||
let secp = Secp256k1::new();
|
||||
let public_key = PublicKey::from_secret_key(&secp, &secret_key);
|
||||
let public_key_hex = hex::encode(public_key.serialize());
|
||||
|
||||
// Hash the canonical representation
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(canonical_repr.as_bytes());
|
||||
let hash = hasher.finalize();
|
||||
|
||||
// Create message from hash
|
||||
let message = Message::from_digest_slice(&hash)
|
||||
.map_err(|e| JsValue::from_str(&format!("Invalid message: {}", e)))?;
|
||||
|
||||
// Sign the message
|
||||
let signature = secp.sign_ecdsa(&message, &secret_key);
|
||||
let signature_hex = hex::encode(signature.serialize_compact());
|
||||
|
||||
// Return as JS object
|
||||
let result = serde_json::json!({
|
||||
"public_key": public_key_hex,
|
||||
"signature": signature_hex
|
||||
});
|
||||
|
||||
serde_wasm_bindgen::to_value(&result)
|
||||
.map_err(|e| JsValue::from_str(&format!("Failed to serialize result: {}", e)))
|
||||
}
|
||||
|
||||
/// Create canonical representation of a job for signing
|
||||
/// This matches the format used in runner_rust Job::canonical_representation
|
||||
#[wasm_bindgen]
|
||||
pub fn create_job_canonical_repr(
|
||||
id: String,
|
||||
caller_id: String,
|
||||
context_id: String,
|
||||
payload: String,
|
||||
runner: String,
|
||||
executor: String,
|
||||
timeout: u64,
|
||||
env_vars_json: String,
|
||||
) -> Result<String, JsValue> {
|
||||
// Parse env_vars from JSON
|
||||
let env_vars: std::collections::HashMap<String, String> = serde_json::from_str(&env_vars_json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Invalid env_vars JSON: {}", e)))?;
|
||||
|
||||
// Sort env_vars keys for deterministic ordering
|
||||
let mut env_vars_sorted: Vec<_> = env_vars.iter().collect();
|
||||
env_vars_sorted.sort_by_key(|&(k, _)| k);
|
||||
|
||||
// Create canonical representation (matches Job::canonical_representation in runner_rust)
|
||||
let canonical = format!(
|
||||
"{}:{}:{}:{}:{}:{}:{}:{:?}",
|
||||
id,
|
||||
caller_id,
|
||||
context_id,
|
||||
payload,
|
||||
runner,
|
||||
executor,
|
||||
timeout,
|
||||
env_vars_sorted
|
||||
);
|
||||
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ struct Args {
|
||||
/// Bind address for OpenRPC HTTP server
|
||||
#[arg(long, default_value = "127.0.0.1")]
|
||||
bind_address: String,
|
||||
|
||||
/// Bootstrap an initial admin API key with the given name
|
||||
#[arg(long = "bootstrap-admin-key", value_name = "NAME")]
|
||||
bootstrap_admin_key: Option<String>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -105,6 +109,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
};
|
||||
|
||||
// Bootstrap admin key if requested
|
||||
if let Some(admin_key_name) = args.bootstrap_admin_key {
|
||||
info!("Bootstrapping admin API key: {}", admin_key_name);
|
||||
let admin_key = supervisor.bootstrap_admin_key(admin_key_name).await;
|
||||
println!("\n╔════════════════════════════════════════════════════════════╗");
|
||||
println!("║ 🔑 Admin API Key Created ║");
|
||||
println!("╚════════════════════════════════════════════════════════════╝");
|
||||
println!(" Name: {}", admin_key.name);
|
||||
println!(" Key: {}", admin_key.key);
|
||||
println!(" Scope: {}", admin_key.scope.as_str());
|
||||
println!(" ⚠️ SAVE THIS KEY - IT WILL NOT BE SHOWN AGAIN!");
|
||||
println!("╚════════════════════════════════════════════════════════════╝\n");
|
||||
}
|
||||
|
||||
// Print startup information
|
||||
let server_url = format!("http://{}:{}", args.bind_address, args.port);
|
||||
println!("\n╔════════════════════════════════════════════════════════════╗");
|
||||
|
||||
146
docs/AUTH.md
Normal file
146
docs/AUTH.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Hero Supervisor Authentication
|
||||
|
||||
The Hero Supervisor now supports API key-based authentication with three permission scopes:
|
||||
|
||||
## Permission Scopes
|
||||
|
||||
1. **Admin** - Full access to all operations including key management
|
||||
2. **Registrar** - Can register new runners
|
||||
3. **User** - Can create and manage jobs
|
||||
|
||||
## Starting the Supervisor with an Admin Key
|
||||
|
||||
Bootstrap an initial admin key when starting the supervisor:
|
||||
|
||||
```bash
|
||||
cargo run --bin supervisor -- --bootstrap-admin-key "my-admin"
|
||||
```
|
||||
|
||||
This will output:
|
||||
|
||||
```
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ 🔑 Admin API Key Created ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
Name: my-admin
|
||||
Key: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
Scope: admin
|
||||
⚠️ SAVE THIS KEY - IT WILL NOT BE SHOWN AGAIN!
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
**IMPORTANT:** Save this key securely - it will not be displayed again!
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Verify API Key
|
||||
|
||||
Verify a key and get its metadata:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:3030 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "auth.verify",
|
||||
"params": {
|
||||
"key": "your-api-key-here"
|
||||
},
|
||||
"id": 1
|
||||
}'
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"valid": true,
|
||||
"name": "my-admin",
|
||||
"scope": "admin"
|
||||
},
|
||||
"id": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Create New API Key (Admin Only)
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:3030 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "auth.create_key",
|
||||
"params": {
|
||||
"admin_key": "your-admin-key",
|
||||
"name": "runner-bot",
|
||||
"scope": "registrar"
|
||||
},
|
||||
"id": 1
|
||||
}'
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"key": "new-generated-uuid",
|
||||
"name": "runner-bot",
|
||||
"scope": "registrar",
|
||||
"created_at": "2025-10-27T15:00:00Z",
|
||||
"expires_at": null
|
||||
},
|
||||
"id": 1
|
||||
}
|
||||
```
|
||||
|
||||
### List All API Keys (Admin Only)
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:3030 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "auth.list_keys",
|
||||
"params": {
|
||||
"admin_key": "your-admin-key"
|
||||
},
|
||||
"id": 1
|
||||
}'
|
||||
```
|
||||
|
||||
### Remove API Key (Admin Only)
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:3030 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "auth.remove_key",
|
||||
"params": {
|
||||
"admin_key": "your-admin-key",
|
||||
"key": "key-to-remove"
|
||||
},
|
||||
"id": 1
|
||||
}'
|
||||
```
|
||||
|
||||
## Using Keys in the Admin UI
|
||||
|
||||
The admin UI will use the `auth.verify` endpoint during login to:
|
||||
1. Validate the provided API key
|
||||
2. Retrieve the key's name and scope
|
||||
3. Display the user's name and permissions in the header
|
||||
4. Show/hide UI elements based on scope
|
||||
|
||||
## Migration from Legacy Secrets
|
||||
|
||||
The supervisor still supports the legacy secret-based authentication for backward compatibility:
|
||||
- `--admin-secret` - Legacy admin secrets
|
||||
- `--user-secret` - Legacy user secrets
|
||||
- `--register-secret` - Legacy register secrets
|
||||
|
||||
However, the new API key system is recommended for better management and auditability.
|
||||
@@ -1 +1,70 @@
|
||||
cargo run
|
||||
#!/bin/bash
|
||||
|
||||
# Hero Supervisor Development Runner
|
||||
# Runs both the supervisor backend and admin UI frontend
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║ Starting Hero Supervisor Development Environment ║${NC}"
|
||||
echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||
|
||||
# Function to cleanup background processes on exit
|
||||
cleanup() {
|
||||
echo -e "\n${YELLOW}Shutting down...${NC}"
|
||||
kill $(jobs -p) 2>/dev/null || true
|
||||
exit 0
|
||||
}
|
||||
|
||||
trap cleanup SIGINT SIGTERM
|
||||
|
||||
# Check if Redis is running
|
||||
if ! pgrep -x "redis-server" > /dev/null; then
|
||||
echo -e "${YELLOW}⚠️ Redis not detected. Starting Redis...${NC}"
|
||||
redis-server --daemonize yes
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
# Start the supervisor with bootstrap admin key
|
||||
echo -e "${GREEN}🚀 Starting Hero Supervisor...${NC}"
|
||||
cargo run --bin supervisor -- \
|
||||
--bootstrap-admin-key "admin" \
|
||||
--redis-url "redis://localhost:6379" \
|
||||
--port 3030 \
|
||||
--bind-address "127.0.0.1" &
|
||||
|
||||
SUPERVISOR_PID=$!
|
||||
|
||||
# Wait for supervisor to start
|
||||
echo -e "${BLUE}⏳ Waiting for supervisor to initialize...${NC}"
|
||||
sleep 3
|
||||
|
||||
# Start the admin UI
|
||||
echo -e "${GREEN}🎨 Starting Admin UI...${NC}"
|
||||
cd clients/admin-ui
|
||||
trunk serve --port 8080 &
|
||||
|
||||
ADMIN_UI_PID=$!
|
||||
|
||||
# Wait a bit for trunk to start
|
||||
sleep 2
|
||||
|
||||
echo -e "\n${GREEN}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ ✅ Development Environment Ready! ║${NC}"
|
||||
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo -e "${BLUE} 📡 Supervisor API:${NC} http://127.0.0.1:3030"
|
||||
echo -e "${BLUE} 🎨 Admin UI:${NC} http://127.0.0.1:8080"
|
||||
echo -e "${BLUE} 🔗 Redis:${NC} redis://localhost:6379"
|
||||
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo -e "\n${YELLOW}💡 Check the supervisor output above for your admin API key!${NC}"
|
||||
echo -e "${YELLOW} Use it to login to the Admin UI at http://127.0.0.1:8080${NC}\n"
|
||||
echo -e "${YELLOW}Press Ctrl+C to stop all services${NC}\n"
|
||||
|
||||
# Wait for both processes
|
||||
wait
|
||||
134
src/auth.rs
Normal file
134
src/auth.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
//! Authentication and API key management
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// API key scope/permission level
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ApiKeyScope {
|
||||
/// Full access - can manage keys, runners, jobs
|
||||
Admin,
|
||||
/// Can register new runners
|
||||
Registrar,
|
||||
/// Can create and manage jobs
|
||||
User,
|
||||
}
|
||||
|
||||
impl ApiKeyScope {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ApiKeyScope::Admin => "admin",
|
||||
ApiKeyScope::Registrar => "registrar",
|
||||
ApiKeyScope::User => "user",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An API key with metadata
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ApiKey {
|
||||
/// The actual key value (UUID or custom string)
|
||||
pub key: String,
|
||||
/// Human-readable name for the key
|
||||
pub name: String,
|
||||
/// Permission scope
|
||||
pub scope: ApiKeyScope,
|
||||
/// When the key was created
|
||||
pub created_at: String,
|
||||
/// Optional expiration timestamp
|
||||
pub expires_at: Option<String>,
|
||||
}
|
||||
|
||||
impl ApiKey {
|
||||
/// Create a new API key with a generated UUID
|
||||
pub fn new(name: String, scope: ApiKeyScope) -> Self {
|
||||
Self {
|
||||
key: Uuid::new_v4().to_string(),
|
||||
name,
|
||||
scope,
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
expires_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new API key with a specific key value
|
||||
pub fn with_key(key: String, name: String, scope: ApiKeyScope) -> Self {
|
||||
Self {
|
||||
key,
|
||||
name,
|
||||
scope,
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
expires_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// API key store
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ApiKeyStore {
|
||||
/// Map of key -> ApiKey
|
||||
keys: HashMap<String, ApiKey>,
|
||||
}
|
||||
|
||||
impl ApiKeyStore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
keys: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a new API key
|
||||
pub fn add_key(&mut self, key: ApiKey) {
|
||||
self.keys.insert(key.key.clone(), key);
|
||||
}
|
||||
|
||||
/// Remove an API key by its key value
|
||||
pub fn remove_key(&mut self, key: &str) -> Option<ApiKey> {
|
||||
self.keys.remove(key)
|
||||
}
|
||||
|
||||
/// Get an API key by its key value
|
||||
pub fn get_key(&self, key: &str) -> Option<&ApiKey> {
|
||||
self.keys.get(key)
|
||||
}
|
||||
|
||||
/// Verify a key and return its metadata if valid
|
||||
pub fn verify_key(&self, key: &str) -> Option<&ApiKey> {
|
||||
self.get_key(key)
|
||||
}
|
||||
|
||||
/// List all keys with a specific scope
|
||||
pub fn list_keys_by_scope(&self, scope: ApiKeyScope) -> Vec<&ApiKey> {
|
||||
self.keys
|
||||
.values()
|
||||
.filter(|k| k.scope == scope)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// List all keys
|
||||
pub fn list_all_keys(&self) -> Vec<&ApiKey> {
|
||||
self.keys.values().collect()
|
||||
}
|
||||
|
||||
/// Count keys by scope
|
||||
pub fn count_by_scope(&self, scope: ApiKeyScope) -> usize {
|
||||
self.keys.values().filter(|k| k.scope == scope).count()
|
||||
}
|
||||
|
||||
/// Bootstrap with an initial admin key
|
||||
pub fn bootstrap_admin_key(&mut self, name: String) -> ApiKey {
|
||||
let key = ApiKey::new(name, ApiKeyScope::Admin);
|
||||
self.add_key(key.clone());
|
||||
key
|
||||
}
|
||||
}
|
||||
|
||||
/// Response for auth verification
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthVerifyResponse {
|
||||
pub valid: bool,
|
||||
pub name: String,
|
||||
pub scope: String,
|
||||
}
|
||||
@@ -7,6 +7,8 @@ pub mod job;
|
||||
pub mod supervisor;
|
||||
pub mod app;
|
||||
pub mod openrpc;
|
||||
pub mod auth;
|
||||
pub mod services;
|
||||
|
||||
#[cfg(feature = "mycelium")]
|
||||
pub mod mycelium;
|
||||
|
||||
262
src/openrpc.rs
262
src/openrpc.rs
@@ -13,7 +13,7 @@ use log::{debug, info, error};
|
||||
|
||||
use crate::supervisor::Supervisor;
|
||||
use crate::runner::{Runner, RunnerError};
|
||||
use crate::runner::{ProcessManagerError, ProcessStatus, LogInfo};
|
||||
use crate::runner::{ProcessStatus, LogInfo};
|
||||
use crate::job::Job;
|
||||
use crate::ProcessManagerType;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -69,12 +69,10 @@ fn invalid_params_error(msg: &str) -> ErrorObject<'static> {
|
||||
}
|
||||
|
||||
/// Request parameters for registering a new runner
|
||||
/// TODO: Move secret to HTTP Authorization header for better security
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
/// The secret is extracted from Authorization header
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct RegisterRunnerParams {
|
||||
pub secret: String,
|
||||
pub name: String,
|
||||
// Note: queue is derived from name (name = queue)
|
||||
}
|
||||
|
||||
/// Request parameters for runner management operations
|
||||
@@ -112,17 +110,15 @@ pub struct RunnerConfig {
|
||||
}
|
||||
|
||||
/// Request parameters for running a job
|
||||
/// TODO: Move secret to HTTP Authorization header for better security
|
||||
/// The secret is extracted from Authorization header
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct RunJobParams {
|
||||
pub secret: String,
|
||||
pub job: Job,
|
||||
}
|
||||
|
||||
/// Request parameters for starting a job
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct StartJobParams {
|
||||
pub secret: String,
|
||||
pub job_id: String,
|
||||
}
|
||||
|
||||
@@ -139,9 +135,6 @@ pub enum JobResult {
|
||||
pub struct JobStatusResponse {
|
||||
pub job_id: String,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub started_at: Option<String>,
|
||||
pub completed_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Request parameters for queuing a job
|
||||
@@ -169,7 +162,6 @@ pub struct StopJobParams {
|
||||
/// Request parameters for deleting a job
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct DeleteJobParams {
|
||||
pub secret: String,
|
||||
pub job_id: String,
|
||||
}
|
||||
|
||||
@@ -257,6 +249,23 @@ pub struct LogInfoWrapper {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Thread-local storage for the current request's API key
|
||||
thread_local! {
|
||||
static CURRENT_API_KEY: std::cell::RefCell<Option<String>> = std::cell::RefCell::new(None);
|
||||
}
|
||||
|
||||
/// Set the current API key for this request
|
||||
pub fn set_current_api_key(key: Option<String>) {
|
||||
CURRENT_API_KEY.with(|k| {
|
||||
*k.borrow_mut() = key;
|
||||
});
|
||||
}
|
||||
|
||||
/// Get the current API key for this request
|
||||
pub fn get_current_api_key() -> Option<String> {
|
||||
CURRENT_API_KEY.with(|k| k.borrow().clone())
|
||||
}
|
||||
|
||||
impl From<LogInfo> for LogInfoWrapper {
|
||||
fn from(log: crate::runner::LogInfo) -> Self {
|
||||
LogInfoWrapper {
|
||||
@@ -284,12 +293,34 @@ pub struct SupervisorInfoResponse {
|
||||
pub runners_count: usize,
|
||||
}
|
||||
|
||||
/// Request parameters for auth verification
|
||||
/// Empty - the key is extracted from Authorization header
|
||||
#[derive(Debug, Deserialize, Serialize, Default)]
|
||||
pub struct AuthVerifyParams {}
|
||||
|
||||
/// Request parameters for creating API keys
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct CreateApiKeyParams {
|
||||
pub name: String,
|
||||
pub scope: String, // "admin", "registrar", or "user"
|
||||
}
|
||||
|
||||
/// Request parameters for removing API keys
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct RemoveApiKeyParams {
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
/// Request parameters for listing API keys - empty, uses header auth
|
||||
#[derive(Debug, Deserialize, Serialize, Default)]
|
||||
pub struct ListApiKeysParams {}
|
||||
|
||||
/// OpenRPC trait defining all supervisor methods
|
||||
#[rpc(server)]
|
||||
pub trait SupervisorRpc {
|
||||
/// Register a new runner with secret-based authentication
|
||||
#[method(name = "register_runner")]
|
||||
async fn register_runner(&self, params: RegisterRunnerParams) -> RpcResult<String>;
|
||||
async fn register_runner(&self, name: String) -> RpcResult<String>;
|
||||
|
||||
/// Create a job without queuing it to a runner
|
||||
#[method(name = "jobs.create")]
|
||||
@@ -423,6 +454,22 @@ pub trait SupervisorRpc {
|
||||
#[method(name = "get_supervisor_info")]
|
||||
async fn get_supervisor_info(&self, admin_secret: String) -> RpcResult<SupervisorInfoResponse>;
|
||||
|
||||
/// Verify an API key and return its metadata
|
||||
#[method(name = "auth.verify")]
|
||||
async fn auth_verify(&self) -> RpcResult<crate::auth::AuthVerifyResponse>;
|
||||
|
||||
/// Create a new API key (admin only)
|
||||
#[method(name = "auth.create_key")]
|
||||
async fn auth_create_key(&self, name: String, scope: String) -> RpcResult<crate::auth::ApiKey>;
|
||||
|
||||
/// Remove an API key (admin only)
|
||||
#[method(name = "auth.remove_key")]
|
||||
async fn auth_remove_key(&self, key: String) -> RpcResult<bool>;
|
||||
|
||||
/// List all API keys (admin only)
|
||||
#[method(name = "auth.list_keys")]
|
||||
async fn auth_list_keys(&self) -> RpcResult<Vec<crate::auth::ApiKey>>;
|
||||
|
||||
/// OpenRPC discovery method - returns the OpenRPC document describing this API
|
||||
#[method(name = "rpc.discover")]
|
||||
async fn rpc_discover(&self) -> RpcResult<serde_json::Value>;
|
||||
@@ -447,26 +494,35 @@ fn parse_process_manager_type(pm_type: &str, session_name: Option<String>) -> Re
|
||||
/// This eliminates the need for a wrapper struct
|
||||
#[async_trait]
|
||||
impl SupervisorRpcServer for Arc<Mutex<Supervisor>> {
|
||||
async fn register_runner(&self, params: RegisterRunnerParams) -> RpcResult<String> {
|
||||
debug!("OpenRPC request: register_runner with params: {:?}", params);
|
||||
async fn register_runner(&self, name: String) -> RpcResult<String> {
|
||||
debug!("OpenRPC request: register_runner with name: {}", name);
|
||||
|
||||
// Get API key from Authorization header
|
||||
let key = get_current_api_key()
|
||||
.ok_or_else(|| ErrorObject::owned(-32602, "Missing Authorization header", None::<()>))?;
|
||||
|
||||
let mut supervisor = self.lock().await;
|
||||
// Queue name is the same as runner name
|
||||
|
||||
// register_runner now handles API key verification internally
|
||||
supervisor
|
||||
.register_runner(¶ms.secret, ¶ms.name, ¶ms.name)
|
||||
.register_runner(&key, &name, &name)
|
||||
.await
|
||||
.map_err(runner_error_to_rpc_error)?;
|
||||
|
||||
// Return the runner name that was registered
|
||||
Ok(params.name)
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
async fn jobs_create(&self, params: RunJobParams) -> RpcResult<String> {
|
||||
debug!("OpenRPC request: jobs.create with params: {:?}", params);
|
||||
|
||||
// Get secret from Authorization header
|
||||
let secret = get_current_api_key()
|
||||
.ok_or_else(|| ErrorObject::owned(-32602, "Missing Authorization header", None::<()>))?;
|
||||
|
||||
let mut supervisor = self.lock().await;
|
||||
let job_id = supervisor
|
||||
.create_job(¶ms.secret, params.job)
|
||||
.create_job(&secret, params.job)
|
||||
.await
|
||||
.map_err(runner_error_to_rpc_error)?;
|
||||
|
||||
@@ -485,9 +541,13 @@ impl SupervisorRpcServer for Arc<Mutex<Supervisor>> {
|
||||
async fn job_run(&self, params: RunJobParams) -> RpcResult<JobResult> {
|
||||
debug!("OpenRPC request: job.run with params: {:?}", params);
|
||||
|
||||
// Get secret from Authorization header
|
||||
let secret = get_current_api_key()
|
||||
.ok_or_else(|| ErrorObject::owned(-32602, "Missing Authorization header", None::<()>))?;
|
||||
|
||||
let mut supervisor = self.lock().await;
|
||||
match supervisor
|
||||
.run_job(¶ms.secret, params.job)
|
||||
.run_job(&secret, params.job)
|
||||
.await
|
||||
.map_err(runner_error_to_rpc_error)? {
|
||||
Some(output) => Ok(JobResult::Success { success: output }),
|
||||
@@ -498,9 +558,13 @@ impl SupervisorRpcServer for Arc<Mutex<Supervisor>> {
|
||||
async fn job_start(&self, params: StartJobParams) -> RpcResult<()> {
|
||||
debug!("OpenRPC request: job.start with params: {:?}", params);
|
||||
|
||||
// Get secret from Authorization header
|
||||
let secret = get_current_api_key()
|
||||
.ok_or_else(|| ErrorObject::owned(-32602, "Missing Authorization header", None::<()>))?;
|
||||
|
||||
let mut supervisor = self.lock().await;
|
||||
supervisor
|
||||
.start_job(¶ms.secret, ¶ms.job_id)
|
||||
.start_job(&secret, ¶ms.job_id)
|
||||
.await
|
||||
.map_err(runner_error_to_rpc_error)
|
||||
}
|
||||
@@ -549,9 +613,13 @@ impl SupervisorRpcServer for Arc<Mutex<Supervisor>> {
|
||||
async fn job_delete(&self, params: DeleteJobParams) -> RpcResult<()> {
|
||||
debug!("OpenRPC request: job.delete with params: {:?}", params);
|
||||
|
||||
// Get secret from Authorization header
|
||||
let secret = get_current_api_key()
|
||||
.ok_or_else(|| ErrorObject::owned(-32602, "Missing Authorization header", None::<()>))?;
|
||||
|
||||
let mut supervisor = self.lock().await;
|
||||
supervisor
|
||||
.delete_job(¶ms.job_id)
|
||||
.delete_job_with_auth(&secret, ¶ms.job_id)
|
||||
.await
|
||||
.map_err(runner_error_to_rpc_error)
|
||||
}
|
||||
@@ -875,6 +943,92 @@ impl SupervisorRpcServer for Arc<Mutex<Supervisor>> {
|
||||
})
|
||||
}
|
||||
|
||||
async fn auth_verify(&self) -> RpcResult<crate::auth::AuthVerifyResponse> {
|
||||
debug!("OpenRPC request: auth.verify");
|
||||
let supervisor = self.lock().await;
|
||||
|
||||
// Get key from thread-local (set by middleware from Authorization header)
|
||||
let key = get_current_api_key()
|
||||
.ok_or_else(|| ErrorObject::owned(-32602, "Missing Authorization header", None::<()>))?;
|
||||
|
||||
match supervisor.verify_api_key(&key).await {
|
||||
Some(api_key) => {
|
||||
Ok(crate::auth::AuthVerifyResponse {
|
||||
valid: true,
|
||||
name: api_key.name,
|
||||
scope: api_key.scope.as_str().to_string(),
|
||||
})
|
||||
}
|
||||
None => {
|
||||
Ok(crate::auth::AuthVerifyResponse {
|
||||
valid: false,
|
||||
name: String::new(),
|
||||
scope: String::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn auth_create_key(&self, name: String, scope: String) -> RpcResult<crate::auth::ApiKey> {
|
||||
debug!("OpenRPC request: auth.create_key");
|
||||
|
||||
// Get API key from Authorization header
|
||||
let key = get_current_api_key()
|
||||
.ok_or_else(|| ErrorObject::owned(-32602, "Missing Authorization header", None::<()>))?;
|
||||
|
||||
let supervisor = self.lock().await;
|
||||
|
||||
// Verify admin key
|
||||
if !supervisor.is_admin_key(&key).await {
|
||||
return Err(ErrorObject::owned(-32603, "Admin permissions required", None::<()>));
|
||||
}
|
||||
|
||||
// Parse scope
|
||||
let api_scope = match scope.to_lowercase().as_str() {
|
||||
"admin" => crate::auth::ApiKeyScope::Admin,
|
||||
"registrar" => crate::auth::ApiKeyScope::Registrar,
|
||||
"user" => crate::auth::ApiKeyScope::User,
|
||||
_ => return Err(ErrorObject::owned(-32602, "Invalid scope. Must be 'admin', 'registrar', or 'user'", None::<()>)),
|
||||
};
|
||||
|
||||
let api_key = supervisor.create_api_key(name, api_scope).await;
|
||||
Ok(api_key)
|
||||
}
|
||||
|
||||
async fn auth_remove_key(&self, key_to_remove: String) -> RpcResult<bool> {
|
||||
debug!("OpenRPC request: auth.remove_key");
|
||||
|
||||
// Get API key from Authorization header
|
||||
let key = get_current_api_key()
|
||||
.ok_or_else(|| ErrorObject::owned(-32602, "Missing Authorization header", None::<()>))?;
|
||||
|
||||
let supervisor = self.lock().await;
|
||||
|
||||
// Verify admin key
|
||||
if !supervisor.is_admin_key(&key).await {
|
||||
return Err(ErrorObject::owned(-32603, "Admin permissions required", None::<()>));
|
||||
}
|
||||
|
||||
Ok(supervisor.remove_api_key(&key_to_remove).await.is_some())
|
||||
}
|
||||
|
||||
async fn auth_list_keys(&self) -> RpcResult<Vec<crate::auth::ApiKey>> {
|
||||
debug!("OpenRPC request: auth.list_keys");
|
||||
|
||||
// Get API key from Authorization header
|
||||
let key = get_current_api_key()
|
||||
.ok_or_else(|| ErrorObject::owned(-32602, "Missing Authorization header", None::<()>))?;
|
||||
|
||||
let supervisor = self.lock().await;
|
||||
|
||||
// Verify admin key
|
||||
if !supervisor.is_admin_key(&key).await {
|
||||
return Err(ErrorObject::owned(-32603, "Admin permissions required", None::<()>));
|
||||
}
|
||||
|
||||
Ok(supervisor.list_api_keys().await)
|
||||
}
|
||||
|
||||
async fn rpc_discover(&self) -> RpcResult<serde_json::Value> {
|
||||
debug!("OpenRPC request: rpc.discover");
|
||||
|
||||
@@ -915,6 +1069,55 @@ pub async fn start_server_with_supervisor(
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
/// HTTP middleware layer to extract Authorization header
|
||||
#[derive(Clone)]
|
||||
struct AuthExtractLayer;
|
||||
|
||||
impl<S> tower::Layer<S> for AuthExtractLayer {
|
||||
type Service = AuthExtractService<S>;
|
||||
|
||||
fn layer(&self, inner: S) -> Self::Service {
|
||||
AuthExtractService { inner }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AuthExtractService<S> {
|
||||
inner: S,
|
||||
}
|
||||
|
||||
impl<S, B> tower::Service<hyper::Request<B>> for AuthExtractService<S>
|
||||
where
|
||||
S: tower::Service<hyper::Request<B>> + Clone + Send + 'static,
|
||||
S::Future: Send + 'static,
|
||||
B: Send + 'static,
|
||||
{
|
||||
type Response = S::Response;
|
||||
type Error = S::Error;
|
||||
type Future = std::pin::Pin<Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>>;
|
||||
|
||||
fn poll_ready(&mut self, cx: &mut std::task::Context<'_>) -> std::task::Poll<Result<(), Self::Error>> {
|
||||
self.inner.poll_ready(cx)
|
||||
}
|
||||
|
||||
fn call(&mut self, req: hyper::Request<B>) -> Self::Future {
|
||||
// Extract Authorization header
|
||||
let api_key = req.headers()
|
||||
.get("authorization")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|h| h.strip_prefix("Bearer "))
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Store in thread-local
|
||||
set_current_api_key(api_key);
|
||||
|
||||
let mut inner = self.inner.clone();
|
||||
Box::pin(async move {
|
||||
inner.call(req).await
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Start HTTP OpenRPC server (Unix socket support would require additional dependencies)
|
||||
pub async fn start_http_openrpc_server(
|
||||
supervisor: Arc<Mutex<Supervisor>>,
|
||||
@@ -929,9 +1132,14 @@ pub async fn start_http_openrpc_server(
|
||||
.allow_headers(Any)
|
||||
.allow_methods(Any);
|
||||
|
||||
// Start HTTP server with CORS
|
||||
// Build HTTP middleware stack with auth extraction
|
||||
let http_middleware = tower::ServiceBuilder::new()
|
||||
.layer(AuthExtractLayer)
|
||||
.layer(cors);
|
||||
|
||||
// Start HTTP server with middleware
|
||||
let http_server = Server::builder()
|
||||
.set_http_middleware(tower::ServiceBuilder::new().layer(cors))
|
||||
.set_http_middleware(http_middleware)
|
||||
.build(http_addr)
|
||||
.await?;
|
||||
let http_handle = http_server.start(supervisor.into_rpc());
|
||||
@@ -1015,9 +1223,11 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let params = RunJobParams {
|
||||
secret: "test-secret".to_string(),
|
||||
job: job.clone(),
|
||||
};
|
||||
|
||||
// Set the API key in thread-local for the test
|
||||
set_current_api_key(Some("test-secret".to_string()));
|
||||
|
||||
let result = supervisor.jobs_create(params).await;
|
||||
// Should work or fail gracefully without Redis
|
||||
@@ -1025,7 +1235,6 @@ mod tests {
|
||||
|
||||
// Test job.start
|
||||
let start_params = StartJobParams {
|
||||
secret: "test-secret".to_string(),
|
||||
job_id: "test-job".to_string(),
|
||||
};
|
||||
|
||||
@@ -1035,7 +1244,6 @@ mod tests {
|
||||
|
||||
// Test invalid secret
|
||||
let invalid_params = StartJobParams {
|
||||
secret: "invalid".to_string(),
|
||||
job_id: "test-job".to_string(),
|
||||
};
|
||||
|
||||
|
||||
312
src/services.rs
Normal file
312
src/services.rs
Normal file
@@ -0,0 +1,312 @@
|
||||
//! Service layer for persistent storage of keys, runners, and jobs
|
||||
//!
|
||||
//! This module provides database/storage services for the supervisor.
|
||||
//! Currently uses in-memory storage, but designed to be easily extended
|
||||
//! to use Redis, PostgreSQL, or other persistent storage backends.
|
||||
|
||||
use crate::auth::{ApiKey, ApiKeyScope};
|
||||
use crate::job::Job;
|
||||
use crate::runner::Runner;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Service for managing API keys
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApiKeyService {
|
||||
store: Arc<Mutex<HashMap<String, ApiKey>>>,
|
||||
}
|
||||
|
||||
impl ApiKeyService {
|
||||
/// Create a new API key service
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
store: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Store an API key
|
||||
pub async fn store(&self, key: ApiKey) -> Result<(), String> {
|
||||
let mut store = self.store.lock().await;
|
||||
store.insert(key.key.clone(), key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get an API key by its key string
|
||||
pub async fn get(&self, key: &str) -> Option<ApiKey> {
|
||||
let store = self.store.lock().await;
|
||||
store.get(key).cloned()
|
||||
}
|
||||
|
||||
/// List all API keys
|
||||
pub async fn list(&self) -> Vec<ApiKey> {
|
||||
let store = self.store.lock().await;
|
||||
store.values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Remove an API key
|
||||
pub async fn remove(&self, key: &str) -> Option<ApiKey> {
|
||||
let mut store = self.store.lock().await;
|
||||
store.remove(key)
|
||||
}
|
||||
|
||||
/// Count API keys by scope
|
||||
pub async fn count_by_scope(&self, scope: ApiKeyScope) -> usize {
|
||||
let store = self.store.lock().await;
|
||||
store.values().filter(|k| k.scope == scope).count()
|
||||
}
|
||||
|
||||
/// Clear all API keys (for testing)
|
||||
pub async fn clear(&self) {
|
||||
let mut store = self.store.lock().await;
|
||||
store.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ApiKeyService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Service for managing runners
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RunnerService {
|
||||
store: Arc<Mutex<HashMap<String, RunnerMetadata>>>,
|
||||
}
|
||||
|
||||
/// Metadata about a runner for storage
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RunnerMetadata {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub queue: String,
|
||||
pub registered_at: String,
|
||||
pub registered_by: String, // API key name that registered this runner
|
||||
}
|
||||
|
||||
impl RunnerService {
|
||||
/// Create a new runner service
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
store: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Store runner metadata
|
||||
pub async fn store(&self, metadata: RunnerMetadata) -> Result<(), String> {
|
||||
let mut store = self.store.lock().await;
|
||||
store.insert(metadata.id.clone(), metadata);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get runner metadata by ID
|
||||
pub async fn get(&self, id: &str) -> Option<RunnerMetadata> {
|
||||
let store = self.store.lock().await;
|
||||
store.get(id).cloned()
|
||||
}
|
||||
|
||||
/// List all runners
|
||||
pub async fn list(&self) -> Vec<RunnerMetadata> {
|
||||
let store = self.store.lock().await;
|
||||
store.values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Remove a runner
|
||||
pub async fn remove(&self, id: &str) -> Option<RunnerMetadata> {
|
||||
let mut store = self.store.lock().await;
|
||||
store.remove(id)
|
||||
}
|
||||
|
||||
/// Count total runners
|
||||
pub async fn count(&self) -> usize {
|
||||
let store = self.store.lock().await;
|
||||
store.len()
|
||||
}
|
||||
|
||||
/// Clear all runners (for testing)
|
||||
pub async fn clear(&self) {
|
||||
let mut store = self.store.lock().await;
|
||||
store.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RunnerService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Service for managing jobs
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JobService {
|
||||
store: Arc<Mutex<HashMap<String, JobMetadata>>>,
|
||||
}
|
||||
|
||||
/// Metadata about a job for storage
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JobMetadata {
|
||||
pub job_id: String,
|
||||
pub runner: String,
|
||||
pub created_at: String,
|
||||
pub created_by: String, // API key name that created this job
|
||||
pub status: String,
|
||||
pub job: Job,
|
||||
}
|
||||
|
||||
impl JobService {
|
||||
/// Create a new job service
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
store: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Store job metadata
|
||||
pub async fn store(&self, metadata: JobMetadata) -> Result<(), String> {
|
||||
let mut store = self.store.lock().await;
|
||||
store.insert(metadata.job_id.clone(), metadata);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get job metadata by ID
|
||||
pub async fn get(&self, job_id: &str) -> Option<JobMetadata> {
|
||||
let store = self.store.lock().await;
|
||||
store.get(job_id).cloned()
|
||||
}
|
||||
|
||||
/// List all jobs
|
||||
pub async fn list(&self) -> Vec<JobMetadata> {
|
||||
let store = self.store.lock().await;
|
||||
store.values().cloned().collect()
|
||||
}
|
||||
|
||||
/// List jobs by runner
|
||||
pub async fn list_by_runner(&self, runner: &str) -> Vec<JobMetadata> {
|
||||
let store = self.store.lock().await;
|
||||
store.values()
|
||||
.filter(|j| j.runner == runner)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// List jobs by creator (API key name)
|
||||
pub async fn list_by_creator(&self, creator: &str) -> Vec<JobMetadata> {
|
||||
let store = self.store.lock().await;
|
||||
store.values()
|
||||
.filter(|j| j.created_by == creator)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Update job status
|
||||
pub async fn update_status(&self, job_id: &str, status: String) -> Result<(), String> {
|
||||
let mut store = self.store.lock().await;
|
||||
if let Some(metadata) = store.get_mut(job_id) {
|
||||
metadata.status = status;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Job not found: {}", job_id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a job
|
||||
pub async fn remove(&self, job_id: &str) -> Option<JobMetadata> {
|
||||
let mut store = self.store.lock().await;
|
||||
store.remove(job_id)
|
||||
}
|
||||
|
||||
/// Count total jobs
|
||||
pub async fn count(&self) -> usize {
|
||||
let store = self.store.lock().await;
|
||||
store.len()
|
||||
}
|
||||
|
||||
/// Clear all jobs (for testing)
|
||||
pub async fn clear(&self) {
|
||||
let mut store = self.store.lock().await;
|
||||
store.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for JobService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Combined service container for all storage services
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Services {
|
||||
pub api_keys: ApiKeyService,
|
||||
pub runners: RunnerService,
|
||||
pub jobs: JobService,
|
||||
}
|
||||
|
||||
impl Services {
|
||||
/// Create a new services container
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
api_keys: ApiKeyService::new(),
|
||||
runners: RunnerService::new(),
|
||||
jobs: JobService::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all data (for testing)
|
||||
pub async fn clear_all(&self) {
|
||||
self.api_keys.clear().await;
|
||||
self.runners.clear().await;
|
||||
self.jobs.clear().await;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Services {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_api_key_service() {
|
||||
let service = ApiKeyService::new();
|
||||
|
||||
let key = ApiKey {
|
||||
key: "test-key".to_string(),
|
||||
name: "test".to_string(),
|
||||
scope: ApiKeyScope::User,
|
||||
};
|
||||
|
||||
service.store(key.clone()).await.unwrap();
|
||||
assert_eq!(service.get("test-key").await.unwrap().name, "test");
|
||||
assert_eq!(service.list().await.len(), 1);
|
||||
|
||||
service.remove("test-key").await;
|
||||
assert!(service.get("test-key").await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_runner_service() {
|
||||
let service = RunnerService::new();
|
||||
|
||||
let metadata = RunnerMetadata {
|
||||
id: "runner1".to_string(),
|
||||
name: "runner1".to_string(),
|
||||
queue: "queue1".to_string(),
|
||||
registered_at: "2024-01-01".to_string(),
|
||||
registered_by: "admin".to_string(),
|
||||
};
|
||||
|
||||
service.store(metadata.clone()).await.unwrap();
|
||||
assert_eq!(service.get("runner1").await.unwrap().name, "runner1");
|
||||
assert_eq!(service.count().await, 1);
|
||||
|
||||
service.remove("runner1").await;
|
||||
assert!(service.get("runner1").await.is_none());
|
||||
}
|
||||
}
|
||||
@@ -98,12 +98,16 @@ pub struct Supervisor {
|
||||
redis_client: redis::Client,
|
||||
/// Namespace for queue keys
|
||||
namespace: String,
|
||||
/// Admin secrets for full access
|
||||
/// Admin secrets for full access (deprecated - use api_keys)
|
||||
admin_secrets: Vec<String>,
|
||||
/// User secrets for limited access
|
||||
/// User secrets for limited access (deprecated - use api_keys)
|
||||
user_secrets: Vec<String>,
|
||||
/// Register secrets for runner registration
|
||||
/// Register secrets for runner registration (deprecated - use api_keys)
|
||||
register_secrets: Vec<String>,
|
||||
/// API key store for named key management
|
||||
api_keys: Arc<Mutex<crate::auth::ApiKeyStore>>,
|
||||
/// Services for persistent storage
|
||||
services: crate::services::Services,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
@@ -243,6 +247,8 @@ impl SupervisorBuilder {
|
||||
admin_secrets: self.admin_secrets,
|
||||
user_secrets: self.user_secrets,
|
||||
register_secrets: self.register_secrets,
|
||||
api_keys: Arc::new(Mutex::new(crate::auth::ApiKeyStore::new())),
|
||||
services: crate::services::Services::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -265,12 +271,16 @@ impl Supervisor {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register a new runner with secret-based authentication
|
||||
pub async fn register_runner(&mut self, secret: &str, name: &str, _queue: &str) -> RunnerResult<()> {
|
||||
// Check if the secret is valid (admin or register secret)
|
||||
if !self.admin_secrets.contains(&secret.to_string()) &&
|
||||
!self.register_secrets.contains(&secret.to_string()) {
|
||||
return Err(RunnerError::InvalidSecret("Invalid secret for runner registration".to_string()));
|
||||
/// Register a new runner with API key authentication
|
||||
pub async fn register_runner(&mut self, key: &str, name: &str, _queue: &str) -> RunnerResult<()> {
|
||||
// Verify API key and check scope
|
||||
let api_key = self.verify_api_key(key).await
|
||||
.ok_or_else(|| RunnerError::InvalidSecret("Invalid API key".to_string()))?;
|
||||
|
||||
// Check if key has admin or registrar scope
|
||||
if api_key.scope != crate::auth::ApiKeyScope::Admin &&
|
||||
api_key.scope != crate::auth::ApiKeyScope::Registrar {
|
||||
return Err(RunnerError::InvalidSecret("Insufficient permissions. Requires admin or registrar scope".to_string()));
|
||||
}
|
||||
|
||||
// Create a basic runner config for the named runner
|
||||
@@ -287,18 +297,36 @@ impl Supervisor {
|
||||
self.add_runner(config).await
|
||||
}
|
||||
|
||||
/// Create a job (fire-and-forget, non-blocking) with secret-based authentication
|
||||
pub async fn create_job(&mut self, secret: &str, job: crate::job::Job) -> RunnerResult<String> {
|
||||
// Check if the secret is valid (admin or user secret)
|
||||
if !self.admin_secrets.contains(&secret.to_string()) &&
|
||||
!self.user_secrets.contains(&secret.to_string()) {
|
||||
return Err(RunnerError::InvalidSecret("Invalid secret for job creation".to_string()));
|
||||
/// Create a job (fire-and-forget, non-blocking) with API key authentication
|
||||
pub async fn create_job(&mut self, key: &str, job: crate::job::Job) -> RunnerResult<String> {
|
||||
// Verify API key and check scope
|
||||
let api_key = self.verify_api_key(key).await
|
||||
.ok_or_else(|| RunnerError::InvalidSecret("Invalid API key".to_string()))?;
|
||||
|
||||
// Check if key has admin or user scope
|
||||
if api_key.scope != crate::auth::ApiKeyScope::Admin &&
|
||||
api_key.scope != crate::auth::ApiKeyScope::User {
|
||||
return Err(RunnerError::InvalidSecret("Insufficient permissions. Requires admin or user scope".to_string()));
|
||||
}
|
||||
|
||||
// Find the runner by name
|
||||
let runner = job.runner.clone();
|
||||
let job_id = job.id.clone(); // Store job ID before moving job
|
||||
|
||||
if let Some(_runner) = self.runners.get(&runner) {
|
||||
// Store job metadata in the database
|
||||
let job_metadata = crate::services::JobMetadata {
|
||||
job_id: job_id.clone(),
|
||||
runner: runner.clone(),
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
created_by: api_key.name.clone(),
|
||||
status: "queued".to_string(),
|
||||
job: job.clone(),
|
||||
};
|
||||
|
||||
self.services.jobs.store(job_metadata).await
|
||||
.map_err(|e| RunnerError::ConfigError { reason: format!("Failed to store job: {}", e) })?;
|
||||
|
||||
// Use the supervisor's queue_job_to_runner method (fire-and-forget)
|
||||
self.queue_job_to_runner(&runner, job).await?;
|
||||
Ok(job_id) // Return the job ID immediately
|
||||
@@ -309,13 +337,17 @@ impl Supervisor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a job on the appropriate runner with secret-based authentication
|
||||
/// Run a job on the appropriate runner with API key authentication
|
||||
/// This is a synchronous operation that queues the job, waits for the result, and returns it
|
||||
pub async fn run_job(&mut self, secret: &str, job: crate::job::Job) -> RunnerResult<Option<String>> {
|
||||
// Check if the secret is valid (admin or user secret)
|
||||
if !self.admin_secrets.contains(&secret.to_string()) &&
|
||||
!self.user_secrets.contains(&secret.to_string()) {
|
||||
return Err(RunnerError::InvalidSecret("Invalid secret for job execution".to_string()));
|
||||
pub async fn run_job(&mut self, key: &str, job: crate::job::Job) -> RunnerResult<Option<String>> {
|
||||
// Verify API key and check scope
|
||||
let api_key = self.verify_api_key(key).await
|
||||
.ok_or_else(|| RunnerError::InvalidSecret("Invalid API key".to_string()))?;
|
||||
|
||||
// Check if key has admin or user scope
|
||||
if api_key.scope != crate::auth::ApiKeyScope::Admin &&
|
||||
api_key.scope != crate::auth::ApiKeyScope::User {
|
||||
return Err(RunnerError::InvalidSecret("Insufficient permissions. Requires admin or user scope".to_string()));
|
||||
}
|
||||
|
||||
// Find the runner by name
|
||||
@@ -405,10 +437,25 @@ impl Supervisor {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a job by ID
|
||||
/// Delete a job by ID (no authentication - should be called from authenticated endpoints)
|
||||
pub async fn delete_job(&mut self, job_id: &str) -> RunnerResult<()> {
|
||||
self.client.delete_job(&job_id).await.map_err(RunnerError::from)
|
||||
}
|
||||
|
||||
/// Delete a job by ID with authentication
|
||||
pub async fn delete_job_with_auth(&mut self, secret: &str, job_id: &str) -> RunnerResult<()> {
|
||||
// Verify API key and check scope
|
||||
let api_key = self.verify_api_key(secret).await
|
||||
.ok_or_else(|| RunnerError::InvalidSecret("Invalid API key".to_string()))?;
|
||||
|
||||
// Check if key has admin or user scope
|
||||
if api_key.scope != crate::auth::ApiKeyScope::Admin &&
|
||||
api_key.scope != crate::auth::ApiKeyScope::User {
|
||||
return Err(RunnerError::InvalidSecret("Insufficient permissions. Requires admin or user scope".to_string()));
|
||||
}
|
||||
|
||||
self.delete_job(job_id).await
|
||||
}
|
||||
|
||||
/// List all managed runners
|
||||
pub fn list_runners(&self) -> Vec<&str> {
|
||||
@@ -664,6 +711,11 @@ impl Supervisor {
|
||||
self.admin_secrets.len()
|
||||
}
|
||||
|
||||
/// Get admin secrets (returns cloned vector for security)
|
||||
pub fn get_admin_secrets(&self) -> Vec<String> {
|
||||
self.admin_secrets.clone()
|
||||
}
|
||||
|
||||
/// Add a user secret
|
||||
pub fn add_user_secret(&mut self, secret: String) {
|
||||
if !self.user_secrets.contains(&secret) {
|
||||
@@ -723,28 +775,43 @@ impl Supervisor {
|
||||
self.client.list_jobs().await.map_err(RunnerError::from)
|
||||
}
|
||||
|
||||
/// List all jobs with full details from Redis
|
||||
/// List all jobs from the database
|
||||
pub async fn list_jobs_from_db(&self) -> Vec<crate::services::JobMetadata> {
|
||||
self.services.jobs.list().await
|
||||
}
|
||||
|
||||
/// List jobs by runner from the database
|
||||
pub async fn list_jobs_by_runner(&self, runner: &str) -> Vec<crate::services::JobMetadata> {
|
||||
self.services.jobs.list_by_runner(runner).await
|
||||
}
|
||||
|
||||
/// List jobs by creator (API key name) from the database
|
||||
pub async fn list_jobs_by_creator(&self, creator: &str) -> Vec<crate::services::JobMetadata> {
|
||||
self.services.jobs.list_by_creator(creator).await
|
||||
}
|
||||
|
||||
/// Get a specific job from the database
|
||||
pub async fn get_job_from_db(&self, job_id: &str) -> Option<crate::services::JobMetadata> {
|
||||
self.services.jobs.get(job_id).await
|
||||
}
|
||||
|
||||
/// List all jobs with full details from the database
|
||||
pub async fn list_all_jobs(&self) -> RunnerResult<Vec<crate::job::Job>> {
|
||||
let job_ids = self.client.list_jobs().await?;
|
||||
let mut jobs = Vec::new();
|
||||
|
||||
for job_id in job_ids {
|
||||
if let Ok(job) = self.client.get_job(&job_id).await {
|
||||
jobs.push(job);
|
||||
}
|
||||
}
|
||||
|
||||
let job_metadata_list = self.services.jobs.list().await;
|
||||
let jobs = job_metadata_list.into_iter().map(|metadata| metadata.job).collect();
|
||||
Ok(jobs)
|
||||
}
|
||||
|
||||
/// Start a previously created job by queuing it to its assigned runner
|
||||
pub async fn start_job(&mut self, secret: &str, job_id: &str) -> RunnerResult<()> {
|
||||
// Check if the secret is valid (admin or user secret)
|
||||
if !self.admin_secrets.contains(&secret.to_string()) &&
|
||||
!self.user_secrets.contains(&secret.to_string()) {
|
||||
return Err(RunnerError::AuthenticationError {
|
||||
message: "Invalid secret for job operations".to_string()
|
||||
});
|
||||
// Verify API key and check scope
|
||||
let api_key = self.verify_api_key(secret).await
|
||||
.ok_or_else(|| RunnerError::InvalidSecret("Invalid API key".to_string()))?;
|
||||
|
||||
// Check if key has admin or user scope
|
||||
if api_key.scope != crate::auth::ApiKeyScope::Admin &&
|
||||
api_key.scope != crate::auth::ApiKeyScope::User {
|
||||
return Err(RunnerError::InvalidSecret("Insufficient permissions. Requires admin or user scope".to_string()));
|
||||
}
|
||||
|
||||
// Get the job from Redis
|
||||
@@ -757,81 +824,41 @@ impl Supervisor {
|
||||
|
||||
/// Get the status of a job
|
||||
pub async fn get_job_status(&self, job_id: &str) -> RunnerResult<crate::openrpc::JobStatusResponse> {
|
||||
use redis::AsyncCommands;
|
||||
|
||||
let mut conn = self.redis_client.get_multiplexed_async_connection().await
|
||||
.map_err(|e| RunnerError::RedisError { source: e })?;
|
||||
|
||||
// Get job data from Redis hash
|
||||
let job_data: std::collections::HashMap<String, String> = conn.hgetall(format!("{}:job:{}", self.namespace, job_id)).await
|
||||
.map_err(|e| RunnerError::RedisError { source: e })?;
|
||||
|
||||
if job_data.is_empty() {
|
||||
return Err(RunnerError::JobNotFound { job_id: job_id.to_string() });
|
||||
}
|
||||
|
||||
let status = job_data.get("status").unwrap_or(&"unknown".to_string()).clone();
|
||||
let created_at = job_data.get("created_at").unwrap_or(&"".to_string()).clone();
|
||||
let started_at = job_data.get("started_at").cloned();
|
||||
let completed_at = job_data.get("completed_at").cloned();
|
||||
// Use the client's get_status method
|
||||
let status = self.client.get_status(job_id).await
|
||||
.map_err(|e| match e {
|
||||
crate::job::JobError::NotFound(_) => RunnerError::JobNotFound { job_id: job_id.to_string() },
|
||||
_ => RunnerError::from(e)
|
||||
})?;
|
||||
|
||||
Ok(crate::openrpc::JobStatusResponse {
|
||||
job_id: job_id.to_string(),
|
||||
status,
|
||||
created_at,
|
||||
started_at,
|
||||
completed_at,
|
||||
status: status.as_str().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the result of a job (blocks until result is available)
|
||||
/// Get the result of a job (returns immediately with current result or error)
|
||||
pub async fn get_job_result(&self, job_id: &str) -> RunnerResult<Option<String>> {
|
||||
use redis::AsyncCommands;
|
||||
use tokio::time::{sleep, Duration};
|
||||
// Use client's get_status to check if job exists and get its status
|
||||
let status = self.client.get_status(job_id).await
|
||||
.map_err(|e| match e {
|
||||
crate::job::JobError::NotFound(_) => RunnerError::JobNotFound { job_id: job_id.to_string() },
|
||||
_ => RunnerError::from(e)
|
||||
})?;
|
||||
|
||||
let mut conn = self.redis_client.get_multiplexed_async_connection().await
|
||||
.map_err(|e| RunnerError::RedisError { source: e })?;
|
||||
|
||||
// Poll for job completion with timeout
|
||||
for _ in 0..300 { // 5 minutes timeout (300 * 1 second)
|
||||
let job_data: std::collections::HashMap<String, String> = conn.hgetall(format!("{}:job:{}", self.namespace, job_id)).await
|
||||
.map_err(|e| RunnerError::RedisError { source: e })?;
|
||||
// If job has error status, get the error message using client method
|
||||
if status.as_str() == "error" {
|
||||
let error_msg = self.client.get_error(job_id).await
|
||||
.map_err(|e| RunnerError::from(e))?;
|
||||
|
||||
if job_data.is_empty() {
|
||||
return Err(RunnerError::JobNotFound { job_id: job_id.to_string() });
|
||||
}
|
||||
|
||||
let status_str = "unknown".to_string();
|
||||
let status = job_data.get("status").unwrap_or(&status_str);
|
||||
|
||||
match status.as_str() {
|
||||
"completed" => {
|
||||
return Ok(job_data.get("result").cloned());
|
||||
},
|
||||
"failed" | "timeout" => {
|
||||
let default_error = "Job failed".to_string();
|
||||
let error_msg = job_data.get("error").unwrap_or(&default_error).clone();
|
||||
return Ok(Some(format!("Error: {}", error_msg)));
|
||||
},
|
||||
_ => {
|
||||
// Job still running, wait and check again
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
return Ok(Some(format!("Error: {}", error_msg.unwrap_or_else(|| "Unknown error".to_string()))));
|
||||
}
|
||||
|
||||
// Timeout reached
|
||||
Ok(Some("Error: Timeout waiting for job result".to_string()))
|
||||
}
|
||||
|
||||
/// Get runners count
|
||||
pub fn runners_count(&self) -> usize {
|
||||
self.runners.len()
|
||||
}
|
||||
|
||||
/// Get admin secrets (returns cloned vector for security)
|
||||
pub fn get_admin_secrets(&self) -> Vec<String> {
|
||||
self.admin_secrets.clone()
|
||||
// Use client's get_result to get the result
|
||||
let result = self.client.get_result(job_id).await
|
||||
.map_err(|e| RunnerError::from(e))?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Get user secrets (returns cloned vector for security)
|
||||
@@ -843,6 +870,68 @@ impl Supervisor {
|
||||
pub fn get_register_secrets(&self) -> Vec<String> {
|
||||
self.register_secrets.clone()
|
||||
}
|
||||
|
||||
/// Get runners count
|
||||
pub fn runners_count(&self) -> usize {
|
||||
self.runners.len()
|
||||
}
|
||||
|
||||
// API Key Management Methods
|
||||
|
||||
/// Create a new API key
|
||||
pub async fn create_api_key(&self, name: String, scope: crate::auth::ApiKeyScope) -> crate::auth::ApiKey {
|
||||
let mut store = self.api_keys.lock().await;
|
||||
let key = crate::auth::ApiKey::new(name, scope);
|
||||
store.add_key(key.clone());
|
||||
key
|
||||
}
|
||||
|
||||
/// Create an API key with a specific key value
|
||||
pub async fn create_api_key_with_value(&self, key_value: String, name: String, scope: crate::auth::ApiKeyScope) -> crate::auth::ApiKey {
|
||||
let mut store = self.api_keys.lock().await;
|
||||
let key = crate::auth::ApiKey::with_key(key_value, name, scope);
|
||||
store.add_key(key.clone());
|
||||
key
|
||||
}
|
||||
|
||||
/// Remove an API key
|
||||
pub async fn remove_api_key(&self, key: &str) -> Option<crate::auth::ApiKey> {
|
||||
let mut store = self.api_keys.lock().await;
|
||||
store.remove_key(key)
|
||||
}
|
||||
|
||||
/// Verify an API key and return its metadata
|
||||
pub async fn verify_api_key(&self, key: &str) -> Option<crate::auth::ApiKey> {
|
||||
let store = self.api_keys.lock().await;
|
||||
store.verify_key(key).cloned()
|
||||
}
|
||||
|
||||
/// List all API keys
|
||||
pub async fn list_api_keys(&self) -> Vec<crate::auth::ApiKey> {
|
||||
let store = self.api_keys.lock().await;
|
||||
store.list_all_keys().into_iter().cloned().collect()
|
||||
}
|
||||
|
||||
/// List API keys by scope
|
||||
pub async fn list_api_keys_by_scope(&self, scope: crate::auth::ApiKeyScope) -> Vec<crate::auth::ApiKey> {
|
||||
let store = self.api_keys.lock().await;
|
||||
store.list_keys_by_scope(scope).into_iter().cloned().collect()
|
||||
}
|
||||
|
||||
/// Bootstrap an initial admin key (useful for first-time setup)
|
||||
pub async fn bootstrap_admin_key(&self, name: String) -> crate::auth::ApiKey {
|
||||
let mut store = self.api_keys.lock().await;
|
||||
store.bootstrap_admin_key(name)
|
||||
}
|
||||
|
||||
/// Check if a key has admin scope
|
||||
pub async fn is_admin_key(&self, key: &str) -> bool {
|
||||
if let Some(api_key) = self.verify_api_key(key).await {
|
||||
api_key.scope == crate::auth::ApiKeyScope::Admin
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Supervisor {
|
||||
@@ -857,6 +946,8 @@ impl Default for Supervisor {
|
||||
admin_secrets: Vec::new(),
|
||||
user_secrets: Vec::new(),
|
||||
register_secrets: Vec::new(),
|
||||
api_keys: Arc::new(Mutex::new(crate::auth::ApiKeyStore::new())),
|
||||
services: crate::services::Services::new(),
|
||||
client: Client::default(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user