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>
395 lines
16 KiB
Rust
395 lines
16 KiB
Rust
// 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()
|
||
}
|