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:
13
src/app.rs
13
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.
|
||||
<Router>
|
||||
// Temporary navigation bar — will be replaced by task #9.
|
||||
<nav>
|
||||
<a href="/">"Home"</a>
|
||||
" | "
|
||||
<a href="/networks">"Networks"</a>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
// <Routes> 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 {
|
||||
<a href="/">"← Back to home"</a>
|
||||
</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!("/networks") view=NetworksPage/>
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
|
||||
@@ -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
|
||||
|
||||
154
src/client/networks.rs
Normal file
154
src/client/networks.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
@@ -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<AnyPool, DbError> {
|
||||
// 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user