Compare commits
5 Commits
a4fc5b176f
...
e0ddf58a17
| Author | SHA1 | Date | |
|---|---|---|---|
| e0ddf58a17 | |||
| d9ee121fbb | |||
| e17b8ee722 | |||
| 55d8ed9f72 | |||
| 30dd1ad0b0 |
@@ -46,6 +46,6 @@
|
|||||||
- Les ports peuvent avoir une description pour indiquer quel est le protocole le plus probable d'être utiliser sur ce port (ex: 22 - SSH, 53 - DNS, 80 - HTTP, 443 - HTTPS)
|
- Les ports peuvent avoir une description pour indiquer quel est le protocole le plus probable d'être utiliser sur ce port (ex: 22 - SSH, 53 - DNS, 80 - HTTP, 443 - HTTPS)
|
||||||
- Un port peut être associé à une application, l'association n'est pas strict car un port peut être utilisé par plusieurs applications.
|
- Un port peut être associé à une application, l'association n'est pas strict car un port peut être utilisé par plusieurs applications.
|
||||||
- Une application possede un nom, un ou plusieurs ports.
|
- Une application possede un nom, un ou plusieurs ports.
|
||||||
- Un réseaux et définit par son CIDR (ex: 192.168.1.0/24)
|
- Un réseaux est définit par son nom et son CIDR (ex: 192.168.1.0/24)
|
||||||
- L'application peut gérer plusieurs réseaux distinct.
|
- L'application peut gérer plusieurs réseaux distinct.
|
||||||
- Chaques hôtes doit appartenir au réseaux dans lequel il est définit.
|
- Chaques hôtes doit appartenir au réseaux dans lequel il est définit.
|
||||||
|
|||||||
@@ -91,6 +91,10 @@ site-root = "target/site" # Dossier racine des fichiers compilés par trunk
|
|||||||
site-pkg-dir = "pkg" # Sous-dossier des assets WASM/JS dans site-root
|
site-pkg-dir = "pkg" # Sous-dossier des assets WASM/JS dans site-root
|
||||||
site-addr = "127.0.0.1:3000" # Adresse d'écoute du serveur Axum
|
site-addr = "127.0.0.1:3000" # Adresse d'écoute du serveur Axum
|
||||||
reload-port = 3001 # Port WebSocket pour le hot-reload en développement
|
reload-port = 3001 # Port WebSocket pour le hot-reload en développement
|
||||||
|
style-file = "style/rust-ipam.css" # Source CSS compilé dans pkg/rust-ipam.css
|
||||||
|
# Features activées par cargo-leptos lors du build
|
||||||
|
bin-features = ["ssr"] # SSR binary (Axum server)
|
||||||
|
lib-features = ["hydrate"] # WASM bundle (browser)
|
||||||
|
|
||||||
# Profil de compilation WASM optimisé pour réduire la taille du fichier .wasm
|
# Profil de compilation WASM optimisé pour réduire la taille du fichier .wasm
|
||||||
# Un fichier WASM plus petit = page qui charge plus vite
|
# Un fichier WASM plus petit = page qui charge plus vite
|
||||||
|
|||||||
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 '';
|
||||||
62
seeds/postgres/dev_seed.sql
Normal file
62
seeds/postgres/dev_seed.sql
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
-- dev_seed.sql (PostgreSQL) — development test data
|
||||||
|
--
|
||||||
|
-- Running this script is idempotent: existing rows are left untouched
|
||||||
|
-- and missing rows are inserted. Safe to run multiple times.
|
||||||
|
--
|
||||||
|
-- Load with: cargo run --features ssr --bin seed
|
||||||
|
|
||||||
|
-- ── Networks ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- LAN — 192.168.1.0/24
|
||||||
|
INSERT INTO hosts (name, ip, network_id)
|
||||||
|
SELECT name, ip, (SELECT id FROM networks WHERE cidr = '192.168.1.0/24')
|
||||||
|
FROM (VALUES
|
||||||
|
('gateway', '192.168.1.1'),
|
||||||
|
('workstation-01', '192.168.1.10'),
|
||||||
|
('workstation-02', '192.168.1.11'),
|
||||||
|
('workstation-03', '192.168.1.12'),
|
||||||
|
('nas-01', '192.168.1.20'),
|
||||||
|
('printer-01', '192.168.1.50')
|
||||||
|
) AS t(name, ip)
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM hosts WHERE hosts.name = t.name AND hosts.ip = t.ip);
|
||||||
|
|
||||||
|
-- DMZ — 192.168.10.0/24
|
||||||
|
INSERT INTO hosts (name, ip, network_id)
|
||||||
|
SELECT name, ip, (SELECT id FROM networks WHERE cidr = '192.168.10.0/24')
|
||||||
|
FROM (VALUES
|
||||||
|
('web-server-01', '192.168.10.10'),
|
||||||
|
('web-server-02', '192.168.10.11'),
|
||||||
|
('db-server-01', '192.168.10.20'),
|
||||||
|
('mail-server-01', '192.168.10.30')
|
||||||
|
) AS t(name, ip)
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM hosts WHERE hosts.name = t.name AND hosts.ip = t.ip);
|
||||||
|
|
||||||
|
-- Corporate backbone — 10.0.0.0/8
|
||||||
|
INSERT INTO hosts (name, ip, network_id)
|
||||||
|
SELECT name, ip, (SELECT id FROM networks WHERE cidr = '10.0.0.0/8')
|
||||||
|
FROM (VALUES
|
||||||
|
('core-switch-01', '10.0.0.1'),
|
||||||
|
('monitoring-01', '10.0.1.10'),
|
||||||
|
('backup-server-01', '10.0.1.20'),
|
||||||
|
('log-server-01', '10.0.1.30')
|
||||||
|
) AS t(name, ip)
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM hosts WHERE hosts.name = t.name AND hosts.ip = t.ip);
|
||||||
|
|
||||||
|
-- VPN — 172.16.0.0/16
|
||||||
|
INSERT INTO hosts (name, ip, network_id)
|
||||||
|
SELECT name, ip, (SELECT id FROM networks WHERE cidr = '172.16.0.0/16')
|
||||||
|
FROM (VALUES
|
||||||
|
('vpn-gateway-01', '172.16.0.1'),
|
||||||
|
('vpn-client-01', '172.16.1.10'),
|
||||||
|
('vpn-client-02', '172.16.1.11')
|
||||||
|
) AS t(name, ip)
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM hosts WHERE hosts.name = t.name AND hosts.ip = t.ip);
|
||||||
42
seeds/sqlite/dev_seed.sql
Normal file
42
seeds/sqlite/dev_seed.sql
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
-- dev_seed.sql (SQLite) — development test data
|
||||||
|
--
|
||||||
|
-- Running this script is idempotent: existing rows are left untouched
|
||||||
|
-- and missing rows are inserted. Safe to run multiple times.
|
||||||
|
--
|
||||||
|
-- Load with: cargo run --features ssr --bin seed
|
||||||
|
|
||||||
|
-- ── Networks ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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.
|
||||||
|
-- Network IDs are resolved by subquery on cidr for portability.
|
||||||
|
|
||||||
|
-- LAN — 192.168.1.0/24
|
||||||
|
INSERT INTO hosts (name, ip, network_id) SELECT 'gateway', '192.168.1.1', id FROM networks WHERE cidr = '192.168.1.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'gateway' AND ip = '192.168.1.1');
|
||||||
|
INSERT INTO hosts (name, ip, network_id) SELECT 'workstation-01', '192.168.1.10', id FROM networks WHERE cidr = '192.168.1.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'workstation-01' AND ip = '192.168.1.10');
|
||||||
|
INSERT INTO hosts (name, ip, network_id) SELECT 'workstation-02', '192.168.1.11', id FROM networks WHERE cidr = '192.168.1.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'workstation-02' AND ip = '192.168.1.11');
|
||||||
|
INSERT INTO hosts (name, ip, network_id) SELECT 'workstation-03', '192.168.1.12', id FROM networks WHERE cidr = '192.168.1.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'workstation-03' AND ip = '192.168.1.12');
|
||||||
|
INSERT INTO hosts (name, ip, network_id) SELECT 'nas-01', '192.168.1.20', id FROM networks WHERE cidr = '192.168.1.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'nas-01' AND ip = '192.168.1.20');
|
||||||
|
INSERT INTO hosts (name, ip, network_id) SELECT 'printer-01', '192.168.1.50', id FROM networks WHERE cidr = '192.168.1.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'printer-01' AND ip = '192.168.1.50');
|
||||||
|
|
||||||
|
-- DMZ — 192.168.10.0/24
|
||||||
|
INSERT INTO hosts (name, ip, network_id) SELECT 'web-server-01', '192.168.10.10', id FROM networks WHERE cidr = '192.168.10.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'web-server-01' AND ip = '192.168.10.10');
|
||||||
|
INSERT INTO hosts (name, ip, network_id) SELECT 'web-server-02', '192.168.10.11', id FROM networks WHERE cidr = '192.168.10.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'web-server-02' AND ip = '192.168.10.11');
|
||||||
|
INSERT INTO hosts (name, ip, network_id) SELECT 'db-server-01', '192.168.10.20', id FROM networks WHERE cidr = '192.168.10.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'db-server-01' AND ip = '192.168.10.20');
|
||||||
|
INSERT INTO hosts (name, ip, network_id) SELECT 'mail-server-01', '192.168.10.30', id FROM networks WHERE cidr = '192.168.10.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'mail-server-01' AND ip = '192.168.10.30');
|
||||||
|
|
||||||
|
-- Corporate backbone — 10.0.0.0/8
|
||||||
|
INSERT INTO hosts (name, ip, network_id) SELECT 'core-switch-01', '10.0.0.1', id FROM networks WHERE cidr = '10.0.0.0/8' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'core-switch-01' AND ip = '10.0.0.1');
|
||||||
|
INSERT INTO hosts (name, ip, network_id) SELECT 'monitoring-01', '10.0.1.10', id FROM networks WHERE cidr = '10.0.0.0/8' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'monitoring-01' AND ip = '10.0.1.10');
|
||||||
|
INSERT INTO hosts (name, ip, network_id) SELECT 'backup-server-01', '10.0.1.20', id FROM networks WHERE cidr = '10.0.0.0/8' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'backup-server-01' AND ip = '10.0.1.20');
|
||||||
|
INSERT INTO hosts (name, ip, network_id) SELECT 'log-server-01', '10.0.1.30', id FROM networks WHERE cidr = '10.0.0.0/8' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'log-server-01' AND ip = '10.0.1.30');
|
||||||
|
|
||||||
|
-- VPN — 172.16.0.0/16
|
||||||
|
INSERT INTO hosts (name, ip, network_id) SELECT 'vpn-gateway-01', '172.16.0.1', id FROM networks WHERE cidr = '172.16.0.0/16' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'vpn-gateway-01' AND ip = '172.16.0.1');
|
||||||
|
INSERT INTO hosts (name, ip, network_id) SELECT 'vpn-client-01', '172.16.1.10', id FROM networks WHERE cidr = '172.16.0.0/16' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'vpn-client-01' AND ip = '172.16.1.10');
|
||||||
|
INSERT INTO hosts (name, ip, network_id) SELECT 'vpn-client-02', '172.16.1.11', id FROM networks WHERE cidr = '172.16.0.0/16' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'vpn-client-02' AND ip = '172.16.1.11');
|
||||||
@@ -8,9 +8,10 @@ use crate::models::Network;
|
|||||||
// Network row augmented with pre-computed counts.
|
// Network row augmented with pre-computed counts.
|
||||||
// Defined here (not in models.rs) because it is a presentation model
|
// Defined here (not in models.rs) because it is a presentation model
|
||||||
// specific to the Networks page, not a pure domain entity.
|
// specific to the Networks page, not a pure domain entity.
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct NetworkWithCounts {
|
pub struct NetworkWithCounts {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
pub cidr: String,
|
pub cidr: String,
|
||||||
/// Number of hosts whose IP falls within this network's CIDR range.
|
/// Number of hosts whose IP falls within this network's CIDR range.
|
||||||
pub host_count: i64,
|
pub host_count: i64,
|
||||||
@@ -28,25 +29,15 @@ pub async fn get_networks() -> Result<Vec<Network>, ServerFnError> {
|
|||||||
use sqlx::AnyPool;
|
use sqlx::AnyPool;
|
||||||
use crate::server::repository::networks as repo;
|
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>()
|
let pool = use_context::<AnyPool>()
|
||||||
.ok_or_else(|| ServerFnError::new("Database pool not found in 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)
|
repo::list_networks(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns all networks enriched with host and application counts.
|
/// 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]
|
#[server]
|
||||||
pub async fn get_networks_with_counts() -> Result<Vec<NetworkWithCounts>, ServerFnError> {
|
pub async fn get_networks_with_counts() -> Result<Vec<NetworkWithCounts>, ServerFnError> {
|
||||||
use sqlx::{AnyPool, Row};
|
use sqlx::{AnyPool, Row};
|
||||||
@@ -57,6 +48,7 @@ pub async fn get_networks_with_counts() -> Result<Vec<NetworkWithCounts>, Server
|
|||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT
|
"SELECT
|
||||||
n.id,
|
n.id,
|
||||||
|
n.name,
|
||||||
n.cidr,
|
n.cidr,
|
||||||
(SELECT COUNT(*) FROM hosts WHERE network_id = n.id) AS host_count,
|
(SELECT COUNT(*) FROM hosts WHERE network_id = n.id) AS host_count,
|
||||||
(SELECT COUNT(DISTINCT ap.application_id)
|
(SELECT COUNT(DISTINCT ap.application_id)
|
||||||
@@ -75,6 +67,7 @@ pub async fn get_networks_with_counts() -> Result<Vec<NetworkWithCounts>, Server
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|row| NetworkWithCounts {
|
.map(|row| NetworkWithCounts {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
|
name: row.get("name"),
|
||||||
cidr: row.get("cidr"),
|
cidr: row.get("cidr"),
|
||||||
host_count: row.get("host_count"),
|
host_count: row.get("host_count"),
|
||||||
application_count: row.get("application_count"),
|
application_count: row.get("application_count"),
|
||||||
@@ -86,23 +79,24 @@ pub async fn get_networks_with_counts() -> Result<Vec<NetworkWithCounts>, Server
|
|||||||
|
|
||||||
// ─── Mutations ────────────────────────────────────────────────────────────────
|
// ─── 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.
|
/// Returns an error if the CIDR is malformed or already exists.
|
||||||
#[server]
|
#[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 sqlx::AnyPool;
|
||||||
use crate::server::{repository::networks as repo, validation::validate_cidr};
|
use crate::server::{repository::networks as repo, validation::validate_cidr};
|
||||||
|
|
||||||
let pool = use_context::<AnyPool>()
|
let pool = use_context::<AnyPool>()
|
||||||
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||||
|
|
||||||
// Validate the CIDR before touching the database.
|
if name.trim().is_empty() {
|
||||||
// Example of a valid CIDR: "192.168.1.0/24"
|
return Err(ServerFnError::new("Network name cannot be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
validate_cidr(&cidr).map_err(|e| ServerFnError::new(e.to_string()))?;
|
validate_cidr(&cidr).map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
repo::create_network(&pool, &cidr)
|
repo::create_network(&pool, name.trim(), &cidr)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|||||||
89
src/bin/seed.rs
Normal file
89
src/bin/seed.rs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
// bin/seed.rs — Development seed loader
|
||||||
|
//
|
||||||
|
// Inserts a realistic set of networks and hosts into the database so the UI
|
||||||
|
// can be tested without manual data entry.
|
||||||
|
//
|
||||||
|
// The seed is idempotent: running it multiple times never duplicates rows.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// cargo run --features ssr --bin seed
|
||||||
|
//
|
||||||
|
// The DATABASE_URL is read from the .env file (or environment variable),
|
||||||
|
// exactly like the main server.
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
use rust_ipam::server::{
|
||||||
|
config::{AppConfig, DatabaseBackend},
|
||||||
|
db::{create_pool, run_migrations},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load .env so DATABASE_URL is available without exporting it manually.
|
||||||
|
// Errors are ignored: the variable may already be set in the environment.
|
||||||
|
let _ = dotenvy::dotenv();
|
||||||
|
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let config = AppConfig::from_env()
|
||||||
|
.expect("Configuration error — check DATABASE_URL in your .env file");
|
||||||
|
|
||||||
|
tracing::info!("Connecting to {} ({})", config.backend, config.database_url);
|
||||||
|
|
||||||
|
let pool = create_pool(&config)
|
||||||
|
.await
|
||||||
|
.expect("Failed to connect to database");
|
||||||
|
|
||||||
|
run_migrations(&pool, &config.backend)
|
||||||
|
.await
|
||||||
|
.expect("Migration failed");
|
||||||
|
|
||||||
|
// Pick the seed file that matches the active backend.
|
||||||
|
// `include_str!` embeds the SQL at compile time so the binary is self-contained.
|
||||||
|
let sql = match config.backend {
|
||||||
|
DatabaseBackend::Sqlite => include_str!("../../seeds/sqlite/dev_seed.sql"),
|
||||||
|
DatabaseBackend::Postgres => include_str!("../../seeds/postgres/dev_seed.sql"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Strip comment lines first, then split on ';'.
|
||||||
|
// sqlx does not support multiple statements in a single `query()` call.
|
||||||
|
// Without pre-stripping comments, a block like "-- section\nINSERT …"
|
||||||
|
// would start with "--" and get incorrectly discarded.
|
||||||
|
let sql_stripped: String = sql
|
||||||
|
.lines()
|
||||||
|
.filter(|line| !line.trim().starts_with("--"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
let statements: Vec<String> = sql_stripped
|
||||||
|
.split(';')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let total = statements.len();
|
||||||
|
for (i, stmt) in statements.iter().enumerate() {
|
||||||
|
sqlx::query(stmt)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| panic!("Statement {}/{} failed: {}\nSQL: {}", i + 1, total, e, stmt));
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Seed complete — {} statement(s) executed.", total);
|
||||||
|
|
||||||
|
// Count what was inserted so the operator can confirm at a glance.
|
||||||
|
let network_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM networks")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let host_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM hosts")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
tracing::info!("Database now contains {} network(s) and {} host(s).", network_count, host_count);
|
||||||
|
}
|
||||||
@@ -74,7 +74,7 @@ fn AddHostModal(
|
|||||||
{move || networks_res.get()
|
{move || networks_res.get()
|
||||||
.and_then(|r| r.ok())
|
.and_then(|r| r.ok())
|
||||||
.map(|nets| nets.into_iter().map(|n| {
|
.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())
|
}).collect_view())
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
@@ -137,7 +137,7 @@ fn FilterBar(
|
|||||||
{move || networks_res.get()
|
{move || networks_res.get()
|
||||||
.and_then(|r| r.ok())
|
.and_then(|r| r.ok())
|
||||||
.map(|nets| nets.into_iter().map(|n| {
|
.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())
|
}).collect_view())
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -1,41 +1,77 @@
|
|||||||
// client/networks.rs — Networks page
|
// 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::prelude::*;
|
||||||
use leptos::form::ActionForm;
|
use leptos::form::ActionForm;
|
||||||
|
|
||||||
use crate::api::networks::{CreateNetwork, DeleteNetwork, get_networks_with_counts};
|
use crate::api::networks::{CreateNetwork, DeleteNetwork, NetworkWithCounts, get_networks_with_counts};
|
||||||
|
|
||||||
|
// ─── Delete confirmation modal ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn DeleteConfirmModal(
|
||||||
|
network: NetworkWithCounts,
|
||||||
|
delete_action: ServerAction<DeleteNetwork>,
|
||||||
|
pending_delete: RwSignal<Option<NetworkWithCounts>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let id = network.id;
|
||||||
|
let label = format!("{} ({})", network.name, network.cidr);
|
||||||
|
let host_count = network.host_count;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="modal-backdrop" on:click=move |_| pending_delete.set(None)>
|
||||||
|
<div class="modal" on:click=move |e| e.stop_propagation()>
|
||||||
|
<div class="modal__header">
|
||||||
|
<h2>"Delete network"</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal__body">
|
||||||
|
<p>"Delete network " <strong>{label}</strong> "?"</p>
|
||||||
|
{(host_count > 0).then(|| view! {
|
||||||
|
<p class="warning">
|
||||||
|
"Warning: "
|
||||||
|
{host_count}
|
||||||
|
{if host_count == 1 { " host" } else { " hosts" }}
|
||||||
|
" belonging to this network will also be deleted."
|
||||||
|
</p>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal__actions">
|
||||||
|
<button
|
||||||
|
class="btn-secondary"
|
||||||
|
type="button"
|
||||||
|
on:click=move |_| pending_delete.set(None)
|
||||||
|
>"Cancel"</button>
|
||||||
|
<button
|
||||||
|
class="btn-danger"
|
||||||
|
type="button"
|
||||||
|
on:click=move |_| {
|
||||||
|
delete_action.dispatch(DeleteNetwork { id });
|
||||||
|
}
|
||||||
|
>"Delete"</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn NetworksPage() -> impl IntoView {
|
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 create_action = ServerAction::<CreateNetwork>::new();
|
||||||
let delete_action = ServerAction::<DeleteNetwork>::new();
|
let delete_action = ServerAction::<DeleteNetwork>::new();
|
||||||
|
|
||||||
// ── Data resource ─────────────────────────────────────────────────────────
|
// Stores the network pending deletion; Some = modal open, None = closed.
|
||||||
//
|
let pending_delete: RwSignal<Option<NetworkWithCounts>> = RwSignal::new(None);
|
||||||
// `Resource::new(source, fetcher)`:
|
|
||||||
// - source : a closure whose return value Leptos tracks reactively
|
// Close the modal automatically after a successful deletion.
|
||||||
// - fetcher : an async closure called whenever the source changes
|
Effect::new(move |_| {
|
||||||
//
|
if let Some(Ok(_)) = delete_action.value().get() {
|
||||||
// By reading `.version()` from both actions, the list automatically
|
pending_delete.set(None);
|
||||||
// re-fetches after any create or delete, keeping the view in sync.
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let networks = Resource::new(
|
let networks = Resource::new(
|
||||||
move || (create_action.version().get(), delete_action.version().get()),
|
move || (create_action.version().get(), delete_action.version().get()),
|
||||||
|_| get_networks_with_counts(),
|
|_| get_networks_with_counts(),
|
||||||
@@ -45,15 +81,28 @@ pub fn NetworksPage() -> impl IntoView {
|
|||||||
<div class="networks-page">
|
<div class="networks-page">
|
||||||
<h1>"Networks"</h1>
|
<h1>"Networks"</h1>
|
||||||
|
|
||||||
|
// ── Delete confirmation modal ──────────────────────────────────────
|
||||||
|
{move || pending_delete.get().map(|network| view! {
|
||||||
|
<DeleteConfirmModal
|
||||||
|
network=network
|
||||||
|
delete_action=delete_action
|
||||||
|
pending_delete=pending_delete
|
||||||
|
/>
|
||||||
|
})}
|
||||||
|
|
||||||
// ── Add form ──────────────────────────────────────────────────────
|
// ── 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">
|
<section class="add-form">
|
||||||
<h2>"Add a network"</h2>
|
<h2>"Add a network"</h2>
|
||||||
<ActionForm action=create_action>
|
<ActionForm action=create_action>
|
||||||
|
<label>
|
||||||
|
"Name"
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
placeholder="e.g. LAN, DMZ, VPN"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
"CIDR block"
|
"CIDR block"
|
||||||
<input
|
<input
|
||||||
@@ -66,9 +115,6 @@ pub fn NetworksPage() -> impl IntoView {
|
|||||||
<button type="submit">"Add"</button>
|
<button type="submit">"Add"</button>
|
||||||
</ActionForm>
|
</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 || {
|
{move || {
|
||||||
create_action
|
create_action
|
||||||
.value()
|
.value()
|
||||||
@@ -82,7 +128,6 @@ pub fn NetworksPage() -> impl IntoView {
|
|||||||
<section class="list">
|
<section class="list">
|
||||||
<h2>"All networks"</h2>
|
<h2>"All networks"</h2>
|
||||||
|
|
||||||
// Show delete errors above the list.
|
|
||||||
{move || {
|
{move || {
|
||||||
delete_action
|
delete_action
|
||||||
.value()
|
.value()
|
||||||
@@ -91,12 +136,8 @@ pub fn NetworksPage() -> impl IntoView {
|
|||||||
.map(|e| view! { <p class="error">"Delete failed: " {e.to_string()}</p> })
|
.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> }>
|
<Suspense fallback=|| view! { <p>"Loading networks…"</p> }>
|
||||||
{move || {
|
{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 {
|
networks.get().map(|result| match result {
|
||||||
Err(e) => view! {
|
Err(e) => view! {
|
||||||
<p class="error">"Could not load networks: " {e.to_string()}</p>
|
<p class="error">"Could not load networks: " {e.to_string()}</p>
|
||||||
@@ -113,6 +154,7 @@ pub fn NetworksPage() -> impl IntoView {
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th>"Name"</th>
|
||||||
<th>"CIDR"</th>
|
<th>"CIDR"</th>
|
||||||
<th class="col-count">"Hosts"</th>
|
<th class="col-count">"Hosts"</th>
|
||||||
<th class="col-count">"Applications"</th>
|
<th class="col-count">"Applications"</th>
|
||||||
@@ -123,16 +165,16 @@ pub fn NetworksPage() -> impl IntoView {
|
|||||||
{list
|
{list
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|network| {
|
.map(|network| {
|
||||||
let id = network.id;
|
let network_clone = network.clone();
|
||||||
view! {
|
view! {
|
||||||
<tr>
|
<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.host_count}</td>
|
||||||
<td class="col-count">{network.application_count}</td>
|
<td class="col-count">{network.application_count}</td>
|
||||||
<td class="col-actions">
|
<td class="col-actions">
|
||||||
<button on:click=move |_| {
|
<button on:click=move |_| {
|
||||||
delete_action
|
pending_delete.set(Some(network_clone.clone()));
|
||||||
.dispatch(DeleteNetwork { id });
|
|
||||||
}>
|
}>
|
||||||
"Delete"
|
"Delete"
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ impl ThemeChoice {
|
|||||||
// ─── DOM helpers (WASM only) ─────────────────────────────────────────────────
|
// ─── DOM helpers (WASM only) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
// Reads the stored theme name from localStorage.
|
// Reads the stored theme name from localStorage.
|
||||||
#[cfg(target_arch = "wasm32")]
|
// Guard on `hydrate` feature rather than `target_arch` because web-sys is
|
||||||
|
// only activated by that feature in Cargo.toml.
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
fn load_stored_theme() -> Option<ThemeChoice> {
|
fn load_stored_theme() -> Option<ThemeChoice> {
|
||||||
let storage = web_sys::window()?.local_storage().ok()??;
|
let storage = web_sys::window()?.local_storage().ok()??;
|
||||||
let value = storage.get_item(STORAGE_KEY).ok()??;
|
let value = storage.get_item(STORAGE_KEY).ok()??;
|
||||||
@@ -79,7 +81,7 @@ fn load_stored_theme() -> Option<ThemeChoice> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Applies `data-theme` attribute to <html> and persists to localStorage.
|
// Applies `data-theme` attribute to <html> and persists to localStorage.
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(feature = "hydrate")]
|
||||||
fn apply_and_persist(choice: &ThemeChoice) {
|
fn apply_and_persist(choice: &ThemeChoice) {
|
||||||
let Some(window) = web_sys::window() else { return };
|
let Some(window) = web_sys::window() else { return };
|
||||||
let Some(document) = window.document() else { return };
|
let Some(document) = window.document() else { return };
|
||||||
@@ -108,7 +110,7 @@ pub fn ThemeToggle() -> impl IntoView {
|
|||||||
// Does NOT track `theme`, so it never re-runs after the initial mount.
|
// Does NOT track `theme`, so it never re-runs after the initial mount.
|
||||||
// Setting the signal here triggers Effect 2 below.
|
// Setting the signal here triggers Effect 2 below.
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(feature = "hydrate")]
|
||||||
if let Some(stored) = load_stored_theme() {
|
if let Some(stored) = load_stored_theme() {
|
||||||
theme.set(stored);
|
theme.set(stored);
|
||||||
}
|
}
|
||||||
@@ -118,7 +120,7 @@ pub fn ThemeToggle() -> impl IntoView {
|
|||||||
// whenever the signal changes (both on init and after user clicks).
|
// whenever the signal changes (both on init and after user clicks).
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
let current = theme.get(); // tracked — re-runs when theme changes
|
let current = theme.get(); // tracked — re-runs when theme changes
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(feature = "hydrate")]
|
||||||
apply_and_persist(¤t);
|
apply_and_persist(¤t);
|
||||||
// Suppress unused variable warning when compiling for SSR
|
// Suppress unused variable warning when compiling for SSR
|
||||||
let _ = current;
|
let _ = current;
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ pub struct Network {
|
|||||||
/// `i64` is a signed 64-bit integer — maps to `BIGINT` in SQL.
|
/// `i64` is a signed 64-bit integer — maps to `BIGINT` in SQL.
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
|
|
||||||
|
/// Human-readable name. Examples: "LAN", "DMZ", "VPN"
|
||||||
|
pub name: String,
|
||||||
|
|
||||||
/// Address range in CIDR notation.
|
/// Address range in CIDR notation.
|
||||||
/// Examples: "10.0.0.0/8", "172.16.0.0/12", "192.168.1.0/24"
|
/// Examples: "10.0.0.0/8", "172.16.0.0/12", "192.168.1.0/24"
|
||||||
pub cidr: String,
|
pub cidr: String,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use crate::server::db::DbError;
|
|||||||
pub async fn list_networks(pool: &AnyPool) -> Result<Vec<Network>, DbError> {
|
pub async fn list_networks(pool: &AnyPool) -> Result<Vec<Network>, DbError> {
|
||||||
// `fetch_all` runs the query and collects every row into a Vec.
|
// `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![]).
|
// 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)
|
.fetch_all(pool)
|
||||||
.await?;
|
.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,
|
/// `RETURNING id, cidr` reads back the inserted row in a single round-trip,
|
||||||
/// avoiding a separate SELECT after the INSERT.
|
/// avoiding a separate SELECT after the INSERT.
|
||||||
/// Requires SQLite ≥ 3.35 (2021) and any PostgreSQL version.
|
/// Requires SQLite ≥ 3.35 (2021) and any PostgreSQL version.
|
||||||
pub async fn create_network(pool: &AnyPool, cidr: &str) -> Result<Network, DbError> {
|
pub async fn create_network(pool: &AnyPool, name: &str, cidr: &str) -> Result<Network, DbError> {
|
||||||
let row = sqlx::query("INSERT INTO networks (cidr) VALUES ($1) RETURNING id, cidr")
|
let row = sqlx::query(
|
||||||
|
"INSERT INTO networks (name, cidr) VALUES ($1, $2) RETURNING id, name, cidr",
|
||||||
|
)
|
||||||
|
.bind(name)
|
||||||
.bind(cidr)
|
.bind(cidr)
|
||||||
.fetch_one(pool) // exactly one row is returned by RETURNING
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(row_to_network(&row))
|
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 {
|
fn row_to_network(row: &sqlx::any::AnyRow) -> Network {
|
||||||
Network {
|
Network {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
|
name: row.get("name"),
|
||||||
cidr: row.get("cidr"),
|
cidr: row.get("cidr"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -924,6 +924,23 @@ td.col-actions {
|
|||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal__body {
|
||||||
|
margin-bottom: var(--size-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__body p {
|
||||||
|
margin: 0 0 var(--size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
color: var(--color-warning, #b45309);
|
||||||
|
background: var(--color-warning-bg, #fef3c7);
|
||||||
|
border: 1px solid var(--color-warning-border, #fcd34d);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: var(--size-sm) var(--size-md);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
/* Form fields inside modal — single column stack */
|
/* Form fields inside modal — single column stack */
|
||||||
.modal .add-form__fields {
|
.modal .add-form__fields {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user