feat(networks): add Networks page with create/delete and SQLite auto-setup

- Add client/networks.rs: Leptos page with ServerAction + Resource pattern
  * ActionForm for CIDR creation (auto-clears after submit)
  * delete button dispatches DeleteNetwork action per row
  * Resource re-fetches after each create/delete via action.version()
  * Suspense shows "Loading…" while Resource is pending
- Register /networks route in app.rs with temporary nav bar
- Fix db.rs: create_pool now creates the SQLite file if missing
  (AnyPool has no create_if_missing option unlike SqlitePool)
- Remove redundant directory creation from main.rs (handled in db.rs)
- Fix ActionForm import: use leptos::form::ActionForm (not leptos_router)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 22:17:47 +02:00
parent 75c13b261b
commit e902efc04f
4 changed files with 204 additions and 5 deletions

View File

@@ -11,7 +11,7 @@ use leptos_router::{
path, path,
}; };
use crate::client::home::HomePage; use crate::client::{home::HomePage, networks::NetworksPage};
// Shell — full HTML document rendered by the Axum server. // 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. // Router handles client-side navigation without full page reloads.
// On the server, it determines which component to render for the requested URL. // On the server, it determines which component to render for the requested URL.
<Router> <Router>
// Temporary navigation bar — will be replaced by task #9.
<nav>
<a href="/">"Home"</a>
" | "
<a href="/networks">"Networks"</a>
</nav>
<main> <main>
// <Routes> is the container for all route definitions. // <Routes> is the container for all route definitions.
// `fallback` is displayed when no route matches the current URL. // `fallback` is displayed when no route matches the current URL.
@@ -92,10 +99,8 @@ pub fn App() -> impl IntoView {
<a href="/">"← Back to home"</a> <a href="/">"← Back to home"</a>
</div> </div>
}> }>
// path!(/) matches the root URL "/"
// Add new pages here, e.g.:
// <Route path=path!("/networks") view=NetworksPage/>
<Route path=path!("/") view=HomePage/> <Route path=path!("/") view=HomePage/>
<Route path=path!("/networks") view=NetworksPage/>
</Routes> </Routes>
</main> </main>
</Router> </Router>

View File

@@ -10,3 +10,4 @@
// without guarding it with `#[cfg(target_arch = "wasm32")]`. // 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

154
src/client/networks.rs Normal file
View File

@@ -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<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;
use crate::api::networks::{CreateNetwork, DeleteNetwork, get_networks};
#[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();
// ── 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! {
<div class="networks-page">
<h1>"Networks"</h1>
// ── 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>
"CIDR block"
<input
type="text"
name="cidr"
placeholder="e.g. 192.168.1.0/24"
required
/>
</label>
<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()
.get()
.and_then(|r| r.err())
.map(|e| view! { <p class="error">{e.to_string()}</p> })
}}
</section>
// ── Network list ──────────────────────────────────────────────────
<section class="list">
<h2>"All networks"</h2>
// Show delete errors above the list.
{move || {
delete_action
.value()
.get()
.and_then(|r| r.err())
.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>
}
.into_any(),
Ok(list) if list.is_empty() => view! {
<p class="empty">"No networks yet. Add one above."</p>
}
.into_any(),
Ok(list) => view! {
<table>
<thead>
<tr>
<th>"CIDR"</th>
<th>"Actions"</th>
</tr>
</thead>
<tbody>
// `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! {
<tr>
<td>{network.cidr}</td>
<td>
<button on:click=move |_| {
delete_action
.dispatch(DeleteNetwork { id });
}>
"Delete"
</button>
</td>
</tr>
}
})
.collect_view()}
</tbody>
</table>
}
.into_any(),
})
}}
</Suspense>
</section>
</div>
}
}

View File

@@ -35,11 +35,50 @@ pub enum DbError {
/// ///
/// `install_default_drivers()` must be called before `AnyPool::connect` /// `install_default_drivers()` must be called before `AnyPool::connect`
/// to register both the SQLite and PostgreSQL drivers in the `Any` registry. /// 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<AnyPool, DbError> { pub async fn create_pool(config: &AppConfig) -> Result<AnyPool, DbError> {
// Register SQLite and PostgreSQL drivers so `AnyPool` can dispatch // Register SQLite and PostgreSQL drivers so `AnyPool` can dispatch
// to the correct one based on the URL scheme (sqlite:// vs postgres://). // to the correct one based on the URL scheme (sqlite:// vs postgres://).
sqlx::any::install_default_drivers(); 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?; let pool = AnyPool::connect(&config.database_url).await?;
Ok(pool) Ok(pool)
} }