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:
@@ -48,22 +48,17 @@ pub async fn get_hosts_by_network(network_id: i64) -> Result<Vec<Host>, ServerFn
|
|||||||
|
|
||||||
/// Returns a filtered and paginated list of hosts across all networks.
|
/// Returns a filtered and paginated list of hosts across all networks.
|
||||||
///
|
///
|
||||||
/// Filter parameters use sentinel values (0 / empty string) to mean "no filter":
|
/// `port_filter` is a comma-separated list of port numbers (e.g. "80,443").
|
||||||
/// - `name_filter` : substring match on host name (case-insensitive); "" = all
|
/// A host matches only if it has ALL the specified ports open.
|
||||||
/// - `network_id_filter` : exact network id; 0 = all
|
/// An empty string means no port filter.
|
||||||
/// - `port_filter` : hosts with this port open; 0 = all
|
|
||||||
/// - `application_id_filter` : hosts linked to this application; 0 = all
|
|
||||||
/// - `per_page` : items per page; 0 = return everything
|
|
||||||
/// - `page` : 1-indexed page number
|
|
||||||
///
|
///
|
||||||
/// The SQL uses each bind parameter twice in the WHERE clause
|
/// Port conditions are inlined in the SQL as integer literals (safe: values
|
||||||
/// (once for the IS NULL guard, once for the actual comparison).
|
/// are parsed and range-checked before use — no raw user strings are injected).
|
||||||
/// Each $N placeholder refers to the N-th bound argument by index.
|
|
||||||
#[server]
|
#[server]
|
||||||
pub async fn get_hosts_page(
|
pub async fn get_hosts_page(
|
||||||
name_filter: String,
|
name_filter: String,
|
||||||
network_id_filter: i64,
|
network_id_filter: i64,
|
||||||
port_filter: i64,
|
port_filter: String,
|
||||||
application_id_filter: i64,
|
application_id_filter: i64,
|
||||||
page: i64,
|
page: i64,
|
||||||
per_page: i64,
|
per_page: i64,
|
||||||
@@ -73,47 +68,55 @@ pub async fn get_hosts_page(
|
|||||||
let pool = use_context::<AnyPool>()
|
let pool = use_context::<AnyPool>()
|
||||||
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||||
|
|
||||||
// Convert sentinel values to Option for SQL NULL binding.
|
|
||||||
// None → binds as SQL NULL → "$N IS NULL" evaluates to TRUE → filter skipped.
|
|
||||||
let name_like: Option<String> = if name_filter.is_empty() {
|
let name_like: Option<String> = if name_filter.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(format!("%{}%", name_filter))
|
Some(format!("%{}%", name_filter))
|
||||||
};
|
};
|
||||||
let network_id: Option<i64> = if network_id_filter == 0 { None } else { Some(network_id_filter) };
|
let network_id: Option<i64> = if network_id_filter == 0 { None } else { Some(network_id_filter) };
|
||||||
let port: Option<i64> = if port_filter == 0 { None } else { Some(port_filter) };
|
|
||||||
let app_id: Option<i64> = if application_id_filter == 0 { None } else { Some(application_id_filter) };
|
let app_id: Option<i64> = if application_id_filter == 0 { None } else { Some(application_id_filter) };
|
||||||
|
|
||||||
// Each filter param is bound twice so the same $N can appear in both
|
// Parse and validate port numbers from the CSV string.
|
||||||
// the IS NULL guard and the comparison without re-declaring parameters.
|
// Inlined as integer literals in SQL — safe because they are range-checked i64s.
|
||||||
const WHERE: &str = "
|
let ports: Vec<i64> = port_filter
|
||||||
JOIN networks n ON n.id = h.network_id
|
.split(',')
|
||||||
|
.filter_map(|s| s.trim().parse::<i64>().ok())
|
||||||
|
.filter(|&p| p >= 1 && p <= 65535)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// One EXISTS clause per required port (AND semantics: host must have ALL ports).
|
||||||
|
let port_conditions: String = ports
|
||||||
|
.iter()
|
||||||
|
.map(|p| format!(
|
||||||
|
" AND EXISTS (SELECT 1 FROM host_ports WHERE host_id = h.id AND port_number = {p})"
|
||||||
|
))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// $1 = name_like, $2 = network_id, $3 = app_id
|
||||||
|
// Pagination: $4 = limit, $5 = offset
|
||||||
|
let where_clause = format!(
|
||||||
|
"JOIN networks n ON n.id = h.network_id
|
||||||
LEFT JOIN host_ports hp ON hp.host_id = h.id
|
LEFT JOIN host_ports hp ON hp.host_id = h.id
|
||||||
LEFT JOIN application_ports ap ON ap.port_number = hp.port_number
|
LEFT JOIN application_ports ap ON ap.port_number = hp.port_number
|
||||||
WHERE ($1 IS NULL OR LOWER(h.name) LIKE LOWER($1))
|
WHERE ($1 IS NULL OR LOWER(h.name) LIKE LOWER($1))
|
||||||
AND ($2 IS NULL OR h.network_id = $2)
|
AND ($2 IS NULL OR h.network_id = $2)
|
||||||
AND ($3 IS NULL OR EXISTS (
|
AND ($3 IS NULL OR EXISTS (
|
||||||
SELECT 1 FROM host_ports
|
|
||||||
WHERE host_id = h.id AND port_number = $3
|
|
||||||
))
|
|
||||||
AND ($4 IS NULL OR EXISTS (
|
|
||||||
SELECT 1 FROM host_ports hp2
|
SELECT 1 FROM host_ports hp2
|
||||||
JOIN application_ports ap2 ON ap2.port_number = hp2.port_number
|
JOIN application_ports ap2 ON ap2.port_number = hp2.port_number
|
||||||
WHERE hp2.host_id = h.id AND ap2.application_id = $4
|
WHERE hp2.host_id = h.id AND ap2.application_id = $3
|
||||||
))";
|
))
|
||||||
|
{port_conditions}"
|
||||||
|
);
|
||||||
|
|
||||||
// Count matching hosts (ignoring pagination).
|
let count_sql = format!("SELECT COUNT(DISTINCT h.id) FROM hosts h {where_clause}");
|
||||||
let count_sql = format!("SELECT COUNT(DISTINCT h.id) FROM hosts h {WHERE}");
|
|
||||||
let total: i64 = sqlx::query_scalar(&count_sql)
|
let total: i64 = sqlx::query_scalar(&count_sql)
|
||||||
.bind(name_like.as_deref())
|
.bind(name_like.as_deref())
|
||||||
.bind(network_id)
|
.bind(network_id)
|
||||||
.bind(port)
|
|
||||||
.bind(app_id)
|
.bind(app_id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
// Compute pagination bounds.
|
|
||||||
let safe_page = page.max(1);
|
let safe_page = page.max(1);
|
||||||
let (limit, offset, total_pages) = if per_page <= 0 {
|
let (limit, offset, total_pages) = if per_page <= 0 {
|
||||||
(1_000_000_000i64, 0i64, 1i64)
|
(1_000_000_000i64, 0i64, 1i64)
|
||||||
@@ -122,23 +125,21 @@ pub async fn get_hosts_page(
|
|||||||
(per_page, (safe_page - 1) * per_page, tp)
|
(per_page, (safe_page - 1) * per_page, tp)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch the page of hosts with enriched columns.
|
|
||||||
let data_sql = format!(
|
let data_sql = format!(
|
||||||
"SELECT h.id, h.name, h.ip, h.network_id,
|
"SELECT h.id, h.name, h.ip, h.network_id,
|
||||||
n.cidr AS network_cidr,
|
n.cidr AS network_cidr,
|
||||||
COUNT(DISTINCT hp.port_number) AS port_count,
|
COUNT(DISTINCT hp.port_number) AS port_count,
|
||||||
COUNT(DISTINCT ap.application_id) AS application_count
|
COUNT(DISTINCT ap.application_id) AS application_count
|
||||||
FROM hosts h
|
FROM hosts h
|
||||||
{WHERE}
|
{where_clause}
|
||||||
GROUP BY h.id, h.name, h.ip, h.network_id, n.cidr
|
GROUP BY h.id, h.name, h.ip, h.network_id, n.cidr
|
||||||
ORDER BY h.name, h.id
|
ORDER BY h.name, h.id
|
||||||
LIMIT $5 OFFSET $6"
|
LIMIT $4 OFFSET $5"
|
||||||
);
|
);
|
||||||
|
|
||||||
let rows = sqlx::query(&data_sql)
|
let rows = sqlx::query(&data_sql)
|
||||||
.bind(name_like.as_deref())
|
.bind(name_like.as_deref())
|
||||||
.bind(network_id)
|
.bind(network_id)
|
||||||
.bind(port)
|
|
||||||
.bind(app_id)
|
.bind(app_id)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
.bind(offset)
|
.bind(offset)
|
||||||
@@ -170,19 +171,21 @@ pub async fn get_hosts_page(
|
|||||||
|
|
||||||
// ─── Mutations ────────────────────────────────────────────────────────────────
|
// ─── Mutations ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Creates a new host inside the specified network.
|
/// Creates a new host inside the specified network, then opens the given ports.
|
||||||
///
|
///
|
||||||
/// Validates that `ip` falls within the CIDR of `network_id`.
|
/// `ports` is a comma-separated list of port numbers (e.g. "22,80,443").
|
||||||
/// Returns an error if the network does not exist or the IP is out of range.
|
/// Ports are auto-registered in the global catalog if not already present.
|
||||||
|
/// An empty string means no ports are opened.
|
||||||
#[server]
|
#[server]
|
||||||
pub async fn create_host(
|
pub async fn create_host(
|
||||||
name: String,
|
name: String,
|
||||||
ip: String,
|
ip: String,
|
||||||
network_id: i64,
|
network_id: i64,
|
||||||
|
ports: String,
|
||||||
) -> Result<Host, ServerFnError> {
|
) -> Result<Host, ServerFnError> {
|
||||||
use sqlx::AnyPool;
|
use sqlx::AnyPool;
|
||||||
use crate::server::{
|
use crate::server::{
|
||||||
repository::{hosts, networks},
|
repository::{hosts, networks, ports as port_repo},
|
||||||
validation::validate_ip_in_network,
|
validation::validate_ip_in_network,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -192,16 +195,29 @@ pub async fn create_host(
|
|||||||
let network = networks::find_network(&pool, network_id)
|
let network = networks::find_network(&pool, network_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))?
|
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| ServerFnError::new(format!("Network {network_id} not found")))?;
|
||||||
ServerFnError::new(format!("Network {network_id} not found"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
validate_ip_in_network(&ip, &network.cidr)
|
validate_ip_in_network(&ip, &network.cidr)
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
hosts::create_host(&pool, &name, &ip, network_id)
|
let host = hosts::create_host(&pool, &name, &ip, network_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
// Parse, validate, and open each port on the new host.
|
||||||
|
let port_numbers: Vec<u16> = ports
|
||||||
|
.split(',')
|
||||||
|
.filter_map(|s| s.trim().parse::<u16>().ok())
|
||||||
|
.filter(|&p| p >= 1)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for port_number in port_numbers {
|
||||||
|
port_repo::add_port_to_host(&pool, host.id, port_number)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(host)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes a host by id.
|
/// Deletes a host by id.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//
|
//
|
||||||
// Displays all hosts across every network with:
|
// Displays all hosts across every network with:
|
||||||
// - Add button : opens a modal form to create a host inside a chosen network
|
// - 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
|
// - Table : name, IP, network, port count, application count, delete
|
||||||
// - Pagination : configurable page size (15 / 25 / 50 / 100 / All)
|
// - Pagination : configurable page size (15 / 25 / 50 / 100 / All)
|
||||||
//
|
//
|
||||||
@@ -43,9 +43,7 @@ fn AddHostModal(
|
|||||||
});
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
// Backdrop — click outside the card to close
|
|
||||||
<div class="modal-backdrop" on:click=move |_| show_modal.set(false)>
|
<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" on:click=move |e| e.stop_propagation()>
|
||||||
<div class="modal__header">
|
<div class="modal__header">
|
||||||
<h2>"Add a host"</h2>
|
<h2>"Add a host"</h2>
|
||||||
@@ -74,11 +72,21 @@ fn AddHostModal(
|
|||||||
{move || networks_res.get()
|
{move || networks_res.get()
|
||||||
.and_then(|r| r.ok())
|
.and_then(|r| r.ok())
|
||||||
.map(|nets| nets.into_iter().map(|n| {
|
.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())
|
}).collect_view())
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
||||||
|
|
||||||
<div class="modal__actions">
|
<div class="modal__actions">
|
||||||
@@ -108,7 +116,7 @@ fn FilterBar(
|
|||||||
applications_res: Resource<Result<Vec<crate::models::Application>, ServerFnError>>,
|
applications_res: Resource<Result<Vec<crate::models::Application>, ServerFnError>>,
|
||||||
name_filter: RwSignal<String>,
|
name_filter: RwSignal<String>,
|
||||||
network_id_filter: RwSignal<i64>,
|
network_id_filter: RwSignal<i64>,
|
||||||
port_filter: RwSignal<i64>,
|
port_filter: RwSignal<String>,
|
||||||
app_id_filter: RwSignal<i64>,
|
app_id_filter: RwSignal<i64>,
|
||||||
page: RwSignal<i64>,
|
page: RwSignal<i64>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
@@ -137,24 +145,24 @@ fn FilterBar(
|
|||||||
{move || networks_res.get()
|
{move || networks_res.get()
|
||||||
.and_then(|r| r.ok())
|
.and_then(|r| r.ok())
|
||||||
.map(|nets| nets.into_iter().map(|n| {
|
.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())
|
}).collect_view())
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="filter-field">
|
<label class="filter-field">
|
||||||
"Open port"
|
"Open ports"
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="text"
|
||||||
min="1"
|
placeholder="e.g. 80, 443"
|
||||||
max="65535"
|
|
||||||
placeholder="e.g. 443"
|
|
||||||
on:change=move |e| {
|
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);
|
page.set(1);
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<span class="field-hint">"All listed ports must be open"</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="filter-field">
|
<label class="filter-field">
|
||||||
@@ -212,7 +220,6 @@ fn PaginationBar(
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
// Page navigation — hidden when showing all results (per_page == 0)
|
|
||||||
{move || (per_page.get() > 0).then(|| view! {
|
{move || (per_page.get() > 0).then(|| view! {
|
||||||
<div class="pagination-nav">
|
<div class="pagination-nav">
|
||||||
<button
|
<button
|
||||||
@@ -308,24 +315,20 @@ fn HostTable(
|
|||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn HostsPage() -> impl IntoView {
|
pub fn HostsPage() -> impl IntoView {
|
||||||
// Actions
|
|
||||||
let create_action = ServerAction::<CreateHost>::new();
|
let create_action = ServerAction::<CreateHost>::new();
|
||||||
let delete_action = ServerAction::<DeleteHost>::new();
|
let delete_action = ServerAction::<DeleteHost>::new();
|
||||||
|
|
||||||
// Controls the add-host modal
|
|
||||||
let show_modal = RwSignal::new(false);
|
let show_modal = RwSignal::new(false);
|
||||||
|
|
||||||
// Filter signals (0 / "" = no filter)
|
// Filter signals ("" / 0 = no filter)
|
||||||
let name_filter = RwSignal::new(String::new());
|
let name_filter = RwSignal::new(String::new());
|
||||||
let network_id_filter = RwSignal::new(0i64);
|
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);
|
let app_id_filter = RwSignal::new(0i64);
|
||||||
|
|
||||||
// Pagination signals
|
|
||||||
let page = RwSignal::new(1i64);
|
let page = RwSignal::new(1i64);
|
||||||
let per_page = RwSignal::new(15i64);
|
let per_page = RwSignal::new(15i64);
|
||||||
|
|
||||||
// Hosts resource — refetches whenever any filter/pagination/action changes
|
|
||||||
let hosts = Resource::new(
|
let hosts = Resource::new(
|
||||||
move || (
|
move || (
|
||||||
name_filter.get(),
|
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),
|
|(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 networks_res = Resource::new(|| (), |_| get_networks());
|
||||||
let applications_res = Resource::new(|| (), |_| get_applications());
|
let applications_res = Resource::new(|| (), |_| get_applications());
|
||||||
|
|
||||||
// Derived pagination signals
|
|
||||||
let total_pages = Signal::derive(move || {
|
let total_pages = Signal::derive(move || {
|
||||||
hosts.get().and_then(|r| r.ok()).map(|p| p.total_pages).unwrap_or(1)
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Modal — only rendered when show_modal is true
|
|
||||||
{move || show_modal.get().then(|| view! {
|
{move || show_modal.get().then(|| view! {
|
||||||
<AddHostModal
|
<AddHostModal
|
||||||
create_action=create_action
|
create_action=create_action
|
||||||
|
|||||||
@@ -784,6 +784,13 @@ td.col-actions {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
font-size: var(--font-xs, 0.72rem);
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-muted, var(--text-secondary));
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
PAGINATION BAR
|
PAGINATION BAR
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|||||||
Reference in New Issue
Block a user