initial commit
This commit is contained in:
		
							
								
								
									
										630
									
								
								clients/admin-ui/src/app.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										630
									
								
								clients/admin-ui/src/app.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,630 @@
 | 
			
		||||
use yew::prelude::*;
 | 
			
		||||
use wasm_bindgen_futures::spawn_local;
 | 
			
		||||
use gloo::console;
 | 
			
		||||
use hero_supervisor_openrpc_client::wasm::{WasmSupervisorClient, WasmJob};
 | 
			
		||||
use gloo::timers::callback::Interval;
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
use crate::sidebar::{Sidebar, SupervisorInfo};
 | 
			
		||||
use crate::runners::{Runners, RegisterForm};
 | 
			
		||||
use crate::jobs::Jobs;
 | 
			
		||||
 | 
			
		||||
/// Generate a unique job ID client-side using UUID v4
 | 
			
		||||
fn generate_job_id() -> String {
 | 
			
		||||
    uuid::Uuid::new_v4().to_string()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Clone, Default)]
 | 
			
		||||
pub struct JobForm {
 | 
			
		||||
    pub payload: String,
 | 
			
		||||
    pub runner_name: String,
 | 
			
		||||
    pub executor: String,
 | 
			
		||||
    pub secret: String,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Clone, Debug, PartialEq)]
 | 
			
		||||
pub enum PingState {
 | 
			
		||||
    Idle,
 | 
			
		||||
    Waiting,
 | 
			
		||||
    Success(String), // Result message
 | 
			
		||||
    Error(String),   // Error message
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl Default for PingState {
 | 
			
		||||
    fn default() -> Self {
 | 
			
		||||
        PingState::Idle
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Clone)]
 | 
			
		||||
pub struct AppState {
 | 
			
		||||
    pub server_url: String,
 | 
			
		||||
    pub runners: Vec<(String, String)>, // (name, status)
 | 
			
		||||
    pub jobs: Vec<WasmJob>,
 | 
			
		||||
    pub ongoing_jobs: Vec<String>, // Job IDs being polled
 | 
			
		||||
    pub loading: bool,
 | 
			
		||||
    pub register_form: RegisterForm,
 | 
			
		||||
    pub job_form: JobForm,
 | 
			
		||||
    pub supervisor_info: Option<SupervisorInfo>,
 | 
			
		||||
    pub admin_secret: String,
 | 
			
		||||
    pub ping_states: std::collections::HashMap<String, PingState>, // runner_name -> ping_state
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
#[function_component(App)]
 | 
			
