remove unused dep and move job out
This commit is contained in:
14
clients/admin-ui/Cargo.lock
generated
14
clients/admin-ui/Cargo.lock
generated
@@ -1025,6 +1025,19 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "hero-job"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"log",
|
||||
"redis",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hero-supervisor"
|
||||
version = "0.1.0"
|
||||
@@ -1034,6 +1047,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"env_logger 0.10.2",
|
||||
"hero-job",
|
||||
"jsonrpsee",
|
||||
"log",
|
||||
"redis",
|
||||
|
@@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Hero Supervisor</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link data-trunk rel="css" href="styles.css">
|
||||
</head>
|
||||
|
@@ -1,11 +1,13 @@
|
||||
use yew::prelude::*;
|
||||
use gloo::console;
|
||||
use gloo::timers::callback::Interval;
|
||||
use gloo::storage::{LocalStorage, Storage};
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use hero_supervisor_openrpc_client::wasm::{WasmSupervisorClient, WasmJob};
|
||||
use crate::sidebar::{Sidebar, SupervisorInfo, SessionSecretType};
|
||||
use crate::sidebar::{Sidebar, SupervisorInfo, SessionSecretType, SessionData, SESSION_STORAGE_KEY};
|
||||
use crate::runners::{Runners, RegisterForm};
|
||||
use crate::jobs::Jobs;
|
||||
use crate::toast::{Toast, ToastContainer};
|
||||
|
||||
/// Generate a unique job ID client-side using UUID v4
|
||||
fn generate_job_id() -> String {
|
||||
@@ -17,7 +19,6 @@ pub struct JobForm {
|
||||
pub payload: String,
|
||||
pub runner: String,
|
||||
pub executor: String,
|
||||
pub secret: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -45,7 +46,10 @@ pub struct AppState {
|
||||
pub job_form: JobForm,
|
||||
pub supervisor_info: Option<SupervisorInfo>,
|
||||
pub admin_secret: String,
|
||||
pub session_secret: String,
|
||||
pub session_secret_type: SessionSecretType,
|
||||
pub ping_states: std::collections::HashMap<String, PingState>, // runner -> ping_state
|
||||
pub toasts: Vec<Toast>, // Toast notifications
|
||||
}
|
||||
|
||||
|
||||
@@ -54,25 +58,36 @@ pub struct AppState {
|
||||
|
||||
#[function_component(App)]
|
||||
pub fn app() -> Html {
|
||||
let state = use_state(|| AppState {
|
||||
server_url: "http://localhost:3030".to_string(),
|
||||
runners: vec![],
|
||||
jobs: vec![],
|
||||
ongoing_jobs: vec![],
|
||||
loading: false,
|
||||
register_form: RegisterForm {
|
||||
name: String::new(),
|
||||
secret: String::new(),
|
||||
},
|
||||
job_form: JobForm {
|
||||
payload: String::new(),
|
||||
runner: String::new(),
|
||||
executor: String::new(),
|
||||
secret: String::new(),
|
||||
},
|
||||
supervisor_info: None,
|
||||
admin_secret: String::new(),
|
||||
ping_states: std::collections::HashMap::new(),
|
||||
let state = use_state(|| {
|
||||
// Try to load session from localStorage
|
||||
let (session_secret, session_secret_type) = if let Ok(session_data) = LocalStorage::get::<SessionData>(SESSION_STORAGE_KEY) {
|
||||
(session_data.secret, session_data.secret_type)
|
||||
} else {
|
||||
(String::new(), SessionSecretType::None)
|
||||
};
|
||||
|
||||
AppState {
|
||||
server_url: "http://localhost:3030".to_string(),
|
||||
runners: vec![],
|
||||
jobs: vec![],
|
||||
ongoing_jobs: vec![],
|
||||
loading: false,
|
||||
register_form: RegisterForm {
|
||||
name: String::new(),
|
||||
secret: String::new(),
|
||||
},
|
||||
job_form: JobForm {
|
||||
payload: String::new(),
|
||||
runner: String::new(),
|
||||
executor: String::new(),
|
||||
},
|
||||
supervisor_info: None,
|
||||
admin_secret: String::new(),
|
||||
session_secret,
|
||||
session_secret_type,
|
||||
ping_states: std::collections::HashMap::new(),
|
||||
toasts: vec![],
|
||||
}
|
||||
});
|
||||
|
||||
// Set up polling for ongoing jobs every 2 seconds
|
||||
@@ -131,6 +146,90 @@ pub fn app() -> Html {
|
||||
});
|
||||
}
|
||||
|
||||
// Check server connection status periodically
|
||||
{
|
||||
let state = state.clone();
|
||||
use_effect_with((), move |_| {
|
||||
let state = state.clone();
|
||||
let client_url = state.server_url.clone();
|
||||
|
||||
let check_connection = {
|
||||
let state = state.clone();
|
||||
let client_url = client_url.clone();
|
||||
Callback::from(move |_| {
|
||||
let state = state.clone();
|
||||
let client_url = client_url.clone();
|
||||
let client = WasmSupervisorClient::new(client_url.clone());
|
||||
spawn_local(async move {
|
||||
let mut current_state = (*state).clone();
|
||||
|
||||
// Try to ping the server to check connection
|
||||
match client.list_runners().await {
|
||||
Ok(_) => {
|
||||
// Server is reachable, now try to load secrets if we have a session secret
|
||||
let mut admin_secrets = vec![];
|
||||
let mut user_secrets = vec![];
|
||||
let mut register_secrets = vec![];
|
||||
|
||||
// Try to load secrets based on current session secret
|
||||
if !current_state.session_secret.is_empty() {
|
||||
match current_state.session_secret_type {
|
||||
SessionSecretType::Admin => {
|
||||
if let Ok(secrets) = client.list_admin_secrets(¤t_state.session_secret).await {
|
||||
admin_secrets = secrets;
|
||||
}
|
||||
if let Ok(secrets) = client.list_user_secrets(¤t_state.session_secret).await {
|
||||
user_secrets = secrets;
|
||||
}
|
||||
if let Ok(secrets) = client.list_register_secrets(¤t_state.session_secret).await {
|
||||
register_secrets = secrets;
|
||||
}
|
||||
}
|
||||
SessionSecretType::User => {
|
||||
if let Ok(secrets) = client.list_user_secrets(¤t_state.session_secret).await {
|
||||
user_secrets = secrets;
|
||||
}
|
||||
}
|
||||
SessionSecretType::Register => {
|
||||
if let Ok(secrets) = client.list_register_secrets(¤t_state.session_secret).await {
|
||||
register_secrets = secrets;
|
||||
}
|
||||
}
|
||||
SessionSecretType::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
current_state.supervisor_info = Some(SupervisorInfo {
|
||||
server_url: client_url.clone(),
|
||||
runners_count: 0,
|
||||
admin_secrets,
|
||||
user_secrets,
|
||||
register_secrets,
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
// Server is not reachable
|
||||
current_state.supervisor_info = None;
|
||||
}
|
||||
}
|
||||
|
||||
state.set(current_state);
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
// Check connection immediately
|
||||
check_connection.emit(());
|
||||
|
||||
// Set up interval to check connection every 5 seconds
|
||||
let interval = Interval::new(5000, move || {
|
||||
check_connection.emit(());
|
||||
});
|
||||
|
||||
move || drop(interval)
|
||||
});
|
||||
}
|
||||
|
||||
// Load initial data when component mounts
|
||||
let load_initial_data = {
|
||||
let state = state.clone();
|
||||
@@ -262,9 +361,10 @@ pub fn app() -> Html {
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to load runners:", format!("{:?}", e));
|
||||
let mut updated_state = (*state).clone();
|
||||
updated_state.loading = false;
|
||||
state.set(updated_state);
|
||||
let mut error_state = (*state).clone();
|
||||
error_state.loading = false;
|
||||
error_state.toasts.push(Toast::error(format!("Failed to load runners: {:?}", e)));
|
||||
state.set(error_state);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -294,7 +394,10 @@ pub fn app() -> Html {
|
||||
job_form: state.job_form.clone(),
|
||||
supervisor_info: state.supervisor_info.clone(),
|
||||
admin_secret: state.admin_secret.clone(),
|
||||
session_secret: state.session_secret.clone(),
|
||||
session_secret_type: state.session_secret_type.clone(),
|
||||
ping_states: state.ping_states.clone(),
|
||||
toasts: state.toasts.clone(),
|
||||
};
|
||||
state.set(new_state);
|
||||
})
|
||||
@@ -354,7 +457,6 @@ pub fn app() -> Html {
|
||||
"payload" => new_form.payload = value,
|
||||
"runner" => new_form.runner = value,
|
||||
"executor" => new_form.executor = value,
|
||||
"secret" => new_form.secret = value,
|
||||
_ => {}
|
||||
}
|
||||
let mut new_state = (*state).clone();
|
||||
@@ -366,7 +468,7 @@ pub fn app() -> Html {
|
||||
// Run job callback - now uses create_job for immediate display and polling
|
||||
let on_run_job = {
|
||||
let state = state.clone();
|
||||
Callback::from(move |_| {
|
||||
Callback::from(move |_: ()| {
|
||||
let current_state = (*state).clone();
|
||||
let client = WasmSupervisorClient::new(current_state.server_url.clone());
|
||||
let job_form = current_state.job_form.clone();
|
||||
@@ -396,7 +498,7 @@ pub fn app() -> Html {
|
||||
console::log!("Job added to list immediately with ID:", &job_id);
|
||||
|
||||
// Create the job using fire-and-forget create_job method
|
||||
match client.create_job(job_form.secret.clone(), job).await {
|
||||
match client.create_job(current_state.session_secret.clone(), job).await {
|
||||
Ok(returned_job_id) => {
|
||||
console::log!("Job created successfully with ID:", &returned_job_id);
|
||||
}
|
||||
@@ -415,10 +517,71 @@ pub fn app() -> Html {
|
||||
// Supervisor info loaded callback
|
||||
let on_supervisor_info_loaded = {
|
||||
let state = state.clone();
|
||||
Callback::from(move |supervisor_info: SupervisorInfo| {
|
||||
let mut new_state = (*state).clone();
|
||||
new_state.supervisor_info = Some(supervisor_info);
|
||||
state.set(new_state);
|
||||
Callback::from(move |info: SupervisorInfo| {
|
||||
let mut current_state = (*state).clone();
|
||||
current_state.supervisor_info = Some(info);
|
||||
state.set(current_state);
|
||||
})
|
||||
};
|
||||
|
||||
let on_add_secret = {
|
||||
let state = state.clone();
|
||||
Callback::from(move |(secret_type, _secret): (SessionSecretType, String)| {
|
||||
let mut current_state = (*state).clone();
|
||||
if let Some(ref mut info) = current_state.supervisor_info {
|
||||
// Prompt for a new secret
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Ok(Some(new_secret)) = window.prompt_with_message("Enter new secret:") {
|
||||
if !new_secret.is_empty() {
|
||||
match secret_type {
|
||||
SessionSecretType::Admin => {
|
||||
if !info.admin_secrets.contains(&new_secret) {
|
||||
info.admin_secrets.push(new_secret);
|
||||
}
|
||||
}
|
||||
SessionSecretType::User => {
|
||||
if !info.user_secrets.contains(&new_secret) {
|
||||
info.user_secrets.push(new_secret);
|
||||
}
|
||||
}
|
||||
SessionSecretType::Register => {
|
||||
if !info.register_secrets.contains(&new_secret) {
|
||||
info.register_secrets.push(new_secret);
|
||||
}
|
||||
}
|
||||
SessionSecretType::None => {
|
||||
// Do nothing for None type
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
state.set(current_state);
|
||||
})
|
||||
};
|
||||
|
||||
let on_remove_secret = {
|
||||
let state = state.clone();
|
||||
Callback::from(move |(secret_type, secret): (SessionSecretType, String)| {
|
||||
let mut current_state = (*state).clone();
|
||||
if let Some(ref mut info) = current_state.supervisor_info {
|
||||
match secret_type {
|
||||
SessionSecretType::Admin => {
|
||||
info.admin_secrets.retain(|s| s != &secret);
|
||||
}
|
||||
SessionSecretType::User => {
|
||||
info.user_secrets.retain(|s| s != &secret);
|
||||
}
|
||||
SessionSecretType::Register => {
|
||||
info.register_secrets.retain(|s| s != &secret);
|
||||
}
|
||||
SessionSecretType::None => {
|
||||
// Do nothing for None type
|
||||
}
|
||||
}
|
||||
}
|
||||
state.set(current_state);
|
||||
})
|
||||
};
|
||||
|
||||
@@ -439,10 +602,14 @@ pub fn app() -> Html {
|
||||
// Remove runner from the list
|
||||
let mut updated_state = (*state_clone).clone();
|
||||
updated_state.runners.retain(|(name, _)| name != &runner_id);
|
||||
updated_state.toasts.push(Toast::success("Runner removed successfully".to_string()));
|
||||
state_clone.set(updated_state);
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to remove runner:", format!("{:?}", e));
|
||||
let mut error_state = (*state_clone).clone();
|
||||
error_state.toasts.push(Toast::error(format!("Failed to remove runner: {:?}", e)));
|
||||
state_clone.set(error_state);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -470,6 +637,9 @@ pub fn app() -> Html {
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to stop job:", format!("{:?}", e));
|
||||
let mut error_state = (*state_clone).clone();
|
||||
error_state.toasts.push(Toast::error(format!("Failed to stop job: {:?}", e)));
|
||||
state_clone.set(error_state);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -498,16 +668,112 @@ pub fn app() -> Html {
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to delete job:", format!("{:?}", e));
|
||||
let mut error_state = (*state_clone).clone();
|
||||
error_state.toasts.push(Toast::error(format!("Failed to delete job: {:?}", e)));
|
||||
state_clone.set(error_state);
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
// Ping runner callback - uses run_job for immediate result with proper state management
|
||||
let on_ping_runner = {
|
||||
// Toast dismiss callback
|
||||
let on_toast_dismiss = {
|
||||
let state = state.clone();
|
||||
Callback::from(move |(runner_id, secret): (String, String)| {
|
||||
Callback::from(move |toast_id: String| {
|
||||
let mut current_state = (*state).clone();
|
||||
current_state.toasts.retain(|t| t.id != toast_id);
|
||||
state.set(current_state);
|
||||
})
|
||||
};
|
||||
|
||||
// Add toast callback
|
||||
let add_toast = {
|
||||
let state = state.clone();
|
||||
Callback::from(move |toast: Toast| {
|
||||
let mut current_state = (*state).clone();
|
||||
current_state.toasts.push(toast);
|
||||
state.set(current_state);
|
||||
})
|
||||
};
|
||||
|
||||
// Session secret change callback
|
||||
let on_session_secret_change = {
|
||||
let state = state.clone();
|
||||
Callback::from(move |(secret, secret_type): (String, SessionSecretType)| {
|
||||
let mut current_state = (*state).clone();
|
||||
current_state.session_secret = secret.clone();
|
||||
current_state.session_secret_type = secret_type.clone();
|
||||
|
||||
// If we have a session secret, trigger API call to load secrets
|
||||
if !secret.is_empty() {
|
||||
let client = WasmSupervisorClient::new(current_state.server_url.clone());
|
||||
let state_clone = state.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
let mut updated_state = (*state_clone).clone();
|
||||
|
||||
// Try to load secrets from API based on secret type
|
||||
match client.list_runners().await {
|
||||
Ok(_) => {
|
||||
let mut admin_secrets = vec![];
|
||||
let mut user_secrets = vec![];
|
||||
let mut register_secrets = vec![];
|
||||
|
||||
// Load secrets based on secret type
|
||||
match secret_type {
|
||||
SessionSecretType::Admin => {
|
||||
if let Ok(secrets) = client.list_admin_secrets(&secret).await {
|
||||
admin_secrets = secrets;
|
||||
}
|
||||
if let Ok(secrets) = client.list_user_secrets(&secret).await {
|
||||
user_secrets = secrets;
|
||||
}
|
||||
if let Ok(secrets) = client.list_register_secrets(&secret).await {
|
||||
register_secrets = secrets;
|
||||
}
|
||||
}
|
||||
SessionSecretType::User => {
|
||||
if let Ok(secrets) = client.list_user_secrets(&secret).await {
|
||||
user_secrets = secrets;
|
||||
}
|
||||
}
|
||||
SessionSecretType::Register => {
|
||||
if let Ok(secrets) = client.list_register_secrets(&secret).await {
|
||||
register_secrets = secrets;
|
||||
}
|
||||
}
|
||||
SessionSecretType::None => {}
|
||||
}
|
||||
|
||||
updated_state.supervisor_info = Some(SupervisorInfo {
|
||||
server_url: updated_state.server_url.clone(),
|
||||
runners_count: 0,
|
||||
admin_secrets,
|
||||
user_secrets,
|
||||
register_secrets,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
updated_state.supervisor_info = None;
|
||||
// Add error toast
|
||||
let error_msg = format!("Failed to connect to supervisor: {:?}", e);
|
||||
updated_state.toasts.push(Toast::error(error_msg));
|
||||
}
|
||||
}
|
||||
|
||||
state_clone.set(updated_state);
|
||||
});
|
||||
}
|
||||
|
||||
state.set(current_state);
|
||||
})
|
||||
};
|
||||
|
||||
// Run job callback
|
||||
let on_run_job = {
|
||||
let state = state.clone();
|
||||
Callback::from(move |(runner_id, payload): (String, String)| {
|
||||
let current_state = (*state).clone();
|
||||
let client = WasmSupervisorClient::new(current_state.server_url.clone());
|
||||
let state_clone = state.clone();
|
||||
@@ -520,26 +786,22 @@ pub fn app() -> Html {
|
||||
}
|
||||
|
||||
spawn_local(async move {
|
||||
console::log!("Pinging runner:", &runner_id);
|
||||
console::log!("Running job on runner:", &runner_id, "with payload:", &payload);
|
||||
|
||||
// Generate unique job ID client-side
|
||||
let job_id = generate_job_id();
|
||||
|
||||
// Create ping job with client-generated ID
|
||||
let ping_job = WasmJob::new(
|
||||
job_id.clone(),
|
||||
"ping".to_string(),
|
||||
"ping".to_string(),
|
||||
runner_id.clone(),
|
||||
// Use session secret to create job with payload
|
||||
let job = WasmJob::new(
|
||||
generate_job_id(),
|
||||
payload.clone(),
|
||||
"default".to_string(),
|
||||
runner_id.clone()
|
||||
);
|
||||
|
||||
// Use run_job for immediate result instead of create_job
|
||||
match client.run_job(secret, ping_job).await {
|
||||
Ok(result) => {
|
||||
console::log!("Ping successful, result:", &result);
|
||||
// Set ping state to success with result
|
||||
match client.run_job(current_state.session_secret, job).await {
|
||||
Ok(job_id) => {
|
||||
console::log!("Job created successfully:", &job_id);
|
||||
// Set ping state to success with job ID
|
||||
let mut success_state = (*state_clone).clone();
|
||||
success_state.ping_states.insert(runner_id.clone(), PingState::Success(result));
|
||||
success_state.ping_states.insert(runner_id.clone(), PingState::Success(format!("Job created: {}", job_id)));
|
||||
state_clone.set(success_state);
|
||||
|
||||
// Reset to idle after 3 seconds
|
||||
@@ -553,7 +815,7 @@ pub fn app() -> Html {
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
console::error!("Failed to ping runner:", format!("{:?}", e));
|
||||
console::error!("Failed to create job:", format!("{:?}", e));
|
||||
// Set ping state to error
|
||||
let mut error_state = (*state_clone).clone();
|
||||
let error_msg = format!("Error: {:?}", e);
|
||||
@@ -585,45 +847,60 @@ pub fn app() -> Html {
|
||||
});
|
||||
|
||||
html! {
|
||||
<div class="app-container">
|
||||
<Sidebar
|
||||
server_url={state.server_url.clone()}
|
||||
supervisor_info={state.supervisor_info.clone()}
|
||||
session_secret={state.admin_secret.clone()}
|
||||
session_secret_type={SessionSecretType::Admin}
|
||||
on_session_secret_change={on_admin_secret_change}
|
||||
on_supervisor_info_loaded={on_supervisor_info_loaded}
|
||||
/>
|
||||
|
||||
<div class="main-content">
|
||||
<Runners
|
||||
server_url={state.server_url.clone()}
|
||||
runners={state.runners.clone()}
|
||||
register_form={state.register_form.clone()}
|
||||
ping_states={state.ping_states.clone()}
|
||||
on_register_form_change={on_register_form_change}
|
||||
on_register_runner={on_register_runner}
|
||||
on_load_runners={on_load_runners.clone()}
|
||||
on_remove_runner={on_remove_runner}
|
||||
on_ping_runner={on_ping_runner}
|
||||
/>
|
||||
|
||||
<Jobs
|
||||
jobs={state.jobs.clone()}
|
||||
server_url={state.server_url.clone()}
|
||||
job_form={state.job_form.clone()}
|
||||
runners={state.runners.clone()}
|
||||
on_job_form_change={on_job_form_change}
|
||||
on_run_job={on_run_job}
|
||||
on_stop_job={on_stop_job}
|
||||
on_delete_job={on_delete_job}
|
||||
/>
|
||||
|
||||
// Floating refresh button
|
||||
<button class="refresh-btn" onclick={on_load_runners.reform(|_| ())}>
|
||||
{"↻"}
|
||||
</button>
|
||||
<div class="bg-dark min-vh-100">
|
||||
<div class="container-fluid h-100">
|
||||
<div class="row g-0 h-100">
|
||||
<Sidebar
|
||||
server_url={state.server_url.clone()}
|
||||
supervisor_info={state.supervisor_info.clone()}
|
||||
session_secret={state.session_secret.clone()}
|
||||
session_secret_type={state.session_secret_type.clone()}
|
||||
on_session_secret_change={on_session_secret_change}
|
||||
on_supervisor_info_loaded={on_supervisor_info_loaded}
|
||||
on_add_secret={on_add_secret}
|
||||
on_remove_secret={on_remove_secret}
|
||||
/>
|
||||
|
||||
<main class="col-md-9 col-lg-10 overflow-auto h-100">
|
||||
<div class="p-4 main-content">
|
||||
<Runners
|
||||
server_url={state.server_url.clone()}
|
||||
runners={state.runners.clone()}
|
||||
register_form={state.register_form.clone()}
|
||||
ping_states={state.ping_states.clone()}
|
||||
on_register_form_change={on_register_form_change}
|
||||
on_register_runner={on_register_runner}
|
||||
on_load_runners={on_load_runners.clone()}
|
||||
on_remove_runner={on_remove_runner}
|
||||
session_secret={state.session_secret.clone()}
|
||||
on_run_job={on_run_job.clone()}
|
||||
/>
|
||||
|
||||
<Jobs
|
||||
jobs={state.jobs.clone()}
|
||||
server_url={state.server_url.clone()}
|
||||
job_form={state.job_form.clone()}
|
||||
runners={state.runners.clone()}
|
||||
on_job_form_change={on_job_form_change}
|
||||
on_run_job={on_run_job}
|
||||
on_stop_job={on_stop_job}
|
||||
on_delete_job={on_delete_job}
|
||||
/>
|
||||
|
||||
// Floating refresh button
|
||||
<button class="btn btn-primary position-fixed" style="bottom: 20px; right: 20px; z-index: 1000;" onclick={on_load_runners.reform(|_| ())}>
|
||||
{"↻"}
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Toast notifications
|
||||
<ToastContainer
|
||||
toasts={state.toasts.clone()}
|
||||
on_dismiss={on_toast_dismiss}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
@@ -11,7 +11,7 @@ pub struct JobsProps {
|
||||
pub job_form: JobForm,
|
||||
pub runners: Vec<(String, String)>, // (name, status) - list of registered runners
|
||||
pub on_job_form_change: Callback<(String, String)>,
|
||||
pub on_run_job: Callback<()>,
|
||||
pub on_run_job: Callback<(String, String)>, // (runner, payload)
|
||||
pub on_stop_job: Callback<String>,
|
||||
pub on_delete_job: Callback<String>,
|
||||
}
|
||||
@@ -25,7 +25,6 @@ impl PartialEq for JobsProps {
|
||||
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.job_form.secret == other.job_form.secret &&
|
||||
self.runners.len() == other.runners.len()
|
||||
// Note: Callbacks don't implement PartialEq, so we skip them
|
||||
}
|
||||
@@ -57,18 +56,12 @@ pub fn jobs(props: &JobsProps) -> Html {
|
||||
})
|
||||
};
|
||||
|
||||
let on_secret_change = {
|
||||
let on_change = props.on_job_form_change.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
on_change.emit(("secret".to_string(), input.value()));
|
||||
})
|
||||
};
|
||||
|
||||
let on_run_click = {
|
||||
let on_run = props.on_run_job.clone();
|
||||
let job_form = props.job_form.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
on_run.emit(());
|
||||
on_run.emit((job_form.runner.clone(), job_form.payload.clone()));
|
||||
})
|
||||
};
|
||||
|
||||
@@ -84,6 +77,7 @@ pub fn jobs(props: &JobsProps) -> Html {
|
||||
<th>{"Runner"}</th>
|
||||
<th>{"Executor"}</th>
|
||||
<th>{"Status"}</th>
|
||||
<th>{"Actions"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -126,14 +120,10 @@ pub fn jobs(props: &JobsProps) -> Html {
|
||||
onchange={on_executor_change}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge status-not-started">{"Not Started"}</span>
|
||||
</td>
|
||||
<td class="action-cell">
|
||||
<input
|
||||
type="password"
|
||||
class="form-control table-input secret-input"
|
||||
placeholder="Secret"
|
||||
value={props.job_form.secret.clone()}
|
||||
onchange={on_secret_change}
|
||||
/>
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
onclick={on_run_click}
|
||||
@@ -157,8 +147,10 @@ pub fn jobs(props: &JobsProps) -> Html {
|
||||
<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">
|
||||
<span class="status-badge">{"Queued"}</span>
|
||||
<button
|
||||
class="btn-icon btn-stop"
|
||||
title="Stop Job"
|
||||
|
@@ -1,9 +1,10 @@
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
mod app;
|
||||
mod sidebar;
|
||||
mod runners;
|
||||
mod jobs;
|
||||
pub mod app;
|
||||
pub mod sidebar;
|
||||
pub mod runners;
|
||||
pub mod jobs;
|
||||
pub mod toast;
|
||||
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn main() {
|
||||
|
@@ -18,11 +18,12 @@ pub struct RunnersProps {
|
||||
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_ping_runner: Callback<(String, String)>, // (runner, secret)
|
||||
pub on_run_job: Callback<(String, String)>, // (runner, payload)
|
||||
}
|
||||
|
||||
#[function_component(Runners)]
|
||||
@@ -68,152 +69,137 @@ pub fn runners(props: &RunnersProps) -> Html {
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="runners-grid">
|
||||
// Registration card (first card)
|
||||
<div class="card register-card">
|
||||
<div class="card-title">{"+ Register Runner"}</div>
|
||||
<form onsubmit={on_register_runner.reform(|e: web_sys::SubmitEvent| {
|
||||
e.prevent_default();
|
||||
()
|
||||
})}>
|
||||
<div class="form-group">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Runner name"
|
||||
value={props.register_form.name.clone()}
|
||||
onchange={props.on_register_form_change.reform(|e: web_sys::Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target().unwrap().dyn_into().unwrap();
|
||||
("name".to_string(), input.value())
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group form-row">
|
||||
<input
|
||||
type="password"
|
||||
class="form-control form-control-inline"
|
||||
placeholder="Secret"
|
||||
value={props.register_form.secret.clone()}
|
||||
onchange={props.on_register_form_change.reform(|e: web_sys::Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target().unwrap().dyn_into().unwrap();
|
||||
("secret".to_string(), input.value())
|
||||
})}
|
||||
/>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{"Register"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="mb-5">
|
||||
<h2 class="mb-4">{"Runners"}</h2>
|
||||
|
||||
// Existing runner cards
|
||||
{for props.runners.iter().map(|(name, status)| {
|
||||
let status_class = match status.as_str() {
|
||||
"Running" => "status-running",
|
||||
"Stopped" => "status-stopped",
|
||||
"Starting" => "status-starting",
|
||||
"Stopping" => "status-starting",
|
||||
"Registering" => "status-registering",
|
||||
_ => "status-stopped",
|
||||
};
|
||||
|
||||
let name_clone = name.clone();
|
||||
let name_clone2 = name.clone();
|
||||
let on_remove = props.on_remove_runner.clone();
|
||||
let on_ping = props.on_ping_runner.clone();
|
||||
|
||||
html! {
|
||||
<div class="card runner-card">
|
||||
<div class="card-header">
|
||||
<div class="runner-title-section">
|
||||
<div class="runner-title-with-dot">
|
||||
<span class={format!("connection-dot {}", status_class)} title={status.clone()}>
|
||||
{"●"}
|
||||
</span>
|
||||
<div class="card-title">{name}</div>
|
||||
</div>
|
||||
<small class="queue-info">
|
||||
{"redis://localhost:6379/runner:"}{name}
|
||||
</small>
|
||||
</div>
|
||||
<div class="runner-actions-top">
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary btn-remove"
|
||||
title="Remove Runner"
|
||||
onclick={Callback::from(move |_| on_remove.emit(name_clone2.clone()))}
|
||||
>
|
||||
<svg class="trash-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="3,6 5,6 21,6"></polyline>
|
||||
<path d="m5,6 1,14 c0,1.1 0.9,2 2,2 h8 c1.1,0 2,-0.9 2,-2 l1,-14"></path>
|
||||
<path d="m10,11 v6"></path>
|
||||
<path d="m14,11 v6"></path>
|
||||
<path d="M7,6V4c0-1.1,0.9-2,2-2h6c0-1.1,0.9-2,2-2v2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="runner-chart">
|
||||
<div class="chart-placeholder">
|
||||
{"📊 Live job count chart (5s updates)"}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ping-section">
|
||||
{
|
||||
match props.ping_states.get(name).cloned().unwrap_or(PingState::Idle) {
|
||||
PingState::Idle => html! {
|
||||
<div class="input-group input-group-sm">
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
placeholder="Secret"
|
||||
id={format!("ping-secret-{}", name)}
|
||||
/>
|
||||
<button
|
||||
class="btn btn-outline-primary"
|
||||
title="Ping Runner"
|
||||
onclick={Callback::from(move |_| {
|
||||
let window = web_sys::window().unwrap();
|
||||
let document = window.document().unwrap();
|
||||
let input_id = format!("ping-secret-{}", name_clone.clone());
|
||||
if let Some(input) = document.get_element_by_id(&input_id) {
|
||||
let input: web_sys::HtmlInputElement = input.dyn_into().unwrap();
|
||||
let secret = input.value();
|
||||
if !secret.is_empty() {
|
||||
on_ping.emit((name_clone.clone(), secret));
|
||||
input.set_value("");
|
||||
}
|
||||
}
|
||||
})}
|
||||
>
|
||||
{"Ping"}
|
||||
</button>
|
||||
</div>
|
||||
},
|
||||
PingState::Waiting => html! {
|
||||
<div class="ping-status ping-waiting">
|
||||
<span class="ping-spinner">{"⏳"}</span>
|
||||
<span>{"Waiting for response..."}</span>
|
||||
</div>
|
||||
},
|
||||
PingState::Success(result) => html! {
|
||||
<div class="ping-status ping-success">
|
||||
<span class="ping-icon">{"✅"}</span>
|
||||
<span>{format!("Success: {}", result)}</span>
|
||||
</div>
|
||||
},
|
||||
PingState::Error(error) => html! {
|
||||
<div class="ping-status ping-error">
|
||||
<span class="ping-icon">{"❌"}</span>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
},
|
||||
}
|
||||
}
|
||||
// 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>
|
||||
}
|
||||
}
|
||||
|
@@ -2,24 +2,44 @@ use yew::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use gloo::console;
|
||||
use hero_supervisor_openrpc_client::wasm::{WasmSupervisorClient, WasmJob};
|
||||
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_count: usize,
|
||||
pub user_secrets_count: usize,
|
||||
pub register_secrets_count: usize,
|
||||
pub admin_secrets: Vec<String>,
|
||||
pub user_secrets: Vec<String>,
|
||||
pub register_secrets: Vec<String>,
|
||||
pub runners_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
#[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,
|
||||
@@ -28,17 +48,27 @@ pub struct SidebarProps {
|
||||
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 payload_input = use_state(|| String::new());
|
||||
let admin_secrets = use_state(|| Vec::<String>::new());
|
||||
let user_secrets = use_state(|| Vec::<String>::new());
|
||||
let register_secrets = use_state(|| Vec::<String>::new());
|
||||
let 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| {
|
||||
@@ -49,12 +79,11 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
||||
|
||||
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 admin_secrets = admin_secrets.clone();
|
||||
let user_secrets = user_secrets.clone();
|
||||
let register_secrets = register_secrets.clone();
|
||||
let server_url = props.server_url.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();
|
||||
@@ -63,346 +92,431 @@ pub fn sidebar(props: &SidebarProps) -> Html {
|
||||
}
|
||||
|
||||
is_loading.set(true);
|
||||
let client = WasmSupervisorClient::new(server_url.clone());
|
||||
|
||||
let session_secret_input = session_secret_input.clone();
|
||||
let is_loading = is_loading.clone();
|
||||
let admin_secrets = admin_secrets.clone();
|
||||
let user_secrets = user_secrets.clone();
|
||||
let register_secrets = register_secrets.clone();
|
||||
let 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 {
|
||||
// Try to get admin secrets first to determine if this is an admin secret
|
||||
match client.list_admin_secrets(&secret).await {
|
||||
Ok(admin_secret_list) => {
|
||||
// This is an admin secret
|
||||
admin_secrets.set(admin_secret_list);
|
||||
let client = WasmSupervisorClient::new(server_url.clone());
|
||||
|
||||
match client.discover().await {
|
||||
Ok(_) => {
|
||||
console::log!("Connected to supervisor successfully");
|
||||
|
||||
// Also load user and register secrets
|
||||
if let Ok(user_secret_list) = client.list_user_secrets(&secret).await {
|
||||
user_secrets.set(user_secret_list);
|
||||
}
|
||||
if let Ok(register_secret_list) = client.list_register_secrets(&secret).await {
|
||||
register_secrets.set(register_secret_list);
|
||||
}
|
||||
let secret_type = (*selected_secret_type).clone();
|
||||
|
||||
on_session_secret_change.emit((secret, SessionSecretType::Admin));
|
||||
console::log!("Admin session established");
|
||||
}
|
||||
Err(_) => {
|
||||
// Try as user secret - just test if we can make any call with it
|
||||
match client.list_runners().await {
|
||||
Ok(_) => {
|
||||
// This appears to be a valid user secret
|
||||
on_session_secret_change.emit((secret, SessionSecretType::User));
|
||||
console::log!("User session established");
|
||||
// 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()];
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
console::log!("Invalid secret:", format!("{:?}", e));
|
||||
on_session_secret_change.emit((String::new(), SessionSecretType::None));
|
||||
|
||||
// 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()];
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is_loading.set(false);
|
||||
session_secret_input.set(String::new());
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
let on_session_clear = {
|
||||
let on_session_secret_change = props.on_session_secret_change.clone();
|
||||
let admin_secrets = admin_secrets.clone();
|
||||
let user_secrets = user_secrets.clone();
|
||||
let register_secrets = register_secrets.clone();
|
||||
|
||||
Callback::from(move |_: web_sys::MouseEvent| {
|
||||
on_session_secret_change.emit((String::new(), SessionSecretType::None));
|
||||
admin_secrets.set(Vec::new());
|
||||
user_secrets.set(Vec::new());
|
||||
register_secrets.set(Vec::new());
|
||||
console::log!("Session cleared");
|
||||
})
|
||||
};
|
||||
|
||||
let on_payload_change = {
|
||||
let payload_input = payload_input.clone();
|
||||
Callback::from(move |e: web_sys::Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target().unwrap().dyn_into().unwrap();
|
||||
payload_input.set(input.value());
|
||||
})
|
||||
};
|
||||
|
||||
let on_run_click = {
|
||||
let payload_input = payload_input.clone();
|
||||
let server_url = props.server_url.clone();
|
||||
let session_secret = props.session_secret.clone();
|
||||
let is_loading = is_loading.clone();
|
||||
|
||||
Callback::from(move |_: web_sys::MouseEvent| {
|
||||
let payload = (*payload_input).clone();
|
||||
if payload.is_empty() || session_secret.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
is_loading.set(true);
|
||||
let client = WasmSupervisorClient::new(server_url.clone());
|
||||
|
||||
let payload_input = payload_input.clone();
|
||||
let is_loading = is_loading.clone();
|
||||
let session_secret = session_secret.clone();
|
||||
|
||||
spawn_local(async move {
|
||||
// Create WasmJob object using constructor
|
||||
let job = WasmJob::new(
|
||||
uuid::Uuid::new_v4().to_string(),
|
||||
payload.clone(),
|
||||
"osis".to_string(),
|
||||
"default".to_string(),
|
||||
);
|
||||
|
||||
match client.create_job(session_secret.clone(), job).await {
|
||||
Ok(job_id) => {
|
||||
console::log!("Job created successfully:", job_id);
|
||||
payload_input.set(String::new());
|
||||
|
||||
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::log!("Failed to create job:", format!("{:?}", 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="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>{"Supervisor"}</h2>
|
||||
</div>
|
||||
<div class="sidebar-content">
|
||||
<div class="sidebar-sections">
|
||||
// Server Info Section
|
||||
<div class="server-info">
|
||||
<div class="server-header">
|
||||
<h3 class="supervisor-title">{"Hero Supervisor"}</h3>
|
||||
</div>
|
||||
<div class="server-url">
|
||||
<span class="connection-indicator connected"></span>
|
||||
<span class="url-text">{props.server_url.clone()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<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 Secret Management Section
|
||||
<div class="session-section">
|
||||
<div class="session-header">
|
||||
<span class="session-title">{"Session"}</span>
|
||||
{
|
||||
match props.session_secret_type {
|
||||
SessionSecretType::Admin => html! {
|
||||
<span class="session-badge admin">{"Admin"}</span>
|
||||
},
|
||||
SessionSecretType::User => html! {
|
||||
<span class="session-badge user">{"User"}</span>
|
||||
},
|
||||
SessionSecretType::None => html! {
|
||||
<span class="session-badge none">{"None"}</span>
|
||||
}
|
||||
}
|
||||
// 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 {
|
||||
{"🔓"}
|
||||
}
|
||||
</div>
|
||||
|
||||
if props.session_secret_type == SessionSecretType::None {
|
||||
<div class="session-input-row">
|
||||
<input
|
||||
type="password"
|
||||
class="session-input"
|
||||
placeholder="Enter secret to establish session"
|
||||
value={(*session_secret_input).clone()}
|
||||
onchange={on_session_secret_change}
|
||||
disabled={*is_loading}
|
||||
/>
|
||||
<button
|
||||
class="session-btn"
|
||||
onclick={on_session_secret_submit}
|
||||
disabled={*is_loading || session_secret_input.is_empty()}
|
||||
>
|
||||
if *is_loading {
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
} else {
|
||||
<i class="fas fa-sign-in-alt"></i>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
} else {
|
||||
<div class="session-active">
|
||||
<div class="session-info">
|
||||
<span class="session-secret-preview">
|
||||
{format!("{}...", &props.session_secret[..std::cmp::min(8, props.session_secret.len())])}
|
||||
</span>
|
||||
<button
|
||||
class="session-clear-btn"
|
||||
onclick={on_session_clear}
|
||||
>
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
</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>
|
||||
}
|
||||
</div>
|
||||
|
||||
// Secrets Management Section (only visible for admin)
|
||||
if props.session_secret_type == SessionSecretType::Admin {
|
||||
<div class="secrets-section">
|
||||
<div class="secrets-header">
|
||||
<span class="secrets-title">{"Secrets Management"}</span>
|
||||
<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>
|
||||
|
||||
<div class="secrets-content">
|
||||
<div class="secret-group">
|
||||
<div class="secret-header">
|
||||
<span class="secret-title">{"Admin secrets"}</span>
|
||||
<span class="secret-count">{admin_secrets.len()}</span>
|
||||
</div>
|
||||
<div class="secret-list">
|
||||
{ for admin_secrets.iter().enumerate().map(|(i, secret)| {
|
||||
html! {
|
||||
<div class="secret-item" key={i}>
|
||||
<div class="secret-value">{secret.clone()}</div>
|
||||
<button class="btn-icon btn-remove">
|
||||
<i class="fas fa-minus"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
// 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="secret-group">
|
||||
<div class="secret-header">
|
||||
<span class="secret-title">{"User secrets"}</span>
|
||||
<span class="secret-count">{user_secrets.len()}</span>
|
||||
</div>
|
||||
<div class="secret-list">
|
||||
{ for user_secrets.iter().enumerate().map(|(i, secret)| {
|
||||
html! {
|
||||
<div class="secret-item" key={i}>
|
||||
<div class="secret-value">{secret.clone()}</div>
|
||||
<button class="btn-icon btn-remove">
|
||||
<i class="fas fa-minus"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
<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 class="secret-group">
|
||||
<div class="secret-header">
|
||||
<span class="secret-title">{"Register secrets"}</span>
|
||||
<span class="secret-count">{register_secrets.len()}</span>
|
||||
</div>
|
||||
<div class="secret-list">
|
||||
{ for register_secrets.iter().enumerate().map(|(i, secret)| {
|
||||
html! {
|
||||
<div class="secret-item" key={i}>
|
||||
<div class="secret-value">{secret.clone()}</div>
|
||||
<button class="btn-icon btn-remove">
|
||||
<i class="fas fa-minus"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</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>
|
||||
}
|
||||
|
||||
// Quick Actions Section
|
||||
<div class="quick-actions">
|
||||
<div class="quick-actions-header">
|
||||
<span class="quick-actions-title">{"Quick Actions"}</span>
|
||||
</div>
|
||||
<div class="quick-actions-content">
|
||||
if props.session_secret_type != SessionSecretType::None {
|
||||
<div class="action-row">
|
||||
<input
|
||||
type="text"
|
||||
class="action-input"
|
||||
placeholder="Enter payload for job"
|
||||
value={(*payload_input).clone()}
|
||||
onchange={on_payload_change}
|
||||
/>
|
||||
<button
|
||||
class="action-btn run-btn"
|
||||
onclick={on_run_click}
|
||||
disabled={payload_input.is_empty() || *is_loading}
|
||||
>
|
||||
if *is_loading {
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
} else {
|
||||
<i class="fas fa-play"></i>
|
||||
}
|
||||
{"Run"}
|
||||
</button>
|
||||
</div>
|
||||
} else {
|
||||
<div class="action-disabled">
|
||||
<span>{"Establish a session to enable quick actions"}</span>
|
||||
</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>
|
||||
|
||||
// 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>
|
||||
// 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>
|
||||
|
||||
// Documentation Links at Bottom
|
||||
<div class="sidebar-footer">
|
||||
<div class="docs-section">
|
||||
<h5>{"Documentation"}</h5>
|
||||
<div class="docs-links">
|
||||
<a href="https://github.com/herocode/supervisor" target="_blank" class="doc-link">
|
||||
{"📖 User Guide"}
|
||||
</a>
|
||||
<a href="https://github.com/herocode/supervisor/blob/main/README.md" target="_blank" class="doc-link">
|
||||
{"🚀 Getting Started"}
|
||||
</a>
|
||||
<a href="https://github.com/herocode/supervisor/issues" target="_blank" class="doc-link">
|
||||
{"🐛 Report Issues"}
|
||||
</a>
|
||||
<a href="https://github.com/herocode/supervisor/wiki" target="_blank" class="doc-link">
|
||||
{"📚 API Reference"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
639
clients/admin-ui/src/sidebar_old.rs
Normal file
639
clients/admin-ui/src/sidebar_old.rs
Normal file
@@ -0,0 +1,639 @@
|
||||
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>
|
||||
}
|
||||
}
|
165
clients/admin-ui/src/toast.rs
Normal file
165
clients/admin-ui/src/toast.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
//! 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)
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user