diff --git a/src/app.rs b/src/app.rs
index 9dd2044..3455d49 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -11,7 +11,7 @@ use leptos_router::{
path,
};
-use crate::client::home::HomePage;
+use crate::client::{home::HomePage, networks::NetworksPage};
// Shell — full HTML document rendered by the Axum server.
//
@@ -83,6 +83,13 @@ pub fn App() -> impl IntoView {
// Router handles client-side navigation without full page reloads.
// On the server, it determines which component to render for the requested URL.
+ // Temporary navigation bar — will be replaced by task #9.
+
+
// is the container for all route definitions.
// `fallback` is displayed when no route matches the current URL.
@@ -92,10 +99,8 @@ pub fn App() -> impl IntoView {
"← Back to home"
}>
- // path!(/) matches the root URL "/"
- // Add new pages here, e.g.:
- //
+
diff --git a/src/client/mod.rs b/src/client/mod.rs
index d2b6bbe..a789dc3 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -9,4 +9,5 @@
// Do not place code here that requires browser-only APIs (window, document...)
// without guarding it with `#[cfg(target_arch = "wasm32")]`.
-pub mod home; // Home page
+pub mod home; // Home page
+pub mod networks; // Networks list and creation
diff --git a/src/client/networks.rs b/src/client/networks.rs
new file mode 100644
index 0000000..e15b9dc
--- /dev/null
+++ b/src/client/networks.rs
@@ -0,0 +1,154 @@
+// 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;
+
+use crate::api::networks::{CreateNetwork, DeleteNetwork, get_networks};
+
+#[component]
+pub fn NetworksPage() -> impl IntoView {
+ // ── Actions ───────────────────────────────────────────────────────────────
+ //
+ // `ServerAction` binds a `#[server]` function to a reactive action.
+ // Under the hood it posts to `/api/` and updates its signals
+ // (.pending(), .value(), .version()) when the call completes.
+ let create_action = ServerAction::::new();
+ let delete_action = ServerAction::::new();
+
+ // ── 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(),
+ );
+
+ view! {
+
+
"Networks"
+
+ // ── Add form ──────────────────────────────────────────────────────
+ //
+ // `` 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.
+
+
"Add a network"
+
+
+
+
+
+ // Show the error from the last create attempt, if any.
+ // `action.value().get()` → Option>
+ // `.and_then(|r| r.err())` extracts the error when present.
+ {move || {
+ create_action
+ .value()
+ .get()
+ .and_then(|r| r.err())
+ .map(|e| view! {
})
+ }}
+
+ // `` shows `fallback` while the Resource is loading,
+ // then switches to the children once data is available.
+ "Loading networks…" }>
+ {move || {
+ // `networks.get()` → None while loading, Some(result) once done.
+ // Returning None here keeps in its fallback state.
+ networks.get().map(|result| match result {
+ Err(e) => view! {
+
+
+
+ // `collect_view()` turns an iterator of views
+ // into a single renderable fragment.
+ {list
+ .into_iter()
+ .map(|network| {
+ // Capture `id` as a plain i64 (Copy).
+ // Closures in event handlers must be 'static,
+ // so we can't move the full `network` struct.
+ let id = network.id;
+ view! {
+
+
{network.cidr}
+
+
+
+
+ }
+ })
+ .collect_view()}
+
+
+ }
+ .into_any(),
+ })
+ }}
+
+
+
+ }
+}
diff --git a/src/server/db.rs b/src/server/db.rs
index 5f677ea..1cdf28c 100644
--- a/src/server/db.rs
+++ b/src/server/db.rs
@@ -35,11 +35,50 @@ pub enum DbError {
///
/// `install_default_drivers()` must be called before `AnyPool::connect`
/// to register both the SQLite and PostgreSQL drivers in the `Any` registry.
+///
+/// For SQLite, this function also creates the database file and its parent
+/// directory if they do not exist yet. The `AnyPool` driver cannot create
+/// a new SQLite file by itself — unlike the `SqlitePool` which has an
+/// explicit `create_if_missing` option.
pub async fn create_pool(config: &AppConfig) -> Result {
// Register SQLite and PostgreSQL drivers so `AnyPool` can dispatch
// to the correct one based on the URL scheme (sqlite:// vs postgres://).
sqlx::any::install_default_drivers();
+ // SQLite-specific setup: ensure the file exists before connecting.
+ //
+ // `AnyPool` does not expose `create_if_missing` like `SqlitePool` does,
+ // so we must touch the file ourselves.
+ //
+ // The URL is parsed the same way SQLx does it internally:
+ // sqlite://data/ipam.db → strip sqlite:// → path = data/ipam.db
+ if let DatabaseBackend::Sqlite = &config.backend {
+ // Strip both possible prefixes (SQLx accepts both forms)
+ let path_str = config
+ .database_url
+ .trim_start_matches("sqlite://")
+ .trim_start_matches("sqlite:");
+
+ // Skip special filenames (in-memory, shared cache)
+ if path_str != ":memory:" && !path_str.is_empty() {
+ let path = std::path::Path::new(path_str);
+
+ // Create the parent directory if it does not exist
+ if let Some(parent) = path.parent() {
+ if !parent.as_os_str().is_empty() {
+ std::fs::create_dir_all(parent)
+ .map_err(|e| sqlx::Error::Io(e))?;
+ }
+ }
+
+ // Create an empty file so SQLite can open it
+ if !path.exists() {
+ std::fs::File::create(path)
+ .map_err(|e| sqlx::Error::Io(e))?;
+ }
+ }
+ }
+
let pool = AnyPool::connect(&config.database_url).await?;
Ok(pool)
}