581 lines
29 KiB
Rust
581 lines
29 KiB
Rust
// 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<Result<Vec<Application>, ServerFnError>>,
|
||
add_action: ServerAction<AddHostApplications>,
|
||
show_modal: RwSignal<bool>,
|
||
) -> impl IntoView {
|
||
// Full Application structs so names are available in the selected tag list.
|
||
let selected: RwSignal<Vec<Application>> = RwSignal::new(vec![]);
|
||
|
||
view! {
|
||
<div class="modal-backdrop" on:click=move |_| show_modal.set(false)>
|
||
<div class="modal" on:click=move |e| e.stop_propagation()>
|
||
<div class="modal__header">
|
||
<h2>"Add applications"</h2>
|
||
<button class="modal__close" type="button" aria-label="Close"
|
||
on:click=move |_| show_modal.set(false)>
|
||
"×"
|
||
</button>
|
||
</div>
|
||
|
||
<div class="modal__body">
|
||
|
||
// ── Scrollable pick list ──────────────────────────────────
|
||
{move || match available_apps_res.get() {
|
||
None => view! { <p class="empty">"Loading…"</p> }.into_any(),
|
||
Some(r) => match (*r).clone() {
|
||
Err(e) => view! {
|
||
<p class="error">
|
||
"Could not load applications: " {e.to_string()}
|
||
</p>
|
||
}.into_any(),
|
||
Ok(apps) => {
|
||
// Exclude already-selected apps from the displayed list.
|
||
let sel_ids: Vec<i64> = selected.get()
|
||
.iter().map(|a| a.id).collect();
|
||
let displayed: Vec<Application> = apps.into_iter()
|
||
.filter(|a| !sel_ids.contains(&a.id))
|
||
.collect();
|
||
|
||
if displayed.is_empty() && sel_ids.is_empty() {
|
||
view! {
|
||
<p class="empty">
|
||
"All applications are already linked to this host."
|
||
</p>
|
||
}.into_any()
|
||
} else if displayed.is_empty() {
|
||
view! {
|
||
<p class="empty">
|
||
"All available applications have been selected."
|
||
</p>
|
||
}.into_any()
|
||
} else {
|
||
view! {
|
||
<ul class="app-pick-list">
|
||
{displayed.into_iter().map(|app| {
|
||
let app_clone = app.clone();
|
||
view! {
|
||
<li class="app-pick-item"
|
||
on:click=move |_| {
|
||
selected.update(|v| {
|
||
v.push(app_clone.clone());
|
||
});
|
||
}
|
||
>
|
||
<span>{app.name}</span>
|
||
<span class="app-pick-item__add">"+"</span>
|
||
</li>
|
||
}
|
||
}).collect_view()}
|
||
</ul>
|
||
}.into_any()
|
||
}
|
||
}
|
||
}
|
||
}}
|
||
|
||
// ── Selected tags (shown once at least one app is chosen) ─
|
||
{move || (!selected.get().is_empty()).then(|| {
|
||
let sel = selected.get();
|
||
view! {
|
||
<div class="app-selected-section">
|
||
<span class="app-selected-label">"Selected:"</span>
|
||
<div class="app-selected-list">
|
||
{sel.into_iter().map(|app| {
|
||
let app_id = app.id;
|
||
view! {
|
||
<span class="app-selected-tag">
|
||
{app.name}
|
||
<button
|
||
class="app-selected-tag__remove"
|
||
type="button"
|
||
aria-label="Remove"
|
||
on:click=move |_| {
|
||
selected.update(|v| {
|
||
v.retain(|x| x.id != app_id);
|
||
});
|
||
}
|
||
>"×"</button>
|
||
</span>
|
||
}
|
||
}).collect_view()}
|
||
</div>
|
||
</div>
|
||
}
|
||
})}
|
||
|
||
{move || add_action.value().get()
|
||
.and_then(|r| r.err())
|
||
.map(|e| view! { <p class="error">{e.to_string()}</p> })
|
||
}
|
||
</div>
|
||
|
||
<div class="modal__actions">
|
||
<button class="btn-secondary" type="button"
|
||
on:click=move |_| show_modal.set(false)>
|
||
"Cancel"
|
||
</button>
|
||
<button
|
||
class="btn-primary"
|
||
type="button"
|
||
disabled={move || selected.get().is_empty()}
|
||
on:click=move |_| {
|
||
let ids_str = selected.get_untracked()
|
||
.iter()
|
||
.map(|a| a.id.to_string())
|
||
.collect::<Vec<_>>()
|
||
.join(",");
|
||
if !ids_str.is_empty() {
|
||
add_action.dispatch(AddHostApplications {
|
||
host_id,
|
||
application_ids: ids_str,
|
||
});
|
||
}
|
||
}
|
||
>
|
||
"Add selected"
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}.into_any()
|
||
}
|
||
|
||
// ─── Delete confirmation modal ────────────────────────────────────────────────
|
||
|
||
#[component]
|
||
fn DeleteModal(
|
||
host_name: String,
|
||
delete_action: ServerAction<DeleteHost>,
|
||
host_id: i64,
|
||
show_modal: RwSignal<bool>,
|
||
) -> impl IntoView {
|
||
view! {
|
||
<div class="modal-backdrop" on:click=move |_| show_modal.set(false)>
|
||
<div class="modal" on:click=move |e| e.stop_propagation()>
|
||
<div class="modal__header">
|
||
<h2>"Delete host"</h2>
|
||
<button class="modal__close" type="button" aria-label="Close"
|
||
on:click=move |_| show_modal.set(false)>
|
||
"×"
|
||
</button>
|
||
</div>
|
||
<div class="modal__body">
|
||
<p class="warning">
|
||
"Are you sure you want to delete "
|
||
<strong>{host_name}</strong>
|
||
"? All port and application associations will also be removed."
|
||
</p>
|
||
</div>
|
||
<div class="modal__actions">
|
||
<button class="btn-secondary" type="button"
|
||
on:click=move |_| show_modal.set(false)>
|
||
"Cancel"
|
||
</button>
|
||
<button class="btn-danger" type="button"
|
||
on:click=move |_| { delete_action.dispatch(DeleteHost { id: host_id }); }>
|
||
"Delete"
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}.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::<i64>().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::<UpdateHost>::new();
|
||
let add_port_action = ServerAction::<AddHostPort>::new();
|
||
let remove_port_action = ServerAction::<RemoveHostPort>::new();
|
||
let add_app_action = ServerAction::<AddHostApplications>::new();
|
||
let remove_app_action = ServerAction::<RemoveHostApplication>::new();
|
||
let delete_action = ServerAction::<DeleteHost>::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 <Suspense> 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! {
|
||
<div class="host-detail-page">
|
||
<Suspense fallback=|| view! { <p class="empty">"Loading host…"</p> }>
|
||
{move || host.get().map(|r| match (*r).clone() {
|
||
Err(e) => view! {
|
||
<p class="error">"Could not load host: " {e.to_string()}</p>
|
||
}.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! {
|
||
<p class="empty">"No ports open on this host."</p>
|
||
}.into_any()
|
||
} else {
|
||
view! {
|
||
<div class="port-list">
|
||
{ports.into_iter().map(|port| {
|
||
let num = port.number;
|
||
view! {
|
||
<div class="port-row">
|
||
<span class="port-row__number">{num}</span>
|
||
<span class="port-row__desc">
|
||
{port.description.unwrap_or_default()}
|
||
</span>
|
||
<button
|
||
class="btn-danger"
|
||
type="button"
|
||
on:click=move |_| {
|
||
remove_port_action.dispatch(
|
||
RemoveHostPort { host_id: id, port_number: num as i64 }
|
||
);
|
||
}
|
||
>
|
||
"Remove"
|
||
</button>
|
||
</div>
|
||
}
|
||
}).collect_view()}
|
||
</div>
|
||
}.into_any()
|
||
};
|
||
|
||
// Pre-built applications view.
|
||
let apps_list = if applications.is_empty() {
|
||
view! {
|
||
<p class="empty">"No applications linked to this host."</p>
|
||
}.into_any()
|
||
} else {
|
||
view! {
|
||
<div class="app-list">
|
||
{applications.into_iter().map(|app| {
|
||
let app_id = app.id;
|
||
view! {
|
||
<div class="app-row">
|
||
<span class="app-row__name">{app.name}</span>
|
||
<button
|
||
class="btn-danger"
|
||
type="button"
|
||
on:click=move |_| {
|
||
remove_app_action.dispatch(
|
||
RemoveHostApplication { host_id: id, application_id: app_id }
|
||
);
|
||
}
|
||
>
|
||
"Remove"
|
||
</button>
|
||
</div>
|
||
}
|
||
}).collect_view()}
|
||
</div>
|
||
}.into_any()
|
||
};
|
||
|
||
view! {
|
||
// ── Page header ──────────────────────────────────
|
||
<div class="page-header detail-page-header">
|
||
<a class="back-btn" href=move || back_url()>
|
||
{move || back_label()}
|
||
</a>
|
||
<h1 class="detail-page-title">{move || name_sig.get()}</h1>
|
||
</div>
|
||
|
||
// ── Identity form ─────────────────────────────────
|
||
<section class="detail-section">
|
||
<h2 class="detail-section__title">"Identity"</h2>
|
||
<div class="detail-form">
|
||
<label class="detail-field">
|
||
"Name"
|
||
<input
|
||
type="text"
|
||
prop:value=move || name_sig.get()
|
||
on:input=move |e| name_sig.set(event_target_value(&e))
|
||
/>
|
||
</label>
|
||
<label class="detail-field">
|
||
"IP address"
|
||
<input
|
||
type="text"
|
||
prop:value=move || ip_sig.get()
|
||
on:input=move |e| ip_sig.set(event_target_value(&e))
|
||
/>
|
||
</label>
|
||
<label class="detail-field">
|
||
"Network"
|
||
<select on:change=move |e| {
|
||
net_id_sig.set(
|
||
event_target_value(&e).parse().unwrap_or(0)
|
||
);
|
||
}>
|
||
{move || networks_res.get()
|
||
.and_then(|r| (*r).clone().ok())
|
||
.map(|nets| {
|
||
let current = net_id_sig.get();
|
||
nets.into_iter().map(|n| {
|
||
let label = format!("{} - {}", n.name, n.cidr);
|
||
view! {
|
||
<option
|
||
value=n.id.to_string()
|
||
selected=(n.id == current)
|
||
>
|
||
{label}
|
||
</option>
|
||
}
|
||
}).collect_view()
|
||
})
|
||
}
|
||
</select>
|
||
</label>
|
||
|
||
{move || update_action.value().get()
|
||
.and_then(|r| r.err())
|
||
.map(|e| view! { <p class="error">{e.to_string()}</p> })
|
||
}
|
||
|
||
<div class="form-actions">
|
||
<button
|
||
class="btn-primary"
|
||
type="button"
|
||
on:click=move |_| {
|
||
update_action.dispatch(UpdateHost {
|
||
id,
|
||
name: name_sig.get_untracked(),
|
||
ip: ip_sig.get_untracked(),
|
||
network_id: net_id_sig.get_untracked(),
|
||
});
|
||
}
|
||
>
|
||
"Save changes"
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
// ── Ports section ─────────────────────────────────
|
||
<section class="detail-section">
|
||
<h2 class="detail-section__title">
|
||
{format!("Open ports ({})", port_count)}
|
||
</h2>
|
||
|
||
{ports_list}
|
||
|
||
{move || remove_port_action.value().get()
|
||
.and_then(|r| r.err())
|
||
.map(|e| view! {
|
||
<p class="error">"Remove failed: " {e.to_string()}</p>
|
||
})
|
||
}
|
||
|
||
<div class="port-add-row">
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max="65535"
|
||
placeholder="Port number (1–65535)"
|
||
prop:value=move || new_port.get()
|
||
on:input=move |e| new_port.set(event_target_value(&e))
|
||
/>
|
||
<button
|
||
class="btn-primary"
|
||
type="button"
|
||
on:click=move |_| {
|
||
let raw = new_port.get_untracked();
|
||
if let Ok(n) = raw.trim().parse::<i64>() {
|
||
if (1..=65535).contains(&n) {
|
||
add_port_action.dispatch(AddHostPort {
|
||
host_id: id,
|
||
port_number: n,
|
||
});
|
||
new_port.set(String::new());
|
||
}
|
||
}
|
||
}
|
||
>
|
||
"Add port"
|
||
</button>
|
||
{move || add_port_action.value().get()
|
||
.and_then(|r| r.err())
|
||
.map(|e| view! {
|
||
<p class="error">"Add failed: " {e.to_string()}</p>
|
||
})
|
||
}
|
||
</div>
|
||
</section>
|
||
|
||
// ── Applications section ──────────────────────────
|
||
<section class="detail-section">
|
||
<h2 class="detail-section__title">
|
||
{format!("Applications ({})", app_count)}
|
||
</h2>
|
||
|
||
{apps_list}
|
||
|
||
{move || remove_app_action.value().get()
|
||
.and_then(|r| r.err())
|
||
.map(|e| view! {
|
||
<p class="error">"Remove failed: " {e.to_string()}</p>
|
||
})
|
||
}
|
||
|
||
<div class="port-add-row">
|
||
<button
|
||
class="btn-primary"
|
||
type="button"
|
||
on:click=move |_| show_add_app_modal.set(true)
|
||
>
|
||
"+ Add applications"
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
// ── Add applications modal ────────────────────────
|
||
{move || show_add_app_modal.get().then(|| view! {
|
||
<AddAppModal
|
||
host_id=id
|
||
available_apps_res=available_apps_res
|
||
add_action=add_app_action
|
||
show_modal=show_add_app_modal
|
||
/>
|
||
})}
|
||
|
||
// ── Danger zone ───────────────────────────────────
|
||
<div class="danger-zone">
|
||
<button
|
||
class="btn-danger-solid"
|
||
type="button"
|
||
on:click=move |_| show_delete_modal.set(true)
|
||
>
|
||
"Delete host"
|
||
</button>
|
||
</div>
|
||
|
||
// ── Delete modal (conditional) ────────────────────
|
||
{move || show_delete_modal.get().then(|| view! {
|
||
<DeleteModal
|
||
host_name=modal_name.clone()
|
||
delete_action=delete_action
|
||
host_id=id
|
||
show_modal=show_delete_modal
|
||
/>
|
||
})}
|
||
}.into_any()
|
||
}
|
||
})}
|
||
</Suspense>
|
||
</div>
|
||
}.into_any()
|
||
}
|