feat(hosts): add hosts list page with filters, pagination and delete

Implements task #7. The Hosts page provides:
- Name/network/port/application filters (sentinel values instead of
  Option<T> to avoid server function serialization issues)
- Configurable page size (15 default, 25/50/100/All)
- Prev/next navigation with total host count
- Add host form with network selector
- Delete action per row

Sub-components (AddHostForm, FilterBar, PaginationBar, HostTable) each
call .into_any() to erase their concrete view types. This breaks the
deeply-nested generic type that caused "queries overflow the depth
limit!" without requiring an unbounded recursion_limit increase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 23:23:24 +02:00
parent 5b1f30fe24
commit 042793f385
6 changed files with 699 additions and 5 deletions

View File

@@ -1,9 +1,35 @@
// api/hosts.rs — Server functions for hosts
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use crate::models::Host;
// ─── Presentation types ───────────────────────────────────────────────────────
// A host row enriched with its network CIDR and pre-computed counts.
// Used by the paginated hosts list (get_hosts_page).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HostRow {
pub id: i64,
pub name: String,
pub ip: String,
pub network_id: i64,
pub network_cidr: String,
pub port_count: i64,
pub application_count: i64,
}
// Result of a paginated, filtered host query.
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct HostsPage {
pub rows: Vec<HostRow>,
pub total: i64, // total rows matching the filters (ignoring pagination)
pub page: i64, // current page (1-indexed)
pub per_page: i64, // items per page; 0 = all
pub total_pages: i64, // ceil(total / per_page); always ≥ 1
}
// ─── Queries ──────────────────────────────────────────────────────────────────
/// Returns all hosts belonging to a given network.
@@ -20,6 +46,128 @@ pub async fn get_hosts_by_network(network_id: i64) -> Result<Vec<Host>, ServerFn
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Returns a filtered and paginated list of hosts across all networks.
///
/// Filter parameters use sentinel values (0 / empty string) to mean "no filter":
/// - `name_filter` : substring match on host name (case-insensitive); "" = all
/// - `network_id_filter` : exact network id; 0 = all
/// - `port_filter` : hosts with this port open; 0 = all
/// - `application_id_filter` : hosts linked to this application; 0 = all
/// - `per_page` : items per page; 0 = return everything
/// - `page` : 1-indexed page number
///
/// The SQL uses each bind parameter twice in the WHERE clause
/// (once for the IS NULL guard, once for the actual comparison).
/// Each $N placeholder refers to the N-th bound argument by index.
#[server]
pub async fn get_hosts_page(
name_filter: String,
network_id_filter: i64,
port_filter: i64,
application_id_filter: i64,
page: i64,
per_page: i64,
) -> Result<HostsPage, ServerFnError> {
use sqlx::{AnyPool, Row};
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
// Convert sentinel values to Option for SQL NULL binding.
// None → binds as SQL NULL → "$N IS NULL" evaluates to TRUE → filter skipped.
let name_like: Option<String> = if name_filter.is_empty() {
None
} else {
Some(format!("%{}%", name_filter))
};
let network_id: Option<i64> = if network_id_filter == 0 { None } else { Some(network_id_filter) };
let port: Option<i64> = if port_filter == 0 { None } else { Some(port_filter) };
let app_id: Option<i64> = if application_id_filter == 0 { None } else { Some(application_id_filter) };
// Each filter param is bound twice so the same $N can appear in both
// the IS NULL guard and the comparison without re-declaring parameters.
const WHERE: &str = "
JOIN networks n ON n.id = h.network_id
LEFT JOIN host_ports hp ON hp.host_id = h.id
LEFT JOIN application_ports ap ON ap.port_number = hp.port_number
WHERE ($1 IS NULL OR LOWER(h.name) LIKE LOWER($1))
AND ($2 IS NULL OR h.network_id = $2)
AND ($3 IS NULL OR EXISTS (
SELECT 1 FROM host_ports
WHERE host_id = h.id AND port_number = $3
))
AND ($4 IS NULL OR EXISTS (
SELECT 1 FROM host_ports hp2
JOIN application_ports ap2 ON ap2.port_number = hp2.port_number
WHERE hp2.host_id = h.id AND ap2.application_id = $4
))";
// Count matching hosts (ignoring pagination).
let count_sql = format!("SELECT COUNT(DISTINCT h.id) FROM hosts h {WHERE}");
let total: i64 = sqlx::query_scalar(&count_sql)
.bind(name_like.as_deref())
.bind(network_id)
.bind(port)
.bind(app_id)
.fetch_one(&pool)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
// Compute pagination bounds.
let safe_page = page.max(1);
let (limit, offset, total_pages) = if per_page <= 0 {
(1_000_000_000i64, 0i64, 1i64)
} else {
let tp = ((total + per_page - 1) / per_page).max(1);
(per_page, (safe_page - 1) * per_page, tp)
};
// Fetch the page of hosts with enriched columns.
let data_sql = format!(
"SELECT h.id, h.name, h.ip, h.network_id,
n.cidr AS network_cidr,
COUNT(DISTINCT hp.port_number) AS port_count,
COUNT(DISTINCT ap.application_id) AS application_count
FROM hosts h
{WHERE}
GROUP BY h.id, h.name, h.ip, h.network_id, n.cidr
ORDER BY h.name, h.id
LIMIT $5 OFFSET $6"
);
let rows = sqlx::query(&data_sql)
.bind(name_like.as_deref())
.bind(network_id)
.bind(port)
.bind(app_id)
.bind(limit)
.bind(offset)
.fetch_all(&pool)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let host_rows = rows
.into_iter()
.map(|row| HostRow {
id: row.get("id"),
name: row.get("name"),
ip: row.get("ip"),
network_id: row.get("network_id"),
network_cidr: row.get("network_cidr"),
port_count: row.get("port_count"),
application_count: row.get("application_count"),
})
.collect();
Ok(HostsPage {
rows: host_rows,
total,
page: safe_page,
per_page,
total_pages,
})
}
// ─── Mutations ────────────────────────────────────────────────────────────────
/// Creates a new host inside the specified network.
@@ -41,7 +189,6 @@ pub async fn create_host(
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
// Look up the parent network to get its CIDR for IP validation.
let network = networks::find_network(&pool, network_id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
@@ -49,8 +196,6 @@ pub async fn create_host(
ServerFnError::new(format!("Network {network_id} not found"))
})?;
// Enforce the business rule: a host's IP must belong to its network's CIDR.
// This check runs on the server before any INSERT, so the database stays consistent.
validate_ip_in_network(&ip, &network.cidr)
.map_err(|e| ServerFnError::new(e.to_string()))?;