feat(networks): add name field to networks

- Migration 0007: ALTER TABLE networks ADD COLUMN name TEXT NOT NULL DEFAULT ''
- Network model, repository, and API updated to include name
- Networks page: name input in the add form, Name column as first column in table
- Delete modal now shows "Name (CIDR)" for clarity
- Hosts page: network dropdowns now show network name instead of CIDR
- Seeds updated with names (LAN, DMZ, Corporate, VPN)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 01:38:40 +02:00
parent e17b8ee722
commit d9ee121fbb
9 changed files with 55 additions and 78 deletions

View File

@@ -11,6 +11,7 @@ use crate::models::Network;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct NetworkWithCounts {
pub id: i64,
pub name: String,
pub cidr: String,
/// Number of hosts whose IP falls within this network's CIDR range.
pub host_count: i64,
@@ -28,25 +29,15 @@ pub async fn get_networks() -> Result<Vec<Network>, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::networks as repo;
// `use_context` retrieves a value previously registered with `provide_context`.
// The pool was injected in main.rs before every request.
// `ok_or_else` converts `None` into an error (defensive: should never happen).
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
// Propagate any DB error as a ServerFnError so the client sees a clean message.
repo::list_networks(&pool)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Returns all networks enriched with host and application counts.
///
/// A single SQL query fetches everything at once using correlated subqueries,
/// avoiding N+1 round-trips regardless of the number of networks.
///
/// `application_count` = distinct applications whose registered ports appear
/// among the ports open on hosts in each network (via host_ports → application_ports).
#[server]
pub async fn get_networks_with_counts() -> Result<Vec<NetworkWithCounts>, ServerFnError> {
use sqlx::{AnyPool, Row};
@@ -57,6 +48,7 @@ pub async fn get_networks_with_counts() -> Result<Vec<NetworkWithCounts>, Server
let rows = sqlx::query(
"SELECT
n.id,
n.name,
n.cidr,
(SELECT COUNT(*) FROM hosts WHERE network_id = n.id) AS host_count,
(SELECT COUNT(DISTINCT ap.application_id)
@@ -75,6 +67,7 @@ pub async fn get_networks_with_counts() -> Result<Vec<NetworkWithCounts>, Server
.into_iter()
.map(|row| NetworkWithCounts {
id: row.get("id"),
name: row.get("name"),
cidr: row.get("cidr"),
host_count: row.get("host_count"),
application_count: row.get("application_count"),
@@ -86,23 +79,24 @@ pub async fn get_networks_with_counts() -> Result<Vec<NetworkWithCounts>, Server
// ─── Mutations ────────────────────────────────────────────────────────────────
/// Creates a new network with the given CIDR block.
/// Creates a new network with the given name and CIDR block.
///
/// Returns the created record (with its auto-generated id).
/// Returns an error if the CIDR is malformed or already exists.
#[server]
pub async fn create_network(cidr: String) -> Result<Network, ServerFnError> {
pub async fn create_network(name: String, cidr: String) -> Result<Network, ServerFnError> {
use sqlx::AnyPool;
use crate::server::{repository::networks as repo, validation::validate_cidr};
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
// Validate the CIDR before touching the database.
// Example of a valid CIDR: "192.168.1.0/24"
if name.trim().is_empty() {
return Err(ServerFnError::new("Network name cannot be empty"));
}
validate_cidr(&cidr).map_err(|e| ServerFnError::new(e.to_string()))?;
repo::create_network(&pool, &cidr)
repo::create_network(&pool, name.trim(), &cidr)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}

View File

@@ -74,7 +74,7 @@ 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.cidr}</option> }
view! { <option value=n.id.to_string()>{n.name}</option> }
}).collect_view())
}
</select>
@@ -137,7 +137,7 @@ 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.cidr}</option> }
view! { <option value=n.id.to_string()>{n.name}</option> }
}).collect_view())
}
</select>

View File

