feat(home): replace demo with entity count dashboard
Home page now shows one clickable summary card per entity type (Networks, Hosts, Applications). Each card displays the total count fetched from the database via a single get_summary() server function, then navigates to the corresponding page on click. Counts are pre-rendered server-side via <Suspense> so the page is useful even before the WASM bundle loads. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -17,3 +17,4 @@
|
|||||||
pub mod applications;
|
pub mod applications;
|
||||||
pub mod hosts;
|
pub mod hosts;
|
||||||
pub mod networks;
|
pub mod networks;
|
||||||
|
pub mod summary;
|
||||||
|
|||||||
45
src/api/summary.rs
Normal file
45
src/api/summary.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// api/summary.rs — Dashboard summary server function
|
||||||
|
//
|
||||||
|
// Returns the count of each main entity in a single server round-trip.
|
||||||
|
// The home page uses this to display a quick-glance dashboard without
|
||||||
|
// loading the full list of each entity.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// Counts for every top-level entity shown on the dashboard.
|
||||||
|
// Add a field here when a new entity type is introduced.
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
||||||
|
pub struct Summary {
|
||||||
|
pub network_count: i64,
|
||||||
|
pub host_count: i64,
|
||||||
|
pub application_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn get_summary() -> Result<Summary, ServerFnError> {
|
||||||
|
use sqlx::AnyPool;
|
||||||
|
use leptos::prelude::use_context;
|
||||||
|
|
||||||
|
let pool = use_context::<AnyPool>()
|
||||||
|
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||||
|
|
||||||
|
// Three lightweight COUNT queries — no full table scans on the payload side.
|
||||||
|
// sqlx returns COUNT(*) as i64 for both SQLite and PostgreSQL.
|
||||||
|
let network_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM networks")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
let host_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM hosts")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
let application_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM applications")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Summary { network_count, host_count, application_count })
|
||||||
|
}
|
||||||
@@ -1,78 +1,72 @@
|
|||||||
// client/home.rs — Home page
|
// client/home.rs — Dashboard home page
|
||||||
//
|
//
|
||||||
// Demonstrates the core Leptos concepts:
|
// Shows a quick-glance summary card for each main entity (Networks, Hosts,
|
||||||
// - Components (`#[component]`)
|
// Applications). Each card displays the total count and navigates to the
|
||||||
// - Signals (reactive values)
|
// corresponding page on click.
|
||||||
// - Memos (derived values)
|
//
|
||||||
// - Event handling (`on:click`)
|
// The counts are fetched via a single `get_summary()` server function call
|
||||||
|
// so the page only makes one round-trip to the server.
|
||||||
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
use crate::api::summary::get_summary;
|
||||||
|
|
||||||
// Home page component.
|
// A single summary card — count + label + link.
|
||||||
//
|
//
|
||||||
// In Leptos, a component is a plain Rust function annotated with `#[component]`.
|
// `href` : navigation target when the card is clicked
|
||||||
// It is called once to build the reactive graph —
|
// `label` : entity name displayed on the card
|
||||||
// unlike React, it does not re-execute on every update.
|
// `count` : the number to show, or None while loading
|
||||||
|
#[component]
|
||||||
|
fn SummaryCard(href: &'static str, label: &'static str, count: Signal<Option<i64>>) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<a href=href class="summary-card">
|
||||||
|
<span class="summary-card__count">
|
||||||
|
{move || match count.get() {
|
||||||
|
Some(n) => n.to_string(),
|
||||||
|
None => "—".to_string(),
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span class="summary-card__label">{label}</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn HomePage() -> impl IntoView {
|
pub fn HomePage() -> impl IntoView {
|
||||||
// `RwSignal<T>` (Read-Write Signal) is a mutable reactive value.
|
// Fetch all counts in a single server call.
|
||||||
//
|
// Source `|| ()` is constant — the fetcher runs exactly once on mount.
|
||||||
// When `.set()` or `.update()` is called, Leptos automatically identifies
|
let summary = Resource::new(|| (), |_| get_summary());
|
||||||
// all DOM nodes that depend on this signal and updates only those —
|
|
||||||
// no Virtual DOM, no full diff: surgical updates only.
|
|
||||||
//
|
|
||||||
// `i32` = signed 32-bit integer (Rust's default integer type)
|
|
||||||
let counter = RwSignal::new(0i32);
|
|
||||||
|
|
||||||
// `Memo<T>` is a value derived from one or more signals.
|
|
||||||
// It recomputes automatically when `counter` changes, but only
|
|
||||||
// notifies its dependents when the result actually differs.
|
|
||||||
//
|
|
||||||
// `move |_|`: a closure that captures `counter` by move (takes ownership).
|
|
||||||
// - `move` : the closure owns `counter`
|
|
||||||
// - `|_|` : ignores the argument (the previous memo value)
|
|
||||||
let doubled = Memo::new(move |_| counter.get() * 2);
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="home-page">
|
<div class="home-page">
|
||||||
<h1>"Rust IPAM"</h1>
|
<header class="home-header">
|
||||||
<p class="subtitle">"IP Address Manager"</p>
|
<h1>"Rust IPAM"</h1>
|
||||||
|
<p>"IP Address Manager"</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
// --- Reactivity demo ---
|
// `<Suspense>` lets the SSR server wait for the resource before sending
|
||||||
// In the real IPAM app, this section will show the network list,
|
// HTML, so the counts are already filled in on first page load.
|
||||||
// available addresses, usage statistics, etc.
|
// The fallback is only shown in the browser while the WASM loads
|
||||||
<section class="reactivity-demo">
|
// (for users with slow connections).
|
||||||
<h2>"Leptos Reactivity Demo"</h2>
|
<Suspense fallback=|| view! {
|
||||||
|
<div class="summary-grid">
|
||||||
// `{counter}` inserts the signal value directly into the DOM.
|
<div class="summary-card"><span class="summary-card__count">"—"</span><span class="summary-card__label">"Networks"</span></div>
|
||||||
// Leptos updates ONLY this text node when counter changes —
|
<div class="summary-card"><span class="summary-card__count">"—"</span><span class="summary-card__label">"Hosts"</span></div>
|
||||||
// the entire component does not re-render.
|
<div class="summary-card"><span class="summary-card__count">"—"</span><span class="summary-card__label">"Applications"</span></div>
|
||||||
<p>"Counter: " {counter}</p>
|
|
||||||
|
|
||||||
// `{doubled}`: same, but for the memo (derived value)
|
|
||||||
<p>"Doubled: " {doubled}</p>
|
|
||||||
|
|
||||||
<div class="buttons">
|
|
||||||
// `on:click` attaches a JavaScript event listener.
|
|
||||||
//
|
|
||||||
// `.update(|n| *n += 1)`:
|
|
||||||
// - takes a closure receiving a `&mut i32`
|
|
||||||
// - `*n` dereferences the pointer to modify the value
|
|
||||||
// - `+= 1` increments in place
|
|
||||||
<button on:click=move |_| counter.update(|n| *n += 1)>
|
|
||||||
"+"
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button on:click=move |_| counter.update(|n| *n -= 1)>
|
|
||||||
"-"
|
|
||||||
</button>
|
|
||||||
|
|
||||||
// `.set(0)` replaces the value directly (simpler than update here)
|
|
||||||
<button on:click=move |_| counter.set(0)>
|
|
||||||
"Reset"
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
}>
|
||||||
|
{move || summary.get().map(|result| match result {
|
||||||
|
Err(e) => view! {
|
||||||
|
<p class="error">"Could not load summary: " {e.to_string()}</p>
|
||||||
|
}.into_any(),
|
||||||
|
Ok(s) => view! {
|
||||||
|
<div class="summary-grid">
|
||||||
|
<SummaryCard href="/networks" label="Networks" count=Signal::derive(move || Some(s.network_count))/>
|
||||||
|
<SummaryCard href="/hosts" label="Hosts" count=Signal::derive(move || Some(s.host_count))/>
|
||||||
|
<SummaryCard href="/applications" label="Applications" count=Signal::derive(move || Some(s.application_count))/>
|
||||||
|
</div>
|
||||||
|
}.into_any(),
|
||||||
|
})}
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user