feat(networks): add host/application counts columns and fix Actions header

Adds NetworkWithCounts presentation model and get_networks_with_counts()
server function using a single SQL query with correlated subqueries.
Networks table now shows host count, application count, and has the
Actions column header properly right-aligned to match the Delete button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 23:23:12 +02:00
parent 3ee39b96bb
commit 5b1f30fe24
2 changed files with 66 additions and 4 deletions

View File

@@ -1,9 +1,23 @@
// api/networks.rs — Server functions for networks // api/networks.rs — Server functions for networks
use leptos::prelude::*; use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use crate::models::Network; 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 ────────────────────────────────────────────────────────────────── // ─── Queries ──────────────────────────────────────────────────────────────────
/// Returns all networks from the database. /// Returns all networks from the database.
@@ -26,6 +40,50 @@ pub async fn get_networks() -> Result<Vec<Network>, ServerFnError> {
.map_err(|e| ServerFnError::new(e.to_string())) .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<Vec<NetworkWithCounts>, ServerFnError> {
use sqlx::{AnyPool, Row};
let pool = use_context::<AnyPool>()
.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 ──────────────────────────────────────────────────────────────── // ─── Mutations ────────────────────────────────────────────────────────────────
/// Creates a new network with the given CIDR block. /// Creates a new network with the given CIDR block.

View File

@@ -16,7 +16,7 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos::form::ActionForm; use leptos::form::ActionForm;
use crate::api::networks::{CreateNetwork, DeleteNetwork, get_networks}; use crate::api::networks::{CreateNetwork, DeleteNetwork, get_networks_with_counts};
#[component] #[component]
pub fn NetworksPage() -> impl IntoView { 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. // re-fetches after any create or delete, keeping the view in sync.
let networks = Resource::new( let networks = Resource::new(
move || (create_action.version().get(), delete_action.version().get()), move || (create_action.version().get(), delete_action.version().get()),
|_| get_networks(), |_| get_networks_with_counts(),
); );
view! { view! {
@@ -114,7 +114,9 @@ pub fn NetworksPage() -> impl IntoView {
<thead> <thead>
<tr> <tr>
<th>"CIDR"</th> <th>"CIDR"</th>
<th>"Actions"</th> <th class="col-count">"Hosts"</th>
<th class="col-count">"Applications"</th>
<th class="col-actions">"Actions"</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -125,7 +127,9 @@ pub fn NetworksPage() -> impl IntoView {
view! { view! {
<tr> <tr>
<td>{network.cidr}</td> <td>{network.cidr}</td>
<td> <td class="col-count">{network.host_count}</td>
<td class="col-count">{network.application_count}</td>
<td class="col-actions">
<button on:click=move |_| { <button on:click=move |_| {
delete_action delete_action
.dispatch(DeleteNetwork { id }); .dispatch(DeleteNetwork { id });