feat(networks): add network detail page with paginated host list and contextual back button

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 02:37:06 +02:00
parent ba4d2a60c6
commit c3e2d5dcf6
7 changed files with 262 additions and 8 deletions

View File

@@ -77,6 +77,21 @@ pub async fn get_networks_with_counts() -> Result<Vec<NetworkWithCounts>, 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<Network, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::networks as repo;
let pool = use_context::<AnyPool>()
.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.

View File

@@ -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 {
}>
<Route path=path!("/") view=HomePage/>
<Route path=path!("/networks") view=NetworksPage/>
<Route path=path!("/networks/:id") view=NetworkDetailPage/>
<Route path=path!("/hosts") view=HostsPage/>
<Route path=path!("/hosts/:id") view=HostDetailPage/>
</Routes>

View File

@@ -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=<url>` 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::<UpdateHost>::new();
let add_port_action = ServerAction::<AddHostPort>::new();
let remove_port_action = ServerAction::<RemoveHostPort>::new();
@@ -169,7 +181,9 @@ pub fn HostDetailPage() -> impl IntoView {
view! {
// ── Page header ──────────────────────────────────
<div class="page-header detail-page-header">
<a class="back-btn" href="/hosts">"← Hosts"</a>
<a class="back-btn" href=move || back_url()>
{move || back_label()}
</a>
<h1 class="detail-page-title">{move || name_sig.get()}</h1>
</div>

View File

@@ -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)

View File

@@ -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::<i64>().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! {
<div class="network-detail-page">
<Suspense fallback=|| view! { <p class="empty">"Loading network…"</p> }>
{move || network.get().map(|result| match result {
Err(e) => view! {
<p class="error">"Could not load network: " {e.to_string()}</p>
}.into_any(),
Ok(net) => {
let net_id = net.id;
view! {
// ── Header ────────────────────────────────────────
<div class="page-header detail-page-header">
<a class="back-btn" href="/networks">"← Networks"</a>
<h1 class="detail-page-title">{net.name}</h1>
<p class="network-detail-cidr">{net.cidr}</p>
</div>
// ── Hosts section ────────────────────────────────
<section class="detail-section">
<h2 class="detail-section__title">"Hosts"</h2>
// Pagination bar
<div class="pagination-bar">
<div class="pagination-bar__info">
{move || {
let t = total.get();
if t == 0 { "No hosts".to_string() }
else { format!("{} host{}", t, if t == 1 { "" } else { "s" }) }
}}
</div>
<div class="pagination-bar__controls">
<label class="pagination-per-page">
"Per page "
<select on:change=move |e| {
per_page.set(
event_target_value(&e).parse().unwrap_or(15)
);
page.set(1);
}>
{PER_PAGE_OPTIONS.iter().map(|(v, label)| {
view! {
<option value=v.to_string() selected=*v == 15>
{*label}
</option>
}
}).collect_view()}
</select>
</label>
{move || (per_page.get() > 0).then(|| view! {
<div class="pagination-nav">
<button
disabled=move || page.get() <= 1
on:click=move |_| page.update(|p| *p = (*p - 1).max(1))
>""</button>
<span class="pagination-nav__label">
{move || format!(
"Page {} of {}",
page.get(),
total_pages.get().max(1)
)}
</span>
<button
disabled=move || page.get() >= total_pages.get()
on:click=move |_| {
let max = total_pages.get_untracked();
page.update(|p| *p = (*p + 1).min(max));
}
>""</button>
</div>
})}
</div>
</div>
// Host table
<Suspense fallback=|| view! {
<p class="empty">"Loading hosts…"</p>
}>
{move || hosts.get().map(|result| match result {
Err(e) => view! {
<p class="error">
"Could not load hosts: " {e.to_string()}
</p>
}.into_any(),
Ok(HostsPageData { rows, .. }) if rows.is_empty() => view! {
<p class="empty">"No hosts in this network."</p>
}.into_any(),
Ok(HostsPageData { rows, .. }) => view! {
<div class="table-container">
<table>
<thead>
<tr>
<th>"Name"</th>
<th>"IP"</th>
<th class="col-count">"Ports"</th>
<th class="col-count">"Apps"</th>
</tr>
</thead>
<tbody>
{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! {
<tr>
<td>
<a class="table-link" href=href>
{host.name}
</a>
</td>
<td class="cell-mono">{host.ip}</td>
<td class="col-count">
{host.port_count}
</td>
<td class="col-count">
{host.application_count}
</td>
</tr>
}
}).collect_view()}
</tbody>
</table>
</div>
}.into_any(),
})}
</Suspense>
</section>
}.into_any()
}
})}
</Suspense>
</div>
}.into_any()
}

View File

@@ -166,9 +166,15 @@ pub fn NetworksPage() -> impl IntoView {
.into_iter()
.map(|network| {
let network_clone = network.clone();
let net_id = network.id;
view! {
<tr>
<td>{network.name}</td>
<td>
<a class="table-link"
href=format!("/networks/{}", net_id)>
{network.name}
</a>
</td>
<td class="cell-mono">{network.cidr}</td>
<td class="col-count">{network.host_count}</td>
<td class="col-count">{network.application_count}</td>

View File

@@ -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;
}