Files
rust-ipam/src/client/hosts.rs
mathieu eef0ae0b54 fix(hosts): remove port filter hint to fix filter bar alignment
The field-hint span made the port field taller than others; with
align-items: end on the grid, the input was offset upward.
The placeholder now carries the same information.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:58:39 +02:00

395 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// client/hosts.rs — Hosts list page
//
// Displays all hosts across every network with:
// - Add button : opens a modal form to create a host inside a chosen network
// - Filter bar : name (substring), network, open ports (CSV), application
// - Table : name, IP, network, port count, application count, delete
// - Pagination : configurable page size (15 / 25 / 50 / 100 / All)
//
// Sub-components call `.into_any()` on their views to erase the concrete
// Leptos type, preventing the parent from accumulating a deeply-nested
// generic type that overflows 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 modal ───────────────────────────────────────────────────────────
#[component]
fn AddHostModal(
create_action: ServerAction<CreateHost>,
networks_res: Resource<Result<Vec<crate::models::Network>, ServerFnError>>,
show_modal: RwSignal<bool>,
) -> impl IntoView {
// Close the modal automatically after a successful creation.
Effect::new(move |_| {
if let Some(Ok(_)) = create_action.value().get() {
show_modal.set(false);
}
});
view! {
<div class="modal-backdrop" on:click=move |_| show_modal.set(false)>
<div class="modal" on:click=move |e| e.stop_propagation()>
<div class="modal__header">
<h2>"Add a host"</h2>
<button
class="modal__close"
type="button"
aria-label="Close"
on:click=move |_| show_modal.set(false)
>"×"</button>
</div>
<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| {
let label = format!("{} - {}", n.name, n.cidr);
view! { <option value=n.id.to_string()>{label}</option> }
}).collect_view())
}
</select>
</label>
<label>
"Open ports"
<input
type="text"
name="ports"
placeholder="e.g. 22, 80, 443"
/>
<span class="field-hint">"Comma-separated port numbers"</span>
</label>
</div>
<div class="modal__actions">
<button
class="btn-secondary"
type="button"
on:click=move |_| show_modal.set(false)
>"Cancel"</button>
<button type="submit">"Add host"</button>
</div>
</ActionForm>
{move || create_action.value().get()
.and_then(|r| r.err())
.map(|e| view! { <p class="error">{e.to_string()}</p> })
}
</div>
</div>
}.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<String>,
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| {
let label = format!("{} - {}", n.name, n.cidr);
view! { <option value=n.id.to_string()>{label}</option> }
}).collect_view())
}
</select>
</label>
<label class="filter-field">
"Open ports"
<input
type="text"
placeholder="e.g. 80, 443 (all required)"
on:change=move |e| {
port_filter.set(event_target_value(&e));
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>
{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 ───────────────────────────────────────────────────────────────
#[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 {
let create_action = ServerAction::<CreateHost>::new();
let delete_action = ServerAction::<DeleteHost>::new();
let show_modal = RwSignal::new(false);
// Filter signals ("" / 0 = no filter)
let name_filter = RwSignal::new(String::new());
let network_id_filter = RwSignal::new(0i64);
let port_filter = RwSignal::new(String::new()); // CSV of port numbers
let app_id_filter = RwSignal::new(0i64);
let page = RwSignal::new(1i64);
let per_page = RwSignal::new(15i64);
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),
);
let networks_res = Resource::new(|| (), |_| get_networks());
let applications_res = Resource::new(|| (), |_| get_applications());
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">
<div class="page-header">
<h1>"Hosts"</h1>
<button class="btn-primary" on:click=move |_| show_modal.set(true)>
"+ Add host"
</button>
</div>
{move || show_modal.get().then(|| view! {
<AddHostModal
create_action=create_action
networks_res=networks_res
show_modal=show_modal
/>
})}
<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()
}