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:
151
src/api/hosts.rs
151
src/api/hosts.rs
@@ -1,9 +1,35 @@
|
|||||||
// api/hosts.rs — Server functions for hosts
|
// api/hosts.rs — Server functions for hosts
|
||||||
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::models::Host;
|
use crate::models::Host;
|
||||||
|
|
||||||
|
// ─── Presentation types ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// A host row enriched with its network CIDR and pre-computed counts.
|
||||||
|
// Used by the paginated hosts list (get_hosts_page).
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct HostRow {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub ip: String,
|
||||||
|
pub network_id: i64,
|
||||||
|
pub network_cidr: String,
|
||||||
|
pub port_count: i64,
|
||||||
|
pub application_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result of a paginated, filtered host query.
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
||||||
|
pub struct HostsPage {
|
||||||
|
pub rows: Vec<HostRow>,
|
||||||
|
pub total: i64, // total rows matching the filters (ignoring pagination)
|
||||||
|
pub page: i64, // current page (1-indexed)
|
||||||
|
pub per_page: i64, // items per page; 0 = all
|
||||||
|
pub total_pages: i64, // ceil(total / per_page); always ≥ 1
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Queries ──────────────────────────────────────────────────────────────────
|
// ─── Queries ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Returns all hosts belonging to a given network.
|
/// Returns all hosts belonging to a given network.
|
||||||
@@ -20,6 +46,128 @@ pub async fn get_hosts_by_network(network_id: i64) -> Result<Vec<Host>, ServerFn
|
|||||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a filtered and paginated list of hosts across all networks.
|
||||||
|
///
|
||||||
|
/// Filter parameters use sentinel values (0 / empty string) to mean "no filter":
|
||||||
|
/// - `name_filter` : substring match on host name (case-insensitive); "" = all
|
||||||
|
/// - `network_id_filter` : exact network id; 0 = all
|
||||||
|
/// - `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
|
||||||
|
/// (once for the IS NULL guard, once for the actual comparison).
|
||||||
|
/// Each $N placeholder refers to the N-th bound argument by index.
|
||||||
|
#[server]
|
||||||
|
pub async fn get_hosts_page(
|
||||||
|
name_filter: String,
|
||||||
|
network_id_filter: i64,
|
||||||
|
port_filter: i64,
|
||||||
|
application_id_filter: i64,
|
||||||
|
page: i64,
|
||||||
|
per_page: i64,
|
||||||
|
) -> Result<HostsPage, ServerFnError> {
|
||||||
|
use sqlx::{AnyPool, Row};
|
||||||
|
|
||||||
|
let pool = use_context::<AnyPool>()
|
||||||
|
.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() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(format!("%{}%", name_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) };
|
||||||
|
|
||||||
|
// Each filter param is bound twice so the same $N can appear in both
|
||||||
|
// the IS NULL guard and the comparison without re-declaring parameters.
|
||||||
|
const WHERE: &str = "
|
||||||
|
JOIN networks n ON n.id = h.network_id
|
||||||
|
LEFT JOIN host_ports hp ON hp.host_id = h.id
|
||||||
|
LEFT JOIN application_ports ap ON ap.port_number = hp.port_number
|
||||||
|
WHERE ($1 IS NULL OR LOWER(h.name) LIKE LOWER($1))
|
||||||
|
AND ($2 IS NULL OR h.network_id = $2)
|
||||||
|
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
|
||||||
|
JOIN application_ports ap2 ON ap2.port_number = hp2.port_number
|
||||||
|
WHERE hp2.host_id = h.id AND ap2.application_id = $4
|
||||||
|
))";
|
||||||
|
|
||||||
|
// Count matching hosts (ignoring pagination).
|
||||||
|
let count_sql = format!("SELECT COUNT(DISTINCT h.id) FROM hosts h {WHERE}");
|
||||||
|
let total: i64 = sqlx::query_scalar(&count_sql)
|
||||||
|
.bind(name_like.as_deref())
|
||||||
|
.bind(network_id)
|
||||||
|
.bind(port)
|
||||||
|
.bind(app_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
// Compute pagination bounds.
|
||||||
|
let safe_page = page.max(1);
|
||||||
|
let (limit, offset, total_pages) = if per_page <= 0 {
|
||||||
|
(1_000_000_000i64, 0i64, 1i64)
|
||||||
|
} else {
|
||||||
|
let tp = ((total + per_page - 1) / per_page).max(1);
|
||||||
|
(per_page, (safe_page - 1) * per_page, tp)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch the page of hosts with enriched columns.
|
||||||
|
let data_sql = format!(
|
||||||
|
"SELECT h.id, h.name, h.ip, h.network_id,
|
||||||
|
n.cidr AS network_cidr,
|
||||||
|
COUNT(DISTINCT hp.port_number) AS port_count,
|
||||||
|
COUNT(DISTINCT ap.application_id) AS application_count
|
||||||
|
FROM hosts h
|
||||||
|
{WHERE}
|
||||||
|
GROUP BY h.id, h.name, h.ip, h.network_id, n.cidr
|
||||||
|
ORDER BY h.name, h.id
|
||||||
|
LIMIT $5 OFFSET $6"
|
||||||
|
);
|
||||||
|
|
||||||
|
let rows = sqlx::query(&data_sql)
|
||||||
|
.bind(name_like.as_deref())
|
||||||
|
.bind(network_id)
|
||||||
|
.bind(port)
|
||||||
|
.bind(app_id)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
let host_rows = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| HostRow {
|
||||||
|
id: row.get("id"),
|
||||||
|
name: row.get("name"),
|
||||||
|
ip: row.get("ip"),
|
||||||
|
network_id: row.get("network_id"),
|
||||||
|
network_cidr: row.get("network_cidr"),
|
||||||
|
port_count: row.get("port_count"),
|
||||||
|
application_count: row.get("application_count"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(HostsPage {
|
||||||
|
rows: host_rows,
|
||||||
|
total,
|
||||||
|
page: safe_page,
|
||||||
|
per_page,
|
||||||
|
total_pages,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Mutations ────────────────────────────────────────────────────────────────
|
// ─── Mutations ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Creates a new host inside the specified network.
|
/// Creates a new host inside the specified network.
|
||||||
@@ -41,7 +189,6 @@ pub async fn create_host(
|
|||||||
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"))?;
|
||||||
|
|
||||||
// Look up the parent network to get its CIDR for IP validation.
|
|
||||||
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()))?
|
||||||
@@ -49,8 +196,6 @@ pub async fn create_host(
|
|||||||
ServerFnError::new(format!("Network {network_id} not found"))
|
ServerFnError::new(format!("Network {network_id} not found"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Enforce the business rule: a host's IP must belong to its network's CIDR.
|
|
||||||
// This check runs on the server before any INSERT, so the database stays consistent.
|
|
||||||
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()))?;
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use leptos_router::{
|
|||||||
path,
|
path,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::client::{home::HomePage, networks::NetworksPage, theme::ThemeToggle};
|
use crate::client::{home::HomePage, hosts::HostsPage, networks::NetworksPage, theme::ThemeToggle};
|
||||||
|
|
||||||
// Shell — full HTML document rendered by the Axum server.
|
// Shell — full HTML document rendered by the Axum server.
|
||||||
//
|
//
|
||||||
@@ -86,6 +86,7 @@ pub fn App() -> impl IntoView {
|
|||||||
<nav>
|
<nav>
|
||||||
<a href="/">"Rust IPAM"</a>
|
<a href="/">"Rust IPAM"</a>
|
||||||
<a href="/networks">"Networks"</a>
|
<a href="/networks">"Networks"</a>
|
||||||
|
<a href="/hosts">"Hosts"</a>
|
||||||
<span class="nav-spacer"/>
|
<span class="nav-spacer"/>
|
||||||
<ThemeToggle/>
|
<ThemeToggle/>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -101,6 +102,7 @@ pub fn App() -> impl IntoView {
|
|||||||
}>
|
}>
|
||||||
<Route path=path!("/") view=HomePage/>
|
<Route path=path!("/") view=HomePage/>
|
||||||
<Route path=path!("/networks") view=NetworksPage/>
|
<Route path=path!("/networks") view=NetworksPage/>
|
||||||
|
<Route path=path!("/hosts") view=HostsPage/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
352
src/client/hosts.rs
Normal file
352
src/client/hosts.rs
Normal 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()
|
||||||
|
}
|
||||||
@@ -10,5 +10,6 @@
|
|||||||
// without guarding it with `#[cfg(target_arch = "wasm32")]`.
|
// without guarding it with `#[cfg(target_arch = "wasm32")]`.
|
||||||
|
|
||||||
pub mod home; // Home page
|
pub mod home; // Home page
|
||||||
|
pub mod hosts; // Hosts list with filters and pagination
|
||||||
pub mod networks; // Networks list and creation
|
pub mod networks; // Networks list and creation
|
||||||
pub mod theme; // Theme toggle component (light / dark / system)
|
pub mod theme; // Theme toggle component (light / dark / system)
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
// Leptos view! macros generate deeply-nested generic types; the default
|
||||||
|
// limit of 128 is not enough for pages with many components.
|
||||||
|
#![recursion_limit = "512"]
|
||||||
|
|
||||||
// lib.rs — Shared library root
|
// lib.rs — Shared library root
|
||||||
//
|
//
|
||||||
// This file is compiled in BOTH modes:
|
// This file is compiled in BOTH modes:
|
||||||
|
|||||||
@@ -515,8 +515,24 @@ tbody td {
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody td:last-child {
|
/* Count columns (Hosts, Applications...) — right-aligned, muted color */
|
||||||
|
th.col-count,
|
||||||
|
td.col-count {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th.col-count {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions column — right-aligned to match the button position */
|
||||||
|
th.col-actions,
|
||||||
|
td.col-actions {
|
||||||
|
text-align: right;
|
||||||
|
width: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
@@ -716,3 +732,177 @@ tbody td:last-child {
|
|||||||
.not-found a:hover {
|
.not-found a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
TABLE UTILITIES — shared across pages
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* Clickable link inside a table cell */
|
||||||
|
.table-link {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monospace cell (IPs, CIDRs…) */
|
||||||
|
.cell-mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
FILTER BAR
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: var(--size-md) var(--size-lg);
|
||||||
|
margin-bottom: var(--size-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar__fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: var(--size-md);
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--size-xs);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
PAGINATION BAR
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.pagination-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--size-sm);
|
||||||
|
padding: var(--size-sm) 0;
|
||||||
|
margin-bottom: var(--size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-bar__info {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-bar__controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--size-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-per-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--size-xs);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-per-page select {
|
||||||
|
width: auto;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-nav button {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: var(--font-base);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
min-width: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-nav button:hover:not(:disabled) {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-nav button:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-nav__label {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0 var(--size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
HOSTS PAGE
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.hosts-page h1 {
|
||||||
|
margin-bottom: var(--size-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosts-page .add-form {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: var(--size-lg);
|
||||||
|
margin-bottom: var(--size-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosts-page .add-form h2 {
|
||||||
|
margin-bottom: var(--size-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-form__fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: var(--size-md);
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-form__fields button[type="submit"] {
|
||||||
|
align-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Delete button inside hosts table */
|
||||||
|
.hosts-page td button {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--danger);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosts-page td button:hover {
|
||||||
|
background: var(--danger-light);
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user