From 2a6d925e59a556ead3f852196e825c4be2c40e8f Mon Sep 17 00:00:00 2001 From: mathieu Date: Sat, 16 May 2026 02:21:00 +0200 Subject: [PATCH] feat(hosts): add host detail page with identity edit, port management and delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Repository: add update_host (name, IP, network reassignment with CIDR validation) - API: get_host_detail (host + resolved network + ports), update_host, add_host_port, remove_host_port server functions - Client: HostDetailPage at /hosts/:id — identity form, ports list with per-port Remove button, Add port input, delete confirmation modal with navigation back to /hosts on success - CSS: detail-section cards, port-row list, btn-danger-solid, back-link Co-Authored-By: Claude Sonnet 4.6 --- src/api/hosts.rs | 123 +++++++++++++ src/app.rs | 9 +- src/client/host_detail.rs | 321 +++++++++++++++++++++++++++++++++ src/client/mod.rs | 9 +- src/server/repository/hosts.rs | 27 +++ style/rust-ipam.css | 137 ++++++++++++++ 6 files changed, 621 insertions(+), 5 deletions(-) create mode 100644 src/client/host_detail.rs diff --git a/src/api/hosts.rs b/src/api/hosts.rs index 7f3c8ee..f400a0f 100644 --- a/src/api/hosts.rs +++ b/src/api/hosts.rs @@ -5,6 +5,8 @@ use serde::{Deserialize, Serialize}; use crate::models::Host; +use crate::models::Port; + // ─── Presentation types ─────────────────────────────────────────────────────── // A host row enriched with its network CIDR and pre-computed counts. @@ -30,8 +32,54 @@ pub struct HostsPage { pub total_pages: i64, // ceil(total / per_page); always ≥ 1 } +// Full host detail: identity fields + resolved network + open ports. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct HostDetail { + pub id: i64, + pub name: String, + pub ip: String, + pub network_id: i64, + pub network_name: String, + pub network_cidr: String, + pub ports: Vec, +} + // ─── Queries ────────────────────────────────────────────────────────────────── +/// Returns full detail for a single host: identity, network, and open ports. +#[server] +pub async fn get_host_detail(id: i64) -> Result { + use sqlx::AnyPool; + use crate::server::repository::{hosts as host_repo, networks, ports as port_repo}; + + let pool = use_context::() + .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; + + let host = host_repo::find_host(&pool, id) + .await + .map_err(|e| ServerFnError::new(e.to_string()))? + .ok_or_else(|| ServerFnError::new(format!("Host {id} not found")))?; + + let network = networks::find_network(&pool, host.network_id) + .await + .map_err(|e| ServerFnError::new(e.to_string()))? + .ok_or_else(|| ServerFnError::new(format!("Network {} not found", host.network_id)))?; + + let ports = port_repo::list_ports_for_host(&pool, id) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + + Ok(HostDetail { + id: host.id, + name: host.name, + ip: host.ip, + network_id: host.network_id, + network_name: network.name, + network_cidr: network.cidr, + ports, + }) +} + /// Returns all hosts belonging to a given network. #[server] pub async fn get_hosts_by_network(network_id: i64) -> Result, ServerFnError> { @@ -220,6 +268,81 @@ pub async fn create_host( Ok(host) } +/// Updates a host's name, IP address, and network assignment. +/// +/// Validates that the new IP falls within the CIDR of the new network. +#[server] +pub async fn update_host( + id: i64, + name: String, + ip: String, + network_id: i64, +) -> Result { + use sqlx::AnyPool; + use crate::server::{ + repository::{hosts as host_repo, networks}, + validation::validate_ip_in_network, + }; + + let pool = use_context::() + .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; + + if name.trim().is_empty() { + return Err(ServerFnError::new("Name must not be empty")); + } + + let network = networks::find_network(&pool, network_id) + .await + .map_err(|e| ServerFnError::new(e.to_string()))? + .ok_or_else(|| ServerFnError::new(format!("Network {network_id} not found")))?; + + validate_ip_in_network(&ip, &network.cidr) + .map_err(|e| ServerFnError::new(e.to_string()))?; + + host_repo::update_host(&pool, id, name.trim(), ip.trim(), network_id) + .await + .map_err(|e| ServerFnError::new(e.to_string()))? + .ok_or_else(|| ServerFnError::new(format!("Host {id} not found"))) +} + +/// Opens a single port on a host. +/// +/// Auto-registers the port in the global catalog if not already present. +/// If the port is already open on this host, the call is a no-op. +#[server] +pub async fn add_host_port(host_id: i64, port_number: i64) -> Result<(), ServerFnError> { + use sqlx::AnyPool; + use crate::server::repository::ports as port_repo; + + let pool = use_context::() + .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; + + if !(1..=65535).contains(&port_number) { + return Err(ServerFnError::new("Port number must be between 1 and 65535")); + } + + port_repo::add_port_to_host(&pool, host_id, port_number as u16) + .await + .map_err(|e| ServerFnError::new(e.to_string())) +} + +/// Closes a port on a host (removes the host-port association). +/// +/// The port entry in the global catalog is not deleted. +/// If the port was not open on this host, the call is a no-op. +#[server] +pub async fn remove_host_port(host_id: i64, port_number: i64) -> Result<(), ServerFnError> { + use sqlx::AnyPool; + use crate::server::repository::ports as port_repo; + + let pool = use_context::() + .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; + + port_repo::remove_port_from_host(&pool, host_id, port_number as u16) + .await + .map_err(|e| ServerFnError::new(e.to_string())) +} + /// Deletes a host by id. /// /// Also removes all its port associations (via `ON DELETE CASCADE`). diff --git a/src/app.rs b/src/app.rs index b5d9ea1..d1490b9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,7 +11,13 @@ use leptos_router::{ path, }; -use crate::client::{home::HomePage, hosts::HostsPage, networks::NetworksPage, theme::ThemeToggle}; +use crate::client::{ + home::HomePage, + host_detail::HostDetailPage, + hosts::HostsPage, + networks::NetworksPage, + theme::ThemeToggle, +}; // Shell — full HTML document rendered by the Axum server. // @@ -103,6 +109,7 @@ pub fn App() -> impl IntoView { + diff --git a/src/client/host_detail.rs b/src/client/host_detail.rs new file mode 100644 index 0000000..1868291 --- /dev/null +++ b/src/client/host_detail.rs @@ -0,0 +1,321 @@ +// client/host_detail.rs — Host detail page +// +// Shows all information for a single host: +// - Identity form : name, IP, network dropdown — editable, saved with "Save changes" +// - Ports section : full list with Remove per port + Add port input +// - Delete button : opens a confirmation modal, then navigates back to /hosts + +use leptos::prelude::*; +use leptos_router::hooks::{use_navigate, use_params_map}; + +use crate::api::{ + hosts::{AddHostPort, DeleteHost, RemoveHostPort, UpdateHost, get_host_detail}, + networks::get_networks, +}; + +// ─── Delete confirmation modal ──────────────────────────────────────────────── + +#[component] +fn DeleteModal( + host_name: String, + delete_action: ServerAction, + host_id: i64, + show_modal: RwSignal, +) -> impl IntoView { + view! { + + }.into_any() +} + +// ─── Main page component ────────────────────────────────────────────────────── + +#[component] +pub fn HostDetailPage() -> impl IntoView { + // Read the `:id` segment from the URL. + // `use_params_map()` returns a reactive map of all URL path parameters. + let params = use_params_map(); + let host_id = move || { + params.read().get("id") + .and_then(|s| s.parse::().ok()) + .unwrap_or(0) + }; + + let update_action = ServerAction::::new(); + let add_port_action = ServerAction::::new(); + let remove_port_action = ServerAction::::new(); + let delete_action = ServerAction::::new(); + + let show_delete_modal = RwSignal::new(false); + + // Reload detail after any mutation that touches this host. + // The resource key includes action versions so it invalidates automatically. + let host = Resource::new( + move || ( + host_id(), + update_action.version().get(), + add_port_action.version().get(), + remove_port_action.version().get(), + ), + |(id, _, _, _)| get_host_detail(id), + ); + + // Networks dropdown — LocalResource avoids SSR/hydration mismatch. + let networks_res = LocalResource::new(|| get_networks()); + + // Edit-field signals, populated once by the Effect below. + // Using signals (rather than local variables) keeps them stable across + // re-renders and lets the user edit without triggering a resource reload. + let name_sig = RwSignal::new(String::new()); + let ip_sig = RwSignal::new(String::new()); + let net_id_sig = RwSignal::new(0i64); + + // Input value for the "add port" row. + let new_port = RwSignal::new(String::new()); + + // Sync edit signals whenever the host resource delivers fresh data. + // This runs on initial load and after every successful mutation. + Effect::new(move |_| { + if let Some(Ok(ref detail)) = host.get() { + name_sig.set(detail.name.clone()); + ip_sig.set(detail.ip.clone()); + net_id_sig.set(detail.network_id); + } + }); + + // Navigate back to the list after a successful delete. + // `use_navigate()` must be called during component setup (not inside a closure). + let navigate = use_navigate(); + Effect::new(move |_| { + if let Some(Ok(true)) = delete_action.value().get() { + navigate("/hosts", Default::default()); + } + }); + + view! { +
+ "Loading host…"

}> + {move || host.get().map(|result| match result { + Err(e) => view! { +

"Could not load host: " {e.to_string()}

+ }.into_any(), + + Ok(detail) => { + let id = detail.id; + let modal_name = detail.name.clone(); + let port_count = detail.ports.len(); + let ports = detail.ports; + + // Pre-built ports view — consumes `ports` once, not reactively. + let ports_list = if ports.is_empty() { + view! { +

"No ports open on this host."

+ }.into_any() + } else { + view! { +
+ {ports.into_iter().map(|port| { + let num = port.number; + view! { +
+ {num} + + {port.description.unwrap_or_default()} + + +
+ } + }).collect_view()} +
+ }.into_any() + }; + + view! { + // ── Page header ────────────────────────────────── + + + // ── Identity form ───────────────────────────────── +
+

"Identity"

+
+ + + + + {move || update_action.value().get() + .and_then(|r| r.err()) + .map(|e| view! {

{e.to_string()}

}) + } + +
+ +
+
+
+ + // ── Ports section ───────────────────────────────── +
+

+ {format!("Open ports ({})", port_count)} +

+ + {ports_list} + + {move || remove_port_action.value().get() + .and_then(|r| r.err()) + .map(|e| view! { +

"Remove failed: " {e.to_string()}

+ }) + } + + // Add port row +
+ + + {move || add_port_action.value().get() + .and_then(|r| r.err()) + .map(|e| view! { +

"Add failed: " {e.to_string()}

+ }) + } +
+
+ + // ── Delete modal (conditional) ──────────────────── + {move || show_delete_modal.get().then(|| view! { + + })} + }.into_any() + } + })} +
+
+ }.into_any() +} diff --git a/src/client/mod.rs b/src/client/mod.rs index 6c047aa..7621da8 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -9,7 +9,8 @@ // Do not place code here that requires browser-only APIs (window, document...) // without guarding it with `#[cfg(target_arch = "wasm32")]`. -pub mod home; // Home page -pub mod hosts; // Hosts list with filters and pagination -pub mod networks; // Networks list and creation -pub mod theme; // Theme toggle component (light / dark / system) +pub mod home; // Home page +pub mod host_detail; // Host detail: identity, ports, edit, delete +pub mod hosts; // Hosts list with filters and pagination +pub mod networks; // Networks list and creation +pub mod theme; // Theme toggle component (light / dark / system) diff --git a/src/server/repository/hosts.rs b/src/server/repository/hosts.rs index e6ccf6e..b0ea091 100644 --- a/src/server/repository/hosts.rs +++ b/src/server/repository/hosts.rs @@ -62,6 +62,33 @@ pub async fn create_host( Ok(row_to_host(&row)) } +/// Updates a host's name, IP address, and network assignment. +/// +/// Returns the updated host, or `None` if the id does not exist. +/// The caller must validate that `ip` falls within the CIDR range of the +/// new `network_id` before calling this function. +pub async fn update_host( + pool: &AnyPool, + id: i64, + name: &str, + ip: &str, + network_id: i64, +) -> Result, DbError> { + let row = sqlx::query( + "UPDATE hosts SET name = $1, ip = $2, network_id = $3 + WHERE id = $4 + RETURNING id, name, ip, network_id", + ) + .bind(name) + .bind(ip) + .bind(network_id) + .bind(id) + .fetch_optional(pool) + .await?; + + Ok(row.as_ref().map(row_to_host)) +} + /// Deletes a host and all its port associations (via `ON DELETE CASCADE`). /// /// Returns `true` if a row was deleted, `false` if the id did not exist. diff --git a/style/rust-ipam.css b/style/rust-ipam.css index 0e38ace..f52bda1 100644 --- a/style/rust-ipam.css +++ b/style/rust-ipam.css @@ -1021,6 +1021,39 @@ td.col-actions { background: var(--accent-hover); } +/* Solid danger button — used for prominent destructive actions (page header) */ +.btn-danger-solid { + background: var(--danger); + color: #fff; + border-color: var(--danger); + font-size: var(--font-sm); + padding: 8px var(--size-md); + font-weight: 500; +} + +.btn-danger-solid:hover { + filter: brightness(0.9); +} + +/* Left cluster inside page header (back link + title) */ +.page-header__left { + display: flex; + align-items: center; + gap: var(--size-md); +} + +.back-link { + font-size: var(--font-sm); + color: var(--text-secondary); + text-decoration: none; + white-space: nowrap; +} + +.back-link:hover { + color: var(--text); + text-decoration: underline; +} + /* Delete button inside hosts table */ .hosts-page td button { background: transparent; @@ -1037,3 +1070,107 @@ td.col-actions { background: var(--danger-light); border-color: var(--danger); } + +/* ============================================================ + HOST DETAIL PAGE + ============================================================ */ + +.host-detail-page { + max-width: 720px; +} + +/* Card-like section grouping related fields */ +.detail-section { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--size-lg); + margin-bottom: var(--size-lg); +} + +.detail-section__title { + font-size: var(--font-base); + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0 0 var(--size-md); +} + +/* Stack of label + input pairs */ +.detail-form { + display: flex; + flex-direction: column; + gap: var(--size-md); +} + +.detail-field { + display: flex; + flex-direction: column; + gap: var(--size-xs); + font-size: var(--font-sm); + font-weight: 500; + color: var(--text-secondary); +} + +.detail-field input, +.detail-field select { + width: 100%; + box-sizing: border-box; +} + +/* Save button aligned to the right */ +.form-actions { + display: flex; + justify-content: flex-end; + padding-top: var(--size-xs); +} + +/* ── Ports list ─────────────────────────────────────────────── */ + +.port-list { + display: flex; + flex-direction: column; + gap: var(--size-xs); + margin-bottom: var(--size-md); +} + +.port-row { + display: flex; + align-items: center; + gap: var(--size-md); + padding: var(--size-sm) var(--size-sm); + border-radius: var(--radius-sm); + background: var(--bg); + border: 1px solid var(--border); +} + +.port-row__number { + font-family: var(--font-mono); + font-size: var(--font-sm); + font-weight: 600; + min-width: 4ch; + color: var(--accent); +} + +.port-row__desc { + flex: 1; + font-size: var(--font-sm); + color: var(--text-secondary); +} + +/* Add port row: input + button side by side */ +.port-add-row { + display: flex; + align-items: center; + gap: var(--size-sm); + padding-top: var(--size-sm); + border-top: 1px solid var(--border); + margin-top: var(--size-sm); + flex-wrap: wrap; +} + +.port-add-row input[type="number"] { + width: 200px; + flex-shrink: 0; +}