// client/host_detail.rs — Host detail page // // Shows all information for a single host: // - Identity form : name, IP, network dropdown — editable, saved with "Save changes" // - Ports section : full list with Remove per port + Add port input // - Applications : directly linked apps with Remove + modal multi-select to add // - Delete button : opens a confirmation modal, then navigates back to /hosts use leptos::prelude::*; use leptos_router::hooks::{use_navigate, use_params_map, use_query_map}; use crate::api::{ hosts::{ AddHostApplications, AddHostPort, DeleteHost, RemoveHostApplication, RemoveHostPort, UpdateHost, get_applications_not_on_host, get_host_detail, }, networks::get_networks, }; use crate::models::Application; // ─── Add applications modal ─────────────────────────────────────────────────── // Scrollable pick list + selected tags: // - Top: scrollable list of available apps; clicking one moves it to the // selected section and removes it from the list. // - Bottom: selected apps shown as removable tags; clicking × puts the app // back in the list. // // The auto-close Effect lives in the PARENT to avoid the re-trigger bug // (an Effect inside a conditionally-rendered component fires on mount and // would immediately close the modal if the action already held a past Ok value). #[component] fn AddAppModal( host_id: i64, available_apps_res: LocalResource, ServerFnError>>, add_action: ServerAction, show_modal: RwSignal, ) -> impl IntoView { // Full Application structs so names are available in the selected tag list. let selected: RwSignal> = RwSignal::new(vec![]); view! { }.into_any() } // ─── Delete confirmation modal ──────────────────────────────────────────────── #[component] fn DeleteModal( host_name: String, delete_action: ServerAction, host_id: i64, show_modal: RwSignal, ) -> impl IntoView { view! { }.into_any() } // ─── Main page component ────────────────────────────────────────────────────── #[component] pub fn HostDetailPage() -> impl IntoView { let params = use_params_map(); let host_id = move || { params.read().get("id") .and_then(|s| s.parse::().ok()) .unwrap_or(0) }; let query = use_query_map(); let back_url = move || { query.read().get("back") .map(|s| s.to_string()) .unwrap_or_else(|| "/hosts".to_string()) }; let back_label = move || { if back_url().starts_with("/networks/") { "← Network" } else { "← Hosts" } }; let update_action = ServerAction::::new(); let add_port_action = ServerAction::::new(); let remove_port_action = ServerAction::::new(); let add_app_action = ServerAction::::new(); let remove_app_action = ServerAction::::new(); let delete_action = ServerAction::::new(); let show_delete_modal = RwSignal::new(false); let show_add_app_modal = RwSignal::new(false); // Auto-close the add-app modal after a successful addition. // Keeping this Effect in the parent avoids the re-trigger bug that would // occur if the Effect were inside AddAppModal (it would fire on mount // if the action already held a previous Ok value). Effect::new(move |_| { if let Some(Ok(_)) = add_app_action.value().get() { show_add_app_modal.set(false); } }); // LocalResource avoids reading the resource outside during hydration, // which would cause a mismatch between the SSR-rendered fallback and the content // the WASM expects to find after the resource resolves. let host = LocalResource::new(move || { let _ = update_action.version().get(); let _ = add_port_action.version().get(); let _ = remove_port_action.version().get(); let _ = add_app_action.version().get(); let _ = remove_app_action.version().get(); get_host_detail(host_id()) }); // Networks dropdown — LocalResource avoids SSR/hydration mismatch. let networks_res = LocalResource::new(|| get_networks()); // Available apps for the modal: re-fetched whenever add/remove completes. let add_app_ver = add_app_action.version(); let remove_app_ver = remove_app_action.version(); let available_apps_res = LocalResource::new(move || { let _ = add_app_ver.get(); let _ = remove_app_ver.get(); get_applications_not_on_host(host_id()) }); let name_sig = RwSignal::new(String::new()); let ip_sig = RwSignal::new(String::new()); let net_id_sig = RwSignal::new(0i64); let new_port = RwSignal::new(String::new()); // Sync edit signals whenever fresh host data arrives. // LocalResource wraps its value in SendWrapper, so we dereference with `*r`. Effect::new(move |_| { if let Some(r) = host.get() { if let Ok(ref detail) = *r { name_sig.set(detail.name.clone()); ip_sig.set(detail.ip.clone()); net_id_sig.set(detail.network_id); } } }); let navigate = use_navigate(); Effect::new(move |_| { if let Some(Ok(true)) = delete_action.value().get() { navigate("/hosts", Default::default()); } }); view! {
"Loading host…"

}> {move || host.get().map(|r| match (*r).clone() { Err(e) => view! {

"Could not load host: " {e.to_string()}

}.into_any(), Ok(detail) => { let id = detail.id; let modal_name = detail.name.clone(); let port_count = detail.ports.len(); let app_count = detail.applications.len(); let ports = detail.ports; let applications = detail.applications; // Pre-built ports view — consumes `ports` once, not reactively. let ports_list = if ports.is_empty() { view! {

"No ports open on this host."

}.into_any() } else { view! {
{ports.into_iter().map(|port| { let num = port.number; view! {
{num} {port.description.unwrap_or_default()}
} }).collect_view()}
}.into_any() }; // Pre-built applications view. let apps_list = if applications.is_empty() { view! {

"No applications linked to this host."

}.into_any() } else { view! {
{applications.into_iter().map(|app| { let app_id = app.id; view! {
{app.name}
} }).collect_view()}
}.into_any() }; view! { // ── Page header ────────────────────────────────── // ── Identity form ─────────────────────────────────

"Identity"

{move || update_action.value().get() .and_then(|r| r.err()) .map(|e| view! {

{e.to_string()}

}) }
// ── Ports section ─────────────────────────────────

{format!("Open ports ({})", port_count)}

{ports_list} {move || remove_port_action.value().get() .and_then(|r| r.err()) .map(|e| view! {

"Remove failed: " {e.to_string()}

}) }
{move || add_port_action.value().get() .and_then(|r| r.err()) .map(|e| view! {

"Add failed: " {e.to_string()}

}) }
// ── Applications section ──────────────────────────

{format!("Applications ({})", app_count)}

{apps_list} {move || remove_app_action.value().get() .and_then(|r| r.err()) .map(|e| view! {

"Remove failed: " {e.to_string()}

}) }
// ── Add applications modal ──────────────────────── {move || show_add_app_modal.get().then(|| view! { })} // ── Danger zone ───────────────────────────────────
// ── Delete modal (conditional) ──────────────────── {move || show_delete_modal.get().then(|| view! { })} }.into_any() } })}
}.into_any() }