From bef28f44a1f3034d02a156f63112f67de59a0c11 Mon Sep 17 00:00:00 2001 From: mathieu Date: Sat, 16 May 2026 22:17:49 +0200 Subject: [PATCH] feat(applications): add application detail page - New page /applications/:id with identity (editable name), associated ports (add/remove), linked hosts (read-only via shared ports), and delete with confirmation modal - Add get_application_detail and update_application server functions - Add ApplicationDetail and HostRef types in api/applications - Add update_application to the repository layer - Application names in the list are now clickable links Co-Authored-By: Claude Sonnet 4.6 --- src/api/applications.rs | 82 +++++++ src/app.rs | 2 + src/client/application_detail.rs | 299 ++++++++++++++++++++++++++ src/client/applications.rs | 7 +- src/client/mod.rs | 15 +- src/server/repository/applications.rs | 12 ++ 6 files changed, 409 insertions(+), 8 deletions(-) create mode 100644 src/client/application_detail.rs diff --git a/src/api/applications.rs b/src/api/applications.rs index 23a1b6d..8aab771 100644 --- a/src/api/applications.rs +++ b/src/api/applications.rs @@ -5,6 +5,24 @@ use serde::{Deserialize, Serialize}; use crate::models::Application; +// Minimal host reference used by ApplicationDetail. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct HostRef { + pub id: i64, + pub name: String, + pub ip: String, +} + +// Full detail for a single application: identity, associated ports, and linked hosts. +// Linked hosts are those that have at least one port matching an application_ports entry. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ApplicationDetail { + pub id: i64, + pub name: String, + pub ports: Vec, + pub hosts: Vec, +} + // Application row enriched with the number of hosts that use at least one of // its registered ports. Host count is computed via the join: // application_ports → host_ports (matched on port_number) → hosts @@ -65,6 +83,52 @@ pub async fn get_applications() -> Result, ServerFnError> { .map_err(|e| ServerFnError::new(e.to_string())) } +/// Returns full detail for a single application: identity, ports, and linked hosts. +/// +/// Linked hosts are hosts that have at least one open port matching one of +/// the application's registered port numbers (via application_ports ↔ host_ports). +#[server] +pub async fn get_application_detail(id: i64) -> Result { + use sqlx::{AnyPool, Row}; + use crate::server::repository::applications as repo; + + let pool = use_context::() + .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; + + let app = repo::find_application(&pool, id) + .await + .map_err(|e| ServerFnError::new(e.to_string()))? + .ok_or_else(|| ServerFnError::new(format!("Application {id} not found")))?; + + let ports = repo::list_ports_for_application(&pool, id) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + + let rows = sqlx::query( + "SELECT DISTINCT h.id, h.name, h.ip + FROM hosts h + JOIN host_ports hp ON hp.host_id = h.id + JOIN application_ports ap ON ap.port_number = hp.port_number + WHERE ap.application_id = $1 + ORDER BY h.name", + ) + .bind(id) + .fetch_all(&pool) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + + let hosts = rows + .iter() + .map(|row| HostRef { + id: row.get("id"), + name: row.get("name"), + ip: row.get("ip"), + }) + .collect(); + + Ok(ApplicationDetail { id: app.id, name: app.name, ports, hosts }) +} + /// Returns the port numbers associated with an application. #[server] pub async fn get_ports_for_application( @@ -83,6 +147,24 @@ pub async fn get_ports_for_application( // ─── Mutations ──────────────────────────────────────────────────────────────── +/// Updates the name of an application and returns the updated record. +#[server] +pub async fn update_application(id: i64, name: String) -> 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"))?; + + if name.trim().is_empty() { + return Err(ServerFnError::new("Application name cannot be empty")); + } + + repo::update_application(&pool, id, name.trim()) + .await + .map_err(|e| ServerFnError::new(e.to_string())) +} + /// Creates a new application, then associates the given port numbers. /// /// `ports` is a comma-separated list of port numbers (e.g. "80,443"). diff --git a/src/app.rs b/src/app.rs index 7553ccb..1fd49f5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -12,6 +12,7 @@ use leptos_router::{ }; use crate::client::{ + application_detail::ApplicationDetailPage, applications::ApplicationsPage, home::HomePage, host_detail::HostDetailPage, @@ -115,6 +116,7 @@ pub fn App() -> impl IntoView { + diff --git a/src/client/application_detail.rs b/src/client/application_detail.rs new file mode 100644 index 0000000..4f3fef0 --- /dev/null +++ b/src/client/application_detail.rs @@ -0,0 +1,299 @@ +// client/application_detail.rs — Application detail page +// +// Shows all information for a single application: +// - Identity form : name — editable, saved with "Save changes" +// - Ports section : ports associated with this application + Add/Remove per port +// - Hosts section : hosts sharing at least one port with this application (read-only) +// - Delete button : confirmation modal, then navigates back to /applications + +use leptos::prelude::*; +use leptos_router::hooks::{use_navigate, use_params_map}; + +use crate::api::applications::{ + AddPortToApplication, DeleteApplication, RemovePortFromApplication, + UpdateApplication, get_application_detail, +}; + +// ─── Delete confirmation modal ──────────────────────────────────────────────── + +#[component] +fn DeleteModal( + app_name: String, + delete_action: ServerAction, + app_id: i64, + show_modal: RwSignal, +) -> impl IntoView { + view! { + + }.into_any() +} + +// ─── Main page component ────────────────────────────────────────────────────── + +#[component] +pub fn ApplicationDetailPage() -> impl IntoView { + let params = use_params_map(); + let app_id = move || { + params.read().get("id") + .and_then(|s| s.parse::().ok()) + .unwrap_or(0) + }; + + let update_action = ServerAction::::new(); + let add_port_action = ServerAction::::new(); + let remove_port_action = ServerAction::::new(); + let delete_action = ServerAction::::new(); + + let show_delete_modal = RwSignal::new(false); + + let app = LocalResource::new(move || { + let _ = update_action.version().get(); + let _ = add_port_action.version().get(); + let _ = remove_port_action.version().get(); + get_application_detail(app_id()) + }); + + let name_sig = RwSignal::new(String::new()); + let new_port = RwSignal::new(String::new()); + + // Sync the editable name whenever fresh data arrives. + Effect::new(move |_| { + if let Some(r) = app.get() { + if let Ok(ref detail) = *r { + name_sig.set(detail.name.clone()); + } + } + }); + + let navigate = use_navigate(); + Effect::new(move |_| { + if let Some(Ok(true)) = delete_action.value().get() { + navigate("/applications", Default::default()); + } + }); + + view! { +
+ // Delete modal lives OUTSIDE so it is not unmounted when + // the application resource re-fetches. + {move || show_delete_modal.get().then(|| view! { + + })} + + "Loading application…"

}> + {move || app.get().map(|r| match (*r).clone() { + Err(e) => view! { +

"Could not load application: " {e.to_string()}

+ }.into_any(), + + Ok(detail) => { + let id = detail.id; + let port_count = detail.ports.len(); + let host_count = detail.hosts.len(); + let ports = detail.ports; + let hosts = detail.hosts; + + let ports_list = if ports.is_empty() { + view! { +

"No ports associated with this application."

+ }.into_any() + } else { + view! { +
+ {ports.into_iter().map(|num| { + view! { +
+ {num} + +
+ } + }).collect_view()} +
+ }.into_any() + }; + + let hosts_list = if hosts.is_empty() { + view! { +

"No hosts share a port with this application."

+ }.into_any() + } else { + view! { +
+ {hosts.into_iter().map(|host| { + view! { +
+ + {host.name} + + {host.ip} +
+ } + }).collect_view()} +
+ }.into_any() + }; + + view! { + // ── Page header ─────────────────────────────────── + + + // ── Identity form ───────────────────────────────── +
+

"Identity"

+
+ + + {move || update_action.value().get() + .and_then(|r| r.err()) + .map(|e| view! {

{e.to_string()}

}) + } + +
+ +
+
+
+ + // ── Ports section ───────────────────────────────── +
+

+ {format!("Associated ports ({})", port_count)} +

+ + {ports_list} + + {move || remove_port_action.value().get() + .and_then(|r| r.err()) + .map(|e| view! { +

"Remove failed: " {e.to_string()}

+ }) + } + +
+ + + {move || add_port_action.value().get() + .and_then(|r| r.err()) + .map(|e| view! { +

"Add failed: " {e.to_string()}

+ }) + } +
+
+ + // ── Hosts section (read-only — linked via shared ports) ── +
+

+ {format!("Linked hosts ({})", host_count)} +

+ {hosts_list} +
+ + // ── Danger zone ─────────────────────────────────── +
+ +
+ }.into_any() + } + })} +
+
+ }.into_any() +} diff --git a/src/client/applications.rs b/src/client/applications.rs index 810da61..2087fc2 100644 --- a/src/client/applications.rs +++ b/src/client/applications.rs @@ -263,7 +263,12 @@ pub fn ApplicationsPage() -> impl IntoView { let app_clone = app.clone(); view! { - {app.name} + + + {app.name} + + {app.host_count}