Files
supervisor/clients/admin-ui/src/sidebar.rs
2025-08-27 10:07:53 +02:00

412 lines
20 KiB
Rust

use yew::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::spawn_local;
use gloo::console;
use hero_supervisor_openrpc_client::wasm::{WasmSupervisorClient, WasmJob};
#[derive(Clone, PartialEq)]
pub struct SupervisorInfo {
pub server_url: String,
pub admin_secrets_count: usize,
pub user_secrets_count: usize,
pub register_secrets_count: usize,
pub runners_count: usize,
}
#[derive(Clone, PartialEq, Debug)]
pub enum SessionSecretType {
None,
User,
Admin,
}
#[derive(Properties, PartialEq)]
pub struct SidebarProps {
pub server_url: String,
pub supervisor_info: Option<SupervisorInfo>,
pub session_secret: String,
pub session_secret_type: SessionSecretType,
pub on_session_secret_change: Callback<(String, SessionSecretType)>,
pub on_supervisor_info_loaded: Callback<SupervisorInfo>,
}
#[function_component(Sidebar)]
pub fn sidebar(props: &SidebarProps) -> Html {
let session_secret_input = use_state(|| String::new());
let payload_input = use_state(|| String::new());
let admin_secrets = use_state(|| Vec::<String>::new());
let user_secrets = use_state(|| Vec::<String>::new());
let register_secrets = use_state(|| Vec::<String>::new());
let is_loading = use_state(|| false);
let on_session_secret_change = {
let session_secret_input = session_secret_input.clone();
Callback::from(move |e: web_sys::Event| {
let input: web_sys::HtmlInputElement = e.target().unwrap().dyn_into().unwrap();
session_secret_input.set(input.value());
})
};
let on_session_secret_submit = {
let session_secret_input = session_secret_input.clone();
let is_loading = is_loading.clone();
let admin_secrets = admin_secrets.clone();
let user_secrets = user_secrets.clone();
let register_secrets = register_secrets.clone();
let server_url = props.server_url.clone();
let on_session_secret_change = props.on_session_secret_change.clone();
Callback::from(move |_: web_sys::MouseEvent| {
let secret = (*session_secret_input).clone();
if secret.is_empty() {
return;
}
is_loading.set(true);
let client = WasmSupervisorClient::new(server_url.clone());
let session_secret_input = session_secret_input.clone();
let is_loading = is_loading.clone();
let admin_secrets = admin_secrets.clone();
let user_secrets = user_secrets.clone();
let register_secrets = register_secrets.clone();
let on_session_secret_change = on_session_secret_change.clone();
spawn_local(async move {
// Try to get admin secrets first to determine if this is an admin secret
match client.list_admin_secrets(&secret).await {
Ok(admin_secret_list) => {
// This is an admin secret
admin_secrets.set(admin_secret_list);
// Also load user and register secrets
if let Ok(user_secret_list) = client.list_user_secrets(&secret).await {
user_secrets.set(user_secret_list);
}
if let Ok(register_secret_list) = client.list_register_secrets(&secret).await {
register_secrets.set(register_secret_list);
}
on_session_secret_change.emit((secret, SessionSecretType::Admin));
console::log!("Admin session established");
}
Err(_) => {
// Try as user secret - just test if we can make any call with it
match client.list_runners().await {
Ok(_) => {
// This appears to be a valid user secret
on_session_secret_change.emit((secret, SessionSecretType::User));
console::log!("User session established");
}
Err(e) => {
console::log!("Invalid secret:", format!("{:?}", e));
on_session_secret_change.emit((String::new(), SessionSecretType::None));
}
}
}
}
is_loading.set(false);
session_secret_input.set(String::new());
});
})
};
let on_session_clear = {
let on_session_secret_change = props.on_session_secret_change.clone();
let admin_secrets = admin_secrets.clone();
let user_secrets = user_secrets.clone();
let register_secrets = register_secrets.clone();
Callback::from(move |_: web_sys::MouseEvent| {
on_session_secret_change.emit((String::new(), SessionSecretType::None));
admin_secrets.set(Vec::new());
user_secrets.set(Vec::new());
register_secrets.set(Vec::new());
console::log!("Session cleared");
})
};
let on_payload_change = {
let payload_input = payload_input.clone();
Callback::from(move |e: web_sys::Event| {
let input: web_sys::HtmlInputElement = e.target().unwrap().dyn_into().unwrap();
payload_input.set(input.value());
})
};
let on_run_click = {
let payload_input = payload_input.clone();
let server_url = props.server_url.clone();
let session_secret = props.session_secret.clone();
let is_loading = is_loading.clone();
Callback::from(move |_: web_sys::MouseEvent| {
let payload = (*payload_input).clone();
if payload.is_empty() || session_secret.is_empty() {
return;
}
is_loading.set(true);
let client = WasmSupervisorClient::new(server_url.clone());
let payload_input = payload_input.clone();
let is_loading = is_loading.clone();
let session_secret = session_secret.clone();
spawn_local(async move {
// Create WasmJob object using constructor
let job = WasmJob::new(
uuid::Uuid::new_v4().to_string(),
payload.clone(),
"osis".to_string(),
"default".to_string(),
);
match client.create_job(session_secret.clone(), job).await {
Ok(job_id) => {
console::log!("Job created successfully:", job_id);
payload_input.set(String::new());
}
Err(e) => {
console::log!("Failed to create job:", format!("{:?}", e));
}
}
is_loading.set(false);
});
})
};
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>
// Session Secret Management Section
<div class="session-section">
<div class="session-header">
<span class="session-title">{"Session"}</span>
{
match props.session_secret_type {
SessionSecretType::Admin => html! {
<span class="session-badge admin">{"Admin"}</span>
},
SessionSecretType::User => html! {
<span class="session-badge user">{"User"}</span>
},
SessionSecretType::None => html! {
<span class="session-badge none">{"None"}</span>
}
}
}
</div>
if props.session_secret_type == SessionSecretType::None {
<div class="session-input-row">
<input
type="password"
class="session-input"
placeholder="Enter secret to establish session"
value={(*session_secret_input).clone()}
onchange={on_session_secret_change}
disabled={*is_loading}
/>
<button
class="session-btn"
onclick={on_session_secret_submit}
disabled={*is_loading || session_secret_input.is_empty()}
>
if *is_loading {
<i class="fas fa-spinner fa-spin"></i>
} else {
<i class="fas fa-sign-in-alt"></i>
}
</button>
</div>
} else {
<div class="session-active">
<div class="session-info">
<span class="session-secret-preview">
{format!("{}...", &props.session_secret[..std::cmp::min(8, props.session_secret.len())])}
</span>
<button
class="session-clear-btn"
onclick={on_session_clear}
>
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
</div>
}
</div>
// Secrets Management Section (only visible for admin)
if props.session_secret_type == SessionSecretType::Admin {
<div class="secrets-section">
<div class="secrets-header">
<span class="secrets-title">{"Secrets Management"}</span>
</div>
<div class="secrets-content">
<div class="secret-group">
<div class="secret-header">
<span class="secret-title">{"Admin secrets"}</span>
<span class="secret-count">{admin_secrets.len()}</span>
</div>
<div class="secret-list">
{ for admin_secrets.iter().enumerate().map(|(i, secret)| {
html! {
<div class="secret-item" key={i}>
<div class="secret-value">{secret.clone()}</div>
<button class="btn-icon btn-remove">
<i class="fas fa-minus"></i>
</button>
</div>
}
})}
</div>
</div>
<div class="secret-group">
<div class="secret-header">
<span class="secret-title">{"User secrets"}</span>
<span class="secret-count">{user_secrets.len()}</span>
</div>
<div class="secret-list">
{ for user_secrets.iter().enumerate().map(|(i, secret)| {
html! {
<div class="secret-item" key={i}>
<div class="secret-value">{secret.clone()}</div>
<button class="btn-icon btn-remove">
<i class="fas fa-minus"></i>
</button>
</div>
}
})}
</div>
</div>
<div class="secret-group">
<div class="secret-header">
<span class="secret-title">{"Register secrets"}</span>
<span class="secret-count">{register_secrets.len()}</span>
</div>
<div class="secret-list">
{ for register_secrets.iter().enumerate().map(|(i, secret)| {
html! {
<div class="secret-item" key={i}>
<div class="secret-value">{secret.clone()}</div>
<button class="btn-icon btn-remove">
<i class="fas fa-minus"></i>
</button>
</div>
}
})}
</div>
</div>
</div>
</div>
}
// Quick Actions Section
<div class="quick-actions">
<div class="quick-actions-header">
<span class="quick-actions-title">{"Quick Actions"}</span>
</div>
<div class="quick-actions-content">
if props.session_secret_type != SessionSecretType::None {
<div class="action-row">
<input
type="text"
class="action-input"
placeholder="Enter payload for job"
value={(*payload_input).clone()}
onchange={on_payload_change}
/>
<button
class="action-btn run-btn"
onclick={on_run_click}
disabled={payload_input.is_empty() || *is_loading}
>
if *is_loading {
<i class="fas fa-spinner fa-spin"></i>
} else {
<i class="fas fa-play"></i>
}
{"Run"}
</button>
</div>
} else {
<div class="action-disabled">
<span>{"Establish a session to enable quick actions"}</span>
</div>
}
</div>
</div>
// Supervisor Info Section
if let Some(info) = &props.supervisor_info {
<div class="supervisor-info">
<div class="supervisor-info-header">
<span class="supervisor-info-title">{"Supervisor Info"}</span>
</div>
<div class="supervisor-info-content">
<div class="info-item">
<span class="info-label">{"Admin secrets:"}</span>
<span class="info-value">{info.admin_secrets_count}</span>
</div>
<div class="info-item">
<span class="info-label">{"User secrets:"}</span>
<span class="info-value">{info.user_secrets_count}</span>
</div>
<div class="info-item">
<span class="info-label">{"Register secrets:"}</span>
<span class="info-value">{info.register_secrets_count}</span>
</div>
<div class="info-item">
<span class="info-label">{"Runners:"}</span>
<span class="info-value">{info.runners_count}</span>
</div>
</div>
</div>
}
</div>
</div>
// 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>
}
}