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)
|
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 ────────────────────────────────────────────────────────────────
|
// ─── Mutations ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Creates a new network with the given name and CIDR block.
|
/// Creates a new network with the given name and CIDR block.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use crate::client::{
|
|||||||
home::HomePage,
|
home::HomePage,
|
||||||
host_detail::HostDetailPage,
|
host_detail::HostDetailPage,
|
||||||
hosts::HostsPage,
|
hosts::HostsPage,
|
||||||
|
network_detail::NetworkDetailPage,
|
||||||
networks::NetworksPage,
|
networks::NetworksPage,
|
||||||
theme::ThemeToggle,
|
theme::ThemeToggle,
|
||||||
};
|
};
|
||||||
@@ -108,6 +109,7 @@ pub fn App() -> impl IntoView {
|
|||||||
}>
|
}>
|
||||||
<Route path=path!("/") view=HomePage/>
|
<Route path=path!("/") view=HomePage/>
|
||||||
<Route path=path!("/networks") view=NetworksPage/>
|
<Route path=path!("/networks") view=NetworksPage/>
|
||||||
|
<Route path=path!("/networks/:id") view=NetworkDetailPage/>
|
||||||
<Route path=path!("/hosts") view=HostsPage/>
|
<Route path=path!("/hosts") view=HostsPage/>
|
||||||
<Route path=path!("/hosts/:id") view=HostDetailPage/>
|
<Route path=path!("/hosts/:id") view=HostDetailPage/>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// - Delete button : opens a confirmation modal, then navigates back to /hosts
|
// - Delete button : opens a confirmation modal, then navigates back to /hosts
|
||||||
|
|
||||||
use leptos::prelude::*;
|
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::{
|
use crate::api::{
|
||||||
hosts::{AddHostPort, DeleteHost, RemoveHostPort, UpdateHost, get_host_detail},
|
hosts::{AddHostPort, DeleteHost, RemoveHostPort, UpdateHost, get_host_detail},
|
||||||
@@ -67,6 +67,18 @@ pub fn HostDetailPage() -> impl IntoView {
|
|||||||
.unwrap_or(0)
|
.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 update_action = ServerAction::<UpdateHost>::new();
|
||||||
let add_port_action = ServerAction::<AddHostPort>::new();
|
let add_port_action = ServerAction::<AddHostPort>::new();
|
||||||
let remove_port_action = ServerAction::<RemoveHostPort>::new();
|
let remove_port_action = ServerAction::<RemoveHostPort>::new();
|
||||||
@@ -169,7 +181,9 @@ pub fn HostDetailPage() -> impl IntoView {
|
|||||||
view! {
|
view! {
|
||||||
// ── Page header ──────────────────────────────────
|
// ── Page header ──────────────────────────────────
|
||||||
<div class="page-header detail-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>
|
<h1 class="detail-page-title">{move || name_sig.get()}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,9 @@
|
|||||||
// Do not place code here that requires browser-only APIs (window, document...)
|
// Do not place code here that requires browser-only APIs (window, document...)
|
||||||
// without guarding it with `#[cfg(target_arch = "wasm32")]`.
|
// without guarding it with `#[cfg(target_arch = "wasm32")]`.
|
||||||
|
|
||||||
pub mod home; // Home page
|
pub mod home; // Home page
|
||||||
pub mod host_detail; // Host detail: identity, ports, edit, delete
|
pub mod host_detail; // Host detail: identity, ports, edit, delete
|
||||||
pub mod hosts; // Hosts list with filters and pagination
|
pub mod hosts; // Hosts list with filters and pagination
|
||||||
pub mod networks; // Networks list and creation
|
pub mod network_detail; // Network detail: info + paginated host list
|
||||||
pub mod theme; // Theme toggle component (light / dark / system)
|
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()
|
.into_iter()
|
||||||
.map(|network| {
|
.map(|network| {
|
||||||
let network_clone = network.clone();
|
let network_clone = network.clone();
|
||||||
|
let net_id = network.id;
|
||||||
view! {
|
view! {
|
||||||
<tr>
|
<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="cell-mono">{network.cidr}</td>
|
||||||
<td class="col-count">{network.host_count}</td>
|
<td class="col-count">{network.host_count}</td>
|
||||||
<td class="col-count">{network.application_count}</td>
|
<td class="col-count">{network.application_count}</td>
|
||||||
|
|||||||
@@ -1207,3 +1207,19 @@ td.col-actions {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: var(--size-lg);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user