feat(hosts): multi-port filter and port list on host creation

- Network dropdowns now show "Name - CIDR" in both filter bar and add modal
- Port filter accepts comma-separated ports (e.g. "80, 443"); a host must
  have ALL listed ports open to match (AND semantics)
- Add host modal has a new "Open ports" field (comma-separated); ports are
  registered in the catalog and linked to the host on creation
- Port conditions are inlined as validated integers in SQL (no injection risk)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 01:50:26 +02:00
parent e0ddf58a17
commit 6018874aa4
3 changed files with 93 additions and 70 deletions

View File

@@ -2,7 +2,7 @@
//
// 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 port, application
// - 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)
//
@@ -43,9 +43,7 @@ fn AddHostModal(
});
view! {
// Backdrop — click outside the card to close
<div class="modal-backdrop" on:click=move |_| show_modal.set(false)>
// stop_propagation keeps clicks inside the card from closing the modal
<div class="modal" on:click=move |e| e.stop_propagation()>
<div class="modal__header">
<h2>"Add a host"</h2>
@@ -74,11 +72,21 @@ fn AddHostModal(
{move || networks_res.get()
.and_then(|r| r.ok())
.map(|nets| nets.into_iter().map(|n| {
view! { <option value=n.id.to_string()>{n.name}</option> }
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">
@@ -108,7 +116,7 @@ fn FilterBar(
applications_res: Resource<Result<Vec<crate::models::Application>, ServerFnError>>,
name_filter: RwSignal<String>,
network_id_filter: RwSignal<i64>,
port_filter: RwSignal<i64>,
port_filter: RwSignal<String>,
app_id_filter: RwSignal<i64>,
page: RwSignal<i64>,
) -> impl IntoView {
@@ -137,24 +145,24 @@ fn FilterBar(
{move || networks_res.get()
.and_then(|r| r.ok())
.map(|nets| nets.into_iter().map(|n| {
view! { <option value=n.id.to_string()>{n.name}</option> }
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 port"
"Open ports"
<input
type="number"
min="1"
max="65535"
placeholder="e.g. 443"
type="text"
placeholder="e.g. 80, 443"
on:change=move |e| {
port_filter.set(event_target_value(&e).parse().unwrap_or(0));
port_filter.set(event_target_value(&e));
page.set(1);
}
/>
<span class="field-hint">"All listed ports must be open"</span>
</label>
<label class="filter-field">
@@ -212,7 +220,6 @@ fn PaginationBar(
</select>
</label>
// Page navigation — hidden when showing all results (per_page == 0)
{move || (per_page.get() > 0).then(|| view! {
<div class="pagination-nav">
<button
@@ -308,24 +315,20 @@ fn HostTable(
#[component]
pub fn HostsPage() -> impl IntoView {
// Actions
let create_action = ServerAction::<CreateHost>::new();
let delete_action = ServerAction::<DeleteHost>::new();
// Controls the add-host modal
let show_modal = RwSignal::new(false);
// Filter signals (0 / "" = no filter)
// 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 port_filter = RwSignal::new(String::new()); // CSV of port numbers
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(),
@@ -340,11 +343,9 @@ pub fn HostsPage() -> impl IntoView {
|(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)
});
@@ -361,7 +362,6 @@ pub fn HostsPage() -> impl IntoView {
</button>
</div>
// Modal — only rendered when show_modal is true
{move || show_modal.get().then(|| view! {
<AddHostModal
create_action=create_action