diff --git a/src/api/hosts.rs b/src/api/hosts.rs
index 8cdd1fd..f55b7da 100644
--- a/src/api/hosts.rs
+++ b/src/api/hosts.rs
@@ -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,
+ 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, 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 {
+ use sqlx::{AnyPool, Row};
+
+ let pool = use_context::()
+ .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 = if name_filter.is_empty() {
+ None
+ } else {
+ Some(format!("%{}%", name_filter))
+ };
+ let network_id: Option = if network_id_filter == 0 { None } else { Some(network_id_filter) };
+ let port: Option = if port_filter == 0 { None } else { Some(port_filter) };
+ let app_id: Option = 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::()
.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()))?;
diff --git a/src/app.rs b/src/app.rs
index ec775f2..b5d9ea1 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -11,7 +11,7 @@ use leptos_router::{
path,
};
-use crate::client::{home::HomePage, networks::NetworksPage, theme::ThemeToggle};
+use crate::client::{home::HomePage, hosts::HostsPage, networks::NetworksPage, theme::ThemeToggle};
// Shell — full HTML document rendered by the Axum server.
//
@@ -86,6 +86,7 @@ pub fn App() -> impl IntoView {
@@ -101,6 +102,7 @@ pub fn App() -> impl IntoView {
}>
+
diff --git a/src/client/hosts.rs b/src/client/hosts.rs
new file mode 100644
index 0000000..fd5fb83
--- /dev/null
+++ b/src/client/hosts.rs
@@ -0,0 +1,352 @@
+// client/hosts.rs — Hosts list page
+//
+// Displays all hosts across every network with:
+// - Add form : create a host inside a chosen network
+// - Filter bar : name (substring), network, open port, application
+// - Table : name, IP, network, port count, application count, delete
+// - Pagination : configurable page size (15 / 25 / 50 / 100 / All)
+//
+// Each sub-component calls `.into_any()` on its view to return `AnyView`
+// (a type-erased wrapper). This prevents Rust from composing all the nested
+// generic types into a single enormous type in the parent's monomorphization,
+// which would otherwise overflow the compiler's query depth limit.
+
+use leptos::prelude::*;
+use leptos::form::ActionForm;
+
+use crate::api::{
+ applications::get_applications,
+ hosts::{CreateHost, DeleteHost, get_hosts_page, HostsPage as HostsPageData},
+ networks::get_networks,
+};
+
+const PER_PAGE_OPTIONS: &[(i64, &str)] = &[
+ (15, "15"),
+ (25, "25"),
+ (50, "50"),
+ (100, "100"),
+ (0, "All"),
+];
+
+// ─── Add host form ────────────────────────────────────────────────────────────
+
+#[component]
+fn AddHostForm(
+ create_action: ServerAction,
+ networks_res: Resource, ServerFnError>>,
+) -> impl IntoView {
+ view! {
+
+ }.into_any()
+}
+
+// ─── Filter bar ───────────────────────────────────────────────────────────────
+
+#[component]
+fn FilterBar(
+ networks_res: Resource, ServerFnError>>,
+ applications_res: Resource, ServerFnError>>,
+ name_filter: RwSignal,
+ network_id_filter: RwSignal,
+ port_filter: RwSignal,
+ app_id_filter: RwSignal,
+ page: RwSignal,
+) -> impl IntoView {
+ view! {
+
+ }.into_any()
+}
+
+// ─── Pagination bar ───────────────────────────────────────────────────────────
+
+#[component]
+fn PaginationBar(
+ total: Signal,
+ page: RwSignal,
+ per_page: RwSignal,
+ total_pages: Signal,
+) -> impl IntoView {
+ view! {
+
+ }.into_any()
+}
+
+// ─── Host table ───────────────────────────────────────────────────────────────
+
+// Separate component for the table body to further reduce type depth in HostsPage.
+#[component]
+fn HostTable(
+ hosts: Resource>,
+ delete_action: ServerAction,
+) -> impl IntoView {
+ view! {
+ "Loading hosts…"
}>
+ {move || hosts.get().map(|result| match result {
+ Err(e) => view! {
+ "Could not load hosts: " {e.to_string()}
+ }.into_any(),
+
+ Ok(HostsPageData { rows, .. }) if rows.is_empty() => view! {
+ "No hosts match the current filters."
+ }.into_any(),
+
+ Ok(HostsPageData { rows, .. }) => view! {
+
+
+
+
+ | "Name" |
+ "IP" |
+ "Network" |
+ "Ports" |
+ "Applications" |
+ "Actions" |
+
+
+
+ {rows.into_iter().map(|host| {
+ let id = host.id;
+ view! {
+
+ |
+
+ {host.name}
+
+ |
+ {host.ip} |
+
+
+ {host.network_cidr}
+
+ |
+ {host.port_count} |
+ {host.application_count} |
+
+
+ |
+
+ }
+ }).collect_view()}
+
+
+
+ }.into_any(),
+ })}
+
+ }.into_any()
+}
+
+// ─── Main page component ──────────────────────────────────────────────────────
+
+#[component]
+pub fn HostsPage() -> impl IntoView {
+ // Actions
+ let create_action = ServerAction::::new();
+ let delete_action = ServerAction::::new();
+
+ // Filter signals (0 / "" = no filter)
+ let name_filter = RwSignal::new(String::new());
+ let network_id_filter = RwSignal::new(0i64);
+ let port_filter = RwSignal::new(0i64);
+ let app_id_filter = RwSignal::new(0i64);
+
+ // Pagination signals
+ let page = RwSignal::new(1i64);
+ let per_page = RwSignal::new(15i64);
+
+ // Hosts resource — refetches whenever any filter/pagination/action changes
+ let hosts = Resource::new(
+ move || (
+ name_filter.get(),
+ network_id_filter.get(),
+ port_filter.get(),
+ app_id_filter.get(),
+ page.get(),
+ per_page.get(),
+ create_action.version().get(),
+ delete_action.version().get(),
+ ),
+ |(name, net, port, app, p, pp, _, _)| get_hosts_page(name, net, port, app, p, pp),
+ );
+
+ // Dropdown resources (fetched once on mount)
+ let networks_res = Resource::new(|| (), |_| get_networks());
+ let applications_res = Resource::new(|| (), |_| get_applications());
+
+ // Derived pagination signals
+ let total_pages = Signal::derive(move || {
+ hosts.get().and_then(|r| r.ok()).map(|p| p.total_pages).unwrap_or(1)
+ });
+ let total = Signal::derive(move || {
+ hosts.get().and_then(|r| r.ok()).map(|p| p.total).unwrap_or(0)
+ });
+
+ view! {
+
+
"Hosts"
+
+
+
+
+
+
+ {move || delete_action.value().get()
+ .and_then(|r| r.err())
+ .map(|e| view! { "Delete failed: " {e.to_string()}
})
+ }
+
+
+
+
+
+
+ }.into_any()
+}
diff --git a/src/client/mod.rs b/src/client/mod.rs
index 2d4508f..6c047aa 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -10,5 +10,6 @@
// without guarding it with `#[cfg(target_arch = "wasm32")]`.
pub mod home; // Home page
+pub mod hosts; // Hosts list with filters and pagination
pub mod networks; // Networks list and creation
pub mod theme; // Theme toggle component (light / dark / system)
diff --git a/src/lib.rs b/src/lib.rs
index 7db32c4..ab1edc9 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,3 +1,7 @@
+// Leptos view! macros generate deeply-nested generic types; the default
+// limit of 128 is not enough for pages with many components.
+#![recursion_limit = "512"]
+
// lib.rs — Shared library root
//
// This file is compiled in BOTH modes:
diff --git a/style/rust-ipam.css b/style/rust-ipam.css
index 4ea3c38..83bff3e 100644
--- a/style/rust-ipam.css
+++ b/style/rust-ipam.css
@@ -515,8 +515,24 @@ tbody td {
vertical-align: middle;
}
-tbody td:last-child {
+/* Count columns (Hosts, Applications...) — right-aligned, muted color */
+th.col-count,
+td.col-count {
text-align: right;
+ color: var(--text-secondary);
+ font-variant-numeric: tabular-nums;
+ width: 120px;
+}
+
+th.col-count {
+ color: var(--text-tertiary);
+}
+
+/* Actions column — right-aligned to match the button position */
+th.col-actions,
+td.col-actions {
+ text-align: right;
+ width: 100px;
}
/* ============================================================
@@ -716,3 +732,177 @@ tbody td:last-child {
.not-found a:hover {
text-decoration: underline;
}
+
+/* ============================================================
+ TABLE UTILITIES — shared across pages
+ ============================================================ */
+
+/* Clickable link inside a table cell */
+.table-link {
+ color: var(--accent);
+ text-decoration: none;
+ font-weight: 500;
+}
+
+.table-link:hover {
+ text-decoration: underline;
+}
+
+/* Monospace cell (IPs, CIDRs…) */
+.cell-mono {
+ font-family: var(--font-mono);
+ font-size: var(--font-sm);
+ color: var(--text-secondary);
+}
+
+/* ============================================================
+ FILTER BAR
+ ============================================================ */
+
+.filter-bar {
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-sm);
+ padding: var(--size-md) var(--size-lg);
+ margin-bottom: var(--size-md);
+}
+
+.filter-bar__fields {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ gap: var(--size-md);
+ align-items: end;
+}
+
+.filter-field {
+ display: flex;
+ flex-direction: column;
+ gap: var(--size-xs);
+ font-size: var(--font-sm);
+ font-weight: 500;
+ color: var(--text-secondary);
+}
+
+/* ============================================================
+ PAGINATION BAR
+ ============================================================ */
+
+.pagination-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: var(--size-sm);
+ padding: var(--size-sm) 0;
+ margin-bottom: var(--size-sm);
+}
+
+.pagination-bar__info {
+ font-size: var(--font-sm);
+ color: var(--text-secondary);
+}
+
+.pagination-bar__controls {
+ display: flex;
+ align-items: center;
+ gap: var(--size-md);
+}
+
+.pagination-per-page {
+ display: flex;
+ align-items: center;
+ gap: var(--size-xs);
+ font-size: var(--font-sm);
+ color: var(--text-secondary);
+ font-weight: 500;
+}
+
+.pagination-per-page select {
+ width: auto;
+ padding: 4px 8px;
+ font-size: var(--font-sm);
+}
+
+.pagination-nav {
+ display: flex;
+ align-items: center;
+ gap: var(--size-xs);
+}
+
+.pagination-nav button {
+ background: var(--bg-surface);
+ color: var(--text);
+ border: 1px solid var(--border);
+ padding: 4px 10px;
+ font-size: var(--font-base);
+ border-radius: var(--radius-sm);
+ min-width: 32px;
+}
+
+.pagination-nav button:hover:not(:disabled) {
+ background: var(--bg-hover);
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.pagination-nav button:disabled {
+ opacity: 0.35;
+ cursor: not-allowed;
+}
+
+.pagination-nav__label {
+ font-size: var(--font-sm);
+ color: var(--text-secondary);
+ white-space: nowrap;
+ padding: 0 var(--size-xs);
+}
+
+/* ============================================================
+ HOSTS PAGE
+ ============================================================ */
+
+.hosts-page h1 {
+ margin-bottom: var(--size-lg);
+}
+
+.hosts-page .add-form {
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-sm);
+ padding: var(--size-lg);
+ margin-bottom: var(--size-md);
+}
+
+.hosts-page .add-form h2 {
+ margin-bottom: var(--size-md);
+}
+
+.add-form__fields {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ gap: var(--size-md);
+ align-items: end;
+}
+
+.add-form__fields button[type="submit"] {
+ align-self: end;
+}
+
+/* Delete button inside hosts table */
+.hosts-page td button {
+ background: transparent;
+ color: var(--danger);
+ border: 1px solid transparent;
+ font-size: var(--font-xs);
+ padding: 3px 10px;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ transition: background var(--transition-fast), border-color var(--transition-fast);
+}
+
+.hosts-page td button:hover {
+ background: var(--danger-light);
+ border-color: var(--danger);
+}