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:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
200
src/client/network_detail.rs
Normal file
200
src/client/network_detail.rs
Normal 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()
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user