Compare commits
16 Commits
1b55b13541
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e68845a2ce | |||
| 5b3170c6d1 | |||
| 624839849f | |||
| 255f20cda4 | |||
| a8d98aeee2 | |||
| bef28f44a1 | |||
| cf0a095ada | |||
| 353fe09a99 | |||
| 5228a76468 | |||
| 60e02ca453 | |||
| 052711b720 | |||
| f5058bd54a | |||
| 54a5c2525f | |||
| 359d67fabc | |||
| 5789aba86b | |||
| a6ce382eb5 |
@@ -6,6 +6,15 @@ edition = "2021"
|
|||||||
# Leptos nécessite deux formats de compilation :
|
# Leptos nécessite deux formats de compilation :
|
||||||
# - rlib : bibliothèque normale, utilisée par le serveur Axum
|
# - rlib : bibliothèque normale, utilisée par le serveur Axum
|
||||||
# - cdylib : bibliothèque dynamique compilée en WebAssembly pour le navigateur
|
# - cdylib : bibliothèque dynamique compilée en WebAssembly pour le navigateur
|
||||||
|
[[bin]]
|
||||||
|
name = "rust-ipam"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "seed"
|
||||||
|
path = "src/bin/seed.rs"
|
||||||
|
required-features = ["ssr"]
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
crate-type = ["cdylib", "rlib"]
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
|||||||
8
migrations/postgres/0008_create_host_applications.sql
Normal file
8
migrations/postgres/0008_create_host_applications.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
-- host_applications: direct association between a host and an application.
|
||||||
|
-- Allows explicitly tagging a host with an application regardless of ports.
|
||||||
|
-- One application can only be linked once to a given host.
|
||||||
|
CREATE TABLE IF NOT EXISTS host_applications (
|
||||||
|
host_id BIGINT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
||||||
|
application_id BIGINT NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (host_id, application_id)
|
||||||
|
);
|
||||||
8
migrations/sqlite/0008_create_host_applications.sql
Normal file
8
migrations/sqlite/0008_create_host_applications.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
-- host_applications: direct association between a host and an application.
|
||||||
|
-- Allows explicitly tagging a host with an application regardless of ports.
|
||||||
|
-- One application can only be linked once to a given host.
|
||||||
|
CREATE TABLE IF NOT EXISTS host_applications (
|
||||||
|
host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
||||||
|
application_id INTEGER NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (host_id, application_id)
|
||||||
|
);
|
||||||
@@ -80,15 +80,25 @@ INSERT INTO ports (number, description) VALUES
|
|||||||
(993, 'IMAPS'),
|
(993, 'IMAPS'),
|
||||||
(1194, 'OpenVPN'),
|
(1194, 'OpenVPN'),
|
||||||
(2049, 'NFS'),
|
(2049, 'NFS'),
|
||||||
(3000, 'Grafana'),
|
(3000, 'Grafana / Gitea'),
|
||||||
|
(3306, 'MariaDB / MySQL'),
|
||||||
(3389, 'RDP'),
|
(3389, 'RDP'),
|
||||||
(4500, 'IPSec NAT-T'),
|
(4500, 'IPSec NAT-T'),
|
||||||
(5044, 'Logstash Beats'),
|
(5044, 'Logstash Beats'),
|
||||||
(5432, 'PostgreSQL'),
|
(5432, 'PostgreSQL'),
|
||||||
(5601, 'Kibana'),
|
(5601, 'Kibana'),
|
||||||
|
(6379, 'Redis'),
|
||||||
|
(8096, 'Jellyfin'),
|
||||||
|
(8123, 'Home Assistant'),
|
||||||
|
(8384, 'Syncthing UI'),
|
||||||
|
(8920, 'Jellyfin HTTPS'),
|
||||||
|
(9000, 'Portainer'),
|
||||||
(9090, 'Prometheus'),
|
(9090, 'Prometheus'),
|
||||||
(9100, 'JetDirect'),
|
(9100, 'node_exporter / JetDirect'),
|
||||||
(9200, 'Elasticsearch')
|
(9200, 'Elasticsearch'),
|
||||||
|
(9443, 'Portainer HTTPS'),
|
||||||
|
(22000, 'Syncthing'),
|
||||||
|
(51820, 'WireGuard')
|
||||||
ON CONFLICT (number) DO NOTHING;
|
ON CONFLICT (number) DO NOTHING;
|
||||||
|
|
||||||
-- ── Host ports ────────────────────────────────────────────────────────────────
|
-- ── Host ports ────────────────────────────────────────────────────────────────
|
||||||
@@ -193,3 +203,84 @@ INSERT INTO host_ports (host_id, port_number)
|
|||||||
SELECT h.id, 22 FROM hosts h
|
SELECT h.id, 22 FROM hosts h
|
||||||
WHERE h.name = 'vpn-client-02' AND h.ip = '172.16.1.11'
|
WHERE h.name = 'vpn-client-02' AND h.ip = '172.16.1.11'
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- ── Applications ──────────────────────────────────────────────────────────────
|
||||||
|
-- applications has no UNIQUE constraint on name, so we use WHERE NOT EXISTS.
|
||||||
|
|
||||||
|
INSERT INTO applications (name)
|
||||||
|
SELECT v.name FROM (VALUES
|
||||||
|
('Nginx'),
|
||||||
|
('Pi-hole'),
|
||||||
|
('WireGuard'),
|
||||||
|
('OpenVPN'),
|
||||||
|
('PostgreSQL'),
|
||||||
|
('MariaDB'),
|
||||||
|
('Redis'),
|
||||||
|
('Grafana'),
|
||||||
|
('Prometheus'),
|
||||||
|
('Elasticsearch'),
|
||||||
|
('Kibana'),
|
||||||
|
('Portainer'),
|
||||||
|
('Jellyfin'),
|
||||||
|
('Home Assistant'),
|
||||||
|
('Syncthing'),
|
||||||
|
('Vaultwarden')
|
||||||
|
) AS v(name)
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = v.name);
|
||||||
|
|
||||||
|
-- ── Application ports ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- Nginx: HTTP, HTTPS
|
||||||
|
INSERT INTO application_ports (application_id, port_number) SELECT id, 80 FROM applications WHERE name = 'Nginx' ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO application_ports (application_id, port_number) SELECT id, 443 FROM applications WHERE name = 'Nginx' ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Pi-hole: DNS, HTTP (admin UI), HTTPS
|
||||||
|
INSERT INTO application_ports (application_id, port_number) SELECT id, 53 FROM applications WHERE name = 'Pi-hole' ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO application_ports (application_id, port_number) SELECT id, 80 FROM applications WHERE name = 'Pi-hole' ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO application_ports (application_id, port_number) SELECT id, 443 FROM applications WHERE name = 'Pi-hole' ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- WireGuard
|
||||||
|
INSERT INTO application_ports (application_id, port_number) SELECT id, 51820 FROM applications WHERE name = 'WireGuard' ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- OpenVPN
|
||||||
|
INSERT INTO application_ports (application_id, port_number) SELECT id, 1194 FROM applications WHERE name = 'OpenVPN' ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- PostgreSQL
|
||||||
|
INSERT INTO application_ports (application_id, port_number) SELECT id, 5432 FROM applications WHERE name = 'PostgreSQL' ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- MariaDB
|
||||||
|
INSERT INTO application_ports (application_id, port_number) SELECT id, 3306 FROM applications WHERE name = 'MariaDB' ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Redis
|
||||||
|
INSERT INTO application_ports (application_id, port_number) SELECT id, 6379 FROM applications WHERE name = 'Redis' ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Grafana
|
||||||
|
INSERT INTO application_ports (application_id, port_number) SELECT id, 3000 FROM applications WHERE name = 'Grafana' ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Prometheus
|
||||||
|
INSERT INTO application_ports (application_id, port_number) SELECT id, 9090 FROM applications WHERE name = 'Prometheus' ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Elasticsearch
|
||||||
|
INSERT INTO application_ports (application_id, port_number) SELECT id, 9200 FROM applications WHERE name = 'Elasticsearch' ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Kibana
|
||||||
|
INSERT INTO application_ports (application_id, port_number) SELECT id, 5601 FROM applications WHERE name = 'Kibana' ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Portainer: HTTP, HTTPS
|
||||||
|
INSERT INTO application_ports (application_id, port_number) SELECT id, 9000 FROM applications WHERE name = 'Portainer' ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO application_ports (application_id, port_number) SELECT id, 9443 FROM applications WHERE name = 'Portainer' ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Jellyfin: HTTP, HTTPS
|
||||||
|
INSERT INTO application_ports (application_id, port_number) SELECT id, 8096 FROM applications WHERE name = 'Jellyfin' ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO application_ports (application_id, port_number) SELECT id, 8920 FROM applications WHERE name = 'Jellyfin' ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Home Assistant
|
||||||
|
INSERT INTO application_ports (application_id, port_number) SELECT id, 8123 FROM applications WHERE name = 'Home Assistant' ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Syncthing: UI, data sync
|
||||||
|
INSERT INTO application_ports (application_id, port_number) SELECT id, 8384 FROM applications WHERE name = 'Syncthing' ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO application_ports (application_id, port_number) SELECT id, 22000 FROM applications WHERE name = 'Syncthing' ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Vaultwarden: HTTP, HTTPS
|
||||||
|
INSERT INTO application_ports (application_id, port_number) SELECT id, 80 FROM applications WHERE name = 'Vaultwarden' ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO application_ports (application_id, port_number) SELECT id, 443 FROM applications WHERE name = 'Vaultwarden' ON CONFLICT DO NOTHING;
|
||||||
|
|||||||
@@ -60,15 +60,25 @@ INSERT OR IGNORE INTO ports (number, description) VALUES
|
|||||||
(993, 'IMAPS'),
|
(993, 'IMAPS'),
|
||||||
(1194, 'OpenVPN'),
|
(1194, 'OpenVPN'),
|
||||||
(2049, 'NFS'),
|
(2049, 'NFS'),
|
||||||
(3000, 'Grafana'),
|
(3000, 'Grafana / Gitea'),
|
||||||
|
(3306, 'MariaDB / MySQL'),
|
||||||
(3389, 'RDP'),
|
(3389, 'RDP'),
|
||||||
(4500, 'IPSec NAT-T'),
|
(4500, 'IPSec NAT-T'),
|
||||||
(5044, 'Logstash Beats'),
|
(5044, 'Logstash Beats'),
|
||||||
(5432, 'PostgreSQL'),
|
(5432, 'PostgreSQL'),
|
||||||
(5601, 'Kibana'),
|
(5601, 'Kibana'),
|
||||||
|
(6379, 'Redis'),
|
||||||
|
(8096, 'Jellyfin'),
|
||||||
|
(8123, 'Home Assistant'),
|
||||||
|
(8384, 'Syncthing UI'),
|
||||||
|
(8920, 'Jellyfin HTTPS'),
|
||||||
|
(9000, 'Portainer'),
|
||||||
(9090, 'Prometheus'),
|
(9090, 'Prometheus'),
|
||||||
(9100, 'JetDirect'),
|
(9100, 'node_exporter / JetDirect'),
|
||||||
(9200, 'Elasticsearch');
|
(9200, 'Elasticsearch'),
|
||||||
|
(9443, 'Portainer HTTPS'),
|
||||||
|
(22000, 'Syncthing'),
|
||||||
|
(51820, 'WireGuard');
|
||||||
|
|
||||||
-- ── Host ports ────────────────────────────────────────────────────────────────
|
-- ── Host ports ────────────────────────────────────────────────────────────────
|
||||||
-- INSERT OR IGNORE is safe: host_ports has a composite PRIMARY KEY (host_id, port_number).
|
-- INSERT OR IGNORE is safe: host_ports has a composite PRIMARY KEY (host_id, port_number).
|
||||||
@@ -124,7 +134,7 @@ INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 993 FROM host
|
|||||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'core-switch-01' AND ip = '10.0.0.1';
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'core-switch-01' AND ip = '10.0.0.1';
|
||||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 161 FROM hosts WHERE name = 'core-switch-01' AND ip = '10.0.0.1';
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 161 FROM hosts WHERE name = 'core-switch-01' AND ip = '10.0.0.1';
|
||||||
|
|
||||||
-- monitoring-01: SSH, HTTP, HTTPS, Prometheus, Grafana
|
-- monitoring-01: SSH, HTTP, HTTPS, Grafana, Prometheus
|
||||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'monitoring-01' AND ip = '10.0.1.10';
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'monitoring-01' AND ip = '10.0.1.10';
|
||||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 80 FROM hosts WHERE name = 'monitoring-01' AND ip = '10.0.1.10';
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 80 FROM hosts WHERE name = 'monitoring-01' AND ip = '10.0.1.10';
|
||||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 443 FROM hosts WHERE name = 'monitoring-01' AND ip = '10.0.1.10';
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 443 FROM hosts WHERE name = 'monitoring-01' AND ip = '10.0.1.10';
|
||||||
@@ -152,3 +162,81 @@ INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 4500 FROM hos
|
|||||||
-- vpn clients: SSH only
|
-- vpn clients: SSH only
|
||||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'vpn-client-01' AND ip = '172.16.1.10';
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'vpn-client-01' AND ip = '172.16.1.10';
|
||||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'vpn-client-02' AND ip = '172.16.1.11';
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'vpn-client-02' AND ip = '172.16.1.11';
|
||||||
|
|
||||||
|
-- ── Applications ──────────────────────────────────────────────────────────────
|
||||||
|
-- applications has no UNIQUE constraint on name, so we use WHERE NOT EXISTS.
|
||||||
|
|
||||||
|
INSERT INTO applications (name) SELECT 'Nginx' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Nginx');
|
||||||
|
INSERT INTO applications (name) SELECT 'Pi-hole' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Pi-hole');
|
||||||
|
INSERT INTO applications (name) SELECT 'WireGuard' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'WireGuard');
|
||||||
|
INSERT INTO applications (name) SELECT 'OpenVPN' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'OpenVPN');
|
||||||
|
INSERT INTO applications (name) SELECT 'PostgreSQL' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'PostgreSQL');
|
||||||
|
INSERT INTO applications (name) SELECT 'MariaDB' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'MariaDB');
|
||||||
|
INSERT INTO applications (name) SELECT 'Redis' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Redis');
|
||||||
|
INSERT INTO applications (name) SELECT 'Grafana' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Grafana');
|
||||||
|
INSERT INTO applications (name) SELECT 'Prometheus' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Prometheus');
|
||||||
|
INSERT INTO applications (name) SELECT 'Elasticsearch' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Elasticsearch');
|
||||||
|
INSERT INTO applications (name) SELECT 'Kibana' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Kibana');
|
||||||
|
INSERT INTO applications (name) SELECT 'Portainer' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Portainer');
|
||||||
|
INSERT INTO applications (name) SELECT 'Jellyfin' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Jellyfin');
|
||||||
|
INSERT INTO applications (name) SELECT 'Home Assistant' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Home Assistant');
|
||||||
|
INSERT INTO applications (name) SELECT 'Syncthing' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Syncthing');
|
||||||
|
INSERT INTO applications (name) SELECT 'Vaultwarden' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Vaultwarden');
|
||||||
|
|
||||||
|
-- ── Application ports ─────────────────────────────────────────────────────────
|
||||||
|
-- application_ports has a composite PRIMARY KEY, so INSERT OR IGNORE is safe.
|
||||||
|
|
||||||
|
-- Nginx: HTTP, HTTPS
|
||||||
|
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 80 FROM applications WHERE name = 'Nginx';
|
||||||
|
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 443 FROM applications WHERE name = 'Nginx';
|
||||||
|
|
||||||
|
-- Pi-hole: DNS, HTTP (admin UI), HTTPS
|
||||||
|
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 53 FROM applications WHERE name = 'Pi-hole';
|
||||||
|
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 80 FROM applications WHERE name = 'Pi-hole';
|
||||||
|
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 443 FROM applications WHERE name = 'Pi-hole';
|
||||||
|
|
||||||
|
-- WireGuard
|
||||||
|
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 51820 FROM applications WHERE name = 'WireGuard';
|
||||||
|
|
||||||
|
-- OpenVPN
|
||||||
|
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 1194 FROM applications WHERE name = 'OpenVPN';
|
||||||
|
|
||||||
|
-- PostgreSQL
|
||||||
|
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 5432 FROM applications WHERE name = 'PostgreSQL';
|
||||||
|
|
||||||
|
-- MariaDB
|
||||||
|
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 3306 FROM applications WHERE name = 'MariaDB';
|
||||||
|
|
||||||
|
-- Redis
|
||||||
|
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 6379 FROM applications WHERE name = 'Redis';
|
||||||
|
|
||||||
|
-- Grafana
|
||||||
|
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 3000 FROM applications WHERE name = 'Grafana';
|
||||||
|
|
||||||
|
-- Prometheus
|
||||||
|
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 9090 FROM applications WHERE name = 'Prometheus';
|
||||||
|
|
||||||
|
-- Elasticsearch
|
||||||
|
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 9200 FROM applications WHERE name = 'Elasticsearch';
|
||||||
|
|
||||||
|
-- Kibana
|
||||||
|
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 5601 FROM applications WHERE name = 'Kibana';
|
||||||
|
|
||||||
|
-- Portainer: HTTP, HTTPS
|
||||||
|
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 9000 FROM applications WHERE name = 'Portainer';
|
||||||
|
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 9443 FROM applications WHERE name = 'Portainer';
|
||||||
|
|
||||||
|
-- Jellyfin: HTTP, HTTPS
|
||||||
|
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 8096 FROM applications WHERE name = 'Jellyfin';
|
||||||
|
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 8920 FROM applications WHERE name = 'Jellyfin';
|
||||||
|
|
||||||
|
-- Home Assistant
|
||||||
|
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 8123 FROM applications WHERE name = 'Home Assistant';
|
||||||
|
|
||||||
|
-- Syncthing: UI, data sync
|
||||||
|
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 8384 FROM applications WHERE name = 'Syncthing';
|
||||||
|
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 22000 FROM applications WHERE name = 'Syncthing';
|
||||||
|
|
||||||
|
-- Vaultwarden: HTTP, HTTPS
|
||||||
|
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 80 FROM applications WHERE name = 'Vaultwarden';
|
||||||
|
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 443 FROM applications WHERE name = 'Vaultwarden';
|
||||||
|
|||||||
@@ -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,20 +147,55 @@ pub async fn get_ports_for_application(
|
|||||||
|
|
||||||
// ─── Mutations ────────────────────────────────────────────────────────────────
|
// ─── Mutations ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Creates a new application and returns the created record.
|
/// Updates the name of an application and returns the updated record.
|
||||||
#[server]
|
#[server]
|
||||||
pub async fn create_application(name: String) -> Result<Application, ServerFnError> {
|
pub async fn update_application(id: i64, name: String) -> Result<Application, ServerFnError> {
|
||||||
use sqlx::AnyPool;
|
use sqlx::AnyPool;
|
||||||
use crate::server::repository::applications as repo;
|
use crate::server::repository::applications as repo;
|
||||||
|
|
||||||
let pool = use_context::<AnyPool>()
|
let pool = use_context::<AnyPool>()
|
||||||
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||||
|
|
||||||
repo::create_application(&pool, &name)
|
if name.trim().is_empty() {
|
||||||
|
return Err(ServerFnError::new("Application name cannot be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
repo::update_application(&pool, id, name.trim())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
.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.
|
/// Deletes an application and all its port associations.
|
||||||
///
|
///
|
||||||
/// Returns `true` if the application existed and was deleted.
|
/// Returns `true` if the application existed and was deleted.
|
||||||
|
|||||||
@@ -3,9 +3,7 @@
|
|||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::models::Host;
|
use crate::models::{Application, Host, Port};
|
||||||
|
|
||||||
use crate::models::Port;
|
|
||||||
|
|
||||||
// ─── Presentation types ───────────────────────────────────────────────────────
|
// ─── Presentation types ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -32,7 +30,7 @@ pub struct HostsPage {
|
|||||||
pub total_pages: i64, // ceil(total / per_page); always ≥ 1
|
pub total_pages: i64, // ceil(total / per_page); always ≥ 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full host detail: identity fields + resolved network + open ports.
|
// Full host detail: identity fields + resolved network + open ports + linked applications.
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct HostDetail {
|
pub struct HostDetail {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
@@ -42,6 +40,7 @@ pub struct HostDetail {
|
|||||||
pub network_name: String,
|
pub network_name: String,
|
||||||
pub network_cidr: String,
|
pub network_cidr: String,
|
||||||
pub ports: Vec<Port>,
|
pub ports: Vec<Port>,
|
||||||
|
pub applications: Vec<Application>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Queries ──────────────────────────────────────────────────────────────────
|
// ─── Queries ──────────────────────────────────────────────────────────────────
|
||||||
@@ -50,7 +49,12 @@ pub struct HostDetail {
|
|||||||
#[server]
|
#[server]
|
||||||
pub async fn get_host_detail(id: i64) -> Result<HostDetail, ServerFnError> {
|
pub async fn get_host_detail(id: i64) -> Result<HostDetail, ServerFnError> {
|
||||||
use sqlx::AnyPool;
|
use sqlx::AnyPool;
|
||||||
use crate::server::repository::{hosts as host_repo, networks, ports as port_repo};
|
use crate::server::repository::{
|
||||||
|
applications as app_repo,
|
||||||
|
hosts as host_repo,
|
||||||
|
networks,
|
||||||
|
ports as port_repo,
|
||||||
|
};
|
||||||
|
|
||||||
let pool = use_context::<AnyPool>()
|
let pool = use_context::<AnyPool>()
|
||||||
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||||
@@ -69,6 +73,10 @@ pub async fn get_host_detail(id: i64) -> Result<HostDetail, ServerFnError> {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
let applications = app_repo::list_applications_for_host(&pool, id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
Ok(HostDetail {
|
Ok(HostDetail {
|
||||||
id: host.id,
|
id: host.id,
|
||||||
name: host.name,
|
name: host.name,
|
||||||
@@ -77,6 +85,7 @@ pub async fn get_host_detail(id: i64) -> Result<HostDetail, ServerFnError> {
|
|||||||
network_name: network.name,
|
network_name: network.name,
|
||||||
network_cidr: network.cidr,
|
network_cidr: network.cidr,
|
||||||
ports,
|
ports,
|
||||||
|
applications,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,6 +352,72 @@ pub async fn remove_host_port(host_id: i64, port_number: i64) -> Result<(), Serv
|
|||||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns all applications not yet directly linked to a host.
|
||||||
|
///
|
||||||
|
/// Used to populate the "add applications" modal on the host detail page.
|
||||||
|
#[server]
|
||||||
|
pub async fn get_applications_not_on_host(
|
||||||
|
host_id: i64,
|
||||||
|
) -> 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_not_on_host(&pool, host_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Links one or more applications to a host.
|
||||||
|
///
|
||||||
|
/// `application_ids` is a comma-separated string of application IDs (e.g. "1,3,7").
|
||||||
|
/// Already-linked applications are silently skipped (no-op).
|
||||||
|
#[server]
|
||||||
|
pub async fn add_host_applications(
|
||||||
|
host_id: i64,
|
||||||
|
application_ids: String,
|
||||||
|
) -> 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"))?;
|
||||||
|
|
||||||
|
let ids: Vec<i64> = application_ids
|
||||||
|
.split(',')
|
||||||
|
.filter_map(|s| s.trim().parse::<i64>().ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for application_id in ids {
|
||||||
|
repo::add_application_to_host(&pool, host_id, application_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the direct link between a host and an application.
|
||||||
|
///
|
||||||
|
/// Returns `true` if the association existed and was removed.
|
||||||
|
#[server]
|
||||||
|
pub async fn remove_host_application(
|
||||||
|
host_id: i64,
|
||||||
|
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::remove_application_from_host(&pool, host_id, application_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
/// Deletes a host by id.
|
/// Deletes a host by id.
|
||||||
///
|
///
|
||||||
/// Also removes all its port associations (via `ON DELETE CASCADE`).
|
/// Also removes all its port associations (via `ON DELETE CASCADE`).
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -85,5 +85,13 @@ async fn main() {
|
|||||||
.await
|
.await
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
tracing::info!("Database now contains {} network(s) and {} host(s).", network_count, host_count);
|
let application_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM applications")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Database now contains {} network(s), {} host(s) and {} application(s).",
|
||||||
|
network_count, host_count, application_count
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
311
src/client/application_detail.rs
Normal file
311
src/client/application_detail.rs
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
// 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_query_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 query = use_query_map();
|
||||||
|
let back_url = move || {
|
||||||
|
query.read().get("back")
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| "/applications".to_string())
|
||||||
|
};
|
||||||
|
let back_label = move || {
|
||||||
|
if back_url().starts_with("/hosts/") { "← Host" } else { "← Applications" }
|
||||||
|
};
|
||||||
|
|
||||||
|
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/{}?back=/applications/{}", host.id, 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=move || back_url()>
|
||||||
|
{move || back_label()}
|
||||||
|
</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()
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::form::ActionForm;
|
use leptos::form::ActionForm;
|
||||||
|
use leptos::html::Input;
|
||||||
|
|
||||||
use crate::api::applications::{
|
use crate::api::applications::{
|
||||||
ApplicationWithCounts, CreateApplication, DeleteApplication,
|
ApplicationWithCounts, CreateApplication, DeleteApplication,
|
||||||
@@ -21,19 +22,33 @@ fn AddApplicationModal(
|
|||||||
create_action: ServerAction<CreateApplication>,
|
create_action: ServerAction<CreateApplication>,
|
||||||
show_modal: RwSignal<bool>,
|
show_modal: RwSignal<bool>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
Effect::new(move |_| {
|
use leptos::task::spawn_local;
|
||||||
if let Some(Ok(_)) = create_action.value().get() {
|
|
||||||
show_modal.set(false);
|
let name_ref = NodeRef::<Input>::new();
|
||||||
|
|
||||||
|
// Defer focus to the next microtask so the element is in the DOM.
|
||||||
|
// Using get_untracked() avoids subscribing to NodeRef's reactive signal,
|
||||||
|
// which would otherwise re-trigger during modal unmount and cause
|
||||||
|
// "closure invoked after being dropped" in wasm-bindgen.
|
||||||
|
spawn_local(async move {
|
||||||
|
if let Some(el) = name_ref.get_untracked() {
|
||||||
|
let _ = el.focus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// close() defers show_modal.set(false) to the next microtask.
|
||||||
|
// Without this, setting the signal synchronously inside a click handler
|
||||||
|
// unmounts the modal (and frees its closures) while the handler is still
|
||||||
|
// on the call stack, causing wasm-bindgen to panic.
|
||||||
|
let close = move || spawn_local(async move { show_modal.set(false) });
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="modal-backdrop" on:click=move |_| show_modal.set(false)>
|
<div class="modal-backdrop" on:click=move |_| close()>
|
||||||
<div class="modal" on:click=move |e| e.stop_propagation()>
|
<div class="modal" on:click=move |e| e.stop_propagation()>
|
||||||
<div class="modal__header">
|
<div class="modal__header">
|
||||||
<h2>"Add an application"</h2>
|
<h2>"Add an application"</h2>
|
||||||
<button class="modal__close" type="button" aria-label="Close"
|
<button class="modal__close" type="button" aria-label="Close"
|
||||||
on:click=move |_| show_modal.set(false)>
|
on:click=move |_| close()>
|
||||||
"×"
|
"×"
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,17 +58,27 @@ fn AddApplicationModal(
|
|||||||
<label>
|
<label>
|
||||||
"Name"
|
"Name"
|
||||||
<input
|
<input
|
||||||
|
node_ref=name_ref
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="e.g. Nginx, PostgreSQL, Prometheus"
|
placeholder="e.g. Nginx, PostgreSQL, Prometheus"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
"Associated ports"
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="ports"
|
||||||
|
placeholder="e.g. 22, 80, 443"
|
||||||
|
/>
|
||||||
|
<span class="field-hint">"Comma-separated port numbers"</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal__actions">
|
<div class="modal__actions">
|
||||||
<button class="btn-secondary" type="button"
|
<button class="btn-secondary" type="button"
|
||||||
on:click=move |_| show_modal.set(false)>
|
on:click=move |_| close()>
|
||||||
"Cancel"
|
"Cancel"
|
||||||
</button>
|
</button>
|
||||||
<button type="submit">"Add application"</button>
|
<button type="submit">"Add application"</button>
|
||||||
@@ -132,6 +157,19 @@ pub fn ApplicationsPage() -> impl IntoView {
|
|||||||
// Name filter (client-side — list is typically small)
|
// Name filter (client-side — list is typically small)
|
||||||
let name_filter = RwSignal::new(String::new());
|
let name_filter = RwSignal::new(String::new());
|
||||||
|
|
||||||
|
// Close the add modal when the action transitions pending→done with Ok.
|
||||||
|
// Lives in the parent so it is never recreated across modal open/close cycles,
|
||||||
|
// which avoids the stale-value re-trigger bug.
|
||||||
|
Effect::new(move |was_pending: Option<bool>| {
|
||||||
|
let is_pending = create_action.pending().get();
|
||||||
|
if was_pending == Some(true) && !is_pending {
|
||||||
|
if let Some(Ok(_)) = create_action.value().get() {
|
||||||
|
show_modal.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is_pending
|
||||||
|
});
|
||||||
|
|
||||||
// Close the delete modal automatically after a successful deletion.
|
// Close the delete modal automatically after a successful deletion.
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
if let Some(Ok(_)) = delete_action.value().get() {
|
if let Some(Ok(_)) = delete_action.value().get() {
|
||||||
@@ -225,10 +263,15 @@ 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 class="btn-danger" on:click=move |_| {
|
||||||
pending_delete.set(Some(app_clone.clone()));
|
pending_delete.set(Some(app_clone.clone()));
|
||||||
}>
|
}>
|
||||||
"Delete"
|
"Delete"
|
||||||
|
|||||||
@@ -3,15 +3,175 @@
|
|||||||
// Shows all information for a single host:
|
// Shows all information for a single host:
|
||||||
// - Identity form : name, IP, network dropdown — editable, saved with "Save changes"
|
// - Identity form : name, IP, network dropdown — editable, saved with "Save changes"
|
||||||
// - Ports section : full list with Remove per port + Add port input
|
// - Ports section : full list with Remove per port + Add port input
|
||||||
|
// - Applications : directly linked apps with Remove + modal multi-select to add
|
||||||
// - Delete button : opens a confirmation modal, then navigates back to /hosts
|
// - Delete button : opens a confirmation modal, then navigates back to /hosts
|
||||||
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos_router::hooks::{use_navigate, use_params_map, use_query_map};
|
use leptos_router::hooks::{use_navigate, use_params_map, use_query_map};
|
||||||
|
|
||||||
use crate::api::{
|
use crate::api::{
|
||||||
hosts::{AddHostPort, DeleteHost, RemoveHostPort, UpdateHost, get_host_detail},
|
hosts::{
|
||||||
|
AddHostApplications, AddHostPort, DeleteHost, RemoveHostApplication,
|
||||||
|
RemoveHostPort, UpdateHost, get_applications_not_on_host, get_host_detail,
|
||||||
|
},
|
||||||
networks::get_networks,
|
networks::get_networks,
|
||||||
};
|
};
|
||||||
|
use crate::models::Application;
|
||||||
|
|
||||||
|
// ─── Add applications modal ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Scrollable pick list + selected tags:
|
||||||
|
// - Top: scrollable list of available apps; clicking one moves it to the
|
||||||
|
// selected section and removes it from the list.
|
||||||
|
// - Bottom: selected apps shown as removable tags; clicking × puts the app
|
||||||
|
// back in the list.
|
||||||
|
//
|
||||||
|
// The auto-close Effect lives in the PARENT to avoid the re-trigger bug
|
||||||
|
// (an Effect inside a conditionally-rendered component fires on mount and
|
||||||
|
// would immediately close the modal if the action already held a past Ok value).
|
||||||
|
#[component]
|
||||||
|
fn AddAppModal(
|
||||||
|
host_id: i64,
|
||||||
|
available_apps_res: LocalResource<Result<Vec<Application>, ServerFnError>>,
|
||||||
|
add_action: ServerAction<AddHostApplications>,
|
||||||
|
show_modal: RwSignal<bool>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
// Full Application structs so names are available in the selected tag list.
|
||||||
|
let selected: RwSignal<Vec<Application>> = RwSignal::new(vec![]);
|
||||||
|
|
||||||
|
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>"Add applications"</h2>
|
||||||
|
<button class="modal__close" type="button" aria-label="Close"
|
||||||
|
on:click=move |_| show_modal.set(false)>
|
||||||
|
"×"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal__body">
|
||||||
|
|
||||||
|
// ── Scrollable pick list ──────────────────────────────────
|
||||||
|
{move || match available_apps_res.get() {
|
||||||
|
None => view! { <p class="empty">"Loading…"</p> }.into_any(),
|
||||||
|
Some(r) => match (*r).clone() {
|
||||||
|
Err(e) => view! {
|
||||||
|
<p class="error">
|
||||||
|
"Could not load applications: " {e.to_string()}
|
||||||
|
</p>
|
||||||
|
}.into_any(),
|
||||||
|
Ok(apps) => {
|
||||||
|
// Exclude already-selected apps from the displayed list.
|
||||||
|
let sel_ids: Vec<i64> = selected.get()
|
||||||
|
.iter().map(|a| a.id).collect();
|
||||||
|
let displayed: Vec<Application> = apps.into_iter()
|
||||||
|
.filter(|a| !sel_ids.contains(&a.id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if displayed.is_empty() && sel_ids.is_empty() {
|
||||||
|
view! {
|
||||||
|
<p class="empty">
|
||||||
|
"All applications are already linked to this host."
|
||||||
|
</p>
|
||||||
|
}.into_any()
|
||||||
|
} else if displayed.is_empty() {
|
||||||
|
view! {
|
||||||
|
<p class="empty">
|
||||||
|
"All available applications have been selected."
|
||||||
|
</p>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
<ul class="app-pick-list">
|
||||||
|
{displayed.into_iter().map(|app| {
|
||||||
|
let app_clone = app.clone();
|
||||||
|
view! {
|
||||||
|
<li class="app-pick-item"
|
||||||
|
on:click=move |_| {
|
||||||
|
selected.update(|v| {
|
||||||
|
v.push(app_clone.clone());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>{app.name}</span>
|
||||||
|
<span class="app-pick-item__add">"+"</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</ul>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
|
||||||
|
// ── Selected tags (shown once at least one app is chosen) ─
|
||||||
|
{move || (!selected.get().is_empty()).then(|| {
|
||||||
|
let sel = selected.get();
|
||||||
|
view! {
|
||||||
|
<div class="app-selected-section">
|
||||||
|
<span class="app-selected-label">"Selected:"</span>
|
||||||
|
<div class="app-selected-list">
|
||||||
|
{sel.into_iter().map(|app| {
|
||||||
|
let app_id = app.id;
|
||||||
|
view! {
|
||||||
|
<span class="app-selected-tag">
|
||||||
|
{app.name}
|
||||||
|
<button
|
||||||
|
class="app-selected-tag__remove"
|
||||||
|
type="button"
|
||||||
|
aria-label="Remove"
|
||||||
|
on:click=move |_| {
|
||||||
|
selected.update(|v| {
|
||||||
|
v.retain(|x| x.id != app_id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
>"×"</button>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
|
||||||
|
{move || add_action.value().get()
|
||||||
|
.and_then(|r| r.err())
|
||||||
|
.map(|e| view! { <p class="error">{e.to_string()}</p> })
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal__actions">
|
||||||
|
<button class="btn-secondary" type="button"
|
||||||
|
on:click=move |_| show_modal.set(false)>
|
||||||
|
"Cancel"
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
type="button"
|
||||||
|
disabled={move || selected.get().is_empty()}
|
||||||
|
on:click=move |_| {
|
||||||
|
let ids_str = selected.get_untracked()
|
||||||
|
.iter()
|
||||||
|
.map(|a| a.id.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",");
|
||||||
|
if !ids_str.is_empty() {
|
||||||
|
add_action.dispatch(AddHostApplications {
|
||||||
|
host_id,
|
||||||
|
application_ids: ids_str,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"Add selected"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Delete confirmation modal ────────────────────────────────────────────────
|
// ─── Delete confirmation modal ────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -36,7 +196,7 @@ fn DeleteModal(
|
|||||||
<p class="warning">
|
<p class="warning">
|
||||||
"Are you sure you want to delete "
|
"Are you sure you want to delete "
|
||||||
<strong>{host_name}</strong>
|
<strong>{host_name}</strong>
|
||||||
"? All port associations will also be removed."
|
"? All port and application associations will also be removed."
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal__actions">
|
<div class="modal__actions">
|
||||||
@@ -58,8 +218,6 @@ fn DeleteModal(
|
|||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn HostDetailPage() -> impl IntoView {
|
pub fn HostDetailPage() -> impl IntoView {
|
||||||
// Read the `:id` segment from the URL.
|
|
||||||
// `use_params_map()` returns a reactive map of all URL path parameters.
|
|
||||||
let params = use_params_map();
|
let params = use_params_map();
|
||||||
let host_id = move || {
|
let host_id = move || {
|
||||||
params.read().get("id")
|
params.read().get("id")
|
||||||
@@ -67,8 +225,6 @@ pub fn HostDetailPage() -> impl IntoView {
|
|||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Optional `?back=<url>` query parameter — used when arriving from a network
|
|
||||||
// detail page so the back button returns there instead of the hosts list.
|
|
||||||
let query = use_query_map();
|
let query = use_query_map();
|
||||||
let back_url = move || {
|
let back_url = move || {
|
||||||
query.read().get("back")
|
query.read().get("back")
|
||||||
@@ -76,53 +232,74 @@ pub fn HostDetailPage() -> impl IntoView {
|
|||||||
.unwrap_or_else(|| "/hosts".to_string())
|
.unwrap_or_else(|| "/hosts".to_string())
|
||||||
};
|
};
|
||||||
let back_label = move || {
|
let back_label = move || {
|
||||||
if back_url().starts_with("/networks/") { "← Network" } else { "← Hosts" }
|
if back_url().starts_with("/networks/") { "← Network" }
|
||||||
|
else if back_url().starts_with("/applications/") { "← Application" }
|
||||||
|
else { "← Hosts" }
|
||||||
};
|
};
|
||||||
|
|
||||||
let update_action = ServerAction::<UpdateHost>::new();
|
let update_action = ServerAction::<UpdateHost>::new();
|
||||||
let add_port_action = ServerAction::<AddHostPort>::new();
|
let add_port_action = ServerAction::<AddHostPort>::new();
|
||||||
let remove_port_action = ServerAction::<RemoveHostPort>::new();
|
let remove_port_action = ServerAction::<RemoveHostPort>::new();
|
||||||
|
let add_app_action = ServerAction::<AddHostApplications>::new();
|
||||||
|
let remove_app_action = ServerAction::<RemoveHostApplication>::new();
|
||||||
let delete_action = ServerAction::<DeleteHost>::new();
|
let delete_action = ServerAction::<DeleteHost>::new();
|
||||||
|
|
||||||
let show_delete_modal = RwSignal::new(false);
|
let show_delete_modal = RwSignal::new(false);
|
||||||
|
let show_add_app_modal = RwSignal::new(false);
|
||||||
|
|
||||||
// Reload detail after any mutation that touches this host.
|
// Auto-close the add-app modal when the action completes successfully.
|
||||||
// The resource key includes action versions so it invalidates automatically.
|
// Lives here (not inside AddAppModal) so it is never recreated across modal open/close cycles.
|
||||||
let host = Resource::new(
|
Effect::new(move |was_pending: Option<bool>| {
|
||||||
move || (
|
let is_pending = add_app_action.pending().get();
|
||||||
host_id(),
|
if was_pending == Some(true) && !is_pending {
|
||||||
update_action.version().get(),
|
if let Some(Ok(_)) = add_app_action.value().get() {
|
||||||
add_port_action.version().get(),
|
show_add_app_modal.set(false);
|
||||||
remove_port_action.version().get(),
|
}
|
||||||
),
|
}
|
||||||
|(id, _, _, _)| get_host_detail(id),
|
is_pending
|
||||||
);
|
});
|
||||||
|
|
||||||
|
// LocalResource avoids reading the resource outside <Suspense> during hydration,
|
||||||
|
// which would cause a mismatch between the SSR-rendered fallback and the content
|
||||||
|
// the WASM expects to find after the resource resolves.
|
||||||
|
let host = LocalResource::new(move || {
|
||||||
|
let _ = update_action.version().get();
|
||||||
|
let _ = add_port_action.version().get();
|
||||||
|
let _ = remove_port_action.version().get();
|
||||||
|
let _ = add_app_action.version().get();
|
||||||
|
let _ = remove_app_action.version().get();
|
||||||
|
get_host_detail(host_id())
|
||||||
|
});
|
||||||
|
|
||||||
// Networks dropdown — LocalResource avoids SSR/hydration mismatch.
|
// Networks dropdown — LocalResource avoids SSR/hydration mismatch.
|
||||||
let networks_res = LocalResource::new(|| get_networks());
|
let networks_res = LocalResource::new(|| get_networks());
|
||||||
|
|
||||||
// Edit-field signals, populated once by the Effect below.
|
// Available apps for the modal: re-fetched whenever add/remove completes.
|
||||||
// Using signals (rather than local variables) keeps them stable across
|
let add_app_ver = add_app_action.version();
|
||||||
// re-renders and lets the user edit without triggering a resource reload.
|
let remove_app_ver = remove_app_action.version();
|
||||||
|
let available_apps_res = LocalResource::new(move || {
|
||||||
|
let _ = add_app_ver.get();
|
||||||
|
let _ = remove_app_ver.get();
|
||||||
|
get_applications_not_on_host(host_id())
|
||||||
|
});
|
||||||
|
|
||||||
let name_sig = RwSignal::new(String::new());
|
let name_sig = RwSignal::new(String::new());
|
||||||
let ip_sig = RwSignal::new(String::new());
|
let ip_sig = RwSignal::new(String::new());
|
||||||
let net_id_sig = RwSignal::new(0i64);
|
let net_id_sig = RwSignal::new(0i64);
|
||||||
|
|
||||||
// Input value for the "add port" row.
|
|
||||||
let new_port = RwSignal::new(String::new());
|
let new_port = RwSignal::new(String::new());
|
||||||
|
|
||||||
// Sync edit signals whenever the host resource delivers fresh data.
|
// Sync edit signals whenever fresh host data arrives.
|
||||||
// This runs on initial load and after every successful mutation.
|
// LocalResource wraps its value in SendWrapper, so we dereference with `*r`.
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
if let Some(Ok(ref detail)) = host.get() {
|
if let Some(r) = host.get() {
|
||||||
|
if let Ok(ref detail) = *r {
|
||||||
name_sig.set(detail.name.clone());
|
name_sig.set(detail.name.clone());
|
||||||
ip_sig.set(detail.ip.clone());
|
ip_sig.set(detail.ip.clone());
|
||||||
net_id_sig.set(detail.network_id);
|
net_id_sig.set(detail.network_id);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Navigate back to the list after a successful delete.
|
|
||||||
// `use_navigate()` must be called during component setup (not inside a closure).
|
|
||||||
let navigate = use_navigate();
|
let navigate = use_navigate();
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
if let Some(Ok(true)) = delete_action.value().get() {
|
if let Some(Ok(true)) = delete_action.value().get() {
|
||||||
@@ -132,17 +309,37 @@ pub fn HostDetailPage() -> impl IntoView {
|
|||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="host-detail-page">
|
<div class="host-detail-page">
|
||||||
|
// Modals live OUTSIDE <Suspense> so they are not unmounted when the
|
||||||
|
// host resource re-fetches (which would kill their reactive subscriptions).
|
||||||
|
{move || show_add_app_modal.get().then(|| view! {
|
||||||
|
<AddAppModal
|
||||||
|
host_id=host_id()
|
||||||
|
available_apps_res=available_apps_res
|
||||||
|
add_action=add_app_action
|
||||||
|
show_modal=show_add_app_modal
|
||||||
|
/>
|
||||||
|
})}
|
||||||
|
{move || show_delete_modal.get().then(|| view! {
|
||||||
|
<DeleteModal
|
||||||
|
host_name=name_sig.get()
|
||||||
|
delete_action=delete_action
|
||||||
|
host_id=host_id()
|
||||||
|
show_modal=show_delete_modal
|
||||||
|
/>
|
||||||
|
})}
|
||||||
|
|
||||||
<Suspense fallback=|| view! { <p class="empty">"Loading host…"</p> }>
|
<Suspense fallback=|| view! { <p class="empty">"Loading host…"</p> }>
|
||||||
{move || host.get().map(|result| match result {
|
{move || host.get().map(|r| match (*r).clone() {
|
||||||
Err(e) => view! {
|
Err(e) => view! {
|
||||||
<p class="error">"Could not load host: " {e.to_string()}</p>
|
<p class="error">"Could not load host: " {e.to_string()}</p>
|
||||||
}.into_any(),
|
}.into_any(),
|
||||||
|
|
||||||
Ok(detail) => {
|
Ok(detail) => {
|
||||||
let id = detail.id;
|
let id = detail.id;
|
||||||
let modal_name = detail.name.clone();
|
|
||||||
let port_count = detail.ports.len();
|
let port_count = detail.ports.len();
|
||||||
|
let app_count = detail.applications.len();
|
||||||
let ports = detail.ports;
|
let ports = detail.ports;
|
||||||
|
let applications = detail.applications;
|
||||||
|
|
||||||
// Pre-built ports view — consumes `ports` once, not reactively.
|
// Pre-built ports view — consumes `ports` once, not reactively.
|
||||||
let ports_list = if ports.is_empty() {
|
let ports_list = if ports.is_empty() {
|
||||||
@@ -178,6 +375,40 @@ pub fn HostDetailPage() -> impl IntoView {
|
|||||||
}.into_any()
|
}.into_any()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Pre-built applications view.
|
||||||
|
let apps_list = if applications.is_empty() {
|
||||||
|
view! {
|
||||||
|
<p class="empty">"No applications linked to this host."</p>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
<div class="app-list">
|
||||||
|
{applications.into_iter().map(|app| {
|
||||||
|
let app_id = app.id;
|
||||||
|
view! {
|
||||||
|
<div class="app-row">
|
||||||
|
<a class="table-link app-row__name"
|
||||||
|
href=format!("/applications/{}?back=/hosts/{}", app_id, id)>
|
||||||
|
{app.name}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
class="btn-danger"
|
||||||
|
type="button"
|
||||||
|
on:click=move |_| {
|
||||||
|
remove_app_action.dispatch(
|
||||||
|
RemoveHostApplication { host_id: id, application_id: app_id }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"Remove"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
// ── Page header ──────────────────────────────────
|
// ── Page header ──────────────────────────────────
|
||||||
<div class="page-header detail-page-header">
|
<div class="page-header detail-page-header">
|
||||||
@@ -223,7 +454,7 @@ pub fn HostDetailPage() -> impl IntoView {
|
|||||||
view! {
|
view! {
|
||||||
<option
|
<option
|
||||||
value=n.id.to_string()
|
value=n.id.to_string()
|
||||||
selected=(n.id == current)
|
selected=n.id == current
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</option>
|
</option>
|
||||||
@@ -273,7 +504,6 @@ pub fn HostDetailPage() -> impl IntoView {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add port row
|
|
||||||
<div class="port-add-row">
|
<div class="port-add-row">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -310,7 +540,33 @@ pub fn HostDetailPage() -> impl IntoView {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
// ── Danger zone ──────────────────────────────────
|
// ── Applications section ──────────────────────────
|
||||||
|
<section class="detail-section">
|
||||||
|
<h2 class="detail-section__title">
|
||||||
|
{format!("Applications ({})", app_count)}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{apps_list}
|
||||||
|
|
||||||
|
{move || remove_app_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">
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
type="button"
|
||||||
|
on:click=move |_| show_add_app_modal.set(true)
|
||||||
|
>
|
||||||
|
"+ Add applications"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
// ── Danger zone ───────────────────────────────────
|
||||||
<div class="danger-zone">
|
<div class="danger-zone">
|
||||||
<button
|
<button
|
||||||
class="btn-danger-solid"
|
class="btn-danger-solid"
|
||||||
@@ -320,16 +576,6 @@ pub fn HostDetailPage() -> impl IntoView {
|
|||||||
"Delete host"
|
"Delete host"
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// ── Delete modal (conditional) ────────────────────
|
|
||||||
{move || show_delete_modal.get().then(|| view! {
|
|
||||||
<DeleteModal
|
|
||||||
host_name=modal_name.clone()
|
|
||||||
delete_action=delete_action
|
|
||||||
host_id=id
|
|
||||||
show_modal=show_delete_modal
|
|
||||||
/>
|
|
||||||
})}
|
|
||||||
}.into_any()
|
}.into_any()
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::form::ActionForm;
|
use leptos::form::ActionForm;
|
||||||
|
use leptos::html::Input;
|
||||||
|
|
||||||
use crate::api::{
|
use crate::api::{
|
||||||
applications::get_applications,
|
applications::get_applications,
|
||||||
@@ -76,15 +77,20 @@ fn AddHostModal(
|
|||||||
networks_res: LocalResource<Result<Vec<crate::models::Network>, ServerFnError>>,
|
networks_res: LocalResource<Result<Vec<crate::models::Network>, ServerFnError>>,
|
||||||
show_modal: RwSignal<bool>,
|
show_modal: RwSignal<bool>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
// Close the modal automatically after a successful creation.
|
use leptos::task::spawn_local;
|
||||||
Effect::new(move |_| {
|
|
||||||
if let Some(Ok(_)) = create_action.value().get() {
|
let name_ref = NodeRef::<Input>::new();
|
||||||
show_modal.set(false);
|
|
||||||
|
spawn_local(async move {
|
||||||
|
if let Some(el) = name_ref.get_untracked() {
|
||||||
|
let _ = el.focus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let close = move || spawn_local(async move { show_modal.set(false) });
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="modal-backdrop" on:click=move |_| show_modal.set(false)>
|
<div class="modal-backdrop" on:click=move |_| close()>
|
||||||
<div class="modal" on:click=move |e| e.stop_propagation()>
|
<div class="modal" on:click=move |e| e.stop_propagation()>
|
||||||
<div class="modal__header">
|
<div class="modal__header">
|
||||||
<h2>"Add a host"</h2>
|
<h2>"Add a host"</h2>
|
||||||
@@ -92,7 +98,7 @@ fn AddHostModal(
|
|||||||
class="modal__close"
|
class="modal__close"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
on:click=move |_| show_modal.set(false)
|
on:click=move |_| close()
|
||||||
>"×"</button>
|
>"×"</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -100,7 +106,7 @@ fn AddHostModal(
|
|||||||
<div class="add-form__fields">
|
<div class="add-form__fields">
|
||||||
<label>
|
<label>
|
||||||
"Name"
|
"Name"
|
||||||
<input type="text" name="name" placeholder="e.g. web-server-01" required/>
|
<input node_ref=name_ref type="text" name="name" placeholder="e.g. web-server-01" required/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
"IP address"
|
"IP address"
|
||||||
@@ -134,7 +140,7 @@ fn AddHostModal(
|
|||||||
<button
|
<button
|
||||||
class="btn-secondary"
|
class="btn-secondary"
|
||||||
type="button"
|
type="button"
|
||||||
on:click=move |_| show_modal.set(false)
|
on:click=move |_| close()
|
||||||
>"Cancel"</button>
|
>"Cancel"</button>
|
||||||
<button type="submit">"Add host"</button>
|
<button type="submit">"Add host"</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -287,12 +293,12 @@ fn PaginationBar(
|
|||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
fn HostTable(
|
fn HostTable(
|
||||||
hosts: Resource<Result<HostsPageData, ServerFnError>>,
|
hosts: LocalResource<Result<HostsPageData, ServerFnError>>,
|
||||||
pending_delete: RwSignal<Option<(i64, String)>>,
|
pending_delete: RwSignal<Option<(i64, String)>>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
<Suspense fallback=|| view! { <p class="empty">"Loading hosts…"</p> }>
|
<Suspense fallback=|| view! { <p class="empty">"Loading hosts…"</p> }>
|
||||||
{move || hosts.get().map(|result| match result {
|
{move || hosts.get().map(|r| match (*r).clone() {
|
||||||
Err(e) => view! {
|
Err(e) => view! {
|
||||||
<p class="error">"Could not load hosts: " {e.to_string()}</p>
|
<p class="error">"Could not load hosts: " {e.to_string()}</p>
|
||||||
}.into_any(),
|
}.into_any(),
|
||||||
@@ -364,6 +370,17 @@ pub fn HostsPage() -> impl IntoView {
|
|||||||
// None = no modal, Some((id, name)) = delete confirmation open.
|
// None = no modal, Some((id, name)) = delete confirmation open.
|
||||||
let pending_delete: RwSignal<Option<(i64, String)>> = RwSignal::new(None);
|
let pending_delete: RwSignal<Option<(i64, String)>> = RwSignal::new(None);
|
||||||
|
|
||||||
|
// Close the add modal on pending→done with Ok (lives in parent to avoid stale-value re-trigger).
|
||||||
|
Effect::new(move |was_pending: Option<bool>| {
|
||||||
|
let is_pending = create_action.pending().get();
|
||||||
|
if was_pending == Some(true) && !is_pending {
|
||||||
|
if let Some(Ok(_)) = create_action.value().get() {
|
||||||
|
show_modal.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is_pending
|
||||||
|
});
|
||||||
|
|
||||||
// Close the delete modal automatically after a successful deletion.
|
// Close the delete modal automatically after a successful deletion.
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
if let Some(Ok(_)) = delete_action.value().get() {
|
if let Some(Ok(_)) = delete_action.value().get() {
|
||||||
@@ -380,28 +397,30 @@ pub fn HostsPage() -> impl IntoView {
|
|||||||
let page = RwSignal::new(1i64);
|
let page = RwSignal::new(1i64);
|
||||||
let per_page = RwSignal::new(15i64);
|
let per_page = RwSignal::new(15i64);
|
||||||
|
|
||||||
let hosts = Resource::new(
|
// LocalResource avoids reading a resource outside <Suspense> during hydration.
|
||||||
move || (
|
// All dependencies (filters, pagination, action versions) are client-side only,
|
||||||
|
// so there is no benefit to SSR for this resource.
|
||||||
|
let hosts = LocalResource::new(move || {
|
||||||
|
let _ = create_action.version().get();
|
||||||
|
let _ = delete_action.version().get();
|
||||||
|
get_hosts_page(
|
||||||
name_filter.get(),
|
name_filter.get(),
|
||||||
network_id_filter.get(),
|
network_id_filter.get(),
|
||||||
port_filter.get(),
|
port_filter.get(),
|
||||||
app_id_filter.get(),
|
app_id_filter.get(),
|
||||||
page.get(),
|
page.get(),
|
||||||
per_page.get(),
|
per_page.get(),
|
||||||
create_action.version().get(),
|
)
|
||||||
delete_action.version().get(),
|
});
|
||||||
),
|
|
||||||
|(name, net, port, app, p, pp, _, _)| get_hosts_page(name, net, port, app, p, pp),
|
|
||||||
);
|
|
||||||
|
|
||||||
let networks_res = LocalResource::new(|| get_networks());
|
let networks_res = LocalResource::new(|| get_networks());
|
||||||
let applications_res = LocalResource::new(|| get_applications());
|
let applications_res = LocalResource::new(|| get_applications());
|
||||||
|
|
||||||
let total_pages = Signal::derive(move || {
|
let total_pages = Signal::derive(move || {
|
||||||
hosts.get().and_then(|r| r.ok()).map(|p| p.total_pages).unwrap_or(1)
|
hosts.get().and_then(|r| (*r).clone().ok()).map(|p| p.total_pages).unwrap_or(1)
|
||||||
});
|
});
|
||||||
let total = Signal::derive(move || {
|
let total = Signal::derive(move || {
|
||||||
hosts.get().and_then(|r| r.ok()).map(|p| p.total).unwrap_or(0)
|
hosts.get().and_then(|r| (*r).clone().ok()).map(|p| p.total).unwrap_or(0)
|
||||||
});
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -54,6 +66,84 @@ pub async fn delete_application(pool: &AnyPool, id: i64) -> Result<bool, DbError
|
|||||||
Ok(result.rows_affected() > 0)
|
Ok(result.rows_affected() > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Host-application associations ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Returns all applications linked directly to a host, ordered by name.
|
||||||
|
pub async fn list_applications_for_host(
|
||||||
|
pool: &AnyPool,
|
||||||
|
host_id: i64,
|
||||||
|
) -> Result<Vec<Application>, DbError> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT a.id, a.name
|
||||||
|
FROM applications a
|
||||||
|
JOIN host_applications ha ON ha.application_id = a.id
|
||||||
|
WHERE ha.host_id = $1
|
||||||
|
ORDER BY a.name",
|
||||||
|
)
|
||||||
|
.bind(host_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.iter().map(row_to_application).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all applications NOT yet linked to a host, ordered by name.
|
||||||
|
pub async fn list_applications_not_on_host(
|
||||||
|
pool: &AnyPool,
|
||||||
|
host_id: i64,
|
||||||
|
) -> Result<Vec<Application>, DbError> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT id, name FROM applications
|
||||||
|
WHERE id NOT IN (
|
||||||
|
SELECT application_id FROM host_applications WHERE host_id = $1
|
||||||
|
)
|
||||||
|
ORDER BY name",
|
||||||
|
)
|
||||||
|
.bind(host_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.iter().map(row_to_application).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Links an application directly to a host.
|
||||||
|
///
|
||||||
|
/// If the link already exists, this is a no-op (not an error).
|
||||||
|
pub async fn add_application_to_host(
|
||||||
|
pool: &AnyPool,
|
||||||
|
host_id: i64,
|
||||||
|
application_id: i64,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
let result = sqlx::query(
|
||||||
|
"INSERT INTO host_applications (host_id, application_id) VALUES ($1, $2)",
|
||||||
|
)
|
||||||
|
.bind(host_id)
|
||||||
|
.bind(application_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(sqlx::Error::Database(ref e)) if e.is_unique_violation() => Ok(()),
|
||||||
|
Err(e) => Err(DbError::Connection(e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the direct link between a host and an application.
|
||||||
|
///
|
||||||
|
/// Returns `true` if the association existed and was removed.
|
||||||
|
pub async fn remove_application_from_host(
|
||||||
|
pool: &AnyPool,
|
||||||
|
host_id: i64,
|
||||||
|
application_id: i64,
|
||||||
|
) -> Result<bool, DbError> {
|
||||||
|
let result = sqlx::query(
|
||||||
|
"DELETE FROM host_applications WHERE host_id = $1 AND application_id = $2",
|
||||||
|
)
|
||||||
|
.bind(host_id)
|
||||||
|
.bind(application_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(result.rows_affected() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Application-port associations ───────────────────────────────────────────
|
// ─── Application-port associations ───────────────────────────────────────────
|
||||||
|
|
||||||
/// Returns all port numbers associated with an application, sorted numerically.
|
/// Returns all port numbers associated with an application, sorted numerically.
|
||||||
|
|||||||
@@ -1223,3 +1223,125 @@ td.col-actions {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
HOST DETAIL — APPLICATIONS SECTION
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* List of applications linked to a host (mirrors .port-list) */
|
||||||
|
.app-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: var(--size-md);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--size-md);
|
||||||
|
padding: var(--size-sm) var(--size-xs);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-row__name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pick list (scrollable, click to select) ── */
|
||||||
|
.app-pick-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 var(--size-sm);
|
||||||
|
padding: 0;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-pick-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--size-sm) var(--size-md);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-pick-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-pick-item:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-pick-item__add {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-pick-item:hover .app-pick-item__add {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Selected tags (shown below the pick list) ── */
|
||||||
|
.app-selected-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--size-xs);
|
||||||
|
padding-top: var(--size-sm);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin-top: var(--size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-selected-label {
|
||||||
|
font-size: var(--font-xs, 0.75rem);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-selected-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-selected-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-selected-tag__remove {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--accent);
|
||||||
|
opacity: 0.7;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-selected-tag__remove:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user