// 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, pub hosts: Vec, } // 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, ServerFnError> { use sqlx::{AnyPool, Row}; let pool = use_context::() .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, ServerFnError> { use sqlx::AnyPool; use crate::server::repository::applications as repo; let pool = use_context::() .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 { use sqlx::{AnyPool, Row}; use crate::server::repository::applications as repo; let pool = use_context::() .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, ServerFnError> { use sqlx::AnyPool; use crate::server::repository::applications as repo; let pool = use_context::() .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 { use sqlx::AnyPool; use crate::server::repository::applications as repo; let pool = use_context::() .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 { use sqlx::AnyPool; use crate::server::repository::applications as repo; let pool = use_context::() .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 = ports .split(',') .filter_map(|s| s.trim().parse::().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 { use sqlx::AnyPool; use crate::server::repository::applications as repo; let pool = use_context::() .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::() .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::() .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())) }