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

@@ -0,0 +1,8 @@
-- host_applications: direct association between a host and an application.
-- Allows explicitly tagging a host with an application regardless of ports.
-- One application can only be linked once to a given host.
CREATE TABLE IF NOT EXISTS host_applications (
host_id BIGINT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
application_id BIGINT NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
PRIMARY KEY (host_id, application_id)
);

View File

@@ -0,0 +1,8 @@
-- host_applications: direct association between a host and an application.
-- Allows explicitly tagging a host with an application regardless of ports.
-- One application can only be linked once to a given host.
CREATE TABLE IF NOT EXISTS host_applications (
host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
application_id INTEGER NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
PRIMARY KEY (host_id, application_id)
);

View File

@@ -3,9 +3,7 @@
use leptos::prelude::*; use leptos::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::models::Host; use crate::models::{Application, Host, Port};
use crate::models::Port;
// ─── Presentation types ─────────────────────────────────────────────────────── // ─── Presentation types ───────────────────────────────────────────────────────
@@ -32,7 +30,7 @@ pub struct HostsPage {
pub total_pages: i64, // ceil(total / per_page); always ≥ 1 pub total_pages: i64, // ceil(total / per_page); always ≥ 1
} }
// Full host detail: identity fields + resolved network + open ports. // Full host detail: identity fields + resolved network + open ports + linked applications.
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HostDetail { pub struct HostDetail {
pub id: i64, pub id: i64,
@@ -42,6 +40,7 @@ pub struct HostDetail {
pub network_name: String, pub network_name: String,
pub network_cidr: String, pub network_cidr: String,
pub ports: Vec<Port>, pub ports: Vec<Port>,
pub applications: Vec<Application>,
} }
// ─── Queries ────────────────────────────────────────────────────────────────── // ─── Queries ──────────────────────────────────────────────────────────────────
@@ -50,7 +49,12 @@ pub struct HostDetail {
#[server] #[server]
pub async fn get_host_detail(id: i64) -> Result<HostDetail, ServerFnError> { pub async fn get_host_detail(id: i64) -> Result<HostDetail, ServerFnError> {
use sqlx::AnyPool; use sqlx::AnyPool;
use crate::server::repository::{hosts as host_repo, networks, ports as port_repo}; use crate::server::repository::{
applications as app_repo,
hosts as host_repo,
networks,
ports as port_repo,
};
let pool = use_context::<AnyPool>() let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
@@ -69,6 +73,10 @@ pub async fn get_host_detail(id: i64) -> Result<HostDetail, ServerFnError> {
.await .await
.map_err(|e| ServerFnError::new(e.to_string()))?; .map_err(|e| ServerFnError::new(e.to_string()))?;
let applications = app_repo::list_applications_for_host(&pool, id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(HostDetail { Ok(HostDetail {
id: host.id, id: host.id,
name: host.name, name: host.name,
@@ -77,6 +85,7 @@ pub async fn get_host_detail(id: i64) -> Result<HostDetail, ServerFnError> {
network_name: network.name, network_name: network.name,
network_cidr: network.cidr, network_cidr: network.cidr,
ports, ports,
applications,
}) })
} }
@@ -343,6 +352,72 @@ pub async fn remove_host_port(host_id: i64, port_number: i64) -> Result<(), Serv
.map_err(|e| ServerFnError::new(e.to_string())) .map_err(|e| ServerFnError::new(e.to_string()))
} }
/// Returns all applications not yet directly linked to a host.
///
/// Used to populate the "add applications" modal on the host detail page.
#[server]
pub async fn get_applications_not_on_host(
host_id: i64,
) -> Result<Vec<Application>, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::applications as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
repo::list_applications_not_on_host(&pool, host_id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Links one or more applications to a host.
///
/// `application_ids` is a comma-separated string of application IDs (e.g. "1,3,7").
/// Already-linked applications are silently skipped (no-op).
#[server]
pub async fn add_host_applications(
host_id: i64,
application_ids: String,
) -> Result<(), ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::applications as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
let ids: Vec<i64> = application_ids
.split(',')
.filter_map(|s| s.trim().parse::<i64>().ok())
.collect();
for application_id in ids {
repo::add_application_to_host(&pool, host_id, application_id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
}
Ok(())
}
/// Removes the direct link between a host and an application.
///
/// Returns `true` if the association existed and was removed.
#[server]
pub async fn remove_host_application(
host_id: i64,
application_id: i64,
) -> Result<bool, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::applications as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
repo::remove_application_from_host(&pool, host_id, application_id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Deletes a host by id. /// Deletes a host by id.
/// ///
/// Also removes all its port associations (via `ON DELETE CASCADE`). /// Also removes all its port associations (via `ON DELETE CASCADE`).

View File

@@ -1,17 +1,122 @@
// client/host_detail.rs — Host detail page // client/host_detail.rs — Host detail page
// //
// Shows all information for a single host: // Shows all information for a single host:
// - Identity form : name, IP, network dropdown — editable, saved with "Save changes" // - Identity form : name, IP, network dropdown — editable, saved with "Save changes"
// - Ports section : full list with Remove per port + Add port input // - Ports section : full list with Remove per port + Add port input
// - Delete button : opens a confirmation modal, then navigates back to /hosts // - 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::prelude::*;
use leptos_router::hooks::{use_navigate, use_params_map, use_query_map}; use leptos_router::hooks::{use_navigate, use_params_map, use_query_map};
use crate::api::{ 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, 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 ──────────────────────────────────────────────── // ─── Delete confirmation modal ────────────────────────────────────────────────
@@ -36,7 +141,7 @@ fn DeleteModal(
<p class="warning"> <p class="warning">
"Are you sure you want to delete " "Are you sure you want to delete "
<strong>{host_name}</strong> <strong>{host_name}</strong>
"? All port associations will also be removed." "? All port and application associations will also be removed."
</p> </p>
</div> </div>
<div class="modal__actions"> <div class="modal__actions">
@@ -58,8 +163,6 @@ fn DeleteModal(
#[component] #[component]
pub fn HostDetailPage() -> impl IntoView { 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 params = use_params_map();
let host_id = move || { let host_id = move || {
params.read().get("id") params.read().get("id")
@@ -67,8 +170,6 @@ pub fn HostDetailPage() -> impl IntoView {
.unwrap_or(0) .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 query = use_query_map();
let back_url = move || { let back_url = move || {
query.read().get("back") query.read().get("back")
@@ -82,37 +183,54 @@ pub fn HostDetailPage() -> impl IntoView {
let update_action = ServerAction::<UpdateHost>::new(); let update_action = ServerAction::<UpdateHost>::new();
let add_port_action = ServerAction::<AddHostPort>::new(); let add_port_action = ServerAction::<AddHostPort>::new();
let remove_port_action = ServerAction::<RemoveHostPort>::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 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. // Auto-close the add-app modal after a successful addition.
// The resource key includes action versions so it invalidates automatically. // 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( let host = Resource::new(
move || ( move || (
host_id(), host_id(),
update_action.version().get(), update_action.version().get(),
add_port_action.version().get(), add_port_action.version().get(),
remove_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. // Networks dropdown — LocalResource avoids SSR/hydration mismatch.
let networks_res = LocalResource::new(|| get_networks()); let networks_res = LocalResource::new(|| get_networks());
// Edit-field signals, populated once by the Effect below. // Available apps for the modal: re-fetched whenever add/remove completes.
// Using signals (rather than local variables) keeps them stable across let add_app_ver = add_app_action.version();
// re-renders and lets the user edit without triggering a resource reload. 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 name_sig = RwSignal::new(String::new());
let ip_sig = RwSignal::new(String::new()); let ip_sig = RwSignal::new(String::new());
let net_id_sig = RwSignal::new(0i64); let net_id_sig = RwSignal::new(0i64);
let new_port = RwSignal::new(String::new());
// Input value for the "add port" row. // Sync edit signals whenever fresh host data arrives.
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.
Effect::new(move |_| { Effect::new(move |_| {
if let Some(Ok(ref detail)) = host.get() { if let Some(Ok(ref detail)) = host.get() {
name_sig.set(detail.name.clone()); 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(); let navigate = use_navigate();
Effect::new(move |_| { Effect::new(move |_| {
if let Some(Ok(true)) = delete_action.value().get() { if let Some(Ok(true)) = delete_action.value().get() {
@@ -139,10 +255,12 @@ pub fn HostDetailPage() -> impl IntoView {
}.into_any(), }.into_any(),
Ok(detail) => { Ok(detail) => {
let id = detail.id; let id = detail.id;
let modal_name = detail.name.clone(); let modal_name = detail.name.clone();
let port_count = detail.ports.len(); let port_count = detail.ports.len();
let ports = detail.ports; let app_count = detail.applications.len();
let ports = detail.ports;
let applications = detail.applications;
// Pre-built ports view — consumes `ports` once, not reactively. // Pre-built ports view — consumes `ports` once, not reactively.
let ports_list = if ports.is_empty() { let ports_list = if ports.is_empty() {
@@ -178,6 +296,37 @@ pub fn HostDetailPage() -> impl IntoView {
}.into_any() }.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! { view! {
// ── Page header ────────────────────────────────── // ── Page header ──────────────────────────────────
<div class="page-header detail-page-header"> <div class="page-header detail-page-header">
@@ -244,13 +393,13 @@ pub fn HostDetailPage() -> impl IntoView {
class="btn-primary" class="btn-primary"
type="button" type="button"
on:click=move |_| { on:click=move |_| {
update_action.dispatch(UpdateHost { update_action.dispatch(UpdateHost {
id, id,
name: name_sig.get_untracked(), name: name_sig.get_untracked(),
ip: ip_sig.get_untracked(), ip: ip_sig.get_untracked(),
network_id: net_id_sig.get_untracked(), network_id: net_id_sig.get_untracked(),
}); });
} }
> >
"Save changes" "Save changes"
</button> </button>
@@ -273,7 +422,6 @@ pub fn HostDetailPage() -> impl IntoView {
}) })
} }
// Add port row
<div class="port-add-row"> <div class="port-add-row">
<input <input
type="number" type="number"
@@ -310,7 +458,43 @@ pub fn HostDetailPage() -> impl IntoView {
</div> </div>
</section> </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"> <div class="danger-zone">
<button <button
class="btn-danger-solid" class="btn-danger-solid"

View File

@@ -54,6 +54,84 @@ pub async fn delete_application(pool: &AnyPool, id: i64) -> Result<bool, DbError
Ok(result.rows_affected() > 0) Ok(result.rows_affected() > 0)
} }
// ─── Host-application associations ───────────────────────────────────────────
/// Returns all applications linked directly to a host, ordered by name.
pub async fn list_applications_for_host(
pool: &AnyPool,
host_id: i64,
) -> Result<Vec<Application>, DbError> {
let rows = sqlx::query(
"SELECT a.id, a.name
FROM applications a
JOIN host_applications ha ON ha.application_id = a.id
WHERE ha.host_id = $1
ORDER BY a.name",
)
.bind(host_id)
.fetch_all(pool)
.await?;
Ok(rows.iter().map(row_to_application).collect())
}
/// Returns all applications NOT yet linked to a host, ordered by name.
pub async fn list_applications_not_on_host(
pool: &AnyPool,
host_id: i64,
) -> Result<Vec<Application>, DbError> {
let rows = sqlx::query(
"SELECT id, name FROM applications
WHERE id NOT IN (
SELECT application_id FROM host_applications WHERE host_id = $1
)
ORDER BY name",
)
.bind(host_id)
.fetch_all(pool)
.await?;
Ok(rows.iter().map(row_to_application).collect())
}
/// Links an application directly to a host.
///
/// If the link already exists, this is a no-op (not an error).
pub async fn add_application_to_host(
pool: &AnyPool,
host_id: i64,
application_id: i64,
) -> Result<(), DbError> {
let result = sqlx::query(
"INSERT INTO host_applications (host_id, application_id) VALUES ($1, $2)",
)
.bind(host_id)
.bind(application_id)
.execute(pool)
.await;
match result {
Ok(_) => Ok(()),
Err(sqlx::Error::Database(ref e)) if e.is_unique_violation() => Ok(()),
Err(e) => Err(DbError::Connection(e)),
}
}
/// Removes the direct link between a host and an application.
///
/// Returns `true` if the association existed and was removed.
pub async fn remove_application_from_host(
pool: &AnyPool,
host_id: i64,
application_id: i64,
) -> Result<bool, DbError> {
let result = sqlx::query(
"DELETE FROM host_applications WHERE host_id = $1 AND application_id = $2",
)
.bind(host_id)
.bind(application_id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
// ─── Application-port associations ─────────────────────────────────────────── // ─── Application-port associations ───────────────────────────────────────────
/// Returns all port numbers associated with an application, sorted numerically. /// Returns all port numbers associated with an application, sorted numerically.

View File

@@ -1223,3 +1223,59 @@ td.col-actions {
color: var(--text-secondary); color: var(--text-secondary);
margin: 0; margin: 0;
} }
/* ============================================================
HOST DETAIL — APPLICATIONS SECTION
============================================================ */
/* List of applications linked to a host (mirrors .port-list) */
.app-list {
display: flex;
flex-direction: column;
margin-bottom: var(--size-md);
border-top: 1px solid var(--border);
}
.app-row {
display: flex;
align-items: center;
gap: var(--size-md);
padding: var(--size-sm) var(--size-xs);
border-bottom: 1px solid var(--border);
}
.app-row__name {
flex: 1;
font-size: var(--font-sm);
}
/* Checkbox list inside the "Add applications" modal */
.app-select-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 320px;
overflow-y: auto;
}
.app-select-item {
display: flex;
align-items: center;
gap: var(--size-sm);
padding: var(--size-sm) var(--size-xs);
border-radius: 4px;
cursor: pointer;
font-size: var(--font-sm);
}
.app-select-item:hover {
background: var(--bg-surface);
}
.app-select-item input[type="checkbox"] {
width: 16px;
height: 16px;
flex-shrink: 0;
cursor: pointer;
accent-color: var(--accent);
}