@@ -1,17 +1,4 @@
// client/networks.rs — Networks page
//
// Displays all CIDR networks managed by the IPAM and lets the user add or
// delete them. All data operations go through Leptos server functions
// (api/networks.rs), which run on the server and are called via HTTP
// from the browser after hydration.
//
// Key Leptos 0.7 concepts used here:
// - `ServerAction<F>` : wraps a `#[server]` function for use with forms / buttons
// - `Resource::new` : async data that re-fetches when its source signal changes
// - `action.version() : a Signal<usize> that increments after each dispatch,
// used here as a dependency to trigger list re-fetches
// - `<ActionForm>` : a form that submits to a ServerAction (no JS needed)
// - `<Suspense>` : shows a fallback while the Resource is loading
use leptos::prelude::*;
use leptos::form::ActionForm;
@@ -27,7 +14,7 @@ fn DeleteConfirmModal(
pending_delete: RwSignal<Option<NetworkWithCounts>>,
) -> impl IntoView {
let id = network.id;
let cidr = network.cidr.clone();
let label = format!("{} ({})", network.name, network.cidr);
let host_count = network.host_count;
view! {
@@ -38,7 +25,7 @@ fn DeleteConfirmModal(
</div>
<div class="modal__body">
<p>"Delete network " <strong>{cidr}</strong> "?"</p>
<p>"Delete network " <strong>{label}</strong> "?"</p>
{(host_count > 0).then(|| view! {
<p class="warning">
"Warning: "
@@ -72,11 +59,6 @@ fn DeleteConfirmModal(
#[component]
pub fn NetworksPage() -> impl IntoView {
// ── Actions ───────────────────────────────────────────────────────────────
//
// `ServerAction<F>` binds a `#[server]` function to a reactive action.
// Under the hood it posts to `/api/<fn-name>` and updates its signals
// (.pending(), .value(), .version()) when the call completes.
let create_action = ServerAction::<CreateNetwork>::new();
let delete_action = ServerAction::<DeleteNetwork>::new();
@@ -90,14 +72,6 @@ pub fn NetworksPage() -> impl IntoView {
}
});
// ── Data resource ─────────────────────────────────────────────────────────
//
// `Resource::new(source, fetcher)`:
// - source : a closure whose return value Leptos tracks reactively
// - fetcher : an async closure called whenever the source changes
//
// By reading `.version()` from both actions, the list automatically
// re-fetches after any create or delete, keeping the view in sync.
let networks = Resource::new(
move || (create_action.version().get(), delete_action.version().get()),
|_| get_networks_with_counts(),
@@ -117,14 +91,18 @@ pub fn NetworksPage() -> impl IntoView {
})}
// ── Add form ──────────────────────────────────────────────────────
//
// `<ActionForm action=create_action>` submits the form to the server
// function registered in `create_action`. The `name` attribute on
// each input must match the parameter name in `create_network(cidr: String)`.
// After submission the form clears itself automatically.
<section class="add-form">
<h2>"Add a network"</h2>
<ActionForm action=create_action>
<label>
"Name"
<input
type="text"
name="name"
placeholder="e.g. LAN, DMZ, VPN"
required
/>
</label>
<label>
"CIDR block"
<input
@@ -137,9 +115,6 @@ pub fn NetworksPage() -> impl IntoView {
<button type="submit">"Add"</button>
</ActionForm>
// Show the error from the last create attempt, if any.
// `action.value().get()` → Option<Result<Network, ServerFnError>>
// `.and_then(|r| r.err())` extracts the error when present.
{move || {
create_action
.value()
@@ -153,7 +128,6 @@ pub fn NetworksPage() -> impl IntoView {
<section class="list">
<h2>"All networks"</h2>
// Show delete errors above the list.
{move || {
delete_action
.value()
@@ -162,12 +136,8 @@ pub fn NetworksPage() -> impl IntoView {
.map(|e| view! { <p class="error">"Delete failed: " {e.to_string()}</p> })
}}
// `<Suspense>` shows `fallback` while the Resource is loading,
// then switches to the children once data is available.
<Suspense fallback=|| view! { <p>"Loading networks…"</p> }>
{move || {
// `networks.get()` → None while loading, Some(result) once done.
// Returning None here keeps <Suspense> in its fallback state.
networks.get().map(|result| match result {
Err(e) => view! {
<p class="error">"Could not load networks: " {e.to_string()}</p>
@@ -184,6 +154,7 @@ pub fn NetworksPage() -> impl IntoView {
<table>
<thead>
<tr>
<th>"Name"</th>
<th>"CIDR"</th>
<th class="col-count">"Hosts"</th>
<th class="col-count">"Applications"</th>
@@ -197,7 +168,8 @@ pub fn NetworksPage() -> impl IntoView {
let network_clone = network.clone();
view! {
<tr>
<td>{network.cidr}</td>
<td>{network.name}</td>
<td class="cell-mono">{network.cidr}</td>
<td class="col-count">{network.host_count}</td>
<td class="col-count">{network.application_count}</td>
<td class="col-actions">

View File

@@ -28,6 +28,9 @@ pub struct Network {
/// `i64` is a signed 64-bit integer — maps to `BIGINT` in SQL.
pub id: i64,
/// Human-readable name. Examples: "LAN", "DMZ", "VPN"
pub name: String,
/// Address range in CIDR notation.
/// Examples: "10.0.0.0/8", "172.16.0.0/12", "192.168.1.0/24"
pub cidr: String,

View File

@@ -11,7 +11,7 @@ use crate::server::db::DbError;
pub async fn list_networks(pool: &AnyPool) -> Result<Vec<Network>, DbError> {
// `fetch_all` runs the query and collects every row into a Vec.
// It returns an error if the query fails; an empty table returns Ok(vec![]).
let rows = sqlx::query("SELECT id, cidr FROM networks ORDER BY id")
let rows = sqlx::query("SELECT id, name, cidr FROM networks ORDER BY id")
.fetch_all(pool)
.await?;
@@ -42,11 +42,14 @@ pub async fn find_network(pool: &AnyPool, id: i64) -> Result<Option<Network>, Db
/// `RETURNING id, cidr` reads back the inserted row in a single round-trip,
/// avoiding a separate SELECT after the INSERT.
/// Requires SQLite ≥ 3.35 (2021) and any PostgreSQL version.
pub async fn create_network(pool: &AnyPool, cidr: &str) -> Result<Network, DbError> {
let row = sqlx::query("INSERT INTO networks (cidr) VALUES ($1) RETURNING id, cidr")
.bind(cidr)
.fetch_one(pool) // exactly one row is returned by RETURNING
.await?;
pub async fn create_network(pool: &AnyPool, name: &str, cidr: &str) -> Result<Network, DbError> {
let row = sqlx::query(
"INSERT INTO networks (name, cidr) VALUES ($1, $2) RETURNING id, name, cidr",
)
.bind(name)
.bind(cidr)
.fetch_one(pool)
.await?;
Ok(row_to_network(&row))
}
@@ -73,7 +76,8 @@ pub async fn delete_network(pool: &AnyPool, id: i64) -> Result<bool, DbError> {
/// The type must implement `sqlx::Decode` for the `Any` backend.
fn row_to_network(row: &sqlx::any::AnyRow) -> Network {
Network {
id: row.get("id"),
id: row.get("id"),
name: row.get("name"),
cidr: row.get("cidr"),
}
}