remove unused dep and move job out
This commit is contained in:
		@@ -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)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user