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! { +
+

"Add a host"

+ +
+ + + + +
+
+ {move || create_action.value().get() + .and_then(|r| r.err()) + .map(|e| view! {

{e.to_string()}

}) + } +
+ }.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! { +
+
+ {move || { + let t = total.get(); + if t == 0 { "No hosts found".to_string() } + else { format!("{} host{}", t, if t == 1 { "" } else { "s" }) } + }} +
+
+ + + // Page navigation — hidden when showing all results (per_page == 0) + {move || (per_page.get() > 0).then(|| view! { +
+ + + {move || format!("Page {} of {}", page.get(), total_pages.get().max(1))} + + +
+ })} +
+
+ }.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! { +
+ + + + + + + + + + + + + {rows.into_iter().map(|host| { + let id = host.id; + view! { + + + + + + + + + } + }).collect_view()} + +
"Name""IP""Network""Ports""Applications""Actions"
+ + {host.name} + + {host.ip} + + {host.network_cidr} + + {host.port_count}{host.application_count} + +
+
+ }.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); +}