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:
@@ -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.
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user