From d9ee121fbbdc7ef4808e36fad6cf1b44a98fa3ed Mon Sep 17 00:00:00 2001 From: mathieu Date: Sat, 16 May 2026 01:38:40 +0200 Subject: [PATCH] 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 --- migrations/postgres/0007_add_network_name.sql | 3 + migrations/sqlite/0007_add_network_name.sql | 3 + seeds/postgres/dev_seed.sql | 11 ++-- seeds/sqlite/dev_seed.sql | 9 ++- src/api/networks.rs | 26 ++++----- src/client/hosts.rs | 4 +- src/client/networks.rs | 56 +++++-------------- src/models.rs | 3 + src/server/repository/networks.rs | 18 +++--- 9 files changed, 55 insertions(+), 78 deletions(-) create mode 100644 migrations/postgres/0007_add_network_name.sql create mode 100644 migrations/sqlite/0007_add_network_name.sql diff --git a/migrations/postgres/0007_add_network_name.sql b/migrations/postgres/0007_add_network_name.sql new file mode 100644 index 0000000..ae1e935 --- /dev/null +++ b/migrations/postgres/0007_add_network_name.sql @@ -0,0 +1,3 @@ +-- Add a human-readable name to networks. +-- DEFAULT '' allows the migration to run on databases that already have rows. +ALTER TABLE networks ADD COLUMN name TEXT NOT NULL DEFAULT ''; diff --git a/migrations/sqlite/0007_add_network_name.sql b/migrations/sqlite/0007_add_network_name.sql new file mode 100644 index 0000000..ae1e935 --- /dev/null +++ b/migrations/sqlite/0007_add_network_name.sql @@ -0,0 +1,3 @@ +-- Add a human-readable name to networks. +-- DEFAULT '' allows the migration to run on databases that already have rows. +ALTER TABLE networks ADD COLUMN name TEXT NOT NULL DEFAULT ''; diff --git a/seeds/postgres/dev_seed.sql b/seeds/postgres/dev_seed.sql index 35ec47c..8fde0f9 100644 --- a/seeds/postgres/dev_seed.sql +++ b/seeds/postgres/dev_seed.sql @@ -7,15 +7,14 @@ -- ── Networks ────────────────────────────────────────────────────────────────── -INSERT INTO networks (cidr) VALUES - ('192.168.1.0/24'), - ('192.168.10.0/24'), - ('10.0.0.0/8'), - ('172.16.0.0/16') +INSERT INTO networks (name, cidr) VALUES + ('LAN', '192.168.1.0/24'), + ('DMZ', '192.168.10.0/24'), + ('Corporate', '10.0.0.0/8'), + ('VPN', '172.16.0.0/16') ON CONFLICT (cidr) DO NOTHING; -- ── Hosts ───────────────────────────────────────────────────────────────────── --- Hosts have no UNIQUE constraint, so we guard with WHERE NOT EXISTS. -- LAN — 192.168.1.0/24 INSERT INTO hosts (name, ip, network_id) diff --git a/seeds/sqlite/dev_seed.sql b/seeds/sqlite/dev_seed.sql index 0e1e4d6..23a9cc7 100644 --- a/seeds/sqlite/dev_seed.sql +++ b/seeds/sqlite/dev_seed.sql @@ -6,12 +6,11 @@ -- Load with: cargo run --features ssr --bin seed -- ── Networks ────────────────────────────────────────────────────────────────── --- INSERT OR IGNORE relies on the UNIQUE constraint on cidr. -INSERT OR IGNORE INTO networks (cidr) VALUES ('192.168.1.0/24'); -INSERT OR IGNORE INTO networks (cidr) VALUES ('192.168.10.0/24'); -INSERT OR IGNORE INTO networks (cidr) VALUES ('10.0.0.0/8'); -INSERT OR IGNORE INTO networks (cidr) VALUES ('172.16.0.0/16'); +INSERT OR IGNORE INTO networks (name, cidr) VALUES ('LAN', '192.168.1.0/24'); +INSERT OR IGNORE INTO networks (name, cidr) VALUES ('DMZ', '192.168.10.0/24'); +INSERT OR IGNORE INTO networks (name, cidr) VALUES ('Corporate', '10.0.0.0/8'); +INSERT OR IGNORE INTO networks (name, cidr) VALUES ('VPN', '172.16.0.0/16'); -- ── Hosts ───────────────────────────────────────────────────────────────────── -- Hosts have no UNIQUE constraint, so we guard each insert with WHERE NOT EXISTS. diff --git a/src/api/networks.rs b/src/api/networks.rs index 7c90650..ce74507 100644 --- a/src/api/networks.rs +++ b/src/api/networks.rs @@ -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, 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::() .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, ServerFnError> { use sqlx::{AnyPool, Row}; @@ -57,6 +48,7 @@ pub async fn get_networks_with_counts() -> Result, 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, 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, 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 { +pub async fn create_network(name: String, cidr: String) -> Result { use sqlx::AnyPool; use crate::server::{repository::networks as repo, validation::validate_cidr}; let pool = use_context::() .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())) } diff --git a/src/client/hosts.rs b/src/client/hosts.rs index b0e3b64..9712a69 100644 --- a/src/client/hosts.rs +++ b/src/client/hosts.rs @@ -74,7 +74,7 @@ fn AddHostModal( {move || networks_res.get() .and_then(|r| r.ok()) .map(|nets| nets.into_iter().map(|n| { - view! { } + view! { } }).collect_view()) } @@ -137,7 +137,7 @@ fn FilterBar( {move || networks_res.get() .and_then(|r| r.ok()) .map(|nets| nets.into_iter().map(|n| { - view! { } + view! { } }).collect_view()) } diff --git a/src/client/networks.rs b/src/client/networks.rs index 4957be3..bfbf39b 100644 --- a/src/client/networks.rs +++ b/src/client/networks.rs @@ -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` : 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 that increments after each dispatch, -// used here as a dependency to trigger list re-fetches -// - `` : a form that submits to a ServerAction (no JS needed) -// - `` : shows a fallback while the Resource is loading use leptos::prelude::*; use leptos::form::ActionForm; @@ -27,7 +14,7 @@ fn DeleteConfirmModal( pending_delete: RwSignal>, ) -> 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(