feat(host-detail): add direct host-application association with modal multi-select

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 03:08:58 +02:00
parent a6ce382eb5
commit 5789aba86b
6 changed files with 450 additions and 41 deletions

View File

@@ -1,17 +1,122 @@
// 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
// - Delete button : opens a confirmation modal, then navigates back to /hosts
// - 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::{AddHostPort, DeleteHost, RemoveHostPort, UpdateHost, get_host_detail},
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 ───────────────────────────────────────────────────
// Multi-select modal: shows every application not yet linked to the host.
// The auto-close Effect lives in the PARENT so that reopening the modal
// after a previous success does not immediately dismiss it again.
#[component]
fn AddAppModal(
host_id: i64,
available_apps_res: LocalResource<Result<Vec<Application>, ServerFnError>>,
add_action: ServerAction<AddHostApplications>,
show_modal: RwSignal<bool>,
) -> impl IntoView {
// Tracks which application IDs the user has checked.
let selected: RwSignal<Vec<i64>> = 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">
{move || available_apps_res.get().map(|res| match (*res).clone() {
Err(e) => view! {
<p class="error">"Could not load applications: " {e.to_string()}</p>
}.into_any(),
Ok(apps) if apps.is_empty() => view! {
<p class="empty">"All applications are already linked to this host."</p>
}.into_any(),
Ok(apps) => view! {
<div class="app-select-list">
{apps.into_iter().map(|app| {
let app_id = app.id;
view! {
<label class="app-select-item">
<input
type="checkbox"
on:change=move |e| {
let checked = event_target_checked(&e);
selected.update(|v| {
if checked {
if !v.contains(&app_id) { v.push(app_id); }
} else {
v.retain(|&x| x != app_id);
}
});
}
/>
{app.name}
</label>
}
}).collect_view()}
</div>
}.into_any(),
})}
</div>
{move || add_action.value().get()
.and_then(|r| r.err())
.map(|e| view! { <p class="error">{e.to_string()}</p> })
}
<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(|id| 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 ────────────────────────────────────────────────
@@ -36,7 +141,7 @@ fn DeleteModal(
<p class="warning">
"Are you sure you want to delete "
<strong>{host_name}</strong>
"? All port associations will also be removed."
"? All port and application associations will also be removed."
</p>
</div>
<div class="modal__actions">
@@ -58,8 +163,6 @@ fn DeleteModal(
#[component]
pub fn HostDetailPage() -> impl IntoView {
// Read the `:id` segment from the URL.
// `use_params_map()` returns a reactive map of all URL path parameters.
let params = use_params_map();
let host_id = move || {
params.read().get("id")
@@ -67,8 +170,6 @@ pub fn HostDetailPage() -> impl IntoView {
.unwrap_or(0)
};
// Optional `?back=<url>` query parameter — used when arriving from a network
// detail page so the back button returns there instead of the hosts list.
let query = use_query_map();
let back_url = move || {
query.read().get("back")
@@ -82,37 +183,54 @@ pub fn HostDetailPage() -> impl IntoView {
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_delete_modal = RwSignal::new(false);
let show_add_app_modal = RwSignal::new(false);
// Reload detail after any mutation that touches this host.
// The resource key includes action versions so it invalidates automatically.
// 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);
}
});
// Reload host detail after any mutation.
let host = Resource::new(
move || (
host_id(),
update_action.version().get(),
add_port_action.version().get(),
remove_port_action.version().get(),
add_app_action.version().get(),
remove_app_action.version().get(),
),
|(id, _, _, _)| get_host_detail(id),
|(id, _, _, _, _, _)| get_host_detail(id),
);
// Networks dropdown — LocalResource avoids SSR/hydration mismatch.
let networks_res = LocalResource::new(|| get_networks());
// Edit-field signals, populated once by the Effect below.
// Using signals (rather than local variables) keeps them stable across
// re-renders and lets the user edit without triggering a resource reload.
// 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());
// Input value for the "add port" row.
let new_port = RwSignal::new(String::new());
// Sync edit signals whenever the host resource delivers fresh data.
// This runs on initial load and after every successful mutation.
// Sync edit signals whenever fresh host data arrives.
Effect::new(move |_| {
if let Some(Ok(ref detail)) = host.get() {
name_sig.set(detail.name.clone());
@@ -121,8 +239,6 @@ pub fn HostDetailPage() -> impl IntoView {
}
});
// Navigate back to the list after a successful delete.
// `use_navigate()` must be called during component setup (not inside a closure).
let navigate = use_navigate();
Effect::new(move |_| {
if let Some(Ok(true)) = delete_action.value().get() {
@@ -139,10 +255,12 @@ pub fn HostDetailPage() -> impl IntoView {
}.into_any(),
Ok(detail) => {
let id = detail.id;
let modal_name = detail.name.clone();
let port_count = detail.ports.len();
let ports = detail.ports;
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() {
@@ -178,6 +296,37 @@ pub fn HostDetailPage() -> impl IntoView {
}.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">
@@ -244,13 +393,13 @@ pub fn HostDetailPage() -> impl IntoView {
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(),
});
}
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>
@@ -273,7 +422,6 @@ pub fn HostDetailPage() -> impl IntoView {
})
}
// Add port row
<div class="port-add-row">
<input
type="number"
@@ -310,7 +458,43 @@ pub fn HostDetailPage() -> impl IntoView {
</div>
</section>
// ── Danger zone ──────────────────────────────────
// ── 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"