feat(hosts): add host detail page with identity edit, port management and delete
- 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 <noreply@anthropic.com>
This commit is contained in:
123
src/api/hosts.rs
123
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<Port>,
|
||||
}
|
||||
|
||||
// ─── Queries ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns full detail for a single host: identity, network, and open ports.
|
||||
#[server]
|
||||
pub async fn get_host_detail(id: i64) -> Result<HostDetail, ServerFnError> {
|
||||
use sqlx::AnyPool;
|
||||
use crate::server::repository::{hosts as host_repo, networks, ports as port_repo};
|
||||
|
||||
let pool = use_context::<AnyPool>()
|
||||
.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<Vec<Host>, 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<Host, ServerFnError> {
|
||||
use sqlx::AnyPool;
|
||||
use crate::server::{
|
||||
repository::{hosts as host_repo, networks},
|
||||
validation::validate_ip_in_network,
|
||||
};
|
||||
|
||||
let pool = use_context::<AnyPool>()
|
||||
.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::<AnyPool>()
|
||||
.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::<AnyPool>()
|
||||
.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`).
|
||||
|
||||
Reference in New Issue
Block a user