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,
|
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>
|
||||||
|
|||||||
@@ -9,4 +9,5 @@
|
|||||||
// Do not place code here that requires browser-only APIs (window, document...)
|
// Do not place code here that requires browser-only APIs (window, document...)
|
||||||
// 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
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`
|
/// `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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user