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! {

{e.to_string()}

}) + }} +
+ + // ── Network list ────────────────────────────────────────────────── +
+

"All networks"

+ + // Show delete errors above the list. + {move || { + delete_action + .value() + .get() + .and_then(|r| r.err()) + .map(|e| view! {

"Delete failed: " {e.to_string()}

}) + }} + + // `` 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! { +

"Could not load networks: " {e.to_string()}

+ } + .into_any(), + + Ok(list) if list.is_empty() => view! { +

"No networks yet. Add one above."

+ } + .into_any(), + + Ok(list) => 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! { + + + + + } + }) + .collect_view()} + +
"CIDR""Actions"
{network.cidr} + +
+ } + .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) }