feat(applications): add application detail page
- New page /applications/:id with identity (editable name), associated ports (add/remove), linked hosts (read-only via shared ports), and delete with confirmation modal - Add get_application_detail and update_application server functions - Add ApplicationDetail and HostRef types in api/applications - Add update_application to the repository layer - Application names in the list are now clickable links Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,24 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::models::Application;
|
use crate::models::Application;
|
||||||
|
|
||||||
|
// Minimal host reference used by ApplicationDetail.
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct HostRef {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub ip: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full detail for a single application: identity, associated ports, and linked hosts.
|
||||||
|
// Linked hosts are those that have at least one port matching an application_ports entry.
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ApplicationDetail {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub ports: Vec<u16>,
|
||||||
|
pub hosts: Vec<HostRef>,
|
||||||
|
}
|
||||||
|
|
||||||
// Application row enriched with the number of hosts that use at least one of
|
// Application row enriched with the number of hosts that use at least one of
|
||||||
// its registered ports. Host count is computed via the join:
|
// its registered ports. Host count is computed via the join:
|
||||||
// application_ports → host_ports (matched on port_number) → hosts
|
// application_ports → host_ports (matched on port_number) → hosts
|
||||||
@@ -65,6 +83,52 @@ pub async fn get_applications() -> Result<Vec<Application>, ServerFnError> {
|
|||||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns full detail for a single application: identity, ports, and linked hosts.
|
||||||
|
///
|
||||||
|
/// Linked hosts are hosts that have at least one open port matching one of
|
||||||
|
/// the application's registered port numbers (via application_ports ↔ host_ports).
|
||||||
|
#[server]
|
||||||
|
pub async fn get_application_detail(id: i64) -> Result<ApplicationDetail, ServerFnError> {
|
||||||
|
use sqlx::{AnyPool, Row};
|
||||||
|
use crate::server::repository::applications as repo;
|
||||||
|
|
||||||
|
let pool = use_context::<AnyPool>()
|
||||||
|
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||||
|
|
||||||
|
let app = repo::find_application(&pool, id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||||
|
.ok_or_else(|| ServerFnError::new(format!("Application {id} not found")))?;
|
||||||
|
|
||||||
|
let ports = repo::list_ports_for_application(&pool, id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT DISTINCT h.id, h.name, h.ip
|
||||||
|
FROM hosts h
|
||||||
|
JOIN host_ports hp ON hp.host_id = h.id
|
||||||
|
JOIN application_ports ap ON ap.port_number = hp.port_number
|
||||||
|
WHERE ap.application_id = $1
|
||||||
|
ORDER BY h.name",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
let hosts = rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| HostRef {
|
||||||
|
id: row.get("id"),
|
||||||
|
name: row.get("name"),
|
||||||
|
ip: row.get("ip"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(ApplicationDetail { id: app.id, name: app.name, ports, hosts })
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the port numbers associated with an application.
|
/// Returns the port numbers associated with an application.
|
||||||
#[server]
|
#[server]
|
||||||
pub async fn get_ports_for_application(
|
pub async fn get_ports_for_application(
|
||||||
@@ -83,6 +147,24 @@ pub async fn get_ports_for_application(
|
|||||||
|
|
||||||
// ─── Mutations ────────────────────────────────────────────────────────────────
|
// ─── Mutations ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Updates the name of an application and returns the updated record.
|
||||||
|
#[server]
|
||||||
|
pub async fn update_application(id: i64, name: String) -> Result<Application, ServerFnError> {
|
||||||
|
use sqlx::AnyPool;
|
||||||
|
use crate::server::repository::applications as repo;
|
||||||
|
|
||||||
|
let pool = use_context::<AnyPool>()
|
||||||
|
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||||
|
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
return Err(ServerFnError::new("Application name cannot be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
repo::update_application(&pool, id, name.trim())
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
/// Creates a new application, then associates the given port numbers.
|
/// Creates a new application, then associates the given port numbers.
|
||||||
///
|
///
|
||||||
/// `ports` is a comma-separated list of port numbers (e.g. "80,443").
|
/// `ports` is a comma-separated list of port numbers (e.g. "80,443").
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use leptos_router::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::client::{
|
use crate::client::{
|
||||||
|
application_detail::ApplicationDetailPage,
|
||||||
applications::ApplicationsPage,
|
applications::ApplicationsPage,
|
||||||
home::HomePage,
|
home::HomePage,
|
||||||
host_detail::HostDetailPage,
|
host_detail::HostDetailPage,
|
||||||
@@ -115,6 +116,7 @@ pub fn App() -> impl IntoView {
|
|||||||
<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/>
|
<Route path=path!("/applications") view=ApplicationsPage/>
|
||||||
|
<Route path=path!("/applications/:id") view=ApplicationDetailPage/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
299
src/client/application_detail.rs
Normal file
299
src/client/application_detail.rs
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
// client/application_detail.rs — Application detail page
|
||||||
|
//
|
||||||
|
// Shows all information for a single application:
|
||||||
|
// - Identity form : name — editable, saved with "Save changes"
|
||||||
|
// - Ports section : ports associated with this application + Add/Remove per port
|
||||||
|
// - Hosts section : hosts sharing at least one port with this application (read-only)
|
||||||
|
// - Delete button : confirmation modal, then navigates back to /applications
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::hooks::{use_navigate, use_params_map};
|
||||||
|
|
||||||
|
use crate::api::applications::{
|
||||||
|
AddPortToApplication, DeleteApplication, RemovePortFromApplication,
|
||||||
|
UpdateApplication, get_application_detail,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Delete confirmation modal ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn DeleteModal(
|
||||||
|
app_name: String,
|
||||||
|
delete_action: ServerAction<DeleteApplication>,
|
||||||
|
app_id: i64,
|
||||||
|
show_modal: RwSignal<bool>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="modal-backdrop" on:click=move |_| show_modal.set(false)>
|
||||||
|
<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 |_| show_modal.set(false)>
|
||||||
|
"×"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal__body">
|
||||||
|
<p class="warning">
|
||||||
|
"Are you sure you want to delete "
|
||||||
|
<strong>{app_name}</strong>
|
||||||
|
"? All port associations will also be removed."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal__actions">
|
||||||
|
<button class="btn-secondary" type="button"
|
||||||
|
on:click=move |_| show_modal.set(false)>
|
||||||
|
"Cancel"
|
||||||
|
</button>
|
||||||
|
<button class="btn-danger" type="button"
|
||||||
|
on:click=move |_| { delete_action.dispatch(DeleteApplication { id: app_id }); }>
|
||||||
|
"Delete"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main page component ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ApplicationDetailPage() -> impl IntoView {
|
||||||
|
let params = use_params_map();
|
||||||
|
let app_id = move || {
|
||||||
|
params.read().get("id")
|
||||||
|
.and_then(|s| s.parse::<i64>().ok())
|
||||||
|
.unwrap_or(0)
|
||||||
|
};
|
||||||
|
|
||||||
|
let update_action = ServerAction::<UpdateApplication>::new();
|
||||||
|
let add_port_action = ServerAction::<AddPortToApplication>::new();
|
||||||
|
let remove_port_action = ServerAction::<RemovePortFromApplication>::new();
|
||||||
|
let delete_action = ServerAction::<DeleteApplication>::new();
|
||||||
|
|
||||||
|
let show_delete_modal = RwSignal::new(false);
|
||||||
|
|
||||||
|
let app = LocalResource::new(move || {
|
||||||
|
let _ = update_action.version().get();
|
||||||
|
let _ = add_port_action.version().get();
|
||||||
|
let _ = remove_port_action.version().get();
|
||||||
|
get_application_detail(app_id())
|
||||||
|
});
|
||||||
|
|
||||||
|
let name_sig = RwSignal::new(String::new());
|
||||||
|
let new_port = RwSignal::new(String::new());
|
||||||
|
|
||||||
|
// Sync the editable name whenever fresh data arrives.
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if let Some(r) = app.get() {
|
||||||
|
if let Ok(ref detail) = *r {
|
||||||
|
name_sig.set(detail.name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let navigate = use_navigate();
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if let Some(Ok(true)) = delete_action.value().get() {
|
||||||
|
navigate("/applications", Default::default());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="application-detail-page">
|
||||||
|
// Delete modal lives OUTSIDE <Suspense> so it is not unmounted when
|
||||||
|
// the application resource re-fetches.
|
||||||
|
{move || show_delete_modal.get().then(|| view! {
|
||||||
|
<DeleteModal
|
||||||
|
app_name=name_sig.get()
|
||||||
|
delete_action=delete_action
|
||||||
|
app_id=app_id()
|
||||||
|
show_modal=show_delete_modal
|
||||||
|
/>
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Suspense fallback=|| view! { <p class="empty">"Loading application…"</p> }>
|
||||||
|
{move || app.get().map(|r| match (*r).clone() {
|
||||||
|
Err(e) => view! {
|
||||||
|
<p class="error">"Could not load application: " {e.to_string()}</p>
|
||||||
|
}.into_any(),
|
||||||
|
|
||||||
|
Ok(detail) => {
|
||||||
|
let id = detail.id;
|
||||||
|
let port_count = detail.ports.len();
|
||||||
|
let host_count = detail.hosts.len();
|
||||||
|
let ports = detail.ports;
|
||||||
|
let hosts = detail.hosts;
|
||||||
|
|
||||||
|
let ports_list = if ports.is_empty() {
|
||||||
|
view! {
|
||||||
|
<p class="empty">"No ports associated with this application."</p>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
<div class="port-list">
|
||||||
|
{ports.into_iter().map(|num| {
|
||||||
|
view! {
|
||||||
|
<div class="port-row">
|
||||||
|
<span class="port-row__number">{num}</span>
|
||||||
|
<button
|
||||||
|
class="btn-danger"
|
||||||
|
type="button"
|
||||||
|
on:click=move |_| {
|
||||||
|
remove_port_action.dispatch(
|
||||||
|
RemovePortFromApplication {
|
||||||
|
application_id: id,
|
||||||
|
port_number: num,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"Remove"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
};
|
||||||
|
|
||||||
|
let hosts_list = if hosts.is_empty() {
|
||||||
|
view! {
|
||||||
|
<p class="empty">"No hosts share a port with this application."</p>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
<div class="app-list">
|
||||||
|
{hosts.into_iter().map(|host| {
|
||||||
|
view! {
|
||||||
|
<div class="app-row">
|
||||||
|
<a class="table-link"
|
||||||
|
href=format!("/hosts/{}", host.id)>
|
||||||
|
{host.name}
|
||||||
|
</a>
|
||||||
|
<span class="cell-mono">{host.ip}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
// ── Page header ───────────────────────────────────
|
||||||
|
<div class="page-header detail-page-header">
|
||||||
|
<a class="back-btn" href="/applications">"← Applications"</a>
|
||||||
|
<h1 class="detail-page-title">{move || name_sig.get()}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// ── Identity form ─────────────────────────────────
|
||||||
|
<section class="detail-section">
|
||||||
|
<h2 class="detail-section__title">"Identity"</h2>
|
||||||
|
<div class="detail-form">
|
||||||
|
<label class="detail-field">
|
||||||
|
"Name"
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
prop:value=move || name_sig.get()
|
||||||
|
on:input=move |e| name_sig.set(event_target_value(&e))
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{move || update_action.value().get()
|
||||||
|
.and_then(|r| r.err())
|
||||||
|
.map(|e| view! { <p class="error">{e.to_string()}</p> })
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
type="button"
|
||||||
|
on:click=move |_| {
|
||||||
|
update_action.dispatch(UpdateApplication {
|
||||||
|
id,
|
||||||
|
name: name_sig.get_untracked(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"Save changes"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
// ── Ports section ─────────────────────────────────
|
||||||
|
<section class="detail-section">
|
||||||
|
<h2 class="detail-section__title">
|
||||||
|
{format!("Associated ports ({})", port_count)}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{ports_list}
|
||||||
|
|
||||||
|
{move || remove_port_action.value().get()
|
||||||
|
.and_then(|r| r.err())
|
||||||
|
.map(|e| view! {
|
||||||
|
<p class="error">"Remove failed: " {e.to_string()}</p>
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="port-add-row">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
placeholder="Port number (1–65535)"
|
||||||
|
prop:value=move || new_port.get()
|
||||||
|
on:input=move |e| new_port.set(event_target_value(&e))
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
type="button"
|
||||||
|
on:click=move |_| {
|
||||||
|
let raw = new_port.get_untracked();
|
||||||
|
if let Ok(n) = raw.trim().parse::<i64>() {
|
||||||
|
if (1..=65535).contains(&n) {
|
||||||
|
add_port_action.dispatch(AddPortToApplication {
|
||||||
|
application_id: id,
|
||||||
|
port_number: n as u16,
|
||||||
|
});
|
||||||
|
new_port.set(String::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"Add port"
|
||||||
|
</button>
|
||||||
|
{move || add_port_action.value().get()
|
||||||
|
.and_then(|r| r.err())
|
||||||
|
.map(|e| view! {
|
||||||
|
<p class="error">"Add failed: " {e.to_string()}</p>
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
// ── Hosts section (read-only — linked via shared ports) ──
|
||||||
|
<section class="detail-section">
|
||||||
|
<h2 class="detail-section__title">
|
||||||
|
{format!("Linked hosts ({})", host_count)}
|
||||||
|
</h2>
|
||||||
|
{hosts_list}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
// ── Danger zone ───────────────────────────────────
|
||||||
|
<div class="danger-zone">
|
||||||
|
<button
|
||||||
|
class="btn-danger-solid"
|
||||||
|
type="button"
|
||||||
|
on:click=move |_| show_delete_modal.set(true)
|
||||||
|
>
|
||||||
|
"Delete application"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
@@ -263,7 +263,12 @@ pub fn ApplicationsPage() -> impl IntoView {
|
|||||||
let app_clone = app.clone();
|
let app_clone = app.clone();
|
||||||
view! {
|
view! {
|
||||||
<tr>
|
<tr>
|
||||||
<td>{app.name}</td>
|
<td>
|
||||||
|
<a class="table-link"
|
||||||
|
href=format!("/applications/{}", app.id)>
|
||||||
|
{app.name}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
<td class="col-count">{app.host_count}</td>
|
<td class="col-count">{app.host_count}</td>
|
||||||
<td class="col-actions">
|
<td class="col-actions">
|
||||||
<button on:click=move |_| {
|
<button on:click=move |_| {
|
||||||
|
|||||||
@@ -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 application_detail; // Application detail: identity, ports, linked hosts, delete
|
||||||
pub mod applications; // Applications list and creation
|
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
|
||||||
|
|||||||
@@ -42,6 +42,18 @@ pub async fn create_application(pool: &AnyPool, name: &str) -> Result<Applicatio
|
|||||||
Ok(row_to_application(&row))
|
Ok(row_to_application(&row))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Updates the name of an application. Returns the updated record.
|
||||||
|
pub async fn update_application(pool: &AnyPool, id: i64, name: &str) -> Result<Application, DbError> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"UPDATE applications SET name = $1 WHERE id = $2 RETURNING id, name",
|
||||||
|
)
|
||||||
|
.bind(name)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row_to_application(&row))
|
||||||
|
}
|
||||||
|
|
||||||
/// Deletes an application and its port associations (via `ON DELETE CASCADE`).
|
/// Deletes an application and its port associations (via `ON DELETE CASCADE`).
|
||||||
///
|
///
|
||||||
/// Returns `true` if a row was deleted, `false` if the id did not exist.
|
/// Returns `true` if a row was deleted, `false` if the id did not exist.
|
||||||
|
|||||||
Reference in New Issue
Block a user