feat(applications): add applications list page with host count and delete modal
- API: ApplicationWithCounts struct + get_applications_with_counts() — counts distinct hosts linked via matching ports (application_ports ↔ host_ports) - ApplicationsPage at /applications: inline add form, table with Name and Hosts columns, delete confirmation modal showing affected host count - Nav: add Applications link Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,56 @@
|
|||||||
// api/applications.rs — Server functions for applications and their port associations
|
// api/applications.rs — Server functions for applications and their port associations
|
||||||
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::models::Application;
|
use crate::models::Application;
|
||||||
|
|
||||||
|
// Application row enriched with the number of hosts that use at least one of
|
||||||
|
// its registered ports. Host count is computed via the join:
|
||||||
|
// application_ports → host_ports (matched on port_number) → hosts
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ApplicationWithCounts {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
/// Distinct hosts that have at least one port matching this application.
|
||||||
|
pub host_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Queries ──────────────────────────────────────────────────────────────────
|
// ─── Queries ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Returns all applications enriched with their associated host count.
|
||||||
|
#[server]
|
||||||
|
pub async fn get_applications_with_counts() -> Result<Vec<ApplicationWithCounts>, ServerFnError> {
|
||||||
|
use sqlx::{AnyPool, Row};
|
||||||
|
|
||||||
|
let pool = use_context::<AnyPool>()
|
||||||
|
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT
|
||||||
|
a.id,
|
||||||
|
a.name,
|
||||||
|
COUNT(DISTINCT hp.host_id) AS host_count
|
||||||
|
FROM applications a
|
||||||
|
LEFT JOIN application_ports ap ON ap.application_id = a.id
|
||||||
|
LEFT JOIN host_ports hp ON hp.port_number = ap.port_number
|
||||||
|
GROUP BY a.id, a.name
|
||||||
|
ORDER BY a.name",
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| ApplicationWithCounts {
|
||||||
|
id: row.get("id"),
|
||||||
|
name: row.get("name"),
|
||||||
|
host_count: row.get("host_count"),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns all applications ordered by name.
|
/// Returns all applications ordered by name.
|
||||||
#[server]
|
#[server]
|
||||||
pub async fn get_applications() -> Result<Vec<Application>, ServerFnError> {
|
pub async fn get_applications() -> Result<Vec<Application>, ServerFnError> {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use leptos_router::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::client::{
|
use crate::client::{
|
||||||
|
applications::ApplicationsPage,
|
||||||
home::HomePage,
|
home::HomePage,
|
||||||
host_detail::HostDetailPage,
|
host_detail::HostDetailPage,
|
||||||
hosts::HostsPage,
|
hosts::HostsPage,
|
||||||
@@ -94,6 +95,7 @@ pub fn App() -> impl IntoView {
|
|||||||
<a href="/">"Rust IPAM"</a>
|
<a href="/">"Rust IPAM"</a>
|
||||||
<a href="/networks">"Networks"</a>
|
<a href="/networks">"Networks"</a>
|
||||||
<a href="/hosts">"Hosts"</a>
|
<a href="/hosts">"Hosts"</a>
|
||||||
|
<a href="/applications">"Applications"</a>
|
||||||
<span class="nav-spacer"/>
|
<span class="nav-spacer"/>
|
||||||
<ThemeToggle/>
|
<ThemeToggle/>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -112,6 +114,7 @@ pub fn App() -> impl IntoView {
|
|||||||
<Route path=path!("/networks/:id") view=NetworkDetailPage/>
|
<Route path=path!("/networks/:id") view=NetworkDetailPage/>
|
||||||
<Route path=path!("/hosts") view=HostsPage/>
|
<Route path=path!("/hosts") view=HostsPage/>
|
||||||
<Route path=path!("/hosts/:id") view=HostDetailPage/>
|
<Route path=path!("/hosts/:id") view=HostDetailPage/>
|
||||||
|
<Route path=path!("/applications") view=ApplicationsPage/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
175
src/client/applications.rs
Normal file
175
src/client/applications.rs
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
// client/applications.rs — Applications list page
|
||||||
|
//
|
||||||
|
// Displays all applications with:
|
||||||
|
// - Add form : inline form to create an application by name
|
||||||
|
// - Table : application name + number of associated hosts
|
||||||
|
// - Delete : confirmation modal before deletion
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos::form::ActionForm;
|
||||||
|
|
||||||
|
use crate::api::applications::{
|
||||||
|
ApplicationWithCounts, CreateApplication, DeleteApplication,
|
||||||
|
get_applications_with_counts,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Delete confirmation modal ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn DeleteAppModal(
|
||||||
|
app: ApplicationWithCounts,
|
||||||
|
delete_action: ServerAction<DeleteApplication>,
|
||||||
|
pending_delete: RwSignal<Option<ApplicationWithCounts>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let id = app.id;
|
||||||
|
let label = app.name.clone();
|
||||||
|
let host_count = app.host_count;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="modal-backdrop" on:click=move |_| pending_delete.set(None)>
|
||||||
|
<div class="modal" on:click=move |e| e.stop_propagation()>
|
||||||
|
<div class="modal__header">
|
||||||
|
<h2>"Delete application"</h2>
|
||||||
|
<button class="modal__close" type="button" aria-label="Close"
|
||||||
|
on:click=move |_| pending_delete.set(None)>
|
||||||
|
"×"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal__body">
|
||||||
|
<p>"Delete application " <strong>{label}</strong> "?"</p>
|
||||||
|
{(host_count > 0).then(|| view! {
|
||||||
|
<p class="warning">
|
||||||
|
"This application is linked to "
|
||||||
|
{host_count}
|
||||||
|
{if host_count == 1 { " host" } else { " hosts" }}
|
||||||
|
" via shared ports. The port associations will be removed."
|
||||||
|
</p>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div class="modal__actions">
|
||||||
|
<button class="btn-secondary" type="button"
|
||||||
|
on:click=move |_| pending_delete.set(None)>
|
||||||
|
"Cancel"
|
||||||
|
</button>
|
||||||
|
<button class="btn-danger" type="button"
|
||||||
|
on:click=move |_| { delete_action.dispatch(DeleteApplication { id }); }>
|
||||||
|
"Delete"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ApplicationsPage() -> impl IntoView {
|
||||||
|
let create_action = ServerAction::<CreateApplication>::new();
|
||||||
|
let delete_action = ServerAction::<DeleteApplication>::new();
|
||||||
|
|
||||||
|
// Some(app) = delete modal open for that app; None = closed.
|
||||||
|
let pending_delete: RwSignal<Option<ApplicationWithCounts>> = RwSignal::new(None);
|
||||||
|
|
||||||
|
// Close the modal automatically after a successful deletion.
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if let Some(Ok(_)) = delete_action.value().get() {
|
||||||
|
pending_delete.set(None);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let applications = Resource::new(
|
||||||
|
move || (create_action.version().get(), delete_action.version().get()),
|
||||||
|
|_| get_applications_with_counts(),
|
||||||
|
);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="applications-page">
|
||||||
|
<h1>"Applications"</h1>
|
||||||
|
|
||||||
|
// ── Delete modal ──────────────────────────────────────────────────
|
||||||
|
{move || pending_delete.get().map(|app| view! {
|
||||||
|
<DeleteAppModal
|
||||||
|
app=app
|
||||||
|
delete_action=delete_action
|
||||||
|
pending_delete=pending_delete
|
||||||
|
/>
|
||||||
|
})}
|
||||||
|
|
||||||
|
// ── Add form ──────────────────────────────────────────────────────
|
||||||
|
<section class="add-form">
|
||||||
|
<h2>"Add an application"</h2>
|
||||||
|
<ActionForm action=create_action>
|
||||||
|
<label>
|
||||||
|
"Name"
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
placeholder="e.g. Nginx, PostgreSQL, Prometheus"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit">"Add"</button>
|
||||||
|
</ActionForm>
|
||||||
|
{move || create_action.value().get()
|
||||||
|
.and_then(|r| r.err())
|
||||||
|
.map(|e| view! { <p class="error">{e.to_string()}</p> })
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
// ── Application list ──────────────────────────────────────────────
|
||||||
|
<section class="list">
|
||||||
|
<h2>"All applications"</h2>
|
||||||
|
|
||||||
|
{move || delete_action.value().get()
|
||||||
|
.and_then(|r| r.err())
|
||||||
|
.map(|e| view! { <p class="error">"Delete failed: " {e.to_string()}</p> })
|
||||||
|
}
|
||||||
|
|
||||||
|
<Suspense fallback=|| view! { <p>"Loading applications…"</p> }>
|
||||||
|
{move || applications.get().map(|result| match result {
|
||||||
|
Err(e) => view! {
|
||||||
|
<p class="error">"Could not load applications: " {e.to_string()}</p>
|
||||||
|
}.into_any(),
|
||||||
|
|
||||||
|
Ok(list) if list.is_empty() => view! {
|
||||||
|
<p class="empty">"No applications yet. Add one above."</p>
|
||||||
|
}.into_any(),
|
||||||
|
|
||||||
|
Ok(list) => view! {
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>"Name"</th>
|
||||||
|
<th class="col-count">"Hosts"</th>
|
||||||
|
<th class="col-actions">"Actions"</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{list.into_iter().map(|app| {
|
||||||
|
let app_clone = app.clone();
|
||||||
|
view! {
|
||||||
|
<tr>
|
||||||
|
<td>{app.name}</td>
|
||||||
|
<td class="col-count">{app.host_count}</td>
|
||||||
|
<td class="col-actions">
|
||||||
|
<button on:click=move |_| {
|
||||||
|
pending_delete.set(Some(app_clone.clone()));
|
||||||
|
}>
|
||||||
|
"Delete"
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}.into_any(),
|
||||||
|
})}
|
||||||
|
</Suspense>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
// 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 applications; // Applications list and creation
|
||||||
pub mod home; // Home page
|
pub mod home; // Home page
|
||||||
pub mod host_detail; // Host detail: identity, ports, edit, delete
|
pub mod host_detail; // Host detail: identity, ports, edit, delete
|
||||||
pub mod hosts; // Hosts list with filters and pagination
|
pub mod hosts; // Hosts list with filters and pagination
|
||||||
|
|||||||
Reference in New Issue
Block a user