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:
3
migrations/postgres/0007_add_network_name.sql
Normal file
3
migrations/postgres/0007_add_network_name.sql
Normal file
@@ -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 '';
|
||||
3
migrations/sqlite/0007_add_network_name.sql
Normal file
3
migrations/sqlite/0007_add_network_name.sql
Normal file
@@ -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 '';
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,10 +42,13 @@ 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")
|
||||
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) // exactly one row is returned by RETURNING
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row_to_network(&row))
|
||||
@@ -74,6 +77,7 @@ pub async fn delete_network(pool: &AnyPool, id: i64) -> Result<bool, DbError> {
|
||||
fn row_to_network(row: &sqlx::any::AnyRow) -> Network {
|
||||
Network {
|
||||
id: row.get("id"),
|
||||
name: row.get("name"),
|
||||
cidr: row.get("cidr"),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user