diff --git a/migrations/postgres/0008_create_host_applications.sql b/migrations/postgres/0008_create_host_applications.sql new file mode 100644 index 0000000..6107e4a --- /dev/null +++ b/migrations/postgres/0008_create_host_applications.sql @@ -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) +); diff --git a/migrations/sqlite/0008_create_host_applications.sql b/migrations/sqlite/0008_create_host_applications.sql new file mode 100644 index 0000000..fd6662a --- /dev/null +++ b/migrations/sqlite/0008_create_host_applications.sql @@ -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) +); diff --git a/src/api/hosts.rs b/src/api/hosts.rs index f400a0f..83a6615 100644 --- a/src/api/hosts.rs +++ b/src/api/hosts.rs @@ -3,9 +3,7 @@ use leptos::prelude::*; use serde::{Deserialize, Serialize}; -use crate::models::Host; - -use crate::models::Port; +use crate::models::{Application, Host, Port}; // ─── Presentation types ─────────────────────────────────────────────────────── @@ -32,7 +30,7 @@ pub struct HostsPage { 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)] pub struct HostDetail { pub id: i64, @@ -42,6 +40,7 @@ pub struct HostDetail { pub network_name: String, pub network_cidr: String, pub ports: Vec, + pub applications: Vec, } // ─── Queries ────────────────────────────────────────────────────────────────── @@ -50,7 +49,12 @@ pub struct HostDetail { #[server] pub async fn get_host_detail(id: i64) -> Result { 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::() .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; @@ -69,6 +73,10 @@ pub async fn get_host_detail(id: i64) -> Result { .await .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 { id: host.id, name: host.name, @@ -77,6 +85,7 @@ pub async fn get_host_detail(id: i64) -> Result { network_name: network.name, network_cidr: network.cidr, 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())) } +/// 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, ServerFnError> { + use sqlx::AnyPool; + use crate::server::repository::applications as repo; + + let pool = use_context::() + .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::() + .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; + + let ids: Vec = application_ids + .split(',') + .filter_map(|s| s.trim().parse::().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 { + use sqlx::AnyPool; + use crate::server::repository::applications as repo; + + let pool = use_context::() + .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. /// /// Also removes all its port associations (via `ON DELETE CASCADE`). diff --git a/src/client/host_detail.rs b/src/client/host_detail.rs index b3898ea..37590ce 100644 --- a/src/client/host_detail.rs +++ b/src/client/host_detail.rs @@ -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, ServerFnError>>, + add_action: ServerAction, + show_modal: RwSignal, +) -> impl IntoView { + // Tracks which application IDs the user has checked. + let selected: RwSignal> = RwSignal::new(vec![]); + + view! { + + }.into_any() +} // ─── Delete confirmation modal ──────────────────────────────────────────────── @@ -36,7 +141,7 @@ fn DeleteModal(

"Are you sure you want to delete " {host_name} - "? All port associations will also be removed." + "? All port and application associations will also be removed."