feat(hosts): add hosts list page with filters, pagination and delete

Implements task #7. The Hosts page provides:
- Name/network/port/application filters (sentinel values instead of
  Option<T> to avoid server function serialization issues)
- Configurable page size (15 default, 25/50/100/All)
- Prev/next navigation with total host count
- Add host form with network selector
- Delete action per row

Sub-components (AddHostForm, FilterBar, PaginationBar, HostTable) each
call .into_any() to erase their concrete view types. This breaks the
deeply-nested generic type that caused "queries overflow the depth
limit!" without requiring an unbounded recursion_limit increase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 23:23:24 +02:00
parent 5b1f30fe24
commit 042793f385
6 changed files with 699 additions and 5 deletions

352
src/client/hosts.rs Normal file
View File

@@ -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<CreateHost>,
networks_res: Resource<Result<Vec<crate::models::Network>, ServerFnError>>,
) -> impl IntoView {
view! {
<section class="add-form">
<h2>"Add a host"</h2>
<ActionForm action=create_action>
<div class="add-form__fields">
<label>
"Name"
<input type="text" name="name" placeholder="e.g. web-server-01" required/>
</label>
<label>
"IP address"
<input type="text" name="ip" placeholder="e.g. 192.168.1.10" required/>
</label>
<label>
"Network"
<select name="network_id" required>
<option value="">"— choose —"</option>
{move || networks_res.get()
.and_then(|r| r.ok())
.map(|nets| nets.into_iter().map(|n| {
view! { <option value=n.id.to_string()>{n.cidr}</option> }
}).collect_view())
}
</select>
</label>
<button type="submit">"Add"</button>
</div>
</ActionForm>
{move || create_action.value().get()
.and_then(|r| r.err())
.map(|e| view! { <p class="error">{e.to_string()}</p> })
}
</section>
}.into_any()
}
// ─── Filter bar ───────────────────────────────────────────────────────────────
#[component]
fn FilterBar(
networks_res: Resource<Result<Vec<crate::models::Network>, ServerFnError>>,
applications_res: Resource<Result<Vec<crate::models::Application>, ServerFnError>>,
name_filter: RwSignal<String>,
network_id_filter: RwSignal<i64>,
port_filter: RwSignal<i64>,
app_id_filter: RwSignal<i64>,
page: RwSignal<i64>,
) -> impl IntoView {
view! {
<section class="filter-bar">
<div class="filter-bar__fields">
<label class="filter-field">
"Name"
<input
type="text"
placeholder="Search…"
on:change=move |e| {
name_filter.set(event_target_value(&e));
page.set(1);
}
/>
</label>
<label class="filter-field">
"Network"
<select on:change=move |e| {
network_id_filter.set(event_target_value(&e).parse().unwrap_or(0));
page.set(1);
}>
<option value="0">"All networks"</option>
{move || networks_res.get()
.and_then(|r| r.ok())
.map(|nets| nets.into_iter().map(|n| {
view! { <option value=n.id.to_string()>{n.cidr}</option> }
}).collect_view())
}
</select>
</label>
<label class="filter-field">
"Open port"
<input
type="number"
min="1"
max="65535"
placeholder="e.g. 443"
on:change=move |e| {
port_filter.set(event_target_value(&e).parse().unwrap_or(0));
page.set(1);
}
/>
</label>
<label class="filter-field">
"Application"
<select on:change=move |e| {
app_id_filter.set(event_target_value(&e).parse().unwrap_or(0));
page.set(1);
}>
<option value="0">"All applications"</option>
{move || applications_res.get()
.and_then(|r| r.ok())
.map(|apps| apps.into_iter().map(|a| {
view! { <option value=a.id.to_string()>{a.name}</option> }
}).collect_view())
}
</select>
</label>
</div>
</section>
}.into_any()
}
// ─── Pagination bar ───────────────────────────────────────────────────────────
#[component]
fn PaginationBar(
total: Signal<i64>,
page: RwSignal<i64>,
per_page: RwSignal<i64>,
total_pages: Signal<i64>,
) -> impl IntoView {
view! {
<div class="pagination-bar">
<div class="pagination-bar__info">
{move || {
let t = total.get();
if t == 0 { "No hosts found".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(|(value, label)| {
view! {
<option value=value.to_string() selected=*value == 15>
{*label}
</option>
}
}).collect_view()}
</select>
</label>
// Page navigation — hidden when showing all results (per_page == 0)
{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>
}.into_any()
}
// ─── Host table ───────────────────────────────────────────────────────────────
// Separate component for the table body to further reduce type depth in HostsPage.
#[component]
fn HostTable(
hosts: Resource<Result<HostsPageData, ServerFnError>>,
delete_action: ServerAction<DeleteHost>,
) -> impl IntoView {
view! {
<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 match the current filters."</p>
}.into_any(),
Ok(HostsPageData { rows, .. }) => view! {
<div class="table-container">
<table>
<thead>
<tr>
<th>"Name"</th>
<th>"IP"</th>
<th>"Network"</th>
<th class="col-count">"Ports"</th>
<th class="col-count">"Applications"</th>
<th class="col-actions">"Actions"</th>
</tr>
</thead>
<tbody>
{rows.into_iter().map(|host| {
let id = host.id;
view! {
<tr>
<td>
<a class="table-link" href=format!("/hosts/{id}")>
{host.name}
</a>
</td>
<td class="cell-mono">{host.ip}</td>
<td>
<a class="table-link" href=format!("/networks/{}", host.network_id)>
{host.network_cidr}
</a>
</td>
<td class="col-count">{host.port_count}</td>
<td class="col-count">{host.application_count}</td>
<td class="col-actions">
<button on:click=move |_| {
delete_action.dispatch(DeleteHost { id });
}>
"Delete"
</button>
</td>
</tr>
}
}).collect_view()}
</tbody>
</table>
</div>
}.into_any(),
})}
</Suspense>
}.into_any()
}
// ─── Main page component ──────────────────────────────────────────────────────
#[component]
pub fn HostsPage() -> impl IntoView {
// Actions
let create_action = ServerAction::<CreateHost>::new();
let delete_action = ServerAction::<DeleteHost>::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! {
<div class="hosts-page">
<h1>"Hosts"</h1>
<AddHostForm create_action=create_action networks_res=networks_res/>
<FilterBar
networks_res=networks_res
applications_res=applications_res
name_filter=name_filter
network_id_filter=network_id_filter
port_filter=port_filter
app_id_filter=app_id_filter
page=page
/>
<section class="list">
{move || delete_action.value().get()
.and_then(|r| r.err())
.map(|e| view! { <p class="error">"Delete failed: " {e.to_string()}</p> })
}
<PaginationBar total=total page=page per_page=per_page total_pages=total_pages/>
<HostTable hosts=hosts delete_action=delete_action/>
</section>
</div>
}.into_any()
}