diff --git a/src/api/networks.rs b/src/api/networks.rs index 1b4e53c..cdfe5aa 100644 --- a/src/api/networks.rs +++ b/src/api/networks.rs @@ -1,9 +1,23 @@ // api/networks.rs — Server functions for networks use leptos::prelude::*; +use serde::{Deserialize, Serialize}; use crate::models::Network; +// Network row augmented with pre-computed counts. +// Defined here (not in models.rs) because it is a presentation model +// specific to the Networks page, not a pure domain entity. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct NetworkWithCounts { + pub id: i64, + pub cidr: String, + /// Number of hosts whose IP falls within this network's CIDR range. + pub host_count: i64, + /// Number of distinct applications linked via ports open on hosts in this network. + pub application_count: i64, +} + // ─── Queries ────────────────────────────────────────────────────────────────── /// Returns all networks from the database. @@ -26,6 +40,50 @@ pub async fn get_networks() -> Result, ServerFnError> { .map_err(|e| ServerFnError::new(e.to_string())) } +/// Returns all networks enriched with host and application counts. +/// +/// A single SQL query fetches everything at once using correlated subqueries, +/// avoiding N+1 round-trips regardless of the number of networks. +/// +/// `application_count` = distinct applications whose registered ports appear +/// among the ports open on hosts in each network (via host_ports → application_ports). +#[server] +pub async fn get_networks_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 + n.id, + n.cidr, + (SELECT COUNT(*) FROM hosts WHERE network_id = n.id) AS host_count, + (SELECT COUNT(DISTINCT ap.application_id) + 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 h.network_id = n.id) AS application_count + FROM networks n + ORDER BY n.id", + ) + .fetch_all(&pool) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + + let networks = rows + .into_iter() + .map(|row| NetworkWithCounts { + id: row.get("id"), + cidr: row.get("cidr"), + host_count: row.get("host_count"), + application_count: row.get("application_count"), + }) + .collect(); + + Ok(networks) +} + // ─── Mutations ──────────────────────────────────────────────────────────────── /// Creates a new network with the given CIDR block. diff --git a/src/client/networks.rs b/src/client/networks.rs index 247beec..98aff34 100644 --- a/src/client/networks.rs +++ b/src/client/networks.rs @@ -16,7 +16,7 @@ use leptos::prelude::*; use leptos::form::ActionForm; -use crate::api::networks::{CreateNetwork, DeleteNetwork, get_networks}; +use crate::api::networks::{CreateNetwork, DeleteNetwork, get_networks_with_counts}; #[component] pub fn NetworksPage() -> impl IntoView { @@ -38,7 +38,7 @@ pub fn NetworksPage() -> impl IntoView { // re-fetches after any create or delete, keeping the view in sync. let networks = Resource::new( move || (create_action.version().get(), delete_action.version().get()), - |_| get_networks(), + |_| get_networks_with_counts(), ); view! { @@ -114,7 +114,9 @@ pub fn NetworksPage() -> impl IntoView { "CIDR" - "Actions" + "Hosts" + "Applications" + "Actions" @@ -125,7 +127,9 @@ pub fn NetworksPage() -> impl IntoView { view! { {network.cidr} - + {network.host_count} + {network.application_count} +