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:
2026-05-16 02:48:53 +02:00
parent 62e9609fe8
commit 4d0be98160
4 changed files with 224 additions and 0 deletions

175
src/client/applications.rs Normal file
View 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>
}
}