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:
8
migrations/postgres/0008_create_host_applications.sql
Normal file
8
migrations/postgres/0008_create_host_applications.sql
Normal 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)
|
||||||
|
);
|
||||||
8
migrations/sqlite/0008_create_host_applications.sql
Normal file
8
migrations/sqlite/0008_create_host_applications.sql
Normal 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)
|
||||||
|
);
|
||||||
@@ -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`).
|
||||||
|
|||||||
@@ -3,15 +3,120 @@
|
|||||||
// 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
|
||||||
|
// - Applications : directly linked apps with Remove + modal multi-select to add
|
||||||
// - Delete button : opens a confirmation modal, then navigates back to /hosts
|
// - 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);
|
||||||
|
|
||||||
// Input value for the "add port" row.
|
|
||||||
let new_port = RwSignal::new(String::new());
|
let new_port = RwSignal::new(String::new());
|
||||||
|
|
||||||
// Sync edit signals whenever the host resource delivers fresh data.
|
// Sync edit signals whenever fresh host data arrives.
|
||||||
// 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() {
|
||||||
@@ -142,7 +258,9 @@ pub fn HostDetailPage() -> impl IntoView {
|
|||||||
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 app_count = detail.applications.len();
|
||||||
let ports = detail.ports;
|
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">
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user