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 hosts;
|
||||
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:
|
||||
// - Components (`#[component]`)
|
||||
// - Signals (reactive values)
|
||||
// - Memos (derived values)
|
||||
// - Event handling (`on:click`)
|
||||
// Shows a quick-glance summary card for each main entity (Networks, Hosts,
|
||||
// Applications). Each card displays the total count and navigates to the
|
||||
// corresponding page 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 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]`.
|
||||
// It is called once to build the reactive graph —
|
||||
// unlike React, it does not re-execute on every update.
|
||||
// `href` : navigation target when the card is clicked
|
||||
// `label` : entity name displayed on the card
|
||||
// `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]
|
||||
pub fn HomePage() -> impl IntoView {
|
||||
// `RwSignal<T>` (Read-Write Signal) is a mutable reactive value.
|
||||
//
|
||||
// When `.set()` or `.update()` is called, Leptos automatically identifies
|
||||
// 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);
|
||||
// Fetch all counts in a single server call.
|
||||
// Source `|| ()` is constant — the fetcher runs exactly once on mount.
|
||||
let summary = Resource::new(|| (), |_| get_summary());
|
||||
|
||||
view! {
|
||||
<div class="home-page">
|
||||
<header class="home-header">
|
||||
<h1>"Rust IPAM"</h1>
|
||||
<p class="subtitle">"IP Address Manager"</p>
|
||||
<p>"IP Address Manager"</p>
|
||||
</header>
|
||||
|
||||
// --- Reactivity demo ---
|
||||
// In the real IPAM app, this section will show the network list,
|
||||
// available addresses, usage statistics, etc.
|
||||
<section class="reactivity-demo">
|
||||
<h2>"Leptos Reactivity Demo"</h2>
|
||||
|
||||
// `{counter}` inserts the signal value directly into the DOM.
|
||||
// Leptos updates ONLY this text node when counter changes —
|
||||
// the entire component does not re-render.
|
||||
<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>
|
||||
// `<Suspense>` lets the SSR server wait for the resource before sending
|
||||
// HTML, so the counts are already filled in on first page load.
|
||||
// The fallback is only shown in the browser while the WASM loads
|
||||
// (for users with slow connections).
|
||||
<Suspense fallback=|| view! {
|
||||
<div class="summary-grid">
|
||||
<div class="summary-card"><span class="summary-card__count">"—"</span><span class="summary-card__label">"Networks"</span></div>
|
||||
<div class="summary-card"><span class="summary-card__count">"—"</span><span class="summary-card__label">"Hosts"</span></div>
|
||||
<div class="summary-card"><span class="summary-card__count">"—"</span><span class="summary-card__label">"Applications"</span></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>
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user