- 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>
252 lines
8.3 KiB
Rust
252 lines
8.3 KiB
Rust
// api/applications.rs — Server functions for applications and their port associations
|
|
|
|
use leptos::prelude::*;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
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
|
|
// 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 ──────────────────────────────────────────────────────────────────
|
|
|
|
/// 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.
|
|
#[server]
|
|
pub async fn get_applications() -> Result<Vec<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"))?;
|
|
|
|
repo::list_applications(&pool)
|
|
.await
|
|
.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.
|
|
#[server]
|
|
pub async fn get_ports_for_application(
|
|
application_id: i64,
|
|
) -> Result<Vec<u16>, 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"))?;
|
|
|
|
repo::list_ports_for_application(&pool, application_id)
|
|
.await
|
|
.map_err(|e| ServerFnError::new(e.to_string()))
|
|
}
|
|
|
|
// ─── 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.
|
|
///
|
|
/// `ports` is a comma-separated list of port numbers (e.g. "80,443").
|
|
/// An empty string means no ports are associated.
|
|
#[server]
|
|
pub async fn create_application(name: String, ports: 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"))?;
|
|
|
|
let app = repo::create_application(&pool, &name)
|
|
.await
|
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
|
|
|
let port_numbers: Vec<u16> = ports
|
|
.split(',')
|
|
.filter_map(|s| s.trim().parse::<u16>().ok())
|
|
.filter(|&p| p >= 1)
|
|
.collect();
|
|
|
|
for port_number in port_numbers {
|
|
repo::add_port_to_application(&pool, app.id, port_number)
|
|
.await
|
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
|
}
|
|
|
|
Ok(app)
|
|
}
|
|
|
|
/// Deletes an application and all its port associations.
|
|
///
|
|
/// Returns `true` if the application existed and was deleted.
|
|
#[server]
|
|
pub async fn delete_application(id: i64) -> Result<bool, 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"))?;
|
|
|
|
repo::delete_application(&pool, id)
|
|
.await
|
|
.map_err(|e| ServerFnError::new(e.to_string()))
|
|
}
|
|
|
|
/// Associates a port number with an application.
|
|
///
|
|
/// If the association already exists, this is a no-op.
|
|
#[server]
|
|
pub async fn add_port_to_application(
|
|
application_id: i64,
|
|
port_number: u16,
|
|
) -> Result<(), 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"))?;
|
|
|
|
repo::add_port_to_application(&pool, application_id, port_number)
|
|
.await
|
|
.map_err(|e| ServerFnError::new(e.to_string()))
|
|
}
|
|
|
|
/// Removes a port association from an application.
|
|
///
|
|
/// If the association does not exist, this is a no-op.
|
|
#[server]
|
|
pub async fn remove_port_from_application(
|
|
application_id: i64,
|
|
port_number: u16,
|
|
) -> Result<(), 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"))?;
|
|
|
|
repo::remove_port_from_application(&pool, application_id, port_number)
|
|
.await
|
|
.map_err(|e| ServerFnError::new(e.to_string()))
|
|
}
|