		||||
pub fn app() -> Html {
 | 
			
		||||
    let state = use_state(|| AppState {
 | 
			
		||||
        server_url: "http://localhost:3030".to_string(),
 | 
			
		||||
        runners: vec![],
 | 
			
		||||
        jobs: vec![],
 | 
			
		||||
        ongoing_jobs: vec![],
 | 
			
		||||
        loading: false,
 | 
			
		||||
        register_form: RegisterForm {
 | 
			
		||||
            name: String::new(),
 | 
			
		||||
            secret: String::new(),
 | 
			
		||||
        },
 | 
			
		||||
        job_form: JobForm {
 | 
			
		||||
            payload: String::new(),
 | 
			
		||||
            runner_name: String::new(),
 | 
			
		||||
            executor: String::new(),
 | 
			
		||||
            secret: String::new(),
 | 
			
		||||
        },
 | 
			
		||||
        supervisor_info: None,
 | 
			
		||||
        admin_secret: String::new(),
 | 
			
		||||
        ping_states: std::collections::HashMap::new(),
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Set up polling for ongoing jobs every 2 seconds
 | 
			
		||||
    {
 | 
			
		||||
        let state = state.clone();
 | 
			
		||||
        use_effect_with((), move |_| {
 | 
			
		||||
            let state = state.clone();
 | 
			
		||||
            
 | 
			
		||||
            let poll_jobs = {
 | 
			
		||||
                let state = state.clone();
 | 
			
		||||
                Callback::from(move |_| {
 | 
			
		||||
                    let current_state = (*state).clone();
 | 
			
		||||
                    if !current_state.ongoing_jobs.is_empty() {
 | 
			
		||||
                        let client = WasmSupervisorClient::new(current_state.server_url.clone());
 | 
			
		||||
                        let state_clone = state.clone();
 | 
			
		||||
                        
 | 
			
		||||
                        spawn_local(async move {
 | 
			
		||||
                            console::log!("Polling ongoing jobs:", format!("{:?}", current_state.ongoing_jobs));
 | 
			
		||||
                            let mut updated_state = (*state_clone).clone();
 | 
			
		||||
                            let mut jobs_to_remove = Vec::new();
 | 
			
		||||
                            
 | 
			
		||||
                            // Poll each ongoing job
 | 
			
		||||
                            for job_id in ¤t_state.ongoing_jobs {
 | 
			
		||||
                                match client.get_job(job_id).await {
 | 
			
		||||
                                    Ok(updated_job) => {
 | 
			
		||||
                                        // Find and update the job in the jobs list
 | 
			
		||||
                                        if let Some(job_index) = updated_state.jobs.iter().position(|j| j.id() == *job_id) {
 | 
			
		||||
                                            updated_state.jobs[job_index] = updated_job.clone();
 | 
			
		||||
                                            console::log!("Updated job status for:", job_id);
 | 
			
		||||
                                        }
 | 
			
		||||
                                    }
 | 
			
		||||
                                    Err(e) => {
 | 
			
		||||
                                        console::error!("Failed to poll job:", job_id, format!("{:?}", e));
 | 
			
		||||
                                        // Remove failed jobs from ongoing list
 | 
			
		||||
                                        jobs_to_remove.push(job_id.clone());
 | 
			
		||||
                                    }
 | 
			
		||||
                                }
 | 
			
		||||
                            }
 | 
			
		||||
                            
 | 
			
		||||
                            // Remove completed/failed jobs from ongoing list
 | 
			
		||||
                            for job_id in jobs_to_remove {
 | 
			
		||||
                                updated_state.ongoing_jobs.retain(|id| id != &job_id);
 | 
			
		||||
                            }
 | 
			
		||||
                            
 | 
			
		||||
                            state_clone.set(updated_state);
 | 
			
		||||
                        });
 | 
			
		||||
                    }
 | 
			
		||||
                })
 | 
			
		||||
            };
 | 
			
		||||
            
 | 
			
		||||
            let interval = Interval::new(2000, move || {
 | 
			
		||||
                poll_jobs.emit(());
 | 
			
		||||
            });
 | 
			
		||||
            
 | 
			
		||||
            move || drop(interval)
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Load initial data when component mounts
 | 
			
		||||
    let load_initial_data = {
 | 
			
		||||
        let state = state.clone();
 | 
			
		||||
        let client_url = state.server_url.clone();
 | 
			
		||||
        Callback::from(move |_: ()| {
 | 
			
		||||
            let state = state.clone();
 | 
			
		||||
            let client = WasmSupervisorClient::new(client_url.clone());
 | 
			
		||||
            spawn_local(async move {
 | 
			
		||||
                console::log!("Loading initial data...");
 | 
			
		||||
                let mut current_state = (*state).clone();
 | 
			
		||||
                current_state.loading = true;
 | 
			
		||||
                state.set(current_state.clone());
 | 
			
		||||
                
 | 
			
		||||
                // Load runners and jobs in parallel
 | 
			
		||||
                let runners_result = client.list_runners().await;
 | 
			
		||||
                let jobs_result = client.list_jobs().await;
 | 
			
		||||
                
 | 
			
		||||
                match (runners_result, jobs_result) {
 | 
			
		||||
                    (Ok(runner_names), Ok(job_ids)) => {
 | 
			
		||||
                        console::log!("Successfully loaded runners:", format!("{:?}", runner_names));
 | 
			
		||||
                        console::log!("Successfully loaded jobs:", format!("{:?}", job_ids));
 | 
			
		||||
                        
 | 
			
		||||
                        let runners_with_status: Vec<(String, String)> = runner_names
 | 
			
		||||
                            .into_iter()
 | 
			
		||||
                            .map(|name| (name, "Running".to_string()))
 | 
			
		||||
                            .collect();
 | 
			
		||||
                        
 | 
			
		||||
                        // Fetch full job details for each job ID and identify unfinished jobs
 | 
			
		||||
                        let mut jobs = Vec::new();
 | 
			
		||||
                        let mut ongoing_jobs = Vec::new();
 | 
			
		||||
                        
 | 
			
		||||
                        for job_id in job_ids {
 | 
			
		||||
                            match client.get_job(&job_id).await {
 | 
			
		||||
                                Ok(job) => {
 | 
			
		||||
                                    // Check if job is unfinished (you may need to adjust this logic based on your job status field)
 | 
			
		||||
                                    // For now, we'll assume all jobs are ongoing until we have proper status checking
 | 
			
		||||
                                    ongoing_jobs.push(job_id.clone());
 | 
			
		||||
                                    jobs.push(job);
 | 
			
		||||
                                }
 | 
			
		||||
                                Err(e) => {
 | 
			
		||||
                                    console::error!("Failed to fetch job details for:", &job_id, format!("{:?}", e));
 | 
			
		||||
                                    // Create placeholder job if fetch fails
 | 
			
		||||
                                    jobs.push(WasmJob::new(job_id.clone(), "Loading...".to_string(), "Unknown".to_string(), "Unknown".to_string()));
 | 
			
		||||
                                }
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                        
 | 
			
		||||
                        let mut updated_state = (*state).clone();
 | 
			
		||||
                        updated_state.runners = runners_with_status;
 | 
			
		||||
                        updated_state.jobs = jobs;
 | 
			
		||||
                        updated_state.ongoing_jobs = ongoing_jobs.clone();
 | 
			
		||||
                        updated_state.loading = false;
 | 
			
		||||
                        console::log!("Added ongoing jobs to polling:", format!("{:?}", ongoing_jobs));
 | 
			
		||||
                        state.set(updated_state);
 | 
			
		||||
                    }
 | 
			
		||||
                    (Ok(runner_names), Err(jobs_err)) => {
 | 
			
		||||
                        console::log!("Successfully loaded runners:", format!("{:?}", runner_names));
 | 
			
		||||
                        console::error!("Failed to load jobs:", format!("{:?}", jobs_err));
 | 
			
		||||
                        
 | 
			
		||||
                        let runners_with_status: Vec<(String, String)> = runner_names
 | 
			
		||||
                            .into_iter()
 | 
			
		||||
                            .map(|name| (name, "Running".to_string()))
 | 
			
		||||
                            .collect();
 | 
			
		||||
                        
 | 
			
		||||
                        let mut updated_state = (*state).clone();
 | 
			
		||||
                        updated_state.runners = runners_with_status;
 | 
			
		||||
                        updated_state.loading = false;
 | 
			
		||||
                        state.set(updated_state);
 | 
			
		||||
                    }
 | 
			
		||||
                    (Err(runners_err), Ok(job_ids)) => {
 | 
			
		||||
                        console::error!("Failed to load runners:", format!("{:?}", runners_err));
 | 
			
		||||
                        console::log!("Successfully loaded jobs:", format!("{:?}", job_ids));
 | 
			
		||||
                        
 | 
			
		||||
                        // Convert job IDs to WasmJob objects
 | 
			
		||||
                        let jobs: Vec<WasmJob> = job_ids
 | 
			
		||||
                            .into_iter()
 | 
			
		||||
                            .map(|id| {
 | 
			
		||||
                                WasmJob::new(id.clone(), "Loading...".to_string(), "Unknown".to_string(), "Unknown".to_string())
 | 
			
		||||
                            })
 | 
			
		||||
                            .collect();
 | 
			
		||||
                        
 | 
			
		||||
                        let mut updated_state = (*state).clone();
 | 
			
		||||
                        updated_state.jobs = jobs;
 | 
			
		||||
                        updated_state.loading = false;
 | 
			
		||||
                        state.set(updated_state);
 | 
			
		||||
                    }
 | 
			
		||||
                    (Err(runners_err), Err(jobs_err)) => {
 | 
			
		||||
                        console::error!("Failed to load runners:", format!("{:?}", runners_err));
 | 
			
		||||
                        console::error!("Failed to load jobs:", format!("{:?}", jobs_err));
 | 
			
		||||
                        let mut updated_state = (*state).clone();
 | 
			
		||||
                        updated_state.loading = false;
 | 
			
		||||
                        state.set(updated_state);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    use_effect_with((), move |_| {
 | 
			
		||||
        load_initial_data.emit(());
 | 
			
		||||
        || ()
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    let on_load_runners = {
 | 
			
		||||
        let state = state.clone();
 | 
			
		||||
        let client_url = state.server_url.clone();
 | 
			
		||||
        Callback::from(move |_: ()| {
 | 
			
		||||
            let state = state.clone();
 | 
			
		||||
            let client = WasmSupervisorClient::new(client_url.clone());
 | 
			
		||||
            spawn_local(async move {
 | 
			
		||||
                console::log!("Loading runners...");
 | 
			
		||||
                let mut current_state = (*state).clone();
 | 
			
		||||
                current_state.loading = true;
 | 
			
		||||
                state.set(current_state.clone());
 | 
			
		||||
                
 | 
			
		||||
                match client.list_runners().await {
 | 
			
		||||
                    Ok(runner_names) => {
 | 
			
		||||
                        console::log!("Successfully loaded runners:", format!("{:?}", runner_names));
 | 
			
		||||
                        // For now, assume all runners are "Running" - we'd need a separate status call
 | 
			
		||||
                        let runners_with_status: Vec<(String, String)> = runner_names
 | 
			
		||||
                            .into_iter()
 | 
			
		||||
                            .map(|name| (name, "Running".to_string()))
 | 
			
		||||
                            .collect();
 | 
			
		||||
                        
 | 
			
		||||
                        let mut updated_state = (*state).clone();
 | 
			
		||||
                        updated_state.runners = runners_with_status;
 | 
			
		||||
                        updated_state.loading = false;
 | 
			
		||||
                        state.set(updated_state);
 | 
			
		||||
                    }
 | 
			
		||||
                    Err(e) => {
 | 
			
		||||
                        console::error!("Failed to load runners:", format!("{:?}", e));
 | 
			
		||||
                        let mut updated_state = (*state).clone();
 | 
			
		||||
                        updated_state.loading = false;
 | 
			
		||||
                        state.set(updated_state);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    let on_register_form_change = {
 | 
			
		||||
        let state = state.clone();
 | 
			
		||||
        Callback::from(move |(field, value): (String, String)| {
 | 
			
		||||
            let mut new_form = state.register_form.clone();
 | 
			
		||||
            match field.as_str() {
 | 
			
		||||
                "name" => new_form.name = value,
 | 
			
		||||
                "secret" => new_form.secret = value,
 | 
			
		||||
                _ => {}
 | 
			
		||||
            }
 | 
			
		||||
            let new_state = AppState {
 | 
			
		||||
                server_url: state.server_url.clone(),
 | 
			
		||||
                runners: state.runners.clone(),
 | 
			
		||||
                jobs: state.jobs.clone(),
 | 
			
		||||
                ongoing_jobs: state.ongoing_jobs.clone(),
 | 
			
		||||
                loading: state.loading,
 | 
			
		||||
                register_form: new_form,
 | 
			
		||||
                job_form: state.job_form.clone(),
 | 
			
		||||
                supervisor_info: state.supervisor_info.clone(),
 | 
			
		||||
                admin_secret: state.admin_secret.clone(),
 | 
			
		||||
                ping_states: state.ping_states.clone(),
 | 
			
		||||
            };
 | 
			
		||||
            state.set(new_state);
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let on_register_runner = {
 | 
			
		||||
        let state = state.clone();
 | 
			
		||||
        Callback::from(move |_: ()| {
 | 
			
		||||
            let current_state = (*state).clone();
 | 
			
		||||
            
 | 
			
		||||
            // Add runner to UI immediately with "Registering" status
 | 
			
		||||
            let new_runner = (
 | 
			
		||||
                current_state.register_form.name.clone(),
 | 
			
		||||
                "Registering".to_string(),
 | 
			
		||||
            );
 | 
			
		||||
            
 | 
			
		||||
            let mut updated_runners = current_state.runners.clone();
 | 
			
		||||
            updated_runners.push(new_runner);
 | 
			
		||||
            
 | 
			
		||||
            let mut temp_state = current_state.clone();
 | 
			
		||||
            temp_state.runners = updated_runners;
 | 
			
		||||
            
 | 
			
		||||
            // Clear form and update status to "Running"
 | 
			
		||||
            temp_state.register_form = RegisterForm {
 | 
			
		||||
                name: String::new(),
 | 
			
		||||
                secret: String::new(),
 | 
			
		||||
            };
 | 
			
		||||
            
 | 
			
		||||
            // Update the newly added runner status to "Running"
 | 
			
		||||
            if let Some(runner) = temp_state.runners.iter_mut()
 | 
			
		||||
                .find(|(name, _)| name == ¤t_state.register_form.name) {
 | 
			
		||||
                runner.1 = "Running".to_string();
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            state.set(temp_state);
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    // Admin secret change callback
 | 
			
		||||
    let on_admin_secret_change = {
 | 
			
		||||
        let state = state.clone();
 | 
			
		||||
        Callback::from(move |admin_secret: String| {
 | 
			
		||||
            let mut new_state = (*state).clone();
 | 
			
		||||
            new_state.admin_secret = admin_secret;
 | 
			
		||||
            state.set(new_state);
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Job form change callback
 | 
			
		||||
    let on_job_form_change = {
 | 
			
		||||
        let state = state.clone();
 | 
			
		||||
        Callback::from(move |(field, value): (String, String)| {
 | 
			
		||||
            let mut new_form = state.job_form.clone();
 | 
			
		||||
            match field.as_str() {
 | 
			
		||||
                "payload" => new_form.payload = value,
 | 
			
		||||
                "runner_name" => new_form.runner_name = value,
 | 
			
		||||
                "executor" => new_form.executor = value,
 | 
			
		||||
                "secret" => new_form.secret = value,
 | 
			
		||||
                _ => {}
 | 
			
		||||
            }
 | 
			
		||||
            let mut new_state = (*state).clone();
 | 
			
		||||
            new_state.job_form = new_form;
 | 
			
		||||
            state.set(new_state);
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Run job callback - now uses create_job for immediate display and polling
 | 
			
		||||
    let on_run_job = {
 | 
			
		||||
        let state = state.clone();
 | 
			
		||||
        Callback::from(move |_| {
 | 
			
		||||
            let current_state = (*state).clone();
 | 
			
		||||
            let client = WasmSupervisorClient::new(current_state.server_url.clone());
 | 
			
		||||
            let job_form = current_state.job_form.clone();
 | 
			
		||||
            let state_clone = state.clone();
 | 
			
		||||
            
 | 
			
		||||
            spawn_local(async move {
 | 
			
		||||
                console::log!("Creating job...");
 | 
			
		||||
                
 | 
			
		||||
                // Generate unique job ID client-side
 | 
			
		||||
                let job_id = generate_job_id();
 | 
			
		||||
                
 | 
			
		||||
                // Create WasmJob from form data with client-generated ID
 | 
			
		||||
                let job = WasmJob::new(
 | 
			
		||||
                    job_id.clone(),
 | 
			
		||||
                    job_form.payload.clone(),
 | 
			
		||||
                    job_form.executor.clone(),
 | 
			
		||||
                    job_form.runner_name.clone(),
 | 
			
		||||
                );
 | 
			
		||||
                
 | 
			
		||||
                // Immediately add job to the list with "pending" status
 | 
			
		||||
                let mut updated_state = (*state_clone).clone();
 | 
			
		||||
                updated_state.jobs.push(job.clone());
 | 
			
		||||
                updated_state.ongoing_jobs.push(job_id.clone());
 | 
			
		||||
                // Clear the job form
 | 
			
		||||
                updated_state.job_form = JobForm::default();
 | 
			
		||||
                state_clone.set(updated_state);
 | 
			
		||||
                console::log!("Job added to list immediately with ID:", &job_id);
 | 
			
		||||
                
 | 
			
		||||
                // Create the job using fire-and-forget create_job method
 | 
			
		||||
                match client.create_job(job_form.secret.clone(), job).await {
 | 
			
		||||
                    Ok(returned_job_id) => {
 | 
			
		||||
                        console::log!("Job created successfully with ID:", &returned_job_id);
 | 
			
		||||
                    }
 | 
			
		||||
                    Err(e) => {
 | 
			
		||||
                        console::error!("Failed to create job:", format!("{:?}", e));
 | 
			
		||||
                        // Remove job from ongoing jobs if creation failed
 | 
			
		||||
                        let mut error_state = (*state_clone).clone();
 | 
			
		||||
                        error_state.ongoing_jobs.retain(|id| id != &job_id);
 | 
			
		||||
                        state_clone.set(error_state);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Supervisor info loaded callback
 | 
			
		||||
    let on_supervisor_info_loaded = {
 | 
			
		||||
        let state = state.clone();
 | 
			
		||||
        Callback::from(move |supervisor_info: SupervisorInfo| {
 | 
			
		||||
            let mut new_state = (*state).clone();
 | 
			
		||||
            new_state.supervisor_info = Some(supervisor_info);
 | 
			
		||||
            state.set(new_state);
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Remove runner callback
 | 
			
		||||
    let on_remove_runner = {
 | 
			
		||||
        let state = state.clone();
 | 
			
		||||
        Callback::from(move |runner_id: String| {
 | 
			
		||||
            let current_state = (*state).clone();
 | 
			
		||||
            let client = WasmSupervisorClient::new(current_state.server_url.clone());
 | 
			
		||||
            let state_clone = state.clone();
 | 
			
		||||
            
 | 
			
		||||
            spawn_local(async move {
 | 
			
		||||
                console::log!("Removing runner:", &runner_id);
 | 
			
		||||
                
 | 
			
		||||
                match client.remove_runner(&runner_id).await {
 | 
			
		||||
                    Ok(_) => {
 | 
			
		||||
                        console::log!("Runner removed successfully");
 | 
			
		||||
                        // Remove runner from the list
 | 
			
		||||
                        let mut updated_state = (*state_clone).clone();
 | 
			
		||||
                        updated_state.runners.retain(|(name, _)| name != &runner_id);
 | 
			
		||||
                        state_clone.set(updated_state);
 | 
			
		||||
                    }
 | 
			
		||||
                    Err(e) => {
 | 
			
		||||
                        console::error!("Failed to remove runner:", format!("{:?}", e));
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Stop job callback
 | 
			
		||||
    let on_stop_job = {
 | 
			
		||||
        let state = state.clone();
 | 
			
		||||
        Callback::from(move |job_id: String| {
 | 
			
		||||
            let current_state = (*state).clone();
 | 
			
		||||
            let client = WasmSupervisorClient::new(current_state.server_url.clone());
 | 
			
		||||
            let state_clone = state.clone();
 | 
			
		||||
            
 | 
			
		||||
            spawn_local(async move {
 | 
			
		||||
                console::log!("Stopping job:", &job_id);
 | 
			
		||||
                
 | 
			
		||||
                match client.stop_job(&job_id).await {
 | 
			
		||||
                    Ok(_) => {
 | 
			
		||||
                        console::log!("Job stopped successfully");
 | 
			
		||||
                        // Remove job from ongoing jobs list
 | 
			
		||||
                        let mut updated_state = (*state_clone).clone();
 | 
			
		||||
                        updated_state.ongoing_jobs.retain(|id| id != &job_id);
 | 
			
		||||
                        state_clone.set(updated_state);
 | 
			
		||||
                    }
 | 
			
		||||
                    Err(e) => {
 | 
			
		||||
                        console::error!("Failed to stop job:", format!("{:?}", e));
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Delete job callback
 | 
			
		||||
    let on_delete_job = {
 | 
			
		||||
        let state = state.clone();
 | 
			
		||||
        Callback::from(move |job_id: String| {
 | 
			
		||||
            let current_state = (*state).clone();
 | 
			
		||||
            let client = WasmSupervisorClient::new(current_state.server_url.clone());
 | 
			
		||||
            let state_clone = state.clone();
 | 
			
		||||
            
 | 
			
		||||
            spawn_local(async move {
 | 
			
		||||
                console::log!("Deleting job:", &job_id);
 | 
			
		||||
                
 | 
			
		||||
                match client.delete_job(&job_id).await {
 | 
			
		||||
                    Ok(_) => {
 | 
			
		||||
                        console::log!("Job deleted successfully");
 | 
			
		||||
                        // Remove job from both jobs list and ongoing jobs list
 | 
			
		||||
                        let mut updated_state = (*state_clone).clone();
 | 
			
		||||
                        updated_state.jobs.retain(|job| job.id() != job_id);
 | 
			
		||||
                        updated_state.ongoing_jobs.retain(|id| id != &job_id);
 | 
			
		||||
                        state_clone.set(updated_state);
 | 
			
		||||
                    }
 | 
			
		||||
                    Err(e) => {
 | 
			
		||||
                        console::error!("Failed to delete job:", format!("{:?}", e));
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Ping runner callback - uses run_job for immediate result with proper state management
 | 
			
		||||
    let on_ping_runner = {
 | 
			
		||||
        let state = state.clone();
 | 
			
		||||
        Callback::from(move |(runner_id, secret): (String, String)| {
 | 
			
		||||
            let current_state = (*state).clone();
 | 
			
		||||
            let client = WasmSupervisorClient::new(current_state.server_url.clone());
 | 
			
		||||
            let state_clone = state.clone();
 | 
			
		||||
            
 | 
			
		||||
            // Set ping state to waiting
 | 
			
		||||
            {
 | 
			
		||||
                let mut updated_state = (*state_clone).clone();
 | 
			
		||||
                updated_state.ping_states.insert(runner_id.clone(), PingState::Waiting);
 | 
			
		||||
                state_clone.set(updated_state);
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            spawn_local(async move {
 | 
			
		||||
                console::log!("Pinging runner:", &runner_id);
 | 
			
		||||
                
 | 
			
		||||
                // Generate unique job ID client-side
 | 
			
		||||
                let job_id = generate_job_id();
 | 
			
		||||
                
 | 
			
		||||
                // Create ping job with client-generated ID
 | 
			
		||||
                let ping_job = WasmJob::new(
 | 
			
		||||
                    job_id.clone(),
 | 
			
		||||
                    "ping".to_string(),
 | 
			
		||||
                    "ping".to_string(),
 | 
			
		||||
                    runner_id.clone(),
 | 
			
		||||
                );
 | 
			
		||||
                
 | 
			
		||||
                // Use run_job for immediate result instead of create_job
 | 
			
		||||
                match client.run_job(secret, ping_job).await {
 | 
			
		||||
                    Ok(result) => {
 | 
			
		||||
                        console::log!("Ping successful, result:", &result);
 | 
			
		||||
                        // Set ping state to success with result
 | 
			
		||||
                        let mut success_state = (*state_clone).clone();
 | 
			
		||||
                        success_state.ping_states.insert(runner_id.clone(), PingState::Success(result));
 | 
			
		||||
                        state_clone.set(success_state);
 | 
			
		||||
                        
 | 
			
		||||
                        // Reset to idle after 3 seconds
 | 
			
		||||
                        let state_reset = state_clone.clone();
 | 
			
		||||
                        let runner_id_reset = runner_id.clone();
 | 
			
		||||
                        spawn_local(async move {
 | 
			
		||||
                            gloo::timers::future::TimeoutFuture::new(3000).await;
 | 
			
		||||
                            let mut reset_state = (*state_reset).clone();
 | 
			
		||||
                            reset_state.ping_states.insert(runner_id_reset, PingState::Idle);
 | 
			
		||||
                            state_reset.set(reset_state);
 | 
			
		||||
                        });
 | 
			
		||||
                    }
 | 
			
		||||
                    Err(e) => {
 | 
			
		||||
                        console::error!("Failed to ping runner:", format!("{:?}", e));
 | 
			
		||||
                        // Set ping state to error
 | 
			
		||||
                        let mut error_state = (*state_clone).clone();
 | 
			
		||||
                        let error_msg = format!("Error: {:?}", e);
 | 
			
		||||
                        error_state.ping_states.insert(runner_id.clone(), PingState::Error(error_msg));
 | 
			
		||||
                        state_clone.set(error_state);
 | 
			
		||||
                        
 | 
			
		||||
                        // Reset to idle after 3 seconds
 | 
			
		||||
                        let state_reset = state_clone.clone();
 | 
			
		||||
                        let runner_id_reset = runner_id.clone();
 | 
			
		||||
                        spawn_local(async move {
 | 
			
		||||
                            gloo::timers::future::TimeoutFuture::new(3000).await;
 | 
			
		||||
                            let mut reset_state = (*state_reset).clone();
 | 
			
		||||
                            reset_state.ping_states.insert(runner_id_reset, PingState::Idle);
 | 
			
		||||
                            state_reset.set(reset_state);
 | 
			
		||||
                        });
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Load initial data
 | 
			
		||||
    use_effect_with((), {
 | 
			
		||||
        let on_load_runners = on_load_runners.clone();
 | 
			
		||||
        move |_| {
 | 
			
		||||
            on_load_runners.emit(());
 | 
			
		||||
            || ()
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    html! {
 | 
			
		||||
        <div class="app-container">
 | 
			
		||||
            <Sidebar 
 | 
			
		||||
                server_url={state.server_url.clone()}
 | 
			
		||||
                supervisor_info={state.supervisor_info.clone()}
 | 
			
		||||
                admin_secret={state.admin_secret.clone()}
 | 
			
		||||
                on_admin_secret_change={on_admin_secret_change}
 | 
			
		||||
                on_supervisor_info_loaded={on_supervisor_info_loaded}
 | 
			
		||||
            />
 | 
			
		||||
            
 | 
			
		||||
            <div class="main-content">
 | 
			
		||||
                <Runners 
 | 
			
		||||
                    server_url={state.server_url.clone()}
 | 
			
		||||
                    runners={state.runners.clone()}
 | 
			
		||||
                    register_form={state.register_form.clone()}
 | 
			
		||||
                    ping_states={state.ping_states.clone()}
 | 
			
		||||
                    on_register_form_change={on_register_form_change}
 | 
			
		||||
                    on_register_runner={on_register_runner}
 | 
			
		||||
                    on_load_runners={on_load_runners.clone()}
 | 
			
		||||
                    on_remove_runner={on_remove_runner}
 | 
			
		||||
                    on_ping_runner={on_ping_runner}
 | 
			
		||||
                />
 | 
			
		||||
                
 | 
			
		||||
                <Jobs 
 | 
			
		||||
                    jobs={state.jobs.clone()}
 | 
			
		||||
                    server_url={state.server_url.clone()}
 | 
			
		||||
                    job_form={state.job_form.clone()}
 | 
			
		||||
                    runners={state.runners.clone()}
 | 
			
		||||
                    on_job_form_change={on_job_form_change}
 | 
			
		||||
                    on_run_job={on_run_job}
 | 
			
		||||
                    on_stop_job={on_stop_job}
 | 
			
		||||
                    on_delete_job={on_delete_job}
 | 
			
		||||
                />
 | 
			
		||||
                
 | 
			
		||||
                // Floating refresh button
 | 
			
		||||
                <button class="refresh-btn" onclick={on_load_runners.reform(|_| ())}>
 | 
			
		||||
                    {"↻"}
 | 
			
		||||
                </button>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1099
									
								
								clients/admin-ui/src/app.rs.backup
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1099
									
								
								clients/admin-ui/src/app.rs.backup
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										296
									
								
								clients/admin-ui/src/components/add_runner.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										296
									
								
								clients/admin-ui/src/components/add_runner.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,296 @@
 | 
			
		||||
use yew::prelude::*;
 | 
			
		||||
use yew_router::prelude::*;
 | 
			
		||||
use gloo::console;
 | 
			
		||||
use wasm_bindgen_futures::spawn_local;
 | 
			
		||||
use web_sys::HtmlInputElement;
 | 
			
		||||
use std::collections::HashMap;
 | 
			
		||||
 | 
			
		||||
use crate::app::Route;
 | 
			
		||||
use crate::types::{AddRunnerForm, RunnerType, ProcessManagerType};
 | 
			
		||||
use crate::services::{SupervisorService, use_supervisor_service};
 | 
			
		||||
 | 
			
		||||
#[function_component(AddRunner)]
 | 
			
		||||
pub fn add_runner() -> Html {
 | 
			
		||||
    let navigator = use_navigator().unwrap();
 | 
			
		||||
    let server_url = "http://localhost:8081";
 | 
			
		||||
    let (service, service_error) = use_supervisor_service(server_url);
 | 
			
		||||
    
 | 
			
		||||
    let form = use_state(|| AddRunnerForm::default());
 | 
			
		||||
    let loading = use_state(|| false);
 | 
			
		||||
    let error = use_state(|| None::<String>);
 | 
			
		||||
    let success = use_state(|| false);
 | 
			
		||||
 | 
			
		||||
    let on_actor_id_change = {
 | 
			
		||||
        let form = form.clone();
 | 
			
		||||
        Callback::from(move |e: Event| {
 | 
			
		||||
            let input: HtmlInputElement = e.target_unchecked_into();
 | 
			
		||||
            let mut new_form = (*form).clone();
 | 
			
		||||
            new_form.actor_id = input.value();
 | 
			
		||||
            form.set(new_form);
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let on_runner_type_change = {
 | 
			
		||||
        let form = form.clone();
 | 
			
		||||
        Callback::from(move |e: Event| {
 | 
			
		||||
            let select: web_sys::HtmlSelectElement = e.target_unchecked_into();
 | 
			
		||||
            let mut new_form = (*form).clone();
 | 
			
		||||
            new_form.runner_type = match select.value().as_str() {
 | 
			
		||||
                "SALRunner" => RunnerType::SALRunner,
 | 
			
		||||
                "OSISRunner" => RunnerType::OSISRunner,
 | 
			
		||||
                "VRunner" => RunnerType::VRunner,
 | 
			
		||||
                _ => RunnerType::SALRunner,
 | 
			
		||||
            };
 | 
			
		||||
            form.set(new_form);
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let on_binary_path_change = {
 | 
			
		||||
        let form = form.clone();
 | 
			
		||||
        Callback::from(move |e: Event| {
 | 
			
		||||
            let input: HtmlInputElement = e.target_unchecked_into();
 | 
			
		||||
            let mut new_form = (*form).clone();
 | 
			
		||||
            new_form.binary_path = input.value();
 | 
			
		||||
            form.set(new_form);
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let on_script_type_change = {
 | 
			
		||||
        let form = form.clone();
 | 
			
		||||
        Callback::from(move |e: Event| {
 | 
			
		||||
            let input: HtmlInputElement = e.target_unchecked_into();
 | 
			
		||||
            let mut new_form = (*form).clone();
 | 
			
		||||
            new_form.script_type = input.value();
 | 
			
		||||
            form.set(new_form);
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let on_process_manager_change = {
 | 
			
		||||
        let form = form.clone();
 | 
			
		||||
        Callback::from(move |e: Event| {
 | 
			
		||||
            let select: web_sys::HtmlSelectElement = e.target_unchecked_into();
 | 
			
		||||
            let mut new_form = (*form).clone();
 | 
			
		||||
            new_form.process_manager_type = match select.value().as_str() {
 | 
			
		||||
                "Tmux" => ProcessManagerType::Tmux,
 | 
			
		||||
                "Simple" => ProcessManagerType::Simple,
 | 
			
		||||
                _ => ProcessManagerType::Simple,
 | 
			
		||||
            };
 | 
			
		||||
            form.set(new_form);
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let on_submit = {
 | 
			
		||||
        let form = form.clone();
 | 
			
		||||
        let service = service.clone();
 | 
			
		||||
        let loading = loading.clone();
 | 
			
		||||
        let error = error.clone();
 | 
			
		||||
        let success = success.clone();
 | 
			
		||||
        let navigator = navigator.clone();
 | 
			
		||||
 | 
			
		||||
        Callback::from(move |e: SubmitEvent| {
 | 
			
		||||
            e.prevent_default();
 | 
			
		||||
            
 | 
			
		||||
            if let Some(service) = &service {
 | 
			
		||||
                let form = form.clone();
 | 
			
		||||
                let service = service.clone();
 | 
			
		||||
                let loading = loading.clone();
 | 
			
		||||
                let error = error.clone();
 | 
			
		||||
                let success = success.clone();
 | 
			
		||||
                let navigator = navigator.clone();
 | 
			
		||||
 | 
			
		||||
                loading.set(true);
 | 
			
		||||
                error.set(None);
 | 
			
		||||
                success.set(false);
 | 
			
		||||
 | 
			
		||||
                spawn_local(async move {
 | 
			
		||||
                    let config = form.to_runner_config();
 | 
			
		||||
                    match service.add_runner(config, form.process_manager_type.clone()).await {
 | 
			
		||||
                        Ok(_) => {
 | 
			
		||||
                            console::log!("Runner added successfully");
 | 
			
		||||
                            success.set(true);
 | 
			
		||||
                            // Navigate back to runners list after a short delay
 | 
			
		||||
                            gloo::timers::callback::Timeout::new(1500, move || {
 | 
			
		||||
                                navigator.push(&Route::Runners);
 | 
			
		||||
                            }).forget();
 | 
			
		||||
                        }
 | 
			
		||||
                        Err(e) => {
 | 
			
		||||
                            console::error!("Failed to add runner:", e.to_string());
 | 
			
		||||
                            error.set(Some(e.to_string()));
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    loading.set(false);
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let on_cancel = {
 | 
			
		||||
        let navigator = navigator.clone();
 | 
			
		||||
        Callback::from(move |_| navigator.push(&Route::Runners))
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    html! {
 | 
			
		||||
        <div class="container-fluid">
 | 
			
		||||
            <div class="row mb-4">
 | 
			
		||||
                <div class="col-12">
 | 
			
		||||
                    <div class="d-flex justify-content-between align-items-center">
 | 
			
		||||
                        <h1 class="h3 mb-0">
 | 
			
		||||
                            <i class="bi bi-plus-circle me-2"></i>
 | 
			
		||||
                            {"Add New Runner"}
 | 
			
		||||
                        </h1>
 | 
			
		||||
                        <button class="btn btn-outline-secondary" onclick={on_cancel.clone()}>
 | 
			
		||||
                            <i class="bi bi-arrow-left me-1"></i>
 | 
			
		||||
                            {"Back to Runners"}
 | 
			
		||||
                        </button>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            // Error display
 | 
			
		||||
            if let Some(err) = service_error {
 | 
			
		||||
                <div class="row mb-4">
 | 
			
		||||
                    <div class="col-12">
 | 
			
		||||
                        <div class="alert alert-danger">
 | 
			
		||||
                            <i class="bi bi-exclamation-triangle-fill me-2"></i>
 | 
			
		||||
                            {"Service Error: "}{err}
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if let Some(err) = error.as_ref() {
 | 
			
		||||
                <div class="row mb-4">
 | 
			
		||||
                    <div class="col-12">
 | 
			
		||||
                        <div class="alert alert-danger">
 | 
			
		||||
                            <i class="bi bi-exclamation-triangle-fill me-2"></i>
 | 
			
		||||
                            {"Error: "}{err}
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if *success {
 | 
			
		||||
                <div class="row mb-4">
 | 
			
		||||
                    <div class="col-12">
 | 
			
		||||
                        <div class="alert alert-success">
 | 
			
		||||
                            <i class="bi bi-check-circle-fill me-2"></i>
 | 
			
		||||
                            {"Runner added successfully! Redirecting..."}
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            <div class="row">
 | 
			
		||||
                <div class="col-lg-8 mx-auto">
 | 
			
		||||
                    <div class="card">
 | 
			
		||||
                        <div class="card-header">
 | 
			
		||||
                            <h5 class="mb-0">{"Runner Configuration"}</h5>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div class="card-body">
 | 
			
		||||
                            <form onsubmit={on_submit}>
 | 
			
		||||
                                <div class="row mb-3">
 | 
			
		||||
                                    <div class="col-md-6">
 | 
			
		||||
                                        <label for="actor_id" class="form-label">{"Actor ID"}</label>
 | 
			
		||||
                                        <input 
 | 
			
		||||
                                            type="text" 
 | 
			
		||||
                                            class="form-control" 
 | 
			
		||||
                                            id="actor_id"
 | 
			
		||||
                                            value={form.actor_id.clone()}
 | 
			
		||||
                                            onchange={on_actor_id_change}
 | 
			
		||||
                                            required=true
 | 
			
		||||
                                            placeholder="e.g., sal_runner_1"
 | 
			
		||||
                                        />
 | 
			
		||||
                                        <div class="form-text">{"Unique identifier for this runner"}</div>
 | 
			
		||||
                                    </div>
 | 
			
		||||
                                    <div class="col-md-6">
 | 
			
		||||
                                        <label for="runner_type" class="form-label">{"Runner Type"}</label>
 | 
			
		||||
                                        <select 
 | 
			
		||||
                                            class="form-select" 
 | 
			
		||||
                                            id="runner_type"
 | 
			
		||||
                                            onchange={on_runner_type_change}
 | 
			
		||||
                                        >
 | 
			
		||||
                                            <option value="SALRunner" selected={matches!(form.runner_type, RunnerType::SALRunner)}>
 | 
			
		||||
                                                {"SAL Runner"}
 | 
			
		||||
                                            </option>
 | 
			
		||||
                                            <option value="OSISRunner" selected={matches!(form.runner_type, RunnerType::OSISRunner)}>
 | 
			
		||||
                                                {"OSIS Runner"}
 | 
			
		||||
                                            </option>
 | 
			
		||||
                                            <option value="VRunner" selected={matches!(form.runner_type, RunnerType::VRunner)}>
 | 
			
		||||
                                                {"V Runner"}
 | 
			
		||||
                                            </option>
 | 
			
		||||
                                        </select>
 | 
			
		||||
                                    </div>
 | 
			
		||||
                                </div>
 | 
			
		||||
 | 
			
		||||
                                <div class="row mb-3">
 | 
			
		||||
                                    <div class="col-md-6">
 | 
			
		||||
                                        <label for="binary_path" class="form-label">{"Binary Path"}</label>
 | 
			
		||||
                                        <input 
 | 
			
		||||
                                            type="text" 
 | 
			
		||||
                                            class="form-control" 
 | 
			
		||||
                                            id="binary_path"
 | 
			
		||||
                                            value={form.binary_path.clone()}
 | 
			
		||||
                                            onchange={on_binary_path_change}
 | 
			
		||||
                                            required=true
 | 
			
		||||
                                            placeholder="/path/to/runner/binary"
 | 
			
		||||
                                        />
 | 
			
		||||
                                        <div class="form-text">{"Full path to the runner executable"}</div>
 | 
			
		||||
                                    </div>
 | 
			
		||||
                                    <div class="col-md-6">
 | 
			
		||||
                                        <label for="script_type" class="form-label">{"Script Type"}</label>
 | 
			
		||||
                                        <input 
 | 
			
		||||
                                            type="text" 
 | 
			
		||||
                                            class="form-control" 
 | 
			
		||||
                                            id="script_type"
 | 
			
		||||
                                            value={form.script_type.clone()}
 | 
			
		||||
                                            onchange={on_script_type_change}
 | 
			
		||||
                                            required=true
 | 
			
		||||
                                            placeholder="e.g., rhai, bash, python"
 | 
			
		||||
                                        />
 | 
			
		||||
                                        <div class="form-text">{"Type of scripts this runner will execute"}</div>
 | 
			
		||||
                                    </div>
 | 
			
		||||
                                </div>
 | 
			
		||||
 | 
			
		||||
                                <div class="row mb-3">
 | 
			
		||||
                                    <div class="col-md-6">
 | 
			
		||||
                                        <label for="process_manager" class="form-label">{"Process Manager"}</label>
 | 
			
		||||
                                        <select 
 | 
			
		||||
                                            class="form-select" 
 | 
			
		||||
                                            id="process_manager"
 | 
			
		||||
                                            onchange={on_process_manager_change}
 | 
			
		||||
                                        >
 | 
			
		||||
                                            <option value="Simple" selected={matches!(form.process_manager_type, ProcessManagerType::Simple)}>
 | 
			
		||||
                                                {"Simple"}
 | 
			
		||||
                                            </option>
 | 
			
		||||
                                            <option value="Tmux" selected={matches!(form.process_manager_type, ProcessManagerType::Tmux)}>
 | 
			
		||||
                                                {"Tmux"}
 | 
			
		||||
                                            </option>
 | 
			
		||||
                                        </select>
 | 
			
		||||
                                        <div class="form-text">{"Process management system to use"}</div>
 | 
			
		||||
                                    </div>
 | 
			
		||||
                                </div>
 | 
			
		||||
 | 
			
		||||
                                <div class="d-flex justify-content-end gap-2">
 | 
			
		||||
                                    <button type="button" class="btn btn-outline-secondary" onclick={on_cancel.clone()}>
 | 
			
		||||
                                        {"Cancel"}
 | 
			
		||||
                                    </button>
 | 
			
		||||
                                    <button type="submit" class="btn btn-primary" disabled={*loading}>
 | 
			
		||||
                                        if *loading {
 | 
			
		||||
                                            <div class="spinner-border spinner-border-sm me-2" role="status">
 | 
			
		||||
                                                <span class="visually-hidden">{"Loading..."}</span>
 | 
			
		||||
                                            </div>
 | 
			
		||||
                                            {"Adding Runner..."}
 | 
			
		||||
                                        } else {
 | 
			
		||||
                                            <i class="bi bi-plus-circle me-1"></i>
 | 
			
		||||
                                            {"Add Runner"}
 | 
			
		||||
                                        }
 | 
			
		||||
                                    </button>
 | 
			
		||||
                                </div>
 | 
			
		||||
                            </form>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										294
									
								
								clients/admin-ui/src/components/dashboard.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										294
									
								
								clients/admin-ui/src/components/dashboard.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,294 @@
 | 
			
		||||
use yew::prelude::*;
 | 
			
		||||
use gloo::console;
 | 
			
		||||
use wasm_bindgen_futures::spawn_local;
 | 
			
		||||
 | 
			
		||||
use crate::types::{RunnerInfo, ProcessStatus};
 | 
			
		||||
use crate::components::{status_badge::StatusBadge, runner_card::RunnerCard};
 | 
			
		||||
use crate::services::{SupervisorService, use_supervisor_service};
 | 
			
		||||
 | 
			
		||||
#[function_component(Dashboard)]
 | 
			
		||||
pub fn dashboard() -> Html {
 | 
			
		||||
    let server_url = "http://localhost:8081"; // Default supervisor server URL
 | 
			
		||||
    let (service, service_error) = use_supervisor_service(server_url);
 | 
			
		||||
    let runners = use_state(|| Vec::<RunnerInfo>::new());
 | 
			
		||||
    let loading = use_state(|| false);
 | 
			
		||||
    let error = use_state(|| None::<String>);
 | 
			
		||||
 | 
			
		||||
    // Load runners on component mount and when service is available
 | 
			
		||||
    {
 | 
			
		||||
        let runners = runners.clone();
 | 
			
		||||
        let loading = loading.clone();
 | 
			
		||||
        let error = error.clone();
 | 
			
		||||
        let service = service.clone();
 | 
			
		||||
 | 
			
		||||
        use_effect_with(service.clone(), move |service| {
 | 
			
		||||
            if let Some(service) = service {
 | 
			
		||||
                let runners = runners.clone();
 | 
			
		||||
                let loading = loading.clone();
 | 
			
		||||
                let error = error.clone();
 | 
			
		||||
                let service = service.clone();
 | 
			
		||||
 | 
			
		||||
                loading.set(true);
 | 
			
		||||
                spawn_local(async move {
 | 
			
		||||
                    match service.get_all_runners().await {
 | 
			
		||||
                        Ok(runner_list) => {
 | 
			
		||||
                            runners.set(runner_list);
 | 
			
		||||
                            error.set(None);
 | 
			
		||||
                        }
 | 
			
		||||
                        Err(e) => {
 | 
			
		||||
                            console::error!("Failed to load runners:", e.to_string());
 | 
			
		||||
                            error.set(Some(e.to_string()));
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    loading.set(false);
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let on_refresh = {
 | 
			
		||||
        let runners = runners.clone();
 | 
			
		||||
        let loading = loading.clone();
 | 
			
		||||
        let error = error.clone();
 | 
			
		||||
        let service = service.clone();
 | 
			
		||||
 | 
			
		||||
        Callback::from(move |_: MouseEvent| {
 | 
			
		||||
            if let Some(service) = &service {
 | 
			
		||||
                let runners = runners.clone();
 | 
			
		||||
                let loading = loading.clone();
 | 
			
		||||
                let error = error.clone();
 | 
			
		||||
                let service = service.clone();
 | 
			
		||||
 | 
			
		||||
                loading.set(true);
 | 
			
		||||
                spawn_local(async move {
 | 
			
		||||
                    match service.get_all_runners().await {
 | 
			
		||||
                        Ok(runner_list) => {
 | 
			
		||||
                            runners.set(runner_list);
 | 
			
		||||
                            error.set(None);
 | 
			
		||||
                        }
 | 
			
		||||
                        Err(e) => {
 | 
			
		||||
                            console::error!("Failed to refresh runners:", e.to_string());
 | 
			
		||||
                            error.set(Some(e.to_string()));
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    loading.set(false);
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let on_start_all = {
 | 
			
		||||
        let service = service.clone();
 | 
			
		||||
        let on_refresh = on_refresh.clone();
 | 
			
		||||
        let loading = loading.clone();
 | 
			
		||||
 | 
			
		||||
        Callback::from(move |_: MouseEvent| {
 | 
			
		||||
            if let Some(service) = &service {
 | 
			
		||||
                let service = service.clone();
 | 
			
		||||
                let on_refresh = on_refresh.clone();
 | 
			
		||||
                let loading = loading.clone();
 | 
			
		||||
 | 
			
		||||
                loading.set(true);
 | 
			
		||||
                spawn_local(async move {
 | 
			
		||||
                    match service.start_all().await {
 | 
			
		||||
                        Ok(results) => {
 | 
			
		||||
                            console::log!("Start all results:", format!("{:?}", results));
 | 
			
		||||
                            on_refresh.emit(web_sys::MouseEvent::new("click").unwrap());
 | 
			
		||||
                        }
 | 
			
		||||
                        Err(e) => {
 | 
			
		||||
                            console::error!("Failed to start all runners:", e.to_string());
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    loading.set(false);
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let on_stop_all = {
 | 
			
		||||
        let service = service.clone();
 | 
			
		||||
        let on_refresh = on_refresh.clone();
 | 
			
		||||
        let loading = loading.clone();
 | 
			
		||||
 | 
			
		||||
        Callback::from(move |_: MouseEvent| {
 | 
			
		||||
            if let Some(service) = &service {
 | 
			
		||||
                if gloo::dialogs::confirm("Are you sure you want to stop all runners?") {
 | 
			
		||||
                    let service = service.clone();
 | 
			
		||||
                    let on_refresh = on_refresh.clone();
 | 
			
		||||
                    let loading = loading.clone();
 | 
			
		||||
 | 
			
		||||
                    loading.set(true);
 | 
			
		||||
                    spawn_local(async move {
 | 
			
		||||
                        match service.stop_all(false).await {
 | 
			
		||||
                            Ok(results) => {
 | 
			
		||||
                                console::log!("Stop all results:", format!("{:?}", results));
 | 
			
		||||
                                on_refresh.emit(web_sys::MouseEvent::new("click").unwrap());
 | 
			
		||||
                            }
 | 
			
		||||
                            Err(e) => {
 | 
			
		||||
                                console::error!("Failed to stop all runners:", e.to_string());
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                        loading.set(false);
 | 
			
		||||
                    });
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Create a proper on_update callback for RunnerCard
 | 
			
		||||
    let on_runner_update = {
 | 
			
		||||
        let on_refresh = on_refresh.clone();
 | 
			
		||||
        Callback::from(move |_: ()| {
 | 
			
		||||
            on_refresh.emit(web_sys::MouseEvent::new("click").unwrap());
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Calculate statistics
 | 
			
		||||
    let total_runners = runners.len();
 | 
			
		||||
    let running_count = runners.iter().filter(|r| r.status == ProcessStatus::Running).count();
 | 
			
		||||
    let stopped_count = runners.iter().filter(|r| r.status == ProcessStatus::Stopped).count();
 | 
			
		||||
    let failed_count = runners.iter().filter(|r| r.status == ProcessStatus::Failed).count();
 | 
			
		||||
 | 
			
		||||
    html! {
 | 
			
		||||
        <div class="container-fluid">
 | 
			
		||||
            <div class="row mb-4">
 | 
			
		||||
                <div class="col-12">
 | 
			
		||||
                    <div class="d-flex justify-content-between align-items-center">
 | 
			
		||||
                        <h1 class="h3 mb-0">
 | 
			
		||||
                            <i class="bi bi-speedometer2 me-2"></i>
 | 
			
		||||
                            {"Dashboard"}
 | 
			
		||||
                        </h1>
 | 
			
		||||
                        <div class="btn-group">
 | 
			
		||||
                            <button class="btn btn-outline-primary" onclick={on_refresh} disabled={*loading}>
 | 
			
		||||
                                <i class="bi bi-arrow-clockwise me-1"></i>
 | 
			
		||||
                                {"Refresh"}
 | 
			
		||||
                            </button>
 | 
			
		||||
                            <button class="btn btn-outline-success" onclick={on_start_all} disabled={*loading || total_runners == 0}>
 | 
			
		||||
                                <i class="bi bi-play-fill me-1"></i>
 | 
			
		||||
                                {"Start All"}
 | 
			
		||||
                            </button>
 | 
			
		||||
                            <button class="btn btn-outline-warning" onclick={on_stop_all} disabled={*loading || total_runners == 0}>
 | 
			
		||||
                                <i class="bi bi-stop-fill me-1"></i>
 | 
			
		||||
                                {"Stop All"}
 | 
			
		||||
                            </button>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            // Error display
 | 
			
		||||
            if let Some(err) = service_error {
 | 
			
		||||
                <div class="row mb-4">
 | 
			
		||||
                    <div class="col-12">
 | 
			
		||||
                        <div class="alert alert-danger">
 | 
			
		||||
                            <i class="bi bi-exclamation-triangle-fill me-2"></i>
 | 
			
		||||
                            {"Service Error: "}{err}
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if let Some(err) = error.as_ref() {
 | 
			
		||||
                <div class="row mb-4">
 | 
			
		||||
                    <div class="col-12">
 | 
			
		||||
                        <div class="alert alert-warning">
 | 
			
		||||
                            <i class="bi bi-exclamation-triangle-fill me-2"></i>
 | 
			
		||||
                            {"Error: "}{err}
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Statistics cards
 | 
			
		||||
            <div class="row mb-4">
 | 
			
		||||
                <div class="col-md-3 mb-3">
 | 
			
		||||
                    <div class="card text-center">
 | 
			
		||||
                        <div class="card-body">
 | 
			
		||||
                            <h2 class="card-title text-primary">{total_runners}</h2>
 | 
			
		||||
                            <p class="card-text">{"Total Runners"}</p>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="col-md-3 mb-3">
 | 
			
		||||
                    <div class="card text-center">
 | 
			
		||||
                        <div class="card-body">
 | 
			
		||||
                            <h2 class="card-title text-success">{running_count}</h2>
 | 
			
		||||
                            <p class="card-text">{"Running"}</p>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="col-md-3 mb-3">
 | 
			
		||||
                    <div class="card text-center">
 | 
			
		||||
                        <div class="card-body">
 | 
			
		||||
                            <h2 class="card-title text-warning">{stopped_count}</h2>
 | 
			
		||||
                            <p class="card-text">{"Stopped"}</p>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="col-md-3 mb-3">
 | 
			
		||||
                    <div class="card text-center">
 | 
			
		||||
                        <div class="card-body">
 | 
			
		||||
                            <h2 class="card-title text-danger">{failed_count}</h2>
 | 
			
		||||
                            <p class="card-text">{"Failed"}</p>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            // Loading state
 | 
			
		||||
            if *loading {
 | 
			
		||||
                <div class="row">
 | 
			
		||||
                    <div class="col-12 text-center">
 | 
			
		||||
                        <div class="spinner-border text-primary" role="status">
 | 
			
		||||
                            <span class="visually-hidden">{"Loading..."}</span>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <p class="mt-2">{"Loading runners..."}</p>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Runners grid
 | 
			
		||||
            if !*loading && total_runners > 0 {
 | 
			
		||||
                <div class="row mb-4">
 | 
			
		||||
                    <div class="col-12">
 | 
			
		||||
                        <h4>{"Active Runners"}</h4>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="row">
 | 
			
		||||
                    {for runners.iter().map(|runner| {
 | 
			
		||||
                        if let Some(service) = &service {
 | 
			
		||||
                            html! {
 | 
			
		||||
                                <RunnerCard 
 | 
			
		||||
                                    runner={runner.clone()} 
 | 
			
		||||
                                    service={service.clone()}
 | 
			
		||||
                                    on_update={on_runner_update.clone()}
 | 
			
		||||
                                />
 | 
			
		||||
                            }
 | 
			
		||||
                        } else {
 | 
			
		||||
                            html! {}
 | 
			
		||||
                        }
 | 
			
		||||
                    })}
 | 
			
		||||
                </div>
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Empty state
 | 
			
		||||
            if !*loading && total_runners == 0 && service.is_some() {
 | 
			
		||||
                <div class="row">
 | 
			
		||||
                    <div class="col-12 text-center">
 | 
			
		||||
                        <div class="card">
 | 
			
		||||
                            <div class="card-body py-5">
 | 
			
		||||
                                <i class="bi bi-cpu display-1 text-muted mb-3"></i>
 | 
			
		||||
                                <h4 class="text-muted">{"No Runners Found"}</h4>
 | 
			
		||||
                                <p class="text-muted">{"Get started by adding your first runner."}</p>
 | 
			
		||||
                                <a href="/runners/add" class="btn btn-primary">
 | 
			
		||||
                                    <i class="bi bi-plus-circle me-1"></i>
 | 
			
		||||
                                    {"Add Runner"}
 | 
			
		||||
                                </a>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            }
 | 
			
		||||
        </div>
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										7
									
								
								clients/admin-ui/src/components/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								clients/admin-ui/src/components/mod.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
			
		||||
pub mod navbar;
 | 
			
		||||
pub mod dashboard;
 | 
			
		||||
pub mod runners;
 | 
			
		||||
pub mod runner_detail;
 | 
			
		||||
pub mod add_runner;
 | 
			
		||||
pub mod runner_card;
 | 
			
		||||
pub mod status_badge;
 | 
			
		||||
							
								
								
									
										67
									
								
								clients/admin-ui/src/components/navbar.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								clients/admin-ui/src/components/navbar.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,67 @@
 | 
			
		||||
use yew::prelude::*;
 | 
			
		||||
use yew_router::prelude::*;
 | 
			
		||||
use crate::app::Route;
 | 
			
		||||
 | 
			
		||||
#[function_component(Navbar)]
 | 
			
		||||
pub fn navbar() -> Html {
 | 
			
		||||
    let navigator = use_navigator().unwrap();
 | 
			
		||||
 | 
			
		||||
    let on_dashboard_click = {
 | 
			
		||||
        let navigator = navigator.clone();
 | 
			
		||||
        Callback::from(move |_| navigator.push(&Route::Dashboard))
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let on_runners_click = {
 | 
			
		||||
        let navigator = navigator.clone();
 | 
			
		||||
        Callback::from(move |_| navigator.push(&Route::Runners))
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let on_add_runner_click = {
 | 
			
		||||
        let navigator = navigator.clone();
 | 
			
		||||
        Callback::from(move |_| navigator.push(&Route::AddRunner))
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    html! {
 | 
			
		||||
        <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
 | 
			
		||||
            <div class="container-fluid">
 | 
			
		||||
                <a class="navbar-brand" href="#">
 | 
			
		||||
                    <i class="bi bi-gear-fill me-2"></i>
 | 
			
		||||
                    {"Hero Supervisor Admin"}
 | 
			
		||||
                </a>
 | 
			
		||||
                
 | 
			
		||||
                <button class="navbar-toggler" type="button" data-bs-toggle="collapse" 
 | 
			
		||||
                        data-bs-target="#navbarNav" aria-controls="navbarNav" 
 | 
			
		||||
                        aria-expanded="false" aria-label="Toggle navigation">
 | 
			
		||||
                    <span class="navbar-toggler-icon"></span>
 | 
			
		||||
                </button>
 | 
			
		||||
                
 | 
			
		||||
                <div class="collapse navbar-collapse" id="navbarNav">
 | 
			
		||||
                    <ul class="navbar-nav me-auto">
 | 
			
		||||
                        <li class="nav-item">
 | 
			
		||||
                            <button class="nav-link btn btn-link" onclick={on_dashboard_click}>
 | 
			
		||||
                                <i class="bi bi-speedometer2 me-1"></i>
 | 
			
		||||
                                {"Dashboard"}
 | 
			
		||||
                            </button>
 | 
			
		||||
                        </li>
 | 
			
		||||
                        <li class="nav-item">
 | 
			
		||||
                            <button class="nav-link btn btn-link" onclick={on_runners_click}>
 | 
			
		||||
                                <i class="bi bi-cpu me-1"></i>
 | 
			
		||||
                                {"Runners"}
 | 
			
		||||
                            </button>
 | 
			
		||||
                        </li>
 | 
			
		||||
                        <li class="nav-item">
 | 
			
		||||
                            <button class="nav-link btn btn-link" onclick={on_add_runner_click}>
 | 
			
		||||
                                <i class="bi bi-plus-circle me-1"></i>
 | 
			
		||||
                                {"Add Runner"}
 | 
			
		||||
                            </button>
 | 
			
		||||
                        </li>
 | 
			
		||||
                    </ul>
 | 
			
		||||
                    
 | 
			
		||||
                    <div class="navbar-text">
 | 
			
		||||
                        <small class="text-muted">{"Connected to Supervisor"}</small>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </nav>
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										191
									
								
								clients/admin-ui/src/components/runner_card.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										191
									
								
								clients/admin-ui/src/components/runner_card.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,191 @@
 | 
			
		||||
use yew::prelude::*;
 | 
			
		||||
use yew_router::prelude::*;
 | 
			
		||||
use gloo::console;
 | 
			
		||||
use wasm_bindgen_futures::spawn_local;
 | 
			
		||||
 | 
			
		||||
use crate::app::Route;
 | 
			
		||||
use crate::types::{RunnerInfo, ProcessStatus};
 | 
			
		||||
use crate::components::status_badge::StatusBadge;
 | 
			
		||||
use crate::services::SupervisorService;
 | 
			
		||||
 | 
			
		||||
#[derive(Properties, PartialEq)]
 | 
			
		||||
pub struct RunnerCardProps {
 | 
			
		||||
    pub runner: RunnerInfo,
 | 
			
		||||
    pub service: SupervisorService,
 | 
			
		||||
    pub on_update: Callback<()>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[function_component(RunnerCard)]
 | 
			
		||||
pub fn runner_card(props: &RunnerCardProps) -> Html {
 | 
			
		||||
    let navigator = use_navigator().unwrap();
 | 
			
		||||
    let loading = use_state(|| false);
 | 
			
		||||
 | 
			
		||||
    let runner_id = props.runner.id.clone();
 | 
			
		||||
    let on_view_details = {
 | 
			
		||||
        let navigator = navigator.clone();
 | 
			
		||||
        let runner_id = runner_id.clone();
 | 
			
		||||
        Callback::from(move |_| {
 | 
			
		||||
            navigator.push(&Route::RunnerDetail { id: runner_id.clone() });
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let on_start = {
 | 
			
		||||
        let service = props.service.clone();
 | 
			
		||||
        let runner_id = runner_id.clone();
 | 
			
		||||
        let loading = loading.clone();
 | 
			
		||||
        let on_update = props.on_update.clone();
 | 
			
		||||
        Callback::from(move |_| {
 | 
			
		||||
            let service = service.clone();
 | 
			
		||||
            let runner_id = runner_id.clone();
 | 
			
		||||
            let loading = loading.clone();
 | 
			
		||||
            let on_update = on_update.clone();
 | 
			
		||||
            
 | 
			
		||||
            loading.set(true);
 | 
			
		||||
            spawn_local(async move {
 | 
			
		||||
                match service.start_runner(&runner_id).await {
 | 
			
		||||
                    Ok(_) => {
 | 
			
		||||
                        console::log!("Runner started successfully");
 | 
			
		||||
                        on_update.emit(());
 | 
			
		||||
                    }
 | 
			
		||||
                    Err(e) => {
 | 
			
		||||
                        console::error!("Failed to start runner:", e.to_string());
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                loading.set(false);
 | 
			
		||||
            });
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let on_stop = {
 | 
			
		||||
        let service = props.service.clone();
 | 
			
		||||
        let runner_id = runner_id.clone();
 | 
			
		||||
        let loading = loading.clone();
 | 
			
		||||
        let on_update = props.on_update.clone();
 | 
			
		||||
        Callback::from(move |_| {
 | 
			
		||||
            let service = service.clone();
 | 
			
		||||
            let runner_id = runner_id.clone();
 | 
			
		||||
            let loading = loading.clone();
 | 
			
		||||
            let on_update = on_update.clone();
 | 
			
		||||
            
 | 
			
		||||
            loading.set(true);
 | 
			
		||||
            spawn_local(async move {
 | 
			
		||||
                match service.stop_runner(&runner_id, false).await {
 | 
			
		||||
                    Ok(_) => {
 | 
			
		||||
                        console::log!("Runner stopped successfully");
 | 
			
		||||
                        on_update.emit(());
 | 
			
		||||
                    }
 | 
			
		||||
                    Err(e) => {
 | 
			
		||||
                        console::error!("Failed to stop runner:", e.to_string());
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                loading.set(false);
 | 
			
		||||
            });
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let on_remove = {
 | 
			
		||||
        let service = props.service.clone();
 | 
			
		||||
        let runner_id = runner_id.clone();
 | 
			
		||||
        let loading = loading.clone();
 | 
			
		||||
        let on_update = props.on_update.clone();
 | 
			
		||||
        Callback::from(move |_| {
 | 
			
		||||
            if gloo::dialogs::confirm("Are you sure you want to remove this runner?") {
 | 
			
		||||
                let service = service.clone();
 | 
			
		||||
                let runner_id = runner_id.clone();
 | 
			
		||||
                let loading = loading.clone();
 | 
			
		||||
                let on_update = on_update.clone();
 | 
			
		||||
                
 | 
			
		||||
                loading.set(true);
 | 
			
		||||
                spawn_local(async move {
 | 
			
		||||
                    match service.remove_runner(&runner_id).await {
 | 
			
		||||
                        Ok(_) => {
 | 
			
		||||
                            console::log!("Runner removed successfully");
 | 
			
		||||
                            on_update.emit(());
 | 
			
		||||
                        }
 | 
			
		||||
                        Err(e) => {
 | 
			
		||||
                            console::error!("Failed to remove runner:", e.to_string());
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    loading.set(false);
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let is_loading = *loading;
 | 
			
		||||
    let can_start = matches!(props.runner.status, ProcessStatus::Stopped | ProcessStatus::Failed);
 | 
			
		||||
    let can_stop = matches!(props.runner.status, ProcessStatus::Running);
 | 
			
		||||
 | 
			
		||||
    html! {
 | 
			
		||||
        <div class="col-md-6 col-lg-4 mb-4">
 | 
			
		||||
            <div class="card h-100">
 | 
			
		||||
                <div class="card-header d-flex justify-content-between align-items-center">
 | 
			
		||||
                    <h6 class="mb-0">
 | 
			
		||||
                        <i class="bi bi-cpu me-2"></i>
 | 
			
		||||
                        {&props.runner.id}
 | 
			
		||||
                    </h6>
 | 
			
		||||
                    <StatusBadge status={props.runner.status.clone()} />
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="card-body">
 | 
			
		||||
                    <div class="mb-2">
 | 
			
		||||
                        <small class="text-muted">{"Type: "}</small>
 | 
			
		||||
                        <span class="badge bg-info">
 | 
			
		||||
                            {format!("{:?}", props.runner.config.runner_type)}
 | 
			
		||||
                        </span>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="mb-2">
 | 
			
		||||
                        <small class="text-muted">{"Script: "}</small>
 | 
			
		||||
                        <code class="small">{&props.runner.config.script_type}</code>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="mb-3">
 | 
			
		||||
                        <small class="text-muted">{"Binary: "}</small>
 | 
			
		||||
                        <code class="small">{props.runner.config.binary_path.to_string_lossy()}</code>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    
 | 
			
		||||
                    if !props.runner.logs.is_empty() {
 | 
			
		||||
                        <div class="mb-3">
 | 
			
		||||
                            <small class="text-muted">{"Recent logs: "}</small>
 | 
			
		||||
                            <div class="log-container p-2 rounded small">
 | 
			
		||||
                                {for props.runner.logs.iter().take(3).map(|log| html! {
 | 
			
		||||
                                    <div>{&log.message}</div>
 | 
			
		||||
                                })}
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    }
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="card-footer">
 | 
			
		||||
                    <div class="btn-group w-100" role="group">
 | 
			
		||||
                        if can_start && !is_loading {
 | 
			
		||||
                            <button class="btn btn-outline-success btn-sm" onclick={on_start}>
 | 
			
		||||
                                <i class="bi bi-play-fill me-1"></i>
 | 
			
		||||
                                {"Start"}
 | 
			
		||||
                            </button>
 | 
			
		||||
                        }
 | 
			
		||||
                        if can_stop && !is_loading {
 | 
			
		||||
                            <button class="btn btn-outline-warning btn-sm" onclick={on_stop}>
 | 
			
		||||
                                <i class="bi bi-stop-fill me-1"></i>
 | 
			
		||||
                                {"Stop"}
 | 
			
		||||
                            </button>
 | 
			
		||||
                        }
 | 
			
		||||
                        if is_loading {
 | 
			
		||||
                            <button class="btn btn-outline-secondary btn-sm" disabled=true>
 | 
			
		||||
                                <div class="spinner-border spinner-border-sm me-1" role="status">
 | 
			
		||||
                                    <span class="visually-hidden">{"Loading..."}</span>
 | 
			
		||||
                                </div>
 | 
			
		||||
                                {"Working..."}
 | 
			
		||||
                            </button>
 | 
			
		||||
                        }
 | 
			
		||||
                        <button class="btn btn-outline-primary btn-sm" onclick={on_view_details}>
 | 
			
		||||
                            <i class="bi bi-eye me-1"></i>
 | 
			
		||||
                            {"Details"}
 | 
			
		||||
                        </button>
 | 
			
		||||
                        <button class="btn btn-outline-danger btn-sm" onclick={on_remove} disabled={is_loading}>
 | 
			
		||||
                            <i class="bi bi-trash me-1"></i>
 | 
			
		||||
                            {"Remove"}
 | 
			
		||||
                        </button>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										437
									
								
								clients/admin-ui/src/components/runner_detail.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										437
									
								
								clients/admin-ui/src/components/runner_detail.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,437 @@
 | 
			
		||||
use yew::prelude::*;
 | 
			
		||||
use yew_router::prelude::*;
 | 
			
		||||
use gloo::console;
 | 
			
		||||
use wasm_bindgen_futures::spawn_local;
 | 
			
		||||
use web_sys::HtmlTextAreaElement;
 | 
			
		||||
 | 
			
		||||
use crate::app::Route;
 | 
			
		||||
use crate::types::{RunnerInfo, ProcessStatus, JobBuilder, JobType};
 | 
			
		||||
use crate::components::status_badge::StatusBadge;
 | 
			
		||||
use crate::services::{SupervisorService, use_supervisor_service};
 | 
			
		||||
 | 
			
		||||
#[derive(Properties, PartialEq)]
 | 
			
		||||
pub struct RunnerDetailProps {
 | 
			
		||||
    pub runner_id: String,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[function_component(RunnerDetail)]
 | 
			
		||||
pub fn runner_detail(props: &RunnerDetailProps) -> Html {
 | 
			
		||||
    let navigator = use_navigator().unwrap();
 | 
			
		||||
    let server_url = "http://localhost:8081";
 | 
			
		||||
    let (service, service_error) = use_supervisor_service(server_url);
 | 
			
		||||
    
 | 
			
		||||
    let runner = use_state(|| None::<RunnerInfo>);
 | 
			
		||||
    let loading = use_state(|| false);
 | 
			
		||||
    let error = use_state(|| None::<String>);
 | 
			
		||||
    let logs_loading = use_state(|| false);
 | 
			
		||||
    let job_script = use_state(|| String::new());
 | 
			
		||||
    let job_loading = use_state(|| false);
 | 
			
		||||
    let job_result = use_state(|| None::<String>);
 | 
			
		||||
 | 
			
		||||
    // Load runner details
 | 
			
		||||
    {
 | 
			
		||||
        let runner = runner.clone();
 | 
			
		||||
        let loading = loading.clone();
 | 
			
		||||
        let error = error.clone();
 | 
			
		||||
        let service = service.clone();
 | 
			
		||||
        let runner_id = props.runner_id.clone();
 | 
			
		||||
 | 
			
		||||
        use_effect_with((service.clone(), runner_id.clone()), move |(service, runner_id)| {
 | 
			
		||||
            if let Some(service) = service {
 | 
			
		||||
                let runner = runner.clone();
 | 
			
		||||
                let loading = loading.clone();
 | 
			
		||||
                let error = error.clone();
 | 
			
		||||
                let service = service.clone();
 | 
			
		||||
                let runner_id = runner_id.clone();
 | 
			
		||||
 | 
			
		||||
                loading.set(true);
 | 
			
		||||
                spawn_local(async move {
 | 
			
		||||
                    match service.get_all_runners().await {
 | 
			
		||||
                        Ok(runners) => {
 | 
			
		||||
                            if let Some(found_runner) = runners.into_iter().find(|r| r.id == runner_id) {
 | 
			
		||||
                                runner.set(Some(found_runner));
 | 
			
		||||
                                error.set(None);
 | 
			
		||||
                            } else {
 | 
			
		||||
                                error.set(Some("Runner not found".to_string()));
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                        Err(e) => {
 | 
			
		||||
                            console::error!("Failed to load runner:", e.to_string());
 | 
			
		||||
                            error.set(Some(e.to_string()));
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    loading.set(false);
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let on_back = {
 | 
			
		||||
        let navigator = navigator.clone();
 | 
			
		||||
        Callback::from(move |_| navigator.push(&Route::Runners))
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let on_start = {
 | 
			
		||||
        let service = service.clone();
 | 
			
		||||
        let runner_id = props.runner_id.clone();
 | 
			
		||||
        let runner = runner.clone();
 | 
			
		||||
        let loading = loading.clone();
 | 
			
		||||
 | 
			
		||||
        Callback::from(move |_| {
 | 
			
		||||
            if let Some(service) = &service {
 | 
			
		||||
                let service = service.clone();
 | 
			
		||||
                let runner_id = runner_id.clone();
 | 
			
		||||
                let runner = runner.clone();
 | 
			
		||||
                let loading = loading.clone();
 | 
			
		||||
 | 
			
		||||
                loading.set(true);
 | 
			
		||||
                spawn_local(async move {
 | 
			
		||||
                    match service.start_runner(&runner_id).await {
 | 
			
		||||
                        Ok(_) => {
 | 
			
		||||
                            console::log!("Runner started successfully");
 | 
			
		||||
                            // Refresh runner status
 | 
			
		||||
                            if let Ok(status) = service.get_runner_status(&runner_id).await {
 | 
			
		||||
                                if let Some(mut current_runner) = (*runner).clone() {
 | 
			
		||||
                                    current_runner.status = status;
 | 
			
		||||
                                    runner.set(Some(current_runner));
 | 
			
		||||
                                }
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                        Err(e) => {
 | 
			
		||||
                            console::error!("Failed to start runner:", e.to_string());
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    loading.set(false);
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let on_stop = {
 | 
			
		||||
        let service = service.clone();
 | 
			
		||||
        let runner_id = props.runner_id.clone();
 | 
			
		||||
        let runner = runner.clone();
 | 
			
		||||
        let loading = loading.clone();
 | 
			
		||||
 | 
			
		||||
        Callback::from(move |_| {
 | 
			
		||||
            if let Some(service) = &service {
 | 
			
		||||
                let service = service.clone();
 | 
			
		||||
                let runner_id = runner_id.clone();
 | 
			
		||||
                let runner = runner.clone();
 | 
			
		||||
                let loading = loading.clone();
 | 
			
		||||
 | 
			
		||||
                loading.set(true);
 | 
			
		||||
                spawn_local(async move {
 | 
			
		||||
                    match service.stop_runner(&runner_id, false).await {
 | 
			
		||||
                        Ok(_) => {
 | 
			
		||||
                            console::log!("Runner stopped successfully");
 | 
			
		||||
                            // Refresh runner status
 | 
			
		||||
                            if let Ok(status) = service.get_runner_status(&runner_id).await {
 | 
			
		||||
                                if let Some(mut current_runner) = (*runner).clone() {
 | 
			
		||||
                                    current_runner.status = status;
 | 
			
		||||
                                    runner.set(Some(current_runner));
 | 
			
		||||
                                }
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                        Err(e) => {
 | 
			
		||||
                            console::error!("Failed to stop runner:", e.to_string());
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    loading.set(false);
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let on_refresh_logs = {
 | 
			
		||||
        let service = service.clone();
 | 
			
		||||
        let runner_id = props.runner_id.clone();
 | 
			
		||||
        let runner = runner.clone();
 | 
			
		||||
        let logs_loading = logs_loading.clone();
 | 
			
		||||
 | 
			
		||||
        Callback::from(move |_| {
 | 
			
		||||
            if let Some(service) = &service {
 | 
			
		||||
                let service = service.clone();
 | 
			
		||||
                let runner_id = runner_id.clone();
 | 
			
		||||
                let runner = runner.clone();
 | 
			
		||||
                let logs_loading = logs_loading.clone();
 | 
			
		||||
 | 
			
		||||
                logs_loading.set(true);
 | 
			
		||||
                spawn_local(async move {
 | 
			
		||||
                    match service.get_runner_logs(&runner_id, Some(100), false).await {
 | 
			
		||||
                        Ok(logs) => {
 | 
			
		||||
                            if let Some(mut current_runner) = (*runner).clone() {
 | 
			
		||||
                                current_runner.logs = logs;
 | 
			
		||||
                                runner.set(Some(current_runner));
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                        Err(e) => {
 | 
			
		||||
                            console::error!("Failed to refresh logs:", e.to_string());
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    logs_loading.set(false);
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let on_script_change = {
 | 
			
		||||
        let job_script = job_script.clone();
 | 
			
		||||
        Callback::from(move |e: Event| {
 | 
			
		||||
            let textarea: HtmlTextAreaElement = e.target_unchecked_into();
 | 
			
		||||
            job_script.set(textarea.value());
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let on_run_job = {
 | 
			
		||||
        let service = service.clone();
 | 
			
		||||
        let runner_id = props.runner_id.clone();
 | 
			
		||||
        let job_script = job_script.clone();
 | 
			
		||||
        let job_loading = job_loading.clone();
 | 
			
		||||
        let job_result = job_result.clone();
 | 
			
		||||
 | 
			
		||||
        Callback::from(move |_| {
 | 
			
		||||
            if let Some(service) = &service {
 | 
			
		||||
                let script = (*job_script).clone();
 | 
			
		||||
                if !script.trim().is_empty() {
 | 
			
		||||
                    let service = service.clone();
 | 
			
		||||
                    let runner_id = runner_id.clone();
 | 
			
		||||
                    let job_loading = job_loading.clone();
 | 
			
		||||
                    let job_result = job_result.clone();
 | 
			
		||||
 | 
			
		||||
                    job_loading.set(true);
 | 
			
		||||
                    job_result.set(None);
 | 
			
		||||
                    spawn_local(async move {
 | 
			
		||||
                        let job = JobBuilder::new()
 | 
			
		||||
                            .caller_id("admin-ui")
 | 
			
		||||
                            .context_id("test-job")
 | 
			
		||||
                            .payload(script)
 | 
			
		||||
                            .job_type(JobType::SAL)
 | 
			
		||||
                            .runner_name(&runner_id)
 | 
			
		||||
                            .build();
 | 
			
		||||
 | 
			
		||||
                        match job {
 | 
			
		||||
                            Ok(job) => {
 | 
			
		||||
                                match service.queue_and_wait(&runner_id, job, 30).await {
 | 
			
		||||
                                    Ok(result) => {
 | 
			
		||||
                                        job_result.set(result);
 | 
			
		||||
                                    }
 | 
			
		||||
                                    Err(e) => {
 | 
			
		||||
                                        job_result.set(Some(format!("Error: {}", e)));
 | 
			
		||||
                                    }
 | 
			
		||||
                                }
 | 
			
		||||
                            }
 | 
			
		||||
                            Err(e) => {
 | 
			
		||||
                                job_result.set(Some(format!("Job creation error: {}", e)));
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                        job_loading.set(false);
 | 
			
		||||
                    });
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    html! {
 | 
			
		||||
        <div class="container-fluid">
 | 
			
		||||
            <div class="row mb-4">
 | 
			
		||||
                <div class="col-12">
 | 
			
		||||
                    <div class="d-flex justify-content-between align-items-center">
 | 
			
		||||
                        <h1 class="h3 mb-0">
 | 
			
		||||
                            <i class="bi bi-cpu me-2"></i>
 | 
			
		||||
                            {"Runner Details: "}{&props.runner_id}
 | 
			
		||||
                        </h1>
 | 
			
		||||
                        <button class="btn btn-outline-secondary" onclick={on_back}>
 | 
			
		||||
                            <i class="bi bi-arrow-left me-1"></i>
 | 
			
		||||
                            {"Back to Runners"}
 | 
			
		||||
                        </button>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            // Error display
 | 
			
		||||
            if let Some(err) = service_error {
 | 
			
		||||
                <div class="row mb-4">
 | 
			
		||||
                    <div class="col-12">
 | 
			
		||||
                        <div class="alert alert-danger">
 | 
			
		||||
                            <i class="bi bi-exclamation-triangle-fill me-2"></i>
 | 
			
		||||
                            {"Service Error: "}{err}
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if let Some(err) = error.as_ref() {
 | 
			
		||||
                <div class="row mb-4">
 | 
			
		||||
                    <div class="col-12">
 | 
			
		||||
                        <div class="alert alert-warning">
 | 
			
		||||
                            <i class="bi bi-exclamation-triangle-fill me-2"></i>
 | 
			
		||||
                            {"Error: "}{err}
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Loading state
 | 
			
		||||
            if *loading {
 | 
			
		||||
                <div class="row">
 | 
			
		||||
                    <div class="col-12 text-center">
 | 
			
		||||
                        <div class="spinner-border text-primary" role="status">
 | 
			
		||||
                            <span class="visually-hidden">{"Loading..."}</span>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <p class="mt-2">{"Loading runner details..."}</p>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Runner details
 | 
			
		||||
            if let Some(runner_info) = runner.as_ref() {
 | 
			
		||||
                <div class="row">
 | 
			
		||||
                    // Left column - Runner info and controls
 | 
			
		||||
                    <div class="col-lg-6 mb-4">
 | 
			
		||||
                        <div class="card h-100">
 | 
			
		||||
                            <div class="card-header d-flex justify-content-between align-items-center">
 | 
			
		||||
                                <h5 class="mb-0">{"Runner Information"}</h5>
 | 
			
		||||
                                <StatusBadge status={runner_info.status.clone()} />
 | 
			
		||||
                            </div>
 | 
			
		||||
                            <div class="card-body">
 | 
			
		||||
                                <div class="row mb-3">
 | 
			
		||||
                                    <div class="col-sm-4"><strong>{"ID:"}</strong></div>
 | 
			
		||||
                                    <div class="col-sm-8"><code>{&runner_info.id}</code></div>
 | 
			
		||||
                                </div>
 | 
			
		||||
                                <div class="row mb-3">
 | 
			
		||||
                                    <div class="col-sm-4"><strong>{"Type:"}</strong></div>
 | 
			
		||||
                                    <div class="col-sm-8">
 | 
			
		||||
                                        <span class="badge bg-info">
 | 
			
		||||
                                            {format!("{:?}", runner_info.config.runner_type)}
 | 
			
		||||
                                        </span>
 | 
			
		||||
                                    </div>
 | 
			
		||||
                                </div>
 | 
			
		||||
                                <div class="row mb-3">
 | 
			
		||||
                                    <div class="col-sm-4"><strong>{"Script Type:"}</strong></div>
 | 
			
		||||
                                    <div class="col-sm-8"><code>{&runner_info.config.script_type}</code></div>
 | 
			
		||||
                                </div>
 | 
			
		||||
                                <div class="row mb-3">
 | 
			
		||||
                                    <div class="col-sm-4"><strong>{"Binary Path:"}</strong></div>
 | 
			
		||||
                                    <div class="col-sm-8"><code class="small">{runner_info.config.binary_path.to_string_lossy()}</code></div>
 | 
			
		||||
                                </div>
 | 
			
		||||
                                <div class="row mb-3">
 | 
			
		||||
                                    <div class="col-sm-4"><strong>{"Restart Policy:"}</strong></div>
 | 
			
		||||
                                    <div class="col-sm-8">{&runner_info.config.restart_policy}</div>
 | 
			
		||||
                                </div>
 | 
			
		||||
                            </div>
 | 
			
		||||
                            <div class="card-footer">
 | 
			
		||||
                                <div class="btn-group w-100">
 | 
			
		||||
                                    if matches!(runner_info.status, ProcessStatus::Stopped | ProcessStatus::Failed) && !*loading {
 | 
			
		||||
                                        <button class="btn btn-outline-success" onclick={on_start}>
 | 
			
		||||
                                            <i class="bi bi-play-fill me-1"></i>
 | 
			
		||||
                                            {"Start"}
 | 
			
		||||
                                        </button>
 | 
			
		||||
                                    }
 | 
			
		||||
                                    if matches!(runner_info.status, ProcessStatus::Running) && !*loading {
 | 
			
		||||
                                        <button class="btn btn-outline-warning" onclick={on_stop}>
 | 
			
		||||
                                            <i class="bi bi-stop-fill me-1"></i>
 | 
			
		||||
                                            {"Stop"}
 | 
			
		||||
                                        </button>
 | 
			
		||||
                                    }
 | 
			
		||||
                                    if *loading {
 | 
			
		||||
                                        <button class="btn btn-outline-secondary" disabled=true>
 | 
			
		||||
                                            <div class="spinner-border spinner-border-sm me-1" role="status">
 | 
			
		||||
                                                <span class="visually-hidden">{"Loading..."}</span>
 | 
			
		||||
                                            </div>
 | 
			
		||||
                                            {"Working..."}
 | 
			
		||||
                                        </button>
 | 
			
		||||
                                    }
 | 
			
		||||
                                </div>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
 | 
			
		||||
                    // Right column - Job execution
 | 
			
		||||
                    <div class="col-lg-6 mb-4">
 | 
			
		||||
                        <div class="card h-100">
 | 
			
		||||
                            <div class="card-header">
 | 
			
		||||
                                <h5 class="mb-0">{"Test Job Execution"}</h5>
 | 
			
		||||
                            </div>
 | 
			
		||||
                            <div class="card-body">
 | 
			
		||||
                                <div class="mb-3">
 | 
			
		||||
                                    <label for="job_script" class="form-label">{"Script Content"}</label>
 | 
			
		||||
                                    <textarea 
 | 
			
		||||
                                        class="form-control" 
 | 
			
		||||
                                        id="job_script"
 | 
			
		||||
                                        rows="6"
 | 
			
		||||
                                        value={(*job_script).clone()}
 | 
			
		||||
                                        onchange={on_script_change}
 | 
			
		||||
                                        placeholder="Enter script content to execute..."
 | 
			
		||||
                                    ></textarea>
 | 
			
		||||
                                </div>
 | 
			
		||||
                                <button 
 | 
			
		||||
                                    class="btn btn-primary w-100 mb-3" 
 | 
			
		||||
                                    onclick={on_run_job}
 | 
			
		||||
                                    disabled={*job_loading || job_script.trim().is_empty()}
 | 
			
		||||
                                >
 | 
			
		||||
                                    if *job_loading {
 | 
			
		||||
                                        <div class="spinner-border spinner-border-sm me-2" role="status">
 | 
			
		||||
                                            <span class="visually-hidden">{"Loading..."}</span>
 | 
			
		||||
                                        </div>
 | 
			
		||||
                                        {"Running Job..."}
 | 
			
		||||
                                    } else {
 | 
			
		||||
                                        <i class="bi bi-play-circle me-1"></i>
 | 
			
		||||
                                        {"Run Job"}
 | 
			
		||||
                                    }
 | 
			
		||||
                                </button>
 | 
			
		||||
                                
 | 
			
		||||
                                if let Some(result) = job_result.as_ref() {
 | 
			
		||||
                                    <div class="mb-3">
 | 
			
		||||
                                        <label class="form-label">{"Job Result"}</label>
 | 
			
		||||
                                        <div class="log-container p-3 rounded">
 | 
			
		||||
                                            <pre class="mb-0">{result}</pre>
 | 
			
		||||
                                        </div>
 | 
			
		||||
                                    </div>
 | 
			
		||||
                                }
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
 | 
			
		||||
                // Logs section
 | 
			
		||||
                <div class="row">
 | 
			
		||||
                    <div class="col-12">
 | 
			
		||||
                        <div class="card">
 | 
			
		||||
                            <div class="card-header d-flex justify-content-between align-items-center">
 | 
			
		||||
                                <h5 class="mb-0">{"Logs"}</h5>
 | 
			
		||||
                                <button class="btn btn-outline-primary btn-sm" onclick={on_refresh_logs} disabled={*logs_loading}>
 | 
			
		||||
                                    if *logs_loading {
 | 
			
		||||
                                        <div class="spinner-border spinner-border-sm me-1" role="status">
 | 
			
		||||
                                            <span class="visually-hidden">{"Loading..."}</span>
 | 
			
		||||
                                        </div>
 | 
			
		||||
                                        {"Refreshing..."}
 | 
			
		||||
                                    } else {
 | 
			
		||||
                                        <i class="bi bi-arrow-clockwise me-1"></i>
 | 
			
		||||
                                        {"Refresh Logs"}
 | 
			
		||||
                                    }
 | 
			
		||||
                                </button>
 | 
			
		||||
                            </div>
 | 
			
		||||
                            <div class="card-body p-0">
 | 
			
		||||
                                if runner_info.logs.is_empty() {
 | 
			
		||||
                                    <div class="p-4 text-center text-muted">
 | 
			
		||||
                                        {"No logs available"}
 | 
			
		||||
                                    </div>
 | 
			
		||||
                                } else {
 | 
			
		||||
                                    <div class="log-container p-3" style="max-height: 400px; overflow-y: auto;">
 | 
			
		||||
                                        {for runner_info.logs.iter().map(|log| html! {
 | 
			
		||||
                                            <div class="mb-1">
 | 
			
		||||
                                                <small class="text-muted me-2">{&log.timestamp}</small>
 | 
			
		||||
                                                {&log.message}
 | 
			
		||||
                                            </div>
 | 
			
		||||
                                        })}
 | 
			
		||||
                                    </div>
 | 
			
		||||
                                }
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            }
 | 
			
		||||
        </div>
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										278
									
								
								clients/admin-ui/src/components/runners.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										278
									
								
								clients/admin-ui/src/components/runners.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,278 @@
 | 
			
		||||
use yew::prelude::*;
 | 
			
		||||
use yew_router::prelude::*;
 | 
			
		||||
use gloo::console;
 | 
			
		||||
use wasm_bindgen_futures::spawn_local;
 | 
			
		||||
 | 
			
		||||
use crate::app::Route;
 | 
			
		||||
use crate::types::{RunnerInfo, ProcessStatus};
 | 
			
		||||
use crate::components::{status_badge::StatusBadge, runner_card::RunnerCard};
 | 
			
		||||
use crate::services::{SupervisorService, use_supervisor_service};
 | 
			
		||||
 | 
			
		||||
#[function_component(RunnersList)]
 | 
			
		||||
pub fn runners_list() -> Html {
 | 
			
		||||
    let navigator = use_navigator().unwrap();
 | 
			
		||||
    let server_url = "http://localhost:8081"; // Default supervisor server URL
 | 
			
		||||
    let (service, service_error) = use_supervisor_service(server_url);
 | 
			
		||||
    let runners = use_state(|| Vec::<RunnerInfo>::new());
 | 
			
		||||
    let loading = use_state(|| false);
 | 
			
		||||
    let error = use_state(|| None::<String>);
 | 
			
		||||
    let view_mode = use_state(|| "grid"); // "grid" or "table"
 | 
			
		||||
 | 
			
		||||
    // Load runners on component mount and when service is available
 | 
			
		||||
    {
 | 
			
		||||
        let runners = runners.clone();
 | 
			
		||||
        let loading = loading.clone();
 | 
			
		||||
        let error = error.clone();
 | 
			
		||||
        let service = service.clone();
 | 
			
		||||
 | 
			
		||||
        use_effect_with(service.clone(), move |service| {
 | 
			
		||||
            if let Some(service) = service {
 | 
			
		||||
                let runners = runners.clone();
 | 
			
		||||
                let loading = loading.clone();
 | 
			
		||||
                let error = error.clone();
 | 
			
		||||
                let service = service.clone();
 | 
			
		||||
 | 
			
		||||
                loading.set(true);
 | 
			
		||||
                spawn_local(async move {
 | 
			
		||||
                    match service.get_all_runners().await {
 | 
			
		||||
                        Ok(runner_list) => {
 | 
			
		||||
                            runners.set(runner_list);
 | 
			
		||||
                            error.set(None);
 | 
			
		||||
                        }
 | 
			
		||||
                        Err(e) => {
 | 
			
		||||
                            console::error!("Failed to load runners:", e.to_string());
 | 
			
		||||
                            error.set(Some(e.to_string()));
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    loading.set(false);
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let on_refresh = {
 | 
			
		||||
        let runners = runners.clone();
 | 
			
		||||
        let loading = loading.clone();
 | 
			
		||||
        let error = error.clone();
 | 
			
		||||
        let service = service.clone();
 | 
			
		||||
 | 
			
		||||
        Callback::from(move |_: MouseEvent| {
 | 
			
		||||
            if let Some(service) = &service {
 | 
			
		||||
                let runners = runners.clone();
 | 
			
		||||
                let loading = loading.clone();
 | 
			
		||||
                let error = error.clone();
 | 
			
		||||
                let service = service.clone();
 | 
			
		||||
 | 
			
		||||
                loading.set(true);
 | 
			
		||||
                spawn_local(async move {
 | 
			
		||||
                    match service.get_all_runners().await {
 | 
			
		||||
                        Ok(runner_list) => {
 | 
			
		||||
                            runners.set(runner_list);
 | 
			
		||||
                            error.set(None);
 | 
			
		||||
                        }
 | 
			
		||||
                        Err(e) => {
 | 
			
		||||
                            console::error!("Failed to refresh runners:", e.to_string());
 | 
			
		||||
                            error.set(Some(e.to_string()));
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    loading.set(false);
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let on_add_runner = {
 | 
			
		||||
        let navigator = navigator.clone();
 | 
			
		||||
        Callback::from(move |_: MouseEvent| navigator.push(&Route::AddRunner))
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let on_toggle_view = {
 | 
			
		||||
        let view_mode = view_mode.clone();
 | 
			
		||||
        Callback::from(move |_: MouseEvent| {
 | 
			
		||||
            let current: &str = view_mode.as_ref();
 | 
			
		||||
            view_mode.set(if current == "grid" { "table" } else { "grid" });
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Create a separate callback for runner updates that matches the expected signature
 | 
			
		||||
    let on_runner_update = {
 | 
			
		||||
        let on_refresh = on_refresh.clone();
 | 
			
		||||
        Callback::from(move |_: ()| {
 | 
			
		||||
            on_refresh.emit(web_sys::MouseEvent::new("click").unwrap());
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    html! {
 | 
			
		||||
        <div class="container-fluid">
 | 
			
		||||
            <div class="row mb-4">
 | 
			
		||||
                <div class="col-12">
 | 
			
		||||
                    <div class="d-flex justify-content-between align-items-center">
 | 
			
		||||
                        <h1 class="h3 mb-0">
 | 
			
		||||
                            <i class="bi bi-cpu me-2"></i>
 | 
			
		||||
                            {"Runners"}
 | 
			
		||||
                        </h1>
 | 
			
		||||
                        <div class="btn-group">
 | 
			
		||||
                            <button class="btn btn-outline-secondary" onclick={on_toggle_view}>
 | 
			
		||||
                                if *view_mode == "grid" {
 | 
			
		||||
                                    <i class="bi bi-table me-1"></i>
 | 
			
		||||
                                    {"Table View"}
 | 
			
		||||
                                } else {
 | 
			
		||||
                                    <i class="bi bi-grid-3x3-gap me-1"></i>
 | 
			
		||||
                                    {"Grid View"}
 | 
			
		||||
                                }
 | 
			
		||||
                            </button>
 | 
			
		||||
                            <button class="btn btn-outline-primary" onclick={on_refresh} disabled={*loading}>
 | 
			
		||||
                                <i class="bi bi-arrow-clockwise me-1"></i>
 | 
			
		||||
                                {"Refresh"}
 | 
			
		||||
                            </button>
 | 
			
		||||
                            <button class="btn btn-primary" onclick={on_add_runner.clone()}>
 | 
			
		||||
                                <i class="bi bi-plus-circle me-1"></i>
 | 
			
		||||
                                {"Add Runner"}
 | 
			
		||||
                            </button>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            // Error display
 | 
			
		||||
            if let Some(err) = service_error {
 | 
			
		||||
                <div class="row mb-4">
 | 
			
		||||
                    <div class="col-12">
 | 
			
		||||
                        <div class="alert alert-danger">
 | 
			
		||||
                            <i class="bi bi-exclamation-triangle-fill me-2"></i>
 | 
			
		||||
                            {"Service Error: "}{err}
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if let Some(err) = error.as_ref() {
 | 
			
		||||
                <div class="row mb-4">
 | 
			
		||||
                    <div class="col-12">
 | 
			
		||||
                        <div class="alert alert-warning">
 | 
			
		||||
                            <i class="bi bi-exclamation-triangle-fill me-2"></i>
 | 
			
		||||
                            {"Error: "}{err}
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Loading state
 | 
			
		||||
            if *loading {
 | 
			
		||||
                <div class="row">
 | 
			
		||||
                    <div class="col-12 text-center">
 | 
			
		||||
                        <div class="spinner-border text-primary" role="status">
 | 
			
		||||
                            <span class="visually-hidden">{"Loading..."}</span>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <p class="mt-2">{"Loading runners..."}</p>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Content based on view mode
 | 
			
		||||
            if !*loading && !runners.is_empty() {
 | 
			
		||||
                if *view_mode == "grid" {
 | 
			
		||||
                    // Grid view
 | 
			
		||||
                    <div class="row">
 | 
			
		||||
                        {for runners.iter().map(|runner| {
 | 
			
		||||
                            if let Some(service) = &service {
 | 
			
		||||
                                html! {
 | 
			
		||||
                                    <RunnerCard 
 | 
			
		||||
                                        runner={runner.clone()} 
 | 
			
		||||
                                        service={service.clone()}
 | 
			
		||||
                                        on_update={on_runner_update.clone()}
 | 
			
		||||
                                    />
 | 
			
		||||
                                }
 | 
			
		||||
                            } else {
 | 
			
		||||
                                html! {}
 | 
			
		||||
                            }
 | 
			
		||||
                        })}
 | 
			
		||||
                    </div>
 | 
			
		||||
                } else {
 | 
			
		||||
                    // Table view
 | 
			
		||||
                    <div class="row">
 | 
			
		||||
                        <div class="col-12">
 | 
			
		||||
                            <div class="card">
 | 
			
		||||
                                <div class="card-body">
 | 
			
		||||
                                    <div class="table-responsive">
 | 
			
		||||
                                        <table class="table table-dark table-hover">
 | 
			
		||||
                                            <thead>
 | 
			
		||||
                                                <tr>
 | 
			
		||||
                                                    <th>{"ID"}</th>
 | 
			
		||||
                                                    <th>{"Type"}</th>
 | 
			
		||||
                                                    <th>{"Status"}</th>
 | 
			
		||||
                                                    <th>{"Script Type"}</th>
 | 
			
		||||
                                                    <th>{"Binary Path"}</th>
 | 
			
		||||
                                                    <th>{"Actions"}</th>
 | 
			
		||||
                                                </tr>
 | 
			
		||||
                                            </thead>
 | 
			
		||||
                                            <tbody>
 | 
			
		||||
                                                {for runners.iter().map(|runner| {
 | 
			
		||||
                                                    let runner_id = runner.id.clone();
 | 
			
		||||
                                                    let on_view_details = {
 | 
			
		||||
                                                        let navigator = navigator.clone();
 | 
			
		||||
                                                        let runner_id = runner_id.clone();
 | 
			
		||||
                                                        Callback::from(move |_| {
 | 
			
		||||
                                                            navigator.push(&Route::RunnerDetail { id: runner_id.clone() });
 | 
			
		||||
                                                        })
 | 
			
		||||
                                                    };
 | 
			
		||||
 | 
			
		||||
                                                    html! {
 | 
			
		||||
                                                        <tr>
 | 
			
		||||
                                                            <td>
 | 
			
		||||
                                                                <code>{&runner.id}</code>
 | 
			
		||||
                                                            </td>
 | 
			
		||||
                                                            <td>
 | 
			
		||||
                                                                <span class="badge bg-info">
 | 
			
		||||
                                                                    {format!("{:?}", runner.config.runner_type)}
 | 
			
		||||
                                                                </span>
 | 
			
		||||
                                                            </td>
 | 
			
		||||
                                                            <td>
 | 
			
		||||
                                                                <StatusBadge status={runner.status.clone()} />
 | 
			
		||||
                                                            </td>
 | 
			
		||||
                                                            <td>
 | 
			
		||||
                                                                <code class="small">{&runner.config.script_type}</code>
 | 
			
		||||
                                                            </td>
 | 
			
		||||
                                                            <td>
 | 
			
		||||
                                                                <code class="small">{runner.config.binary_path.to_string_lossy()}</code>
 | 
			
		||||
                                                            </td>
 | 
			
		||||
                                                            <td>
 | 
			
		||||
                                                                <button class="btn btn-outline-primary btn-sm" onclick={on_view_details}>
 | 
			
		||||
                                                                    <i class="bi bi-eye me-1"></i>
 | 
			
		||||
                                                                    {"Details"}
 | 
			
		||||
                                                                </button>
 | 
			
		||||
                                                            </td>
 | 
			
		||||
                                                        </tr>
 | 
			
		||||
                                                    }
 | 
			
		||||
                                                })}
 | 
			
		||||
                                            </tbody>
 | 
			
		||||
                                        </table>
 | 
			
		||||
                                    </div>
 | 
			
		||||
                                </div>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Empty state
 | 
			
		||||
            if !*loading && runners.is_empty() && service.is_some() {
 | 
			
		||||
                <div class="row">
 | 
			
		||||
                    <div class="col-12 text-center">
 | 
			
		||||
                        <div class="card">
 | 
			
		||||
                            <div class="card-body py-5">
 | 
			
		||||
                                <i class="bi bi-cpu display-1 text-muted mb-3"></i>
 | 
			
		||||
                                <h4 class="text-muted">{"No Runners Found"}</h4>
 | 
			
		||||
                                <p class="text-muted">{"Get started by adding your first runner."}</p>
 | 
			
		||||
                                <button class="btn btn-primary" onclick={on_add_runner.clone()}>
 | 
			
		||||
                                    <i class="bi bi-plus-circle me-1"></i>
 | 
			
		||||
                                    {"Add Runner"}
 | 
			
		||||
                                </button>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            }
 | 
			
		||||
        </div>
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										30
									
								
								clients/admin-ui/src/components/status_badge.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								clients/admin-ui/src/components/status_badge.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,30 @@
 | 
			
		||||
use yew::prelude::*;
 | 
			
		||||
use crate::types::ProcessStatus;
 | 
			
		||||
 | 
			
		||||
#[derive(Properties, PartialEq)]
 | 
			
		||||
pub struct StatusBadgeProps {
 | 
			
		||||
    pub status: ProcessStatus,
 | 
			
		||||
    #[prop_or_default]
 | 
			
		||||
    pub size: Option<String>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[function_component(StatusBadge)]
 | 
			
		||||
pub fn status_badge(props: &StatusBadgeProps) -> Html {
 | 
			
		||||
    let (badge_class, icon, text) = match props.status {
 | 
			
		||||
        ProcessStatus::Running => ("badge bg-success", "bi-play-circle-fill", "Running"),
 | 
			
		||||
        ProcessStatus::Stopped => ("badge bg-danger", "bi-stop-circle-fill", "Stopped"),
 | 
			
		||||
        ProcessStatus::Starting => ("badge bg-warning", "bi-hourglass-split", "Starting"),
 | 
			
		||||
        ProcessStatus::Stopping => ("badge bg-warning", "bi-hourglass-split", "Stopping"),
 | 
			
		||||
        ProcessStatus::Failed => ("badge bg-danger", "bi-exclamation-triangle-fill", "Failed"),
 | 
			
		||||
        ProcessStatus::Unknown => ("badge bg-secondary", "bi-question-circle-fill", "Unknown"),
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let size_class = props.size.as_deref().unwrap_or("");
 | 
			
		||||
 | 
			
		||||
    html! {
 | 
			
		||||
        <span class={format!("{} {}", badge_class, size_class)}>
 | 
			
		||||
            <i class={format!("{} me-1", icon)}></i>
 | 
			
		||||
            {text}
 | 
			
		||||
        </span>
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										185
									
								
								clients/admin-ui/src/jobs.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										185
									
								
								clients/admin-ui/src/jobs.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,185 @@
 | 
			
		||||
use yew::prelude::*;
 | 
			
		||||
use hero_supervisor_openrpc_client::wasm::WasmJob;
 | 
			
		||||
use crate::app::JobForm;
 | 
			
		||||
use web_sys::{Event, HtmlInputElement, MouseEvent};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
#[derive(Properties)]
 | 
			
		||||
pub struct JobsProps {
 | 
			
		||||
    pub jobs: Vec<WasmJob>,
 | 
			
		||||
    pub server_url: String,
 | 
			
		||||
    pub job_form: JobForm,
 | 
			
		||||
    pub runners: Vec<(String, String)>, // (name, status) - list of registered runners
 | 
			
		||||
    pub on_job_form_change: Callback<(String, String)>,
 | 
			
		||||
    pub on_run_job: Callback<()>,
 | 
			
		||||
    pub on_stop_job: Callback<String>,
 | 
			
		||||
    pub on_delete_job: Callback<String>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl PartialEq for JobsProps {
 | 
			
		||||
    fn eq(&self, other: &Self) -> bool {
 | 
			
		||||
        // Since WasmJob doesn't implement PartialEq, we'll compare by length
 | 
			
		||||
        // This is a simple comparison that will trigger re-renders when the job list changes
 | 
			
		||||
        self.jobs.len() == other.jobs.len() && 
 | 
			
		||||
        self.server_url == other.server_url &&
 | 
			
		||||
        self.job_form.payload == other.job_form.payload &&
 | 
			
		||||
        self.job_form.runner_name == other.job_form.runner_name &&
 | 
			
		||||
        self.job_form.executor == other.job_form.executor &&
 | 
			
		||||
        self.job_form.secret == other.job_form.secret &&
 | 
			
		||||
        self.runners.len() == other.runners.len()
 | 
			
		||||
        // Note: Callbacks don't implement PartialEq, so we skip them
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[function_component(Jobs)]
 | 
			
		||||
pub fn jobs(props: &JobsProps) -> Html {
 | 
			
		||||
    let on_payload_change = {
 | 
			
		||||
        let on_change = props.on_job_form_change.clone();
 | 
			
		||||
        Callback::from(move |e: Event| {
 | 
			
		||||
            let input: web_sys::HtmlInputElement = e.target_unchecked_into();
 | 
			
		||||
            on_change.emit(("payload".to_string(), input.value()));
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let on_runner_name_change = {
 | 
			
		||||
        let on_change = props.on_job_form_change.clone();
 | 
			
		||||
        Callback::from(move |e: Event| {
 | 
			
		||||
            let input: HtmlInputElement = e.target_unchecked_into();
 | 
			
		||||
            on_change.emit(("runner_name".to_string(), input.value()));
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let on_executor_change = {
 | 
			
		||||
        let on_change = props.on_job_form_change.clone();
 | 
			
		||||
        Callback::from(move |e: Event| {
 | 
			
		||||
            let input: web_sys::HtmlInputElement = e.target_unchecked_into();
 | 
			
		||||
            on_change.emit(("executor".to_string(), input.value()));
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let on_secret_change = {
 | 
			
		||||
        let on_change = props.on_job_form_change.clone();
 | 
			
		||||
        Callback::from(move |e: Event| {
 | 
			
		||||
            let input: web_sys::HtmlInputElement = e.target_unchecked_into();
 | 
			
		||||
            on_change.emit(("secret".to_string(), input.value()));
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let on_run_click = {
 | 
			
		||||
        let on_run = props.on_run_job.clone();
 | 
			
		||||
        Callback::from(move |_: MouseEvent| {
 | 
			
		||||
            on_run.emit(());
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    html! {
 | 
			
		||||
        <div class="jobs-section">
 | 
			
		||||
            <h2>{"Jobs"}</h2>
 | 
			
		||||
            <div class="table-container">
 | 
			
		||||
                <table class="table">
 | 
			
		||||
                    <thead>
 | 
			
		||||
                        <tr>
 | 
			
		||||
                            <th>{"Job ID"}</th>
 | 
			
		||||
                            <th>{"Payload"}</th>
 | 
			
		||||
                            <th>{"Runner"}</th>
 | 
			
		||||
                            <th>{"Executor"}</th>
 | 
			
		||||
                            <th>{"Status"}</th>
 | 
			
		||||
                        </tr>
 | 
			
		||||
                    </thead>
 | 
			
		||||
                    <tbody>
 | 
			
		||||
                        // Job creation form as first row
 | 
			
		||||
                        <tr class="job-form-row">
 | 
			
		||||
                            <td>
 | 
			
		||||
                                <span class="text-muted">{"New Job"}</span>
 | 
			
		||||
                            </td>
 | 
			
		||||
                            <td>
 | 
			
		||||
                                <input 
 | 
			
		||||
                                    type="text" 
 | 
			
		||||
                                    class="form-control table-input" 
 | 
			
		||||
                                    placeholder="Script content"
 | 
			
		||||
                                    value={props.job_form.payload.clone()}
 | 
			
		||||
                                    onchange={on_payload_change}
 | 
			
		||||
                                />
 | 
			
		||||
                            </td>
 | 
			
		||||
                            <td>
 | 
			
		||||
                                <select 
 | 
			
		||||
                                    class="form-control table-input" 
 | 
			
		||||
                                    value={props.job_form.runner_name.clone()}
 | 
			
		||||
                                    onchange={on_runner_name_change}
 | 
			
		||||
                                >
 | 
			
		||||
                                    <option value="" disabled=true>{"-Select Runner-"}</option>
 | 
			
		||||
                                    { for props.runners.iter().map(|(name, _status)| {
 | 
			
		||||
                                        html! {
 | 
			
		||||
                                            <option value={name.clone()} selected={name == &props.job_form.runner_name}>
 | 
			
		||||
                                                {name}
 | 
			
		||||
                                            </option>
 | 
			
		||||
                                        }
 | 
			
		||||
                                    })}
 | 
			
		||||
                                </select>
 | 
			
		||||
                            </td>
 | 
			
		||||
                            <td>
 | 
			
		||||
                                <input 
 | 
			
		||||
                                    type="text" 
 | 
			
		||||
                                    class="form-control table-input" 
 | 
			
		||||
                                    placeholder="Executor"
 | 
			
		||||
                                    value={props.job_form.executor.clone()}
 | 
			
		||||
                                    onchange={on_executor_change}
 | 
			
		||||
                                />
 | 
			
		||||
                            </td>
 | 
			
		||||
                            <td class="action-cell">
 | 
			
		||||
                                <input 
 | 
			
		||||
                                    type="password" 
 | 
			
		||||
                                    class="form-control table-input secret-input" 
 | 
			
		||||
                                    placeholder="Secret"
 | 
			
		||||
                                    value={props.job_form.secret.clone()}
 | 
			
		||||
                                    onchange={on_secret_change}
 | 
			
		||||
                                />
 | 
			
		||||
                                <button 
 | 
			
		||||
                                    class="btn btn-primary btn-sm"
 | 
			
		||||
                                    onclick={on_run_click}
 | 
			
		||||
                                >
 | 
			
		||||
                                    {"Run"}
 | 
			
		||||
                                </button>
 | 
			
		||||
                            </td>
 | 
			
		||||
                        </tr>
 | 
			
		||||
                        
 | 
			
		||||
                        // Existing jobs
 | 
			
		||||
                        {for props.jobs.iter().map(|job| {
 | 
			
		||||
                            let job_id = job.id();
 | 
			
		||||
                            let on_stop = props.on_stop_job.clone();
 | 
			
		||||
                            let on_delete = props.on_delete_job.clone();
 | 
			
		||||
                            let job_id_stop = job_id.clone();
 | 
			
		||||
                            let job_id_delete = job_id.clone();
 | 
			
		||||
                            
 | 
			
		||||
                            html! {
 | 
			
		||||
                                <tr>
 | 
			
		||||
                                    <td><small class="text-muted">{job_id}</small></td>
 | 
			
		||||
                                    <td><code class="code">{job.payload()}</code></td>
 | 
			
		||||
                                    <td>{job.runner_name()}</td>
 | 
			
		||||
                                    <td>{job.executor()}</td>
 | 
			
		||||
                                    <td class="action-cell">
 | 
			
		||||
                                        <span class="status-badge">{"Queued"}</span>
 | 
			
		||||
                                        <button 
 | 
			
		||||
                                            class="btn-icon btn-stop"
 | 
			
		||||
                                            title="Stop Job"
 | 
			
		||||
                                            onclick={Callback::from(move |_| on_stop.emit(job_id_stop.clone()))}
 | 
			
		||||
                                        >
 | 
			
		||||
                                            {"⏹"}
 | 
			
		||||
                                        </button>
 | 
			
		||||
                                        <button 
 | 
			
		||||
                                            class="btn-icon btn-delete"
 | 
			
		||||
                                            title="Delete Job"
 | 
			
		||||
                                            onclick={Callback::from(move |_| on_delete.emit(job_id_delete.clone()))}
 | 
			
		||||
                                        >
 | 
			
		||||
                                            {"🗑"}
 | 
			
		||||
                                        </button>
 | 
			
		||||
                                    </td>
 | 
			
		||||
                                </tr>
 | 
			
		||||
                            }
 | 
			
		||||
                        })}
 | 
			
		||||
                    </tbody>
 | 
			
		||||
                </table>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										12
									
								
								clients/admin-ui/src/lib.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								clients/admin-ui/src/lib.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,12 @@
 | 
			
		||||
use wasm_bindgen::prelude::*;
 | 
			
		||||
 | 
			
		||||
mod app;
 | 
			
		||||
mod sidebar;
 | 
			
		||||
mod runners;
 | 
			
		||||
mod jobs;
 | 
			
		||||
 | 
			
		||||
#[wasm_bindgen(start)]
 | 
			
		||||
pub fn main() {
 | 
			
		||||
    wasm_logger::init(wasm_logger::Config::default());
 | 
			
		||||
    yew::Renderer::<app::App>::new().render();
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										219
									
								
								clients/admin-ui/src/runners.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										219
									
								
								clients/admin-ui/src/runners.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,219 @@
 | 
			
		||||
use yew::prelude::*;
 | 
			
		||||
use wasm_bindgen_futures::spawn_local;
 | 
			
		||||
use gloo::console;
 | 
			
		||||
use hero_supervisor_openrpc_client::wasm::WasmSupervisorClient;
 | 
			
		||||
use wasm_bindgen::JsCast;
 | 
			
		||||
use crate::app::PingState;
 | 
			
		||||
use std::collections::HashMap;
 | 
			
		||||
 | 
			
		||||
#[derive(Clone, PartialEq)]
 | 
			
		||||
pub struct RegisterForm {
 | 
			
		||||
    pub name: String,
 | 
			
		||||
    pub secret: String,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Properties, PartialEq)]
 | 
			
		||||
pub struct RunnersProps {
 | 
			
		||||
    pub server_url: String,
 | 
			
		||||
    pub runners: Vec<(String, String)>, // (name, status)
 | 
			
		||||
    pub register_form: RegisterForm,
 | 
			
		||||
    pub ping_states: HashMap<String, PingState>, // runner_name -> ping_state
 | 
			
		||||
    pub on_register_form_change: Callback<(String, String)>,
 | 
			
		||||
    pub on_register_runner: Callback<()>,
 | 
			
		||||
    pub on_load_runners: Callback<()>,
 | 
			
		||||
    pub on_remove_runner: Callback<String>,
 | 
			
		||||
    pub on_ping_runner: Callback<(String, String)>, // (runner_name, secret)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[function_component(Runners)]
 | 
			
		||||
pub fn runners(props: &RunnersProps) -> Html {
 | 
			
		||||
    let on_register_runner = {
 | 
			
		||||
        let server_url = props.server_url.clone();
 | 
			
		||||
        let register_form = props.register_form.clone();
 | 
			
		||||
        let on_register_runner = props.on_register_runner.clone();
 | 
			
		||||
        Callback::from(move |_: ()| {
 | 
			
		||||
            let server_url = server_url.clone();
 | 
			
		||||
            let register_form = register_form.clone();
 | 
			
		||||
            let on_register_runner = on_register_runner.clone();
 | 
			
		||||
            let client = WasmSupervisorClient::new(server_url);
 | 
			
		||||
            spawn_local(async move {
 | 
			
		||||
                console::log!("Registering runner...");
 | 
			
		||||
                
 | 
			
		||||
                // Validate form data
 | 
			
		||||
                if register_form.name.is_empty() {
 | 
			
		||||
                    console::error!("Runner name is required");
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
                if register_form.secret.is_empty() {
 | 
			
		||||
                    console::error!("Secret is required");
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                // Make actual registration call (use name as queue)
 | 
			
		||||
                match client.register_runner(
 | 
			
		||||
                    ®ister_form.secret,
 | 
			
		||||
                    ®ister_form.name,
 | 
			
		||||
                    ®ister_form.name, // queue = name
 | 
			
		||||
                ).await {
 | 
			
		||||
                    Ok(runner_name) => {
 | 
			
		||||
                        console::log!("Runner registered successfully:", runner_name);
 | 
			
		||||
                        on_register_runner.emit(());
 | 
			
		||||
                    }
 | 
			
		||||
                    Err(e) => {
 | 
			
		||||
                        console::error!("Failed to register runner:", format!("{:?}", e));
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    html! {
 | 
			
		||||
        <div class="runners-grid">
 | 
			
		||||
            // Registration card (first card)
 | 
			
		||||
            <div class="card register-card">
 | 
			
		||||
                <div class="card-title">{"+ Register Runner"}</div>
 | 
			
		||||
                <form onsubmit={on_register_runner.reform(|e: web_sys::SubmitEvent| {
 | 
			
		||||
                    e.prevent_default();
 | 
			
		||||
                    ()
 | 
			
		||||
                })}>
 | 
			
		||||
                    <div class="form-group">
 | 
			
		||||
                        <input 
 | 
			
		||||
                            type="text" 
 | 
			
		||||
                            class="form-control"
 | 
			
		||||
                            placeholder="Runner name"
 | 
			
		||||
                            value={props.register_form.name.clone()}
 | 
			
		||||
                            onchange={props.on_register_form_change.reform(|e: web_sys::Event| {
 | 
			
		||||
                                let input: web_sys::HtmlInputElement = e.target().unwrap().dyn_into().unwrap();
 | 
			
		||||
                                ("name".to_string(), input.value())
 | 
			
		||||
                            })}
 | 
			
		||||
                        />
 | 
			
		||||
                    </div>
 | 
			
		||||
 | 
			
		||||
                    <div class="form-group form-row">
 | 
			
		||||
                        <input 
 | 
			
		||||
                            type="password" 
 | 
			
		||||
                            class="form-control form-control-inline"
 | 
			
		||||
                            placeholder="Secret"
 | 
			
		||||
                            value={props.register_form.secret.clone()}
 | 
			
		||||
                            onchange={props.on_register_form_change.reform(|e: web_sys::Event| {
 | 
			
		||||
                                let input: web_sys::HtmlInputElement = e.target().unwrap().dyn_into().unwrap();
 | 
			
		||||
                                ("secret".to_string(), input.value())
 | 
			
		||||
                            })}
 | 
			
		||||
                        />
 | 
			
		||||
                        <button type="submit" class="btn btn-primary">
 | 
			
		||||
                            {"Register"}
 | 
			
		||||
                        </button>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </form>
 | 
			
		||||
            </div>
 | 
			
		||||
            
 | 
			
		||||
            // Existing runner cards
 | 
			
		||||
            {for props.runners.iter().map(|(name, status)| {
 | 
			
		||||
                let status_class = match status.as_str() {
 | 
			
		||||
                    "Running" => "status-running",
 | 
			
		||||
                    "Stopped" => "status-stopped",
 | 
			
		||||
                    "Starting" => "status-starting",
 | 
			
		||||
                    "Stopping" => "status-starting",
 | 
			
		||||
                    "Registering" => "status-registering",
 | 
			
		||||
                    _ => "status-stopped",
 | 
			
		||||
                };
 | 
			
		||||
                
 | 
			
		||||
                let name_clone = name.clone();
 | 
			
		||||
                let name_clone2 = name.clone();
 | 
			
		||||
                let on_remove = props.on_remove_runner.clone();
 | 
			
		||||
                let on_ping = props.on_ping_runner.clone();
 | 
			
		||||
                
 | 
			
		||||
                html! {
 | 
			
		||||
                    <div class="card runner-card">
 | 
			
		||||
                        <div class="card-header">
 | 
			
		||||
                            <div class="runner-title-section">
 | 
			
		||||
                                <div class="runner-title-with-dot">
 | 
			
		||||
                                    <span class={format!("connection-dot {}", status_class)} title={status.clone()}>
 | 
			
		||||
                                        {"●"}
 | 
			
		||||
                                    </span>
 | 
			
		||||
                                    <div class="card-title">{name}</div>
 | 
			
		||||
                                </div>
 | 
			
		||||
                                <small class="queue-info">
 | 
			
		||||
                                    {"redis://localhost:6379/runner:"}{name}
 | 
			
		||||
                                </small>
 | 
			
		||||
                            </div>
 | 
			
		||||
                            <div class="runner-actions-top">
 | 
			
		||||
                                <button 
 | 
			
		||||
                                    class="btn btn-sm btn-outline-secondary btn-remove"
 | 
			
		||||
                                    title="Remove Runner"
 | 
			
		||||
                                    onclick={Callback::from(move |_| on_remove.emit(name_clone2.clone()))}
 | 
			
		||||
                                >
 | 
			
		||||
                                    <svg class="trash-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
 | 
			
		||||
                                        <polyline points="3,6 5,6 21,6"></polyline>
 | 
			
		||||
                                        <path d="m5,6 1,14 c0,1.1 0.9,2 2,2 h8 c1.1,0 2,-0.9 2,-2 l1,-14"></path>
 | 
			
		||||
                                        <path d="m10,11 v6"></path>
 | 
			
		||||
                                        <path d="m14,11 v6"></path>
 | 
			
		||||
                                        <path d="M7,6V4c0-1.1,0.9-2,2-2h6c0-1.1,0.9-2,2-2v2"></path>
 | 
			
		||||
                                    </svg>
 | 
			
		||||
                                </button>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div class="runner-chart">
 | 
			
		||||
                            <div class="chart-placeholder">
 | 
			
		||||
                                {"📊 Live job count chart (5s updates)"}
 | 
			
		||||
                            </div>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div class="ping-section">
 | 
			
		||||
                            {
 | 
			
		||||
                                match props.ping_states.get(name).cloned().unwrap_or(PingState::Idle) {
 | 
			
		||||
                                    PingState::Idle => html! {
 | 
			
		||||
                                        <div class="input-group input-group-sm">
 | 
			
		||||
                                            <input 
 | 
			
		||||
                                                type="password" 
 | 
			
		||||
                                                class="form-control" 
 | 
			
		||||
                                                placeholder="Secret"
 | 
			
		||||
                                                id={format!("ping-secret-{}", name)}
 | 
			
		||||
                                            />
 | 
			
		||||
                                            <button 
 | 
			
		||||
                                                class="btn btn-outline-primary"
 | 
			
		||||
                                                title="Ping Runner"
 | 
			
		||||
                                                onclick={Callback::from(move |_| {
 | 
			
		||||
                                                    let window = web_sys::window().unwrap();
 | 
			
		||||
                                                    let document = window.document().unwrap();
 | 
			
		||||
                                                    let input_id = format!("ping-secret-{}", name_clone.clone());
 | 
			
		||||
                                                    if let Some(input) = document.get_element_by_id(&input_id) {
 | 
			
		||||
                                                        let input: web_sys::HtmlInputElement = input.dyn_into().unwrap();
 | 
			
		||||
                                                        let secret = input.value();
 | 
			
		||||
                                                        if !secret.is_empty() {
 | 
			
		||||
                                                            on_ping.emit((name_clone.clone(), secret));
 | 
			
		||||
                                                            input.set_value("");
 | 
			
		||||
                                                        }
 | 
			
		||||
                                                    }
 | 
			
		||||
                                                })}
 | 
			
		||||
                                            >
 | 
			
		||||
                                                {"Ping"}
 | 
			
		||||
                                            </button>
 | 
			
		||||
                                        </div>
 | 
			
		||||
                                    },
 | 
			
		||||
                                    PingState::Waiting => html! {
 | 
			
		||||
                                        <div class="ping-status ping-waiting">
 | 
			
		||||
                                            <span class="ping-spinner">{"⏳"}</span>
 | 
			
		||||
                                            <span>{"Waiting for response..."}</span>
 | 
			
		||||
                                        </div>
 | 
			
		||||
                                    },
 | 
			
		||||
                                    PingState::Success(result) => html! {
 | 
			
		||||
                                        <div class="ping-status ping-success">
 | 
			
		||||
                                            <span class="ping-icon">{"✅"}</span>
 | 
			
		||||
                                            <span>{format!("Success: {}", result)}</span>
 | 
			
		||||
                                        </div>
 | 
			
		||||
                                    },
 | 
			
		||||
                                    PingState::Error(error) => html! {
 | 
			
		||||
                                        <div class="ping-status ping-error">
 | 
			
		||||
                                            <span class="ping-icon">{"❌"}</span>
 | 
			
		||||
                                            <span>{error}</span>
 | 
			
		||||
                                        </div>
 | 
			
		||||
                                    },
 | 
			
		||||
                                }
 | 
			
		||||
                            }
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                }
 | 
			
		||||
            })}
 | 
			
		||||
        </div>
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										145
									
								
								clients/admin-ui/src/services.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										145
									
								
								clients/admin-ui/src/services.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,145 @@
 | 
			
		||||
use gloo::console;
 | 
			
		||||
use std::rc::Rc;
 | 
			
		||||
use std::cell::RefCell;
 | 
			
		||||
use crate::wasm_client::{WasmSupervisorClient, WasmClientResult as ClientResult, RunnerConfig, ProcessManagerType, ProcessStatus, LogInfo, Job, RunnerType};
 | 
			
		||||
use wasm_bindgen_futures::spawn_local;
 | 
			
		||||
use yew::prelude::*;
 | 
			
		||||
 | 
			
		||||
use crate::types::{RunnerInfo, AppState};
 | 
			
		||||
 | 
			
		||||
/// Service for managing supervisor client operations
 | 
			
		||||
#[derive(Clone)]
 | 
			
		||||
pub struct SupervisorService {
 | 
			
		||||
    client: Rc<RefCell<WasmSupervisorClient>>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl PartialEq for SupervisorService {
 | 
			
		||||
    fn eq(&self, other: &Self) -> bool {
 | 
			
		||||
        // Compare by server URL since that's the main identifier
 | 
			
		||||
        self.client.borrow().server_url() == other.client.borrow().server_url()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl SupervisorService {
 | 
			
		||||
    pub fn new(server_url: &str) -> ClientResult<Self> {
 | 
			
		||||
        let client = WasmSupervisorClient::new(server_url);
 | 
			
		||||
        Ok(Self {
 | 
			
		||||
            client: Rc::new(RefCell::new(client)),
 | 
			
		||||
        })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Get all runners with their status and basic info
 | 
			
		||||
    pub async fn get_all_runners(&self) -> ClientResult<Vec<RunnerInfo>> {
 | 
			
		||||
        let runner_ids = self.client.borrow_mut().list_runners().await?;
 | 
			
		||||
        let mut runners = Vec::new();
 | 
			
		||||
 | 
			
		||||
        for id in runner_ids {
 | 
			
		||||
            let status = self.client.borrow_mut().get_runner_status(&id).await.unwrap_or(ProcessStatus::Unknown);
 | 
			
		||||
            let logs = self.client.borrow_mut().get_runner_logs(&id, Some(50), false).await.unwrap_or_default();
 | 
			
		||||
            
 | 
			
		||||
            // Create a basic runner config since we don't have a get_runner_config method
 | 
			
		||||
            let config = RunnerConfig {
 | 
			
		||||
                actor_id: id.clone(),
 | 
			
		||||
                runner_type: RunnerType::SALRunner, // Default
 | 
			
		||||
                binary_path: std::path::PathBuf::from("unknown"),
 | 
			
		||||
                script_type: "unknown".to_string(),
 | 
			
		||||
                args: vec![],
 | 
			
		||||
                env_vars: std::collections::HashMap::new(),
 | 
			
		||||
                working_dir: None,
 | 
			
		||||
                restart_policy: "always".to_string(),
 | 
			
		||||
                health_check_command: None,
 | 
			
		||||
                dependencies: vec![],
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            runners.push(RunnerInfo {
 | 
			
		||||
                id,
 | 
			
		||||
                config,
 | 
			
		||||
                status,
 | 
			
		||||
                logs,
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        Ok(runners)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Add a new runner
 | 
			
		||||
    pub async fn add_runner(&self, config: RunnerConfig, process_manager_type: ProcessManagerType) -> ClientResult<()> {
 | 
			
		||||
        self.client.borrow_mut().add_runner(config, process_manager_type).await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Remove a runner
 | 
			
		||||
    pub async fn remove_runner(&self, actor_id: &str) -> ClientResult<()> {
 | 
			
		||||
        self.client.borrow_mut().remove_runner(actor_id).await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Start a runner
 | 
			
		||||
    pub async fn start_runner(&self, actor_id: &str) -> ClientResult<()> {
 | 
			
		||||
        self.client.borrow_mut().start_runner(actor_id).await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Stop a runner
 | 
			
		||||
    pub async fn stop_runner(&self, actor_id: &str, force: bool) -> ClientResult<()> {
 | 
			
		||||
        self.client.borrow_mut().stop_runner(actor_id, force).await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Get runner status
 | 
			
		||||
    pub async fn get_runner_status(&self, actor_id: &str) -> ClientResult<ProcessStatus> {
 | 
			
		||||
        self.client.borrow_mut().get_runner_status(actor_id).await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Get runner logs
 | 
			
		||||
    pub async fn get_runner_logs(&self, actor_id: &str, lines: Option<usize>, follow: bool) -> ClientResult<Vec<LogInfo>> {
 | 
			
		||||
        self.client.borrow_mut().get_runner_logs(actor_id, lines, follow).await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Start all runners
 | 
			
		||||
    pub async fn start_all(&self) -> ClientResult<Vec<(String, bool)>> {
 | 
			
		||||
        self.client.borrow_mut().start_all().await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Stop all runners
 | 
			
		||||
    pub async fn stop_all(&self, force: bool) -> ClientResult<Vec<(String, bool)>> {
 | 
			
		||||
        self.client.borrow_mut().stop_all(force).await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Queue a job to a runner
 | 
			
		||||
    pub async fn queue_job(&self, runner_name: &str, job: Job) -> ClientResult<()> {
 | 
			
		||||
        self.client.borrow_mut().queue_job_to_runner(runner_name, job).await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Queue a job and wait for result
 | 
			
		||||
    pub async fn queue_and_wait(&self, runner_name: &str, job: Job, timeout_secs: u64) -> ClientResult<Option<String>> {
 | 
			
		||||
        self.client.borrow_mut().queue_and_wait(runner_name, job, timeout_secs).await
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Hook for managing supervisor service state
 | 
			
		||||
#[hook]
 | 
			
		||||
pub fn use_supervisor_service(server_url: &str) -> (Option<SupervisorService>, Option<String>) {
 | 
			
		||||
    let server_url = server_url.to_string();
 | 
			
		||||
    let service_state = use_state(|| None);
 | 
			
		||||
    let error_state = use_state(|| None);
 | 
			
		||||
 | 
			
		||||
    {
 | 
			
		||||
        let service_state = service_state.clone();
 | 
			
		||||
        let error_state = error_state.clone();
 | 
			
		||||
        let server_url = server_url.clone();
 | 
			
		||||
        
 | 
			
		||||
        use_effect_with(server_url.clone(), move |_| {
 | 
			
		||||
            spawn_local(async move {
 | 
			
		||||
                match SupervisorService::new(&server_url) {
 | 
			
		||||
                    Ok(service) => {
 | 
			
		||||
                        service_state.set(Some(service));
 | 
			
		||||
                        error_state.set(None);
 | 
			
		||||
                    }
 | 
			
		||||
                    Err(e) => {
 | 
			
		||||
                        console::error!("Failed to create supervisor service:", e.to_string());
 | 
			
		||||
                        error_state.set(Some(e.to_string()));
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    ((*service_state).clone(), (*error_state).clone())
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										292
									
								
								clients/admin-ui/src/sidebar.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										292
									
								
								clients/admin-ui/src/sidebar.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,292 @@
 | 
			
		||||
use yew::prelude::*;
 | 
			
		||||
use wasm_bindgen::JsCast;
 | 
			
		||||
use wasm_bindgen_futures::spawn_local;
 | 
			
		||||
use gloo::console;
 | 
			
		||||
use hero_supervisor_openrpc_client::wasm::WasmSupervisorClient;
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
#[derive(Clone, PartialEq)]
 | 
			
		||||
pub struct SupervisorInfo {
 | 
			
		||||
    pub server_url: String,
 | 
			
		||||
    pub admin_secrets_count: usize,
 | 
			
		||||
    pub user_secrets_count: usize,
 | 
			
		||||
    pub register_secrets_count: usize,
 | 
			
		||||
    pub runners_count: usize,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Properties, PartialEq)]
 | 
			
		||||
pub struct SidebarProps {
 | 
			
		||||
    pub server_url: String,
 | 
			
		||||
    pub supervisor_info: Option<SupervisorInfo>,
 | 
			
		||||
    pub admin_secret: String,
 | 
			
		||||
    pub on_admin_secret_change: Callback<String>,
 | 
			
		||||
    pub on_supervisor_info_loaded: Callback<SupervisorInfo>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[function_component(Sidebar)]
 | 
			
		||||
pub fn sidebar(props: &SidebarProps) -> Html {
 | 
			
		||||
    let is_unlocked = use_state(|| false);
 | 
			
		||||
    let unlock_secret = use_state(|| String::new());
 | 
			
		||||
    let admin_secrets = use_state(|| Vec::<String>::new());
 | 
			
		||||
    let user_secrets = use_state(|| Vec::<String>::new());
 | 
			
		||||
    let register_secrets = use_state(|| Vec::<String>::new());
 | 
			
		||||
    let is_loading = use_state(|| false);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    let on_unlock_secret_change = {
 | 
			
		||||
        let unlock_secret = unlock_secret.clone();
 | 
			
		||||
        Callback::from(move |e: web_sys::Event| {
 | 
			
		||||
            let input: web_sys::HtmlInputElement = e.target().unwrap().dyn_into().unwrap();
 | 
			
		||||
            unlock_secret.set(input.value());
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    let on_unlock_submit = {
 | 
			
		||||
        let unlock_secret = unlock_secret.clone();
 | 
			
		||||
        let is_unlocked = is_unlocked.clone();
 | 
			
		||||
        let is_loading = is_loading.clone();
 | 
			
		||||
        let admin_secrets = admin_secrets.clone();
 | 
			
		||||
        let user_secrets = user_secrets.clone();
 | 
			
		||||
        let register_secrets = register_secrets.clone();
 | 
			
		||||
        let server_url = props.server_url.clone();
 | 
			
		||||
        
 | 
			
		||||
        Callback::from(move |_: web_sys::MouseEvent| {
 | 
			
		||||
            let unlock_secret = unlock_secret.clone();
 | 
			
		||||
            let is_unlocked = is_unlocked.clone();
 | 
			
		||||
            let is_loading = is_loading.clone();
 | 
			
		||||
            let admin_secrets = admin_secrets.clone();
 | 
			
		||||
            let user_secrets = user_secrets.clone();
 | 
			
		||||
            let register_secrets = register_secrets.clone();
 | 
			
		||||
            let server_url = server_url.clone();
 | 
			
		||||
            let secret_value = (*unlock_secret).clone();
 | 
			
		||||
            
 | 
			
		||||
            if secret_value.is_empty() {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            is_loading.set(true);
 | 
			
		||||
            
 | 
			
		||||
            spawn_local(async move {
 | 
			
		||||
                let client = WasmSupervisorClient::new(server_url);
 | 
			
		||||
                
 | 
			
		||||
                // Try to load all secrets
 | 
			
		||||
                match client.list_admin_secrets(&secret_value).await {
 | 
			
		||||
                    Ok(secrets) => {
 | 
			
		||||
                        admin_secrets.set(secrets);
 | 
			
		||||
                        
 | 
			
		||||
                        // Load user secrets
 | 
			
		||||
                        if let Ok(user_secs) = client.list_user_secrets(&secret_value).await {
 | 
			
		||||
                            user_secrets.set(user_secs);
 | 
			
		||||
                        }
 | 
			
		||||
                        
 | 
			
		||||
                        // Load register secrets
 | 
			
		||||
                        if let Ok(reg_secs) = client.list_register_secrets(&secret_value).await {
 | 
			
		||||
                            register_secrets.set(reg_secs);
 | 
			
		||||
                        }
 | 
			
		||||
                        
 | 
			
		||||
                        is_unlocked.set(true);
 | 
			
		||||
                        unlock_secret.set(String::new());
 | 
			
		||||
                        console::log!("Secrets unlocked successfully");
 | 
			
		||||
                    }
 | 
			
		||||
                    Err(e) => {
 | 
			
		||||
                        console::error!("Failed to unlock secrets:", format!("{:?}", e));
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                is_loading.set(false);
 | 
			
		||||
            });
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let on_lock_click = {
 | 
			
		||||
        let is_unlocked = is_unlocked.clone();
 | 
			
		||||
        let admin_secrets = admin_secrets.clone();
 | 
			
		||||
        let user_secrets = user_secrets.clone();
 | 
			
		||||
        let register_secrets = register_secrets.clone();
 | 
			
		||||
        
 | 
			
		||||
        Callback::from(move |_: web_sys::MouseEvent| {
 | 
			
		||||
            is_unlocked.set(false);
 | 
			
		||||
            admin_secrets.set(Vec::new());
 | 
			
		||||
            user_secrets.set(Vec::new());
 | 
			
		||||
            register_secrets.set(Vec::new());
 | 
			
		||||
            console::log!("Secrets locked");
 | 
			
		||||
        })
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    html! {
 | 
			
		||||
        <div class="sidebar">
 | 
			
		||||
            <div class="sidebar-header">
 | 
			
		||||
                <h2>{"Supervisor"}</h2>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="sidebar-content">
 | 
			
		||||
                <div class="sidebar-sections">
 | 
			
		||||
                    // Server Info Section
 | 
			
		||||
                    <div class="server-info">
 | 
			
		||||
                        <div class="server-header">
 | 
			
		||||
                            <h3 class="supervisor-title">{"Hero Supervisor"}</h3>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div class="server-url">
 | 
			
		||||
                            <span class="connection-indicator connected"></span>
 | 
			
		||||
                            <span class="url-text">{props.server_url.clone()}</span>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
 | 
			
		||||
                    // Secrets Management Section
 | 
			
		||||
                    <div class="secrets-section">
 | 
			
		||||
                        <div class="secrets-header">
 | 
			
		||||
                            <span class="secrets-title">{"Secrets"}</span>
 | 
			
		||||
                            if !*is_unlocked {
 | 
			
		||||
                                <button 
 | 
			
		||||
                                    class="unlock-btn"
 | 
			
		||||
                                    onclick={on_unlock_submit}
 | 
			
		||||
                                    disabled={*is_loading || unlock_secret.is_empty()}
 | 
			
		||||
                                >
 | 
			
		||||
                                    <i class={if *is_loading { "fas fa-spinner fa-spin" } else { "fas fa-unlock" }}></i>
 | 
			
		||||
                                </button>
 | 
			
		||||
                            } else {
 | 
			
		||||
                                <button 
 | 
			
		||||
                                    class="lock-btn"
 | 
			
		||||
                                    onclick={on_lock_click}
 | 
			
		||||
                                >
 | 
			
		||||
                                    <i class="fas fa-lock"></i>
 | 
			
		||||
                                </button>
 | 
			
		||||
                            }
 | 
			
		||||
                        </div>
 | 
			
		||||
                        
 | 
			
		||||
                        if !*is_unlocked {
 | 
			
		||||
                            <div class="unlock-input-row">
 | 
			
		||||
                                <input 
 | 
			
		||||
                                    type="password" 
 | 
			
		||||
                                    class="unlock-input"
 | 
			
		||||
                                    placeholder="Enter admin secret to unlock"
 | 
			
		||||
                                    value={(*unlock_secret).clone()}
 | 
			
		||||
                                    onchange={on_unlock_secret_change}
 | 
			
		||||
                                    disabled={*is_loading}
 | 
			
		||||
                                />
 | 
			
		||||
                            </div>
 | 
			
		||||
                        }
 | 
			
		||||
                        
 | 
			
		||||
                        if *is_unlocked {
 | 
			
		||||
                            <div class="secrets-content">
 | 
			
		||||
                                <div class="secret-group">
 | 
			
		||||
                                    <div class="secret-header">
 | 
			
		||||
                                        <span class="secret-title">{"Admin secrets"}</span>
 | 
			
		||||
                                    </div>
 | 
			
		||||
                                    <div class="secret-list">
 | 
			
		||||
                                        { for admin_secrets.iter().enumerate().map(|(i, secret)| {
 | 
			
		||||
                                            html! {
 | 
			
		||||
                                                <div class="secret-item" key={i}>
 | 
			
		||||
                                                    <div class="secret-value">{secret.clone()}</div>
 | 
			
		||||
                                                    <button class="btn-icon btn-remove">
 | 
			
		||||
                                                        <i class="fas fa-minus"></i>
 | 
			
		||||
                                                    </button>
 | 
			
		||||
                                                </div>
 | 
			
		||||
                                            }
 | 
			
		||||
                                        })}
 | 
			
		||||
                                        <div class="secret-add-row">
 | 
			
		||||
                                            <input 
 | 
			
		||||
                                                type="text" 
 | 
			
		||||
                                                class="secret-add-input"
 | 
			
		||||
                                                placeholder="New admin secret"
 | 
			
		||||
                                            />
 | 
			
		||||
                                            <button class="btn-icon btn-add">
 | 
			
		||||
                                                <i class="fas fa-plus"></i>
 | 
			
		||||
                                            </button>
 | 
			
		||||
                                        </div>
 | 
			
		||||
                                    </div>
 | 
			
		||||
                                </div>
 | 
			
		||||
                                
 | 
			
		||||
                                <div class="secret-group">
 | 
			
		||||
                                    <div class="secret-header">
 | 
			
		||||
                                        <span class="secret-title">{"User secrets"}</span>
 | 
			
		||||
                                    </div>
 | 
			
		||||
                                    <div class="secret-list">
 | 
			
		||||
                                        { for user_secrets.iter().enumerate().map(|(i, secret)| {
 | 
			
		||||
                                            html! {
 | 
			
		||||
                                                <div class="secret-item" key={i}>
 | 
			
		||||
                                                    <div class="secret-value">{secret.clone()}</div>
 | 
			
		||||
                                                    <button class="btn-icon btn-remove">
 | 
			
		||||
                                                        <i class="fas fa-minus"></i>
 | 
			
		||||
                                                    </button>
 | 
			
		||||
                                                </div>
 | 
			
		||||
                                            }
 | 
			
		||||
                                        })}
 | 
			
		||||
                                        <div class="secret-add-row">
 | 
			
		||||
                                            <input 
 | 
			
		||||
                                                type="text" 
 | 
			
		||||
                                                class="secret-add-input"
 | 
			
		||||
                                                placeholder="New user secret"
 | 
			
		||||
                                            />
 | 
			
		||||
                                            <button class="btn-icon btn-add">
 | 
			
		||||
                                                <i class="fas fa-plus"></i>
 | 
			
		||||
                                            </button>
 | 
			
		||||
                                        </div>
 | 
			
		||||
                                    </div>
 | 
			
		||||
                                </div>
 | 
			
		||||
                                
 | 
			
		||||
                                <div class="secret-group">
 | 
			
		||||
                                    <div class="secret-header">
 | 
			
		||||
                                        <span class="secret-title">{"Register secrets"}</span>
 | 
			
		||||
                                    </div>
 | 
			
		||||
                                    <div class="secret-list">
 | 
			
		||||
                                        { for register_secrets.iter().enumerate().map(|(i, secret)| {
 | 
			
		||||
                                            html! {
 | 
			
		||||
                                                <div class="secret-item" key={i}>
 | 
			
		||||
                                                    <div class="secret-value">{secret.clone()}</div>
 | 
			
		||||
                                                    <button class="btn-icon btn-remove">
 | 
			
		||||
                                                        <i class="fas fa-minus"></i>
 | 
			
		||||
                                                    </button>
 | 
			
		||||
                                                </div>
 | 
			
		||||
                                            }
 | 
			
		||||
                                        })}
 | 
			
		||||
                                        <div class="secret-add-row">
 | 
			
		||||
                                            <input 
 | 
			
		||||
                                                type="text" 
 | 
			
		||||
                                                class="secret-add-input"
 | 
			
		||||
                                                placeholder="New register secret"
 | 
			
		||||
                                            />
 | 
			
		||||
                                            <button class="btn-icon btn-add">
 | 
			
		||||
                                                <i class="fas fa-plus"></i>
 | 
			
		||||
                                            </button>
 | 
			
		||||
                                        </div>
 | 
			
		||||
                                    </div>
 | 
			
		||||
                                </div>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        }
 | 
			
		||||
                    </div>
 | 
			
		||||
                    
 | 
			
		||||
                    if *is_unlocked {
 | 
			
		||||
                        <div class="save-section">
 | 
			
		||||
                            <button class="save-changes-btn">
 | 
			
		||||
                                {"Save Changes"}
 | 
			
		||||
                            </button>
 | 
			
		||||
                        </div>
 | 
			
		||||
                    }
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            
 | 
			
		||||
            // Documentation Links at Bottom
 | 
			
		||||
            <div class="sidebar-footer">
 | 
			
		||||
                <div class="docs-section">
 | 
			
		||||
                    <h5>{"Documentation"}</h5>
 | 
			
		||||
                    <div class="docs-links">
 | 
			
		||||
                        <a href="https://github.com/herocode/supervisor" target="_blank" class="doc-link">
 | 
			
		||||
                            {"📖 User Guide"}
 | 
			
		||||
                        </a>
 | 
			
		||||
                        <a href="https://github.com/herocode/supervisor/blob/main/README.md" target="_blank" class="doc-link">
 | 
			
		||||
                            {"🚀 Getting Started"}
 | 
			
		||||
                        </a>
 | 
			
		||||
                        <a href="https://github.com/herocode/supervisor/issues" target="_blank" class="doc-link">
 | 
			
		||||
                            {"🐛 Report Issues"}
 | 
			
		||||
                        </a>
 | 
			
		||||
                        <a href="https://github.com/herocode/supervisor/wiki" target="_blank" class="doc-link">
 | 
			
		||||
                            {"📚 API Reference"}
 | 
			
		||||
                        </a>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										61
									
								
								clients/admin-ui/src/types.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								clients/admin-ui/src/types.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,61 @@
 | 
			
		||||
use serde::{Deserialize, Serialize};
 | 
			
		||||
use std::collections::HashMap;
 | 
			
		||||
use std::path::PathBuf;
 | 
			
		||||
use std::time::Duration;
 | 
			
		||||
 | 
			
		||||
// Re-export types from the WASM client
 | 
			
		||||
pub use crate::wasm_client::{
 | 
			
		||||
    WasmClientError as ClientError, WasmClientResult as ClientResult, JobType, ProcessStatus, 
 | 
			
		||||
    RunnerType, RunnerConfig, ProcessManagerType, LogInfo, Job, JobBuilder
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/// UI-specific runner information combining config and status
 | 
			
		||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
 | 
			
		||||
pub struct RunnerInfo {
 | 
			
		||||
    pub id: String,
 | 
			
		||||
    pub config: RunnerConfig,
 | 
			
		||||
    pub status: ProcessStatus,
 | 
			
		||||
    pub logs: Vec<LogInfo>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Form data for adding a new runner
 | 
			
		||||
#[derive(Debug, Clone, Default)]
 | 
			
		||||
pub struct AddRunnerForm {
 | 
			
		||||
    pub actor_id: String,
 | 
			
		||||
    pub runner_type: RunnerType,
 | 
			
		||||
    pub binary_path: String,
 | 
			
		||||
    pub script_type: String,
 | 
			
		||||
    pub args: Vec<String>,
 | 
			
		||||
    pub env_vars: HashMap<String, String>,
 | 
			
		||||
    pub working_dir: Option<PathBuf>,
 | 
			
		||||
    pub restart_policy: String,
 | 
			
		||||
    pub health_check_command: Option<String>,
 | 
			
		||||
    pub dependencies: Vec<String>,
 | 
			
		||||
    pub process_manager_type: ProcessManagerType,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl AddRunnerForm {
 | 
			
		||||
    pub fn to_runner_config(&self) -> RunnerConfig {
 | 
			
		||||
        RunnerConfig {
 | 
			
		||||
            actor_id: self.actor_id.clone(),
 | 
			
		||||
            runner_type: self.runner_type.clone(),
 | 
			
		||||
            binary_path: PathBuf::from(&self.binary_path),
 | 
			
		||||
            script_type: self.script_type.clone(),
 | 
			
		||||
            args: self.args.clone(),
 | 
			
		||||
            env_vars: self.env_vars.clone(),
 | 
			
		||||
            working_dir: self.working_dir.clone(),
 | 
			
		||||
            restart_policy: self.restart_policy.clone(),
 | 
			
		||||
            health_check_command: self.health_check_command.clone(),
 | 
			
		||||
            dependencies: self.dependencies.clone(),
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Application state for managing runners
 | 
			
		||||
#[derive(Debug, Clone, Default)]
 | 
			
		||||
pub struct AppState {
 | 
			
		||||
    pub runners: Vec<RunnerInfo>,
 | 
			
		||||
    pub loading: bool,
 | 
			
		||||
    pub error: Option<String>,
 | 
			
		||||
    pub server_url: String,
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										378
									
								
								clients/admin-ui/src/wasm_client.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										378
									
								
								clients/admin-ui/src/wasm_client.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,378 @@
 | 
			
		||||
use gloo::net::http::Request;
 | 
			
		||||
use serde::{Deserialize, Serialize};
 | 
			
		||||
use serde_json::{json, Value};
 | 
			
		||||
use std::collections::HashMap;
 | 
			
		||||
use std::path::PathBuf;
 | 
			
		||||
use thiserror::Error;
 | 
			
		||||
use uuid::Uuid;
 | 
			
		||||
 | 
			
		||||
/// WASM-compatible client for Hero Supervisor OpenRPC server
 | 
			
		||||
#[derive(Clone)]
 | 
			
		||||
pub struct WasmSupervisorClient {
 | 
			
		||||
    server_url: String,
 | 
			
		||||
    request_id: u64,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Error types for client operations
 | 
			
		||||
#[derive(Error, Debug)]
 | 
			
		||||
pub enum WasmClientError {
 | 
			
		||||
    #[error("HTTP request error: {0}")]
 | 
			
		||||
    Http(String),
 | 
			
		||||
    
 | 
			
		||||
    #[error("JSON serialization error: {0}")]
 | 
			
		||||
    Serialization(#[from] serde_json::Error),
 | 
			
		||||
    
 | 
			
		||||
    #[error("Server error: {message}")]
 | 
			
		||||
    Server { message: String },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Result type for client operations
 | 
			
		||||
pub type WasmClientResult<T> = Result<T, WasmClientError>;
 | 
			
		||||
 | 
			
		||||
/// Types of runners supported by the supervisor
 | 
			
		||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
 | 
			
		||||
pub enum RunnerType {
 | 
			
		||||
    SALRunner,
 | 
			
		||||
    OSISRunner,
 | 
			
		||||
    VRunner,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl Default for RunnerType {
 | 
			
		||||
    fn default() -> Self {
 | 
			
		||||
        RunnerType::SALRunner
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Process manager types
 | 
			
		||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
 | 
			
		||||
pub enum ProcessManagerType {
 | 
			
		||||
    Simple,
 | 
			
		||||
    Tmux,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl Default for ProcessManagerType {
 | 
			
		||||
    fn default() -> Self {
 | 
			
		||||
        ProcessManagerType::Simple
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Configuration for an actor runner
 | 
			
		||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
 | 
			
		||||
pub struct RunnerConfig {
 | 
			
		||||
    pub actor_id: String,
 | 
			
		||||
    pub runner_type: RunnerType,
 | 
			
		||||
    pub binary_path: PathBuf,
 | 
			
		||||
    pub script_type: String,
 | 
			
		||||
    pub args: Vec<String>,
 | 
			
		||||
    pub env_vars: HashMap<String, String>,
 | 
			
		||||
    pub working_dir: Option<PathBuf>,
 | 
			
		||||
    pub restart_policy: String,
 | 
			
		||||
    pub health_check_command: Option<String>,
 | 
			
		||||
    pub dependencies: Vec<String>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Job type enumeration
 | 
			
		||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
 | 
			
		||||
pub enum JobType {
 | 
			
		||||
    SAL,
 | 
			
		||||
    OSIS,
 | 
			
		||||
    V,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Job structure for creating and managing jobs
 | 
			
		||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
 | 
			
		||||
pub struct Job {
 | 
			
		||||
    pub id: String,
 | 
			
		||||
    pub caller_id: String,
 | 
			
		||||
    pub context_id: String,
 | 
			
		||||
    pub payload: String,
 | 
			
		||||
    pub job_type: JobType,
 | 
			
		||||
    pub runner_name: String,
 | 
			
		||||
    pub timeout: Option<u64>,
 | 
			
		||||
    pub env_vars: HashMap<String, String>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Process status information
 | 
			
		||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
 | 
			
		||||
pub enum ProcessStatus {
 | 
			
		||||
    Running,
 | 
			
		||||
    Stopped,
 | 
			
		||||
    Starting,
 | 
			
		||||
    Stopping,
 | 
			
		||||
    Failed,
 | 
			
		||||
    Unknown,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Log information structure
 | 
			
		||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
 | 
			
		||||
pub struct LogInfo {
 | 
			
		||||
    pub timestamp: String,
 | 
			
		||||
    pub level: String,
 | 
			
		||||
    pub message: String,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl WasmSupervisorClient {
 | 
			
		||||
    /// Create a new supervisor client
 | 
			
		||||
    pub fn new(server_url: impl Into<String>) -> Self {
 | 
			
		||||
        Self {
 | 
			
		||||
            server_url: server_url.into(),
 | 
			
		||||
            request_id: 0,
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Get the server URL
 | 
			
		||||
    pub fn server_url(&self) -> &str {
 | 
			
		||||
        &self.server_url
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Make a JSON-RPC request
 | 
			
		||||
    async fn make_request<T>(&mut self, method: &str, params: Value) -> WasmClientResult<T>
 | 
			
		||||
    where
 | 
			
		||||
        T: for<'de> Deserialize<'de>,
 | 
			
		||||
    {
 | 
			
		||||
        self.request_id += 1;
 | 
			
		||||
        
 | 
			
		||||
        let request_body = json!({
 | 
			
		||||
            "jsonrpc": "2.0",
 | 
			
		||||
            "method": method,
 | 
			
		||||
            "params": params,
 | 
			
		||||
            "id": self.request_id
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        let response = Request::post(&self.server_url)
 | 
			
		||||
            .header("Content-Type", "application/json")
 | 
			
		||||
            .json(&request_body)
 | 
			
		||||
            .map_err(|e| WasmClientError::Http(e.to_string()))?
 | 
			
		||||
            .send()
 | 
			
		||||
            .await
 | 
			
		||||
            .map_err(|e| WasmClientError::Http(e.to_string()))?;
 | 
			
		||||
 | 
			
		||||
        if !response.ok() {
 | 
			
		||||
            return Err(WasmClientError::Http(format!(
 | 
			
		||||
                "HTTP error: {} {}",
 | 
			
		||||
                response.status(),
 | 
			
		||||
                response.status_text()
 | 
			
		||||
            )));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let response_text = response
 | 
			
		||||
            .text()
 | 
			
		||||
            .await
 | 
			
		||||
            .map_err(|e| WasmClientError::Http(e.to_string()))?;
 | 
			
		||||
 | 
			
		||||
        let response_json: Value = serde_json::from_str(&response_text)?;
 | 
			
		||||
 | 
			
		||||
        if let Some(error) = response_json.get("error") {
 | 
			
		||||
            return Err(WasmClientError::Server {
 | 
			
		||||
                message: error.get("message")
 | 
			
		||||
                    .and_then(|m| m.as_str())
 | 
			
		||||
                    .unwrap_or("Unknown server error")
 | 
			
		||||
                    .to_string(),
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let result = response_json
 | 
			
		||||
            .get("result")
 | 
			
		||||
            .ok_or_else(|| WasmClientError::Server {
 | 
			
		||||
                message: "No result in response".to_string(),
 | 
			
		||||
            })?;
 | 
			
		||||
 | 
			
		||||
        serde_json::from_value(result.clone()).map_err(Into::into)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Add a new runner to the supervisor
 | 
			
		||||
    pub async fn add_runner(
 | 
			
		||||
        &mut self,
 | 
			
		||||
        config: RunnerConfig,
 | 
			
		||||
        process_manager_type: ProcessManagerType,
 | 
			
		||||
    ) -> WasmClientResult<()> {
 | 
			
		||||
        let params = json!({
 | 
			
		||||
            "config": config,
 | 
			
		||||
            "process_manager_type": process_manager_type
 | 
			
		||||
        });
 | 
			
		||||
        
 | 
			
		||||
        self.make_request("add_runner", params).await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Remove a runner from the supervisor
 | 
			
		||||
    pub async fn remove_runner(&mut self, actor_id: &str) -> WasmClientResult<()> {
 | 
			
		||||
        let params = json!({ "actor_id": actor_id });
 | 
			
		||||
        self.make_request("remove_runner", params).await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// List all runner IDs
 | 
			
		||||
    pub async fn list_runners(&mut self) -> WasmClientResult<Vec<String>> {
 | 
			
		||||
        self.make_request("list_runners", json!({})).await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Start a specific runner
 | 
			
		||||
    pub async fn start_runner(&mut self, actor_id: &str) -> WasmClientResult<()> {
 | 
			
		||||
        let params = json!({ "actor_id": actor_id });
 | 
			
		||||
        self.make_request("start_runner", params).await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Stop a specific runner
 | 
			
		||||
    pub async fn stop_runner(&mut self, actor_id: &str, force: bool) -> WasmClientResult<()> {
 | 
			
		||||
        let params = json!({ "actor_id": actor_id, "force": force });
 | 
			
		||||
        self.make_request("stop_runner", params).await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Get status of a specific runner
 | 
			
		||||
    pub async fn get_runner_status(&mut self, actor_id: &str) -> WasmClientResult<ProcessStatus> {
 | 
			
		||||
        let params = json!({ "actor_id": actor_id });
 | 
			
		||||
        self.make_request("get_runner_status", params).await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Get logs for a specific runner
 | 
			
		||||
    pub async fn get_runner_logs(
 | 
			
		||||
        &mut self,
 | 
			
		||||
        actor_id: &str,
 | 
			
		||||
        lines: Option<usize>,
 | 
			
		||||
        follow: bool,
 | 
			
		||||
    ) -> WasmClientResult<Vec<LogInfo>> {
 | 
			
		||||
        let params = json!({
 | 
			
		||||
            "actor_id": actor_id,
 | 
			
		||||
            "lines": lines,
 | 
			
		||||
            "follow": follow
 | 
			
		||||
        });
 | 
			
		||||
        self.make_request("get_runner_logs", params).await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Queue a job to a specific runner
 | 
			
		||||
    pub async fn queue_job_to_runner(&mut self, runner_name: &str, job: Job) -> WasmClientResult<()> {
 | 
			
		||||
        let params = json!({
 | 
			
		||||
            "runner_name": runner_name,
 | 
			
		||||
            "job": job
 | 
			
		||||
        });
 | 
			
		||||
        self.make_request("queue_job_to_runner", params).await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Queue a job to a specific runner and wait for the result
 | 
			
		||||
    pub async fn queue_and_wait(
 | 
			
		||||
        &mut self,
 | 
			
		||||
        runner_name: &str,
 | 
			
		||||
        job: Job,
 | 
			
		||||
        timeout_secs: u64,
 | 
			
		||||
    ) -> WasmClientResult<Option<String>> {
 | 
			
		||||
        let params = json!({
 | 
			
		||||
            "runner_name": runner_name,
 | 
			
		||||
            "job": job,
 | 
			
		||||
            "timeout_secs": timeout_secs
 | 
			
		||||
        });
 | 
			
		||||
        self.make_request("queue_and_wait", params).await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Get job result by job ID
 | 
			
		||||
    pub async fn get_job_result(&mut self, job_id: &str) -> WasmClientResult<Option<String>> {
 | 
			
		||||
        let params = json!({ "job_id": job_id });
 | 
			
		||||
        self.make_request("get_job_result", params).await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Get status of all runners
 | 
			
		||||
    pub async fn get_all_runner_status(&mut self) -> WasmClientResult<Vec<(String, ProcessStatus)>> {
 | 
			
		||||
        self.make_request("get_all_runner_status", json!({})).await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Start all runners
 | 
			
		||||
    pub async fn start_all(&mut self) -> WasmClientResult<Vec<(String, bool)>> {
 | 
			
		||||
        self.make_request("start_all", json!({})).await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Stop all runners
 | 
			
		||||
    pub async fn stop_all(&mut self, force: bool) -> WasmClientResult<Vec<(String, bool)>> {
 | 
			
		||||
        let params = json!({ "force": force });
 | 
			
		||||
        self.make_request("stop_all", params).await
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Builder for creating jobs with a fluent API
 | 
			
		||||
#[derive(Debug, Clone, Default)]
 | 
			
		||||
pub struct JobBuilder {
 | 
			
		||||
    id: Option<String>,
 | 
			
		||||
    caller_id: Option<String>,
 | 
			
		||||
    context_id: Option<String>,
 | 
			
		||||
    payload: Option<String>,
 | 
			
		||||
    job_type: Option<JobType>,
 | 
			
		||||
    runner_name: Option<String>,
 | 
			
		||||
    timeout: Option<u64>,
 | 
			
		||||
    env_vars: HashMap<String, String>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl JobBuilder {
 | 
			
		||||
    /// Create a new job builder
 | 
			
		||||
    pub fn new() -> Self {
 | 
			
		||||
        Self::default()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Set the caller ID for this job
 | 
			
		||||
    pub fn caller_id(mut self, caller_id: impl Into<String>) -> Self {
 | 
			
		||||
        self.caller_id = Some(caller_id.into());
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Set the context ID for this job
 | 
			
		||||
    pub fn context_id(mut self, context_id: impl Into<String>) -> Self {
 | 
			
		||||
        self.context_id = Some(context_id.into());
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Set the payload (script content) for this job
 | 
			
		||||
    pub fn payload(mut self, payload: impl Into<String>) -> Self {
 | 
			
		||||
        self.payload = Some(payload.into());
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Set the job type
 | 
			
		||||
    pub fn job_type(mut self, job_type: JobType) -> Self {
 | 
			
		||||
        self.job_type = Some(job_type);
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Set the runner name for this job
 | 
			
		||||
    pub fn runner_name(mut self, runner_name: impl Into<String>) -> Self {
 | 
			
		||||
        self.runner_name = Some(runner_name.into());
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Set the timeout for job execution
 | 
			
		||||
    pub fn timeout(mut self, timeout_secs: u64) -> Self {
 | 
			
		||||
        self.timeout = Some(timeout_secs);
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Set a single environment variable
 | 
			
		||||
    pub fn env_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
 | 
			
		||||
        self.env_vars.insert(key.into(), value.into());
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Set multiple environment variables from a HashMap
 | 
			
		||||
    pub fn env_vars(mut self, env_vars: HashMap<String, String>) -> Self {
 | 
			
		||||
        self.env_vars = env_vars;
 | 
			
		||||
        self
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Build the job
 | 
			
		||||
    pub fn build(self) -> WasmClientResult<Job> {
 | 
			
		||||
        Ok(Job {
 | 
			
		||||
            id: self.id.unwrap_or_else(|| Uuid::new_v4().to_string()),
 | 
			
		||||
            caller_id: self.caller_id.ok_or_else(|| WasmClientError::Server {
 | 
			
		||||
                message: "caller_id is required".to_string(),
 | 
			
		||||
            })?,
 | 
			
		||||
            context_id: self.context_id.ok_or_else(|| WasmClientError::Server {
 | 
			
		||||
                message: "context_id is required".to_string(),
 | 
			
		||||
            })?,
 | 
			
		||||
            payload: self.payload.ok_or_else(|| WasmClientError::Server {
 | 
			
		||||
                message: "payload is required".to_string(),
 | 
			
		||||
            })?,
 | 
			
		||||
            job_type: self.job_type.ok_or_else(|| WasmClientError::Server {
 | 
			
		||||
                message: "job_type is required".to_string(),
 | 
			
		||||
            })?,
 | 
			
		||||
            runner_name: self.runner_name.ok_or_else(|| WasmClientError::Server {
 | 
			
		||||
                message: "runner_name is required".to_string(),
 | 
			
		||||
            })?,
 | 
			
		||||
            timeout: self.timeout,
 | 
			
		||||
            env_vars: self.env_vars,
 | 
			
		||||
        })
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user