From 4d0be981601e17e41726b6fd8d23272b9baf4238 Mon Sep 17 00:00:00 2001 From: mathieu Date: Sat, 16 May 2026 02:48:53 +0200 Subject: [PATCH] feat(applications): add applications list page with host count and delete modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API: ApplicationWithCounts struct + get_applications_with_counts() — counts distinct hosts linked via matching ports (application_ports ↔ host_ports) - ApplicationsPage at /applications: inline add form, table with Name and Hosts columns, delete confirmation modal showing affected host count - Nav: add Applications link Co-Authored-By: Claude Sonnet 4.6 --- src/api/applications.rs | 45 ++++++++++ src/app.rs | 3 + src/client/applications.rs | 175 +++++++++++++++++++++++++++++++++++++ src/client/mod.rs | 1 + 4 files changed, 224 insertions(+) create mode 100644 src/client/applications.rs diff --git a/src/api/applications.rs b/src/api/applications.rs index 6a43e5f..857aa15 100644 --- a/src/api/applications.rs +++ b/src/api/applications.rs @@ -1,11 +1,56 @@ // api/applications.rs — Server functions for applications and their port associations use leptos::prelude::*; +use serde::{Deserialize, Serialize}; use crate::models::Application; +// 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 +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ApplicationWithCounts { + pub id: i64, + pub name: String, + /// Distinct hosts that have at least one port matching this application. + pub host_count: i64, +} + // ─── Queries ────────────────────────────────────────────────────────────────── +/// Returns all applications enriched with their associated host count. +#[server] +pub async fn get_applications_with_counts() -> Result, ServerFnError> { + use sqlx::{AnyPool, Row}; + + let pool = use_context::() + .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; + + let rows = sqlx::query( + "SELECT + a.id, + a.name, + COUNT(DISTINCT hp.host_id) AS host_count + FROM applications a + LEFT JOIN application_ports ap ON ap.application_id = a.id + LEFT JOIN host_ports hp ON hp.port_number = ap.port_number + GROUP BY a.id, a.name + ORDER BY a.name", + ) + .fetch_all(&pool) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + + Ok(rows + .into_iter() + .map(|row| ApplicationWithCounts { + id: row.get("id"), + name: row.get("name"), + host_count: row.get("host_count"), + }) + .collect()) +} + /// Returns all applications ordered by name. #[server] pub async fn get_applications() -> Result, ServerFnError> { diff --git a/src/app.rs b/src/app.rs index 4d75a0a..7553ccb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -12,6 +12,7 @@ use leptos_router::{ }; use crate::client::{ + applications::ApplicationsPage, home::HomePage, host_detail::HostDetailPage, hosts::HostsPage, @@ -94,6 +95,7 @@ pub fn App() -> impl IntoView { "Rust IPAM" "Networks" "Hosts" + "Applications" @@ -112,6 +114,7 @@ pub fn App() -> impl IntoView { + diff --git a/src/client/applications.rs b/src/client/applications.rs new file mode 100644 index 0000000..be0c60d --- /dev/null +++ b/src/client/applications.rs @@ -0,0 +1,175 @@ +// client/applications.rs — Applications list page +// +// Displays all applications with: +// - Add form : inline form to create an application by name +// - Table : application name + number of associated hosts +// - Delete : confirmation modal before deletion + +use leptos::prelude::*; +use leptos::form::ActionForm; + +use crate::api::applications::{ + ApplicationWithCounts, CreateApplication, DeleteApplication, + get_applications_with_counts, +}; + +// ─── Delete confirmation modal ──────────────────────────────────────────────── + +#[component] +fn DeleteAppModal( + app: ApplicationWithCounts, + delete_action: ServerAction, + pending_delete: RwSignal>, +) -> impl IntoView { + let id = app.id; + let label = app.name.clone(); + let host_count = app.host_count; + + view! { + + }.into_any() +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +#[component] +pub fn ApplicationsPage() -> impl IntoView { + let create_action = ServerAction::::new(); + let delete_action = ServerAction::::new(); + + // Some(app) = delete modal open for that app; None = closed. + let pending_delete: RwSignal> = RwSignal::new(None); + + // Close the modal automatically after a successful deletion. + Effect::new(move |_| { + if let Some(Ok(_)) = delete_action.value().get() { + pending_delete.set(None); + } + }); + + let applications = Resource::new( + move || (create_action.version().get(), delete_action.version().get()), + |_| get_applications_with_counts(), + ); + + view! { +
+

"Applications"

+ + // ── Delete modal ────────────────────────────────────────────────── + {move || pending_delete.get().map(|app| view! { + + })} + + // ── Add form ────────────────────────────────────────────────────── +
+

"Add an application"

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

{e.to_string()}

}) + } +
+ + // ── Application list ────────────────────────────────────────────── +
+

"All applications"

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

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

}) + } + + "Loading applications…"

}> + {move || applications.get().map(|result| match result { + Err(e) => view! { +

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

+ }.into_any(), + + Ok(list) if list.is_empty() => view! { +

"No applications yet. Add one above."

+ }.into_any(), + + Ok(list) => view! { +
+ + + + + + + + + + {list.into_iter().map(|app| { + let app_clone = app.clone(); + view! { + + + + + + } + }).collect_view()} + +
"Name""Hosts""Actions"
{app.name}{app.host_count} + +
+
+ }.into_any(), + })} +
+
+
+ } +} diff --git a/src/client/mod.rs b/src/client/mod.rs index 7877299..ebf5a89 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -9,6 +9,7 @@ // Do not place code here that requires browser-only APIs (window, document...) // without guarding it with `#[cfg(target_arch = "wasm32")]`. +pub mod applications; // Applications list and creation pub mod home; // Home page pub mod host_detail; // Host detail: identity, ports, edit, delete pub mod hosts; // Hosts list with filters and pagination