From c3e2d5dcf6558013ba22dfb1dbeb61332a6c70d9 Mon Sep 17 00:00:00 2001 From: mathieu Date: Sat, 16 May 2026 02:37:06 +0200 Subject: [PATCH] feat(networks): add network detail page with paginated host list and contextual back button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API: add get_network(id) server function - NetworkDetailPage at /networks/:id — network name + CIDR header, paginated host table (Name, IP, Ports, Apps) linking to /hosts/:id?back=/networks/:id - Networks list: make network name a link to its detail page - HostDetailPage: read ?back= query param to show "← Network" or "← Hosts" and navigate to the correct destination Co-Authored-By: Claude Sonnet 4.6 --- src/api/networks.rs | 15 +++ src/app.rs | 2 + src/client/host_detail.rs | 18 +++- src/client/mod.rs | 11 +- src/client/network_detail.rs | 200 +++++++++++++++++++++++++++++++++++ src/client/networks.rs | 8 +- style/rust-ipam.css | 16 +++ 7 files changed, 262 insertions(+), 8 deletions(-) create mode 100644 src/client/network_detail.rs diff --git a/src/api/networks.rs b/src/api/networks.rs index ce74507..4af0b94 100644 --- a/src/api/networks.rs +++ b/src/api/networks.rs @@ -77,6 +77,21 @@ pub async fn get_networks_with_counts() -> Result, Server Ok(networks) } +/// Returns a single network by id, or an error if it does not exist. +#[server] +pub async fn get_network(id: i64) -> Result { + use sqlx::AnyPool; + use crate::server::repository::networks as repo; + + let pool = use_context::() + .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; + + repo::find_network(&pool, id) + .await + .map_err(|e| ServerFnError::new(e.to_string()))? + .ok_or_else(|| ServerFnError::new(format!("Network {id} not found"))) +} + // ─── Mutations ──────────────────────────────────────────────────────────────── /// Creates a new network with the given name and CIDR block. diff --git a/src/app.rs b/src/app.rs index d1490b9..4d75a0a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,6 +15,7 @@ use crate::client::{ home::HomePage, host_detail::HostDetailPage, hosts::HostsPage, + network_detail::NetworkDetailPage, networks::NetworksPage, theme::ThemeToggle, }; @@ -108,6 +109,7 @@ pub fn App() -> impl IntoView { }> + diff --git a/src/client/host_detail.rs b/src/client/host_detail.rs index a9ee12f..b3898ea 100644 --- a/src/client/host_detail.rs +++ b/src/client/host_detail.rs @@ -6,7 +6,7 @@ // - Delete button : opens a confirmation modal, then navigates back to /hosts use leptos::prelude::*; -use leptos_router::hooks::{use_navigate, use_params_map}; +use leptos_router::hooks::{use_navigate, use_params_map, use_query_map}; use crate::api::{ hosts::{AddHostPort, DeleteHost, RemoveHostPort, UpdateHost, get_host_detail}, @@ -67,6 +67,18 @@ pub fn HostDetailPage() -> impl IntoView { .unwrap_or(0) }; + // Optional `?back=` query parameter — used when arriving from a network + // detail page so the back button returns there instead of the hosts list. + let query = use_query_map(); + let back_url = move || { + query.read().get("back") + .map(|s| s.to_string()) + .unwrap_or_else(|| "/hosts".to_string()) + }; + let back_label = move || { + if back_url().starts_with("/networks/") { "← Network" } else { "← Hosts" } + }; + let update_action = ServerAction::::new(); let add_port_action = ServerAction::::new(); let remove_port_action = ServerAction::::new(); @@ -169,7 +181,9 @@ pub fn HostDetailPage() -> impl IntoView { view! { // ── Page header ────────────────────────────────── diff --git a/src/client/mod.rs b/src/client/mod.rs index 7621da8..7877299 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -9,8 +9,9 @@ // Do not place code here that requires browser-only APIs (window, document...) // without guarding it with `#[cfg(target_arch = "wasm32")]`. -pub mod home; // Home page -pub mod host_detail; // Host detail: identity, ports, edit, delete -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) +pub mod home; // Home page +pub mod host_detail; // Host detail: identity, ports, edit, delete +pub mod hosts; // Hosts list with filters and pagination +pub mod network_detail; // Network detail: info + paginated host list +pub mod networks; // Networks list and creation +pub mod theme; // Theme toggle component (light / dark / system) diff --git a/src/client/network_detail.rs b/src/client/network_detail.rs new file mode 100644 index 0000000..7178fba --- /dev/null +++ b/src/client/network_detail.rs @@ -0,0 +1,200 @@ +// client/network_detail.rs — Network detail page +// +// Displays a single network (name + CIDR) with a paginated list of its hosts. +// Each host name links to /hosts/:id?back=/networks/:network_id so that the +// host detail page can offer a contextual "back to network" button. + +use leptos::prelude::*; +use leptos_router::hooks::use_params_map; + +use crate::api::{ + hosts::{get_hosts_page, HostsPage as HostsPageData}, + networks::get_network, +}; + +const PER_PAGE_OPTIONS: &[(i64, &str)] = &[ + (15, "15"), + (25, "25"), + (50, "50"), + (100, "100"), + (0, "All"), +]; + +// ─── Main page component ────────────────────────────────────────────────────── + +#[component] +pub fn NetworkDetailPage() -> impl IntoView { + let params = use_params_map(); + let network_id = move || { + params.read().get("id") + .and_then(|s| s.parse::().ok()) + .unwrap_or(0) + }; + + let page = RwSignal::new(1i64); + let per_page = RwSignal::new(15i64); + + // Network metadata — reloads only when the ID changes. + let network = Resource::new( + move || network_id(), + |id| get_network(id), + ); + + // Paginated host list for this network. + // Guards against network_id = 0 to avoid fetching all hosts. + let hosts = Resource::new( + move || (network_id(), page.get(), per_page.get()), + |(net_id, p, pp)| async move { + if net_id == 0 { + return Err(ServerFnError::new("Invalid network ID")); + } + get_hosts_page(String::new(), net_id, String::new(), 0, p, pp).await + }, + ); + + let total_pages = Signal::derive(move || { + hosts.get().and_then(|r| r.ok()).map(|d| d.total_pages).unwrap_or(1) + }); + let total = Signal::derive(move || { + hosts.get().and_then(|r| r.ok()).map(|d| d.total).unwrap_or(0) + }); + + view! { +
+ "Loading network…"

}> + {move || network.get().map(|result| match result { + Err(e) => view! { +

"Could not load network: " {e.to_string()}

+ }.into_any(), + + Ok(net) => { + let net_id = net.id; + view! { + // ── Header ──────────────────────────────────────── + + + // ── Hosts section ──────────────────────────────── +
+

"Hosts"

+ + // Pagination bar +
+
+ {move || { + let t = total.get(); + if t == 0 { "No hosts".to_string() } + else { format!("{} host{}", t, if t == 1 { "" } else { "s" }) } + }} +
+
+ + {move || (per_page.get() > 0).then(|| view! { +
+ + + {move || format!( + "Page {} of {}", + page.get(), + total_pages.get().max(1) + )} + + +
+ })} +
+
+ + // Host table + "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 in this network."

+ }.into_any(), + + Ok(HostsPageData { rows, .. }) => view! { +
+ + + + + + + + + + + {rows.into_iter().map(|host| { + // Pass the current network as the back destination + // so the host detail page can link back here. + let href = format!( + "/hosts/{}?back=/networks/{}", + host.id, net_id + ); + view! { + + + + + + + } + }).collect_view()} + +
"Name""IP""Ports""Apps"
+ + {host.name} + + {host.ip} + {host.port_count} + + {host.application_count} +
+
+ }.into_any(), + })} +
+
+ }.into_any() + } + })} +
+
+ }.into_any() +} diff --git a/src/client/networks.rs b/src/client/networks.rs index bfbf39b..beb876a 100644 --- a/src/client/networks.rs +++ b/src/client/networks.rs @@ -166,9 +166,15 @@ pub fn NetworksPage() -> impl IntoView { .into_iter() .map(|network| { let network_clone = network.clone(); + let net_id = network.id; view! { - {network.name} + + + {network.name} + + {network.cidr} {network.host_count} {network.application_count} diff --git a/style/rust-ipam.css b/style/rust-ipam.css index 87a831f..b9fd6c5 100644 --- a/style/rust-ipam.css +++ b/style/rust-ipam.css @@ -1207,3 +1207,19 @@ td.col-actions { justify-content: flex-end; margin-top: var(--size-lg); } + +/* ============================================================ + NETWORK DETAIL PAGE + ============================================================ */ + +.network-detail-page { + max-width: 720px; +} + +/* CIDR displayed below the network name in the header */ +.network-detail-cidr { + font-family: var(--font-mono); + font-size: var(--font-sm); + color: var(--text-secondary); + margin: 0; +}