Compare commits
14 Commits
e0ddf58a17
...
1b55b13541
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b55b13541 | |||
| 4d0be98160 | |||
| 62e9609fe8 | |||
| 7274157a80 | |||
| c3e2d5dcf6 | |||
| ba4d2a60c6 | |||
| 577a655aee | |||
| 2a6d925e59 | |||
| 0221ce26f9 | |||
| d2284727a2 | |||
| eef0ae0b54 | |||
| 19dda00c17 | |||
| df6aecef51 | |||
| 6018874aa4 |
@@ -93,6 +93,7 @@ site-addr = "127.0.0.1:3000" # Adresse d'écoute du serveur Axum
|
|||||||
reload-port = 3001 # Port WebSocket pour le hot-reload en développement
|
reload-port = 3001 # Port WebSocket pour le hot-reload en développement
|
||||||
style-file = "style/rust-ipam.css" # Source CSS compilé dans pkg/rust-ipam.css
|
style-file = "style/rust-ipam.css" # Source CSS compilé dans pkg/rust-ipam.css
|
||||||
# Features activées par cargo-leptos lors du build
|
# Features activées par cargo-leptos lors du build
|
||||||
|
bin-target = "rust-ipam" # Main server binary (excludes src/bin/seed.rs)
|
||||||
bin-features = ["ssr"] # SSR binary (Axum server)
|
bin-features = ["ssr"] # SSR binary (Axum server)
|
||||||
lib-features = ["hydrate"] # WASM bundle (browser)
|
lib-features = ["hydrate"] # WASM bundle (browser)
|
||||||
|
|
||||||
|
|||||||
@@ -60,3 +60,136 @@ FROM (VALUES
|
|||||||
('vpn-client-02', '172.16.1.11')
|
('vpn-client-02', '172.16.1.11')
|
||||||
) AS t(name, ip)
|
) AS t(name, ip)
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM hosts WHERE hosts.name = t.name AND hosts.ip = t.ip);
|
WHERE NOT EXISTS (SELECT 1 FROM hosts WHERE hosts.name = t.name AND hosts.ip = t.ip);
|
||||||
|
|
||||||
|
-- ── Ports catalog ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
INSERT INTO ports (number, description) VALUES
|
||||||
|
(22, 'SSH'),
|
||||||
|
(25, 'SMTP'),
|
||||||
|
(53, 'DNS'),
|
||||||
|
(80, 'HTTP'),
|
||||||
|
(143, 'IMAP'),
|
||||||
|
(161, 'SNMP'),
|
||||||
|
(443, 'HTTPS'),
|
||||||
|
(445, 'SMB'),
|
||||||
|
(465, 'SMTPS'),
|
||||||
|
(500, 'IKE / IPSec'),
|
||||||
|
(514, 'Syslog'),
|
||||||
|
(587, 'SMTP Submission'),
|
||||||
|
(873, 'rsync'),
|
||||||
|
(993, 'IMAPS'),
|
||||||
|
(1194, 'OpenVPN'),
|
||||||
|
(2049, 'NFS'),
|
||||||
|
(3000, 'Grafana'),
|
||||||
|
(3389, 'RDP'),
|
||||||
|
(4500, 'IPSec NAT-T'),
|
||||||
|
(5044, 'Logstash Beats'),
|
||||||
|
(5432, 'PostgreSQL'),
|
||||||
|
(5601, 'Kibana'),
|
||||||
|
(9090, 'Prometheus'),
|
||||||
|
(9100, 'JetDirect'),
|
||||||
|
(9200, 'Elasticsearch')
|
||||||
|
ON CONFLICT (number) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── Host ports ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- gateway: SSH, DNS, HTTP, HTTPS
|
||||||
|
INSERT INTO host_ports (host_id, port_number)
|
||||||
|
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 53, 80, 443]) p
|
||||||
|
WHERE h.name = 'gateway' AND h.ip = '192.168.1.1'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- workstation-01: SSH, RDP
|
||||||
|
INSERT INTO host_ports (host_id, port_number)
|
||||||
|
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 3389]) p
|
||||||
|
WHERE h.name = 'workstation-01' AND h.ip = '192.168.1.10'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- workstation-02: SSH, RDP
|
||||||
|
INSERT INTO host_ports (host_id, port_number)
|
||||||
|
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 3389]) p
|
||||||
|
WHERE h.name = 'workstation-02' AND h.ip = '192.168.1.11'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- workstation-03: SSH, RDP
|
||||||
|
INSERT INTO host_ports (host_id, port_number)
|
||||||
|
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 3389]) p
|
||||||
|
WHERE h.name = 'workstation-03' AND h.ip = '192.168.1.12'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- nas-01: SSH, HTTP, HTTPS, SMB, NFS
|
||||||
|
INSERT INTO host_ports (host_id, port_number)
|
||||||
|
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 80, 443, 445, 2049]) p
|
||||||
|
WHERE h.name = 'nas-01' AND h.ip = '192.168.1.20'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- printer-01: HTTP, HTTPS, JetDirect
|
||||||
|
INSERT INTO host_ports (host_id, port_number)
|
||||||
|
SELECT h.id, p FROM hosts h, unnest(ARRAY[80, 443, 9100]) p
|
||||||
|
WHERE h.name = 'printer-01' AND h.ip = '192.168.1.50'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- web-server-01: SSH, HTTP, HTTPS
|
||||||
|
INSERT INTO host_ports (host_id, port_number)
|
||||||
|
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 80, 443]) p
|
||||||
|
WHERE h.name = 'web-server-01' AND h.ip = '192.168.10.10'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- web-server-02: SSH, HTTP, HTTPS
|
||||||
|
INSERT INTO host_ports (host_id, port_number)
|
||||||
|
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 80, 443]) p
|
||||||
|
WHERE h.name = 'web-server-02' AND h.ip = '192.168.10.11'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- db-server-01: SSH, PostgreSQL
|
||||||
|
INSERT INTO host_ports (host_id, port_number)
|
||||||
|
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 5432]) p
|
||||||
|
WHERE h.name = 'db-server-01' AND h.ip = '192.168.10.20'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- mail-server-01: SSH, SMTP, IMAP, SMTPS, Submission, IMAPS
|
||||||
|
INSERT INTO host_ports (host_id, port_number)
|
||||||
|
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 25, 143, 465, 587, 993]) p
|
||||||
|
WHERE h.name = 'mail-server-01' AND h.ip = '192.168.10.30'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- core-switch-01: SSH, SNMP
|
||||||
|
INSERT INTO host_ports (host_id, port_number)
|
||||||
|
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 161]) p
|
||||||
|
WHERE h.name = 'core-switch-01' AND h.ip = '10.0.0.1'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- monitoring-01: SSH, HTTP, HTTPS, Grafana, Prometheus
|
||||||
|
INSERT INTO host_ports (host_id, port_number)
|
||||||
|
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 80, 443, 3000, 9090]) p
|
||||||
|
WHERE h.name = 'monitoring-01' AND h.ip = '10.0.1.10'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- backup-server-01: SSH, SMB, rsync
|
||||||
|
INSERT INTO host_ports (host_id, port_number)
|
||||||
|
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 445, 873]) p
|
||||||
|
WHERE h.name = 'backup-server-01' AND h.ip = '10.0.1.20'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- log-server-01: SSH, Syslog, Logstash Beats, Elasticsearch, Kibana
|
||||||
|
INSERT INTO host_ports (host_id, port_number)
|
||||||
|
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 514, 5044, 9200, 5601]) p
|
||||||
|
WHERE h.name = 'log-server-01' AND h.ip = '10.0.1.30'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- vpn-gateway-01: SSH, IKE, OpenVPN, IPSec NAT-T
|
||||||
|
INSERT INTO host_ports (host_id, port_number)
|
||||||
|
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 500, 1194, 4500]) p
|
||||||
|
WHERE h.name = 'vpn-gateway-01' AND h.ip = '172.16.0.1'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- vpn clients: SSH only
|
||||||
|
INSERT INTO host_ports (host_id, port_number)
|
||||||
|
SELECT h.id, 22 FROM hosts h
|
||||||
|
WHERE h.name = 'vpn-client-01' AND h.ip = '172.16.1.10'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO host_ports (host_id, port_number)
|
||||||
|
SELECT h.id, 22 FROM hosts h
|
||||||
|
WHERE h.name = 'vpn-client-02' AND h.ip = '172.16.1.11'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|||||||
@@ -40,3 +40,115 @@ INSERT INTO hosts (name, ip, network_id) SELECT 'log-server-01', '10.0.1.30',
|
|||||||
INSERT INTO hosts (name, ip, network_id) SELECT 'vpn-gateway-01', '172.16.0.1', id FROM networks WHERE cidr = '172.16.0.0/16' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'vpn-gateway-01' AND ip = '172.16.0.1');
|
INSERT INTO hosts (name, ip, network_id) SELECT 'vpn-gateway-01', '172.16.0.1', id FROM networks WHERE cidr = '172.16.0.0/16' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'vpn-gateway-01' AND ip = '172.16.0.1');
|
||||||
INSERT INTO hosts (name, ip, network_id) SELECT 'vpn-client-01', '172.16.1.10', id FROM networks WHERE cidr = '172.16.0.0/16' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'vpn-client-01' AND ip = '172.16.1.10');
|
INSERT INTO hosts (name, ip, network_id) SELECT 'vpn-client-01', '172.16.1.10', id FROM networks WHERE cidr = '172.16.0.0/16' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'vpn-client-01' AND ip = '172.16.1.10');
|
||||||
INSERT INTO hosts (name, ip, network_id) SELECT 'vpn-client-02', '172.16.1.11', id FROM networks WHERE cidr = '172.16.0.0/16' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'vpn-client-02' AND ip = '172.16.1.11');
|
INSERT INTO hosts (name, ip, network_id) SELECT 'vpn-client-02', '172.16.1.11', id FROM networks WHERE cidr = '172.16.0.0/16' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'vpn-client-02' AND ip = '172.16.1.11');
|
||||||
|
|
||||||
|
-- ── Ports catalog ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO ports (number, description) VALUES
|
||||||
|
(22, 'SSH'),
|
||||||
|
(25, 'SMTP'),
|
||||||
|
(53, 'DNS'),
|
||||||
|
(80, 'HTTP'),
|
||||||
|
(143, 'IMAP'),
|
||||||
|
(161, 'SNMP'),
|
||||||
|
(443, 'HTTPS'),
|
||||||
|
(445, 'SMB'),
|
||||||
|
(465, 'SMTPS'),
|
||||||
|
(500, 'IKE / IPSec'),
|
||||||
|
(514, 'Syslog'),
|
||||||
|
(587, 'SMTP Submission'),
|
||||||
|
(873, 'rsync'),
|
||||||
|
(993, 'IMAPS'),
|
||||||
|
(1194, 'OpenVPN'),
|
||||||
|
(2049, 'NFS'),
|
||||||
|
(3000, 'Grafana'),
|
||||||
|
(3389, 'RDP'),
|
||||||
|
(4500, 'IPSec NAT-T'),
|
||||||
|
(5044, 'Logstash Beats'),
|
||||||
|
(5432, 'PostgreSQL'),
|
||||||
|
(5601, 'Kibana'),
|
||||||
|
(9090, 'Prometheus'),
|
||||||
|
(9100, 'JetDirect'),
|
||||||
|
(9200, 'Elasticsearch');
|
||||||
|
|
||||||
|
-- ── Host ports ────────────────────────────────────────────────────────────────
|
||||||
|
-- INSERT OR IGNORE is safe: host_ports has a composite PRIMARY KEY (host_id, port_number).
|
||||||
|
-- Host IDs are resolved by subquery on (name, ip) to stay independent of auto-increment values.
|
||||||
|
|
||||||
|
-- gateway: SSH, DNS, HTTP (admin UI), HTTPS (admin UI)
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'gateway' AND ip = '192.168.1.1';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 53 FROM hosts WHERE name = 'gateway' AND ip = '192.168.1.1';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 80 FROM hosts WHERE name = 'gateway' AND ip = '192.168.1.1';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 443 FROM hosts WHERE name = 'gateway' AND ip = '192.168.1.1';
|
||||||
|
|
||||||
|
-- workstations: SSH, RDP
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'workstation-01' AND ip = '192.168.1.10';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 3389 FROM hosts WHERE name = 'workstation-01' AND ip = '192.168.1.10';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'workstation-02' AND ip = '192.168.1.11';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 3389 FROM hosts WHERE name = 'workstation-02' AND ip = '192.168.1.11';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'workstation-03' AND ip = '192.168.1.12';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 3389 FROM hosts WHERE name = 'workstation-03' AND ip = '192.168.1.12';
|
||||||
|
|
||||||
|
-- nas-01: SSH, HTTP (web UI), HTTPS, SMB, NFS
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'nas-01' AND ip = '192.168.1.20';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 80 FROM hosts WHERE name = 'nas-01' AND ip = '192.168.1.20';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 443 FROM hosts WHERE name = 'nas-01' AND ip = '192.168.1.20';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 445 FROM hosts WHERE name = 'nas-01' AND ip = '192.168.1.20';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 2049 FROM hosts WHERE name = 'nas-01' AND ip = '192.168.1.20';
|
||||||
|
|
||||||
|
-- printer-01: HTTP (web UI), HTTPS, JetDirect
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 80 FROM hosts WHERE name = 'printer-01' AND ip = '192.168.1.50';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 443 FROM hosts WHERE name = 'printer-01' AND ip = '192.168.1.50';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 9100 FROM hosts WHERE name = 'printer-01' AND ip = '192.168.1.50';
|
||||||
|
|
||||||
|
-- web servers: SSH, HTTP, HTTPS
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'web-server-01' AND ip = '192.168.10.10';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 80 FROM hosts WHERE name = 'web-server-01' AND ip = '192.168.10.10';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 443 FROM hosts WHERE name = 'web-server-01' AND ip = '192.168.10.10';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'web-server-02' AND ip = '192.168.10.11';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 80 FROM hosts WHERE name = 'web-server-02' AND ip = '192.168.10.11';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 443 FROM hosts WHERE name = 'web-server-02' AND ip = '192.168.10.11';
|
||||||
|
|
||||||
|
-- db-server-01: SSH, PostgreSQL
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'db-server-01' AND ip = '192.168.10.20';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 5432 FROM hosts WHERE name = 'db-server-01' AND ip = '192.168.10.20';
|
||||||
|
|
||||||
|
-- mail-server-01: SSH, SMTP, IMAP, SMTPS, SMTP Submission, IMAPS
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'mail-server-01' AND ip = '192.168.10.30';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 25 FROM hosts WHERE name = 'mail-server-01' AND ip = '192.168.10.30';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 143 FROM hosts WHERE name = 'mail-server-01' AND ip = '192.168.10.30';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 465 FROM hosts WHERE name = 'mail-server-01' AND ip = '192.168.10.30';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 587 FROM hosts WHERE name = 'mail-server-01' AND ip = '192.168.10.30';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 993 FROM hosts WHERE name = 'mail-server-01' AND ip = '192.168.10.30';
|
||||||
|
|
||||||
|
-- core-switch-01: SSH, SNMP
|
||||||
|
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';
|
||||||
|
|
||||||
|
-- monitoring-01: SSH, HTTP, HTTPS, Prometheus, Grafana
|
||||||
|
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, 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, 3000 FROM hosts WHERE name = 'monitoring-01' AND ip = '10.0.1.10';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 9090 FROM hosts WHERE name = 'monitoring-01' AND ip = '10.0.1.10';
|
||||||
|
|
||||||
|
-- backup-server-01: SSH, SMB, rsync
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'backup-server-01' AND ip = '10.0.1.20';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 445 FROM hosts WHERE name = 'backup-server-01' AND ip = '10.0.1.20';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 873 FROM hosts WHERE name = 'backup-server-01' AND ip = '10.0.1.20';
|
||||||
|
|
||||||
|
-- log-server-01: SSH, Syslog, Logstash Beats, Elasticsearch, Kibana
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'log-server-01' AND ip = '10.0.1.30';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 514 FROM hosts WHERE name = 'log-server-01' AND ip = '10.0.1.30';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 5044 FROM hosts WHERE name = 'log-server-01' AND ip = '10.0.1.30';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 9200 FROM hosts WHERE name = 'log-server-01' AND ip = '10.0.1.30';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 5601 FROM hosts WHERE name = 'log-server-01' AND ip = '10.0.1.30';
|
||||||
|
|
||||||
|
-- vpn-gateway-01: SSH, IKE, IPSec NAT-T, OpenVPN
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'vpn-gateway-01' AND ip = '172.16.0.1';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 500 FROM hosts WHERE name = 'vpn-gateway-01' AND ip = '172.16.0.1';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 1194 FROM hosts WHERE name = 'vpn-gateway-01' AND ip = '172.16.0.1';
|
||||||
|
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 4500 FROM hosts WHERE name = 'vpn-gateway-01' AND ip = '172.16.0.1';
|
||||||
|
|
||||||
|
-- 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-02' AND ip = '172.16.1.11';
|
||||||
|
|||||||
@@ -1,11 +1,56 @@
|
|||||||
// api/applications.rs — Server functions for applications and their port associations
|
// api/applications.rs — Server functions for applications and their port associations
|
||||||
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::models::Application;
|
use crate::models::Application;
|
||||||
|
|
||||||
|
// 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 ──────────────────────────────────────────────────────────────────
|
// ─── Queries ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Returns all applications enriched with their associated host count.
|
||||||
|
#[server]
|
||||||
|
pub async fn get_applications_with_counts() -> Result<Vec<ApplicationWithCounts>, ServerFnError> {
|
||||||
|
use sqlx::{AnyPool, Row};
|
||||||
|
|
||||||
|
let pool = use_context::<AnyPool>()
|
||||||
|
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT
|
||||||
|
a.id,
|
||||||
|
a.name,
|
||||||
|
COUNT(DISTINCT hp.host_id) AS host_count
|
||||||
|
FROM applications a
|
||||||
|
LEFT JOIN application_ports ap ON ap.application_id = a.id
|
||||||
|
LEFT JOIN host_ports hp ON hp.port_number = ap.port_number
|
||||||
|
GROUP BY a.id, a.name
|
||||||
|
ORDER BY a.name",
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| ApplicationWithCounts {
|
||||||
|
id: row.get("id"),
|
||||||
|
name: row.get("name"),
|
||||||
|
host_count: row.get("host_count"),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns all applications ordered by name.
|
/// Returns all applications ordered by name.
|
||||||
#[server]
|
#[server]
|
||||||
pub async fn get_applications() -> Result<Vec<Application>, ServerFnError> {
|
pub async fn get_applications() -> Result<Vec<Application>, ServerFnError> {
|
||||||
|
|||||||
233
src/api/hosts.rs
233
src/api/hosts.rs
@@ -5,6 +5,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::models::Host;
|
use crate::models::Host;
|
||||||
|
|
||||||
|
use crate::models::Port;
|
||||||
|
|
||||||
// ─── Presentation types ───────────────────────────────────────────────────────
|
// ─── Presentation types ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
// A host row enriched with its network CIDR and pre-computed counts.
|
// A host row enriched with its network CIDR and pre-computed counts.
|
||||||
@@ -30,8 +32,54 @@ 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.
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct HostDetail {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub ip: String,
|
||||||
|
pub network_id: i64,
|
||||||
|
pub network_name: String,
|
||||||
|
pub network_cidr: String,
|
||||||
|
pub ports: Vec<Port>,
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Queries ──────────────────────────────────────────────────────────────────
|
// ─── Queries ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Returns full detail for a single host: identity, network, and open ports.
|
||||||
|
#[server]
|
||||||
|
pub async fn get_host_detail(id: i64) -> Result<HostDetail, ServerFnError> {
|
||||||
|
use sqlx::AnyPool;
|
||||||
|
use crate::server::repository::{hosts as host_repo, networks, ports as port_repo};
|
||||||
|
|
||||||
|
let pool = use_context::<AnyPool>()
|
||||||
|
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||||
|
|
||||||
|
let host = host_repo::find_host(&pool, id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||||
|
.ok_or_else(|| ServerFnError::new(format!("Host {id} not found")))?;
|
||||||
|
|
||||||
|
let network = networks::find_network(&pool, host.network_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||||
|
.ok_or_else(|| ServerFnError::new(format!("Network {} not found", host.network_id)))?;
|
||||||
|
|
||||||
|
let ports = port_repo::list_ports_for_host(&pool, id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(HostDetail {
|
||||||
|
id: host.id,
|
||||||
|
name: host.name,
|
||||||
|
ip: host.ip,
|
||||||
|
network_id: host.network_id,
|
||||||
|
network_name: network.name,
|
||||||
|
network_cidr: network.cidr,
|
||||||
|
ports,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns all hosts belonging to a given network.
|
/// Returns all hosts belonging to a given network.
|
||||||
#[server]
|
#[server]
|
||||||
pub async fn get_hosts_by_network(network_id: i64) -> Result<Vec<Host>, ServerFnError> {
|
pub async fn get_hosts_by_network(network_id: i64) -> Result<Vec<Host>, ServerFnError> {
|
||||||
@@ -48,22 +96,17 @@ pub async fn get_hosts_by_network(network_id: i64) -> Result<Vec<Host>, ServerFn
|
|||||||
|
|
||||||
/// Returns a filtered and paginated list of hosts across all networks.
|
/// Returns a filtered and paginated list of hosts across all networks.
|
||||||
///
|
///
|
||||||
/// Filter parameters use sentinel values (0 / empty string) to mean "no filter":
|
/// `port_filter` is a comma-separated list of port numbers (e.g. "80,443").
|
||||||
/// - `name_filter` : substring match on host name (case-insensitive); "" = all
|
/// A host matches only if it has ALL the specified ports open.
|
||||||
/// - `network_id_filter` : exact network id; 0 = all
|
/// An empty string means no port filter.
|
||||||
/// - `port_filter` : hosts with this port open; 0 = all
|
|
||||||
/// - `application_id_filter` : hosts linked to this application; 0 = all
|
|
||||||
/// - `per_page` : items per page; 0 = return everything
|
|
||||||
/// - `page` : 1-indexed page number
|
|
||||||
///
|
///
|
||||||
/// The SQL uses each bind parameter twice in the WHERE clause
|
/// Port conditions are inlined in the SQL as integer literals (safe: values
|
||||||
/// (once for the IS NULL guard, once for the actual comparison).
|
/// are parsed and range-checked before use — no raw user strings are injected).
|
||||||
/// Each $N placeholder refers to the N-th bound argument by index.
|
|
||||||
#[server]
|
#[server]
|
||||||
pub async fn get_hosts_page(
|
pub async fn get_hosts_page(
|
||||||
name_filter: String,
|
name_filter: String,
|
||||||
network_id_filter: i64,
|
network_id_filter: i64,
|
||||||
port_filter: i64,
|
port_filter: String,
|
||||||
application_id_filter: i64,
|
application_id_filter: i64,
|
||||||
page: i64,
|
page: i64,
|
||||||
per_page: i64,
|
per_page: i64,
|
||||||
@@ -73,47 +116,55 @@ pub async fn get_hosts_page(
|
|||||||
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"))?;
|
||||||
|
|
||||||
// Convert sentinel values to Option for SQL NULL binding.
|
|
||||||
// None → binds as SQL NULL → "$N IS NULL" evaluates to TRUE → filter skipped.
|
|
||||||
let name_like: Option<String> = if name_filter.is_empty() {
|
let name_like: Option<String> = if name_filter.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(format!("%{}%", name_filter))
|
Some(format!("%{}%", name_filter))
|
||||||
};
|
};
|
||||||
let network_id: Option<i64> = if network_id_filter == 0 { None } else { Some(network_id_filter) };
|
let network_id: Option<i64> = if network_id_filter == 0 { None } else { Some(network_id_filter) };
|
||||||
let port: Option<i64> = if port_filter == 0 { None } else { Some(port_filter) };
|
|
||||||
let app_id: Option<i64> = if application_id_filter == 0 { None } else { Some(application_id_filter) };
|
let app_id: Option<i64> = if application_id_filter == 0 { None } else { Some(application_id_filter) };
|
||||||
|
|
||||||
// Each filter param is bound twice so the same $N can appear in both
|
// Parse and validate port numbers from the CSV string.
|
||||||
// the IS NULL guard and the comparison without re-declaring parameters.
|
// Inlined as integer literals in SQL — safe because they are range-checked i64s.
|
||||||
const WHERE: &str = "
|
let ports: Vec<i64> = port_filter
|
||||||
JOIN networks n ON n.id = h.network_id
|
.split(',')
|
||||||
LEFT JOIN host_ports hp ON hp.host_id = h.id
|
.filter_map(|s| s.trim().parse::<i64>().ok())
|
||||||
LEFT JOIN application_ports ap ON ap.port_number = hp.port_number
|
.filter(|&p| p >= 1 && p <= 65535)
|
||||||
WHERE ($1 IS NULL OR LOWER(h.name) LIKE LOWER($1))
|
.collect();
|
||||||
AND ($2 IS NULL OR h.network_id = $2)
|
|
||||||
AND ($3 IS NULL OR EXISTS (
|
|
||||||
SELECT 1 FROM host_ports
|
|
||||||
WHERE host_id = h.id AND port_number = $3
|
|
||||||
))
|
|
||||||
AND ($4 IS NULL OR EXISTS (
|
|
||||||
SELECT 1 FROM host_ports hp2
|
|
||||||
JOIN application_ports ap2 ON ap2.port_number = hp2.port_number
|
|
||||||
WHERE hp2.host_id = h.id AND ap2.application_id = $4
|
|
||||||
))";
|
|
||||||
|
|
||||||
// Count matching hosts (ignoring pagination).
|
// One EXISTS clause per required port (AND semantics: host must have ALL ports).
|
||||||
let count_sql = format!("SELECT COUNT(DISTINCT h.id) FROM hosts h {WHERE}");
|
let port_conditions: String = ports
|
||||||
|
.iter()
|
||||||
|
.map(|p| format!(
|
||||||
|
" AND EXISTS (SELECT 1 FROM host_ports WHERE host_id = h.id AND port_number = {p})"
|
||||||
|
))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// $1 = name_like, $2 = network_id, $3 = app_id
|
||||||
|
// Pagination: $4 = limit, $5 = offset
|
||||||
|
let where_clause = format!(
|
||||||
|
"JOIN networks n ON n.id = h.network_id
|
||||||
|
LEFT JOIN host_ports hp ON hp.host_id = h.id
|
||||||
|
LEFT JOIN application_ports ap ON ap.port_number = hp.port_number
|
||||||
|
WHERE ($1 IS NULL OR LOWER(h.name) LIKE LOWER($1))
|
||||||
|
AND ($2 IS NULL OR h.network_id = $2)
|
||||||
|
AND ($3 IS NULL OR EXISTS (
|
||||||
|
SELECT 1 FROM host_ports hp2
|
||||||
|
JOIN application_ports ap2 ON ap2.port_number = hp2.port_number
|
||||||
|
WHERE hp2.host_id = h.id AND ap2.application_id = $3
|
||||||
|
))
|
||||||
|
{port_conditions}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let count_sql = format!("SELECT COUNT(DISTINCT h.id) FROM hosts h {where_clause}");
|
||||||
let total: i64 = sqlx::query_scalar(&count_sql)
|
let total: i64 = sqlx::query_scalar(&count_sql)
|
||||||
.bind(name_like.as_deref())
|
.bind(name_like.as_deref())
|
||||||
.bind(network_id)
|
.bind(network_id)
|
||||||
.bind(port)
|
|
||||||
.bind(app_id)
|
.bind(app_id)
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
// Compute pagination bounds.
|
|
||||||
let safe_page = page.max(1);
|
let safe_page = page.max(1);
|
||||||
let (limit, offset, total_pages) = if per_page <= 0 {
|
let (limit, offset, total_pages) = if per_page <= 0 {
|
||||||
(1_000_000_000i64, 0i64, 1i64)
|
(1_000_000_000i64, 0i64, 1i64)
|
||||||
@@ -122,23 +173,21 @@ pub async fn get_hosts_page(
|
|||||||
(per_page, (safe_page - 1) * per_page, tp)
|
(per_page, (safe_page - 1) * per_page, tp)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch the page of hosts with enriched columns.
|
|
||||||
let data_sql = format!(
|
let data_sql = format!(
|
||||||
"SELECT h.id, h.name, h.ip, h.network_id,
|
"SELECT h.id, h.name, h.ip, h.network_id,
|
||||||
n.cidr AS network_cidr,
|
n.cidr AS network_cidr,
|
||||||
COUNT(DISTINCT hp.port_number) AS port_count,
|
COUNT(DISTINCT hp.port_number) AS port_count,
|
||||||
COUNT(DISTINCT ap.application_id) AS application_count
|
COUNT(DISTINCT ap.application_id) AS application_count
|
||||||
FROM hosts h
|
FROM hosts h
|
||||||
{WHERE}
|
{where_clause}
|
||||||
GROUP BY h.id, h.name, h.ip, h.network_id, n.cidr
|
GROUP BY h.id, h.name, h.ip, h.network_id, n.cidr
|
||||||
ORDER BY h.name, h.id
|
ORDER BY h.name, h.id
|
||||||
LIMIT $5 OFFSET $6"
|
LIMIT $4 OFFSET $5"
|
||||||
);
|
);
|
||||||
|
|
||||||
let rows = sqlx::query(&data_sql)
|
let rows = sqlx::query(&data_sql)
|
||||||
.bind(name_like.as_deref())
|
.bind(name_like.as_deref())
|
||||||
.bind(network_id)
|
.bind(network_id)
|
||||||
.bind(port)
|
|
||||||
.bind(app_id)
|
.bind(app_id)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
.bind(offset)
|
.bind(offset)
|
||||||
@@ -170,19 +219,21 @@ pub async fn get_hosts_page(
|
|||||||
|
|
||||||
// ─── Mutations ────────────────────────────────────────────────────────────────
|
// ─── Mutations ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Creates a new host inside the specified network.
|
/// Creates a new host inside the specified network, then opens the given ports.
|
||||||
///
|
///
|
||||||
/// Validates that `ip` falls within the CIDR of `network_id`.
|
/// `ports` is a comma-separated list of port numbers (e.g. "22,80,443").
|
||||||
/// Returns an error if the network does not exist or the IP is out of range.
|
/// Ports are auto-registered in the global catalog if not already present.
|
||||||
|
/// An empty string means no ports are opened.
|
||||||
#[server]
|
#[server]
|
||||||
pub async fn create_host(
|
pub async fn create_host(
|
||||||
name: String,
|
name: String,
|
||||||
ip: String,
|
ip: String,
|
||||||
network_id: i64,
|
network_id: i64,
|
||||||
|
ports: String,
|
||||||
) -> Result<Host, ServerFnError> {
|
) -> Result<Host, ServerFnError> {
|
||||||
use sqlx::AnyPool;
|
use sqlx::AnyPool;
|
||||||
use crate::server::{
|
use crate::server::{
|
||||||
repository::{hosts, networks},
|
repository::{hosts, networks, ports as port_repo},
|
||||||
validation::validate_ip_in_network,
|
validation::validate_ip_in_network,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -192,14 +243,102 @@ pub async fn create_host(
|
|||||||
let network = networks::find_network(&pool, network_id)
|
let network = networks::find_network(&pool, network_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))?
|
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| ServerFnError::new(format!("Network {network_id} not found")))?;
|
||||||
ServerFnError::new(format!("Network {network_id} not found"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
validate_ip_in_network(&ip, &network.cidr)
|
validate_ip_in_network(&ip, &network.cidr)
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
hosts::create_host(&pool, &name, &ip, network_id)
|
let host = hosts::create_host(&pool, &name, &ip, network_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
// Parse, validate, and open each port on the new host.
|
||||||
|
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 {
|
||||||
|
port_repo::add_port_to_host(&pool, host.id, port_number)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates a host's name, IP address, and network assignment.
|
||||||
|
///
|
||||||
|
/// Validates that the new IP falls within the CIDR of the new network.
|
||||||
|
#[server]
|
||||||
|
pub async fn update_host(
|
||||||
|
id: i64,
|
||||||
|
name: String,
|
||||||
|
ip: String,
|
||||||
|
network_id: i64,
|
||||||
|
) -> Result<Host, ServerFnError> {
|
||||||
|
use sqlx::AnyPool;
|
||||||
|
use crate::server::{
|
||||||
|
repository::{hosts as host_repo, networks},
|
||||||
|
validation::validate_ip_in_network,
|
||||||
|
};
|
||||||
|
|
||||||
|
let pool = use_context::<AnyPool>()
|
||||||
|
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||||
|
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
return Err(ServerFnError::new("Name must not be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let network = networks::find_network(&pool, network_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||||
|
.ok_or_else(|| ServerFnError::new(format!("Network {network_id} not found")))?;
|
||||||
|
|
||||||
|
validate_ip_in_network(&ip, &network.cidr)
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
host_repo::update_host(&pool, id, name.trim(), ip.trim(), network_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||||
|
.ok_or_else(|| ServerFnError::new(format!("Host {id} not found")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a single port on a host.
|
||||||
|
///
|
||||||
|
/// Auto-registers the port in the global catalog if not already present.
|
||||||
|
/// If the port is already open on this host, the call is a no-op.
|
||||||
|
#[server]
|
||||||
|
pub async fn add_host_port(host_id: i64, port_number: i64) -> Result<(), ServerFnError> {
|
||||||
|
use sqlx::AnyPool;
|
||||||
|
use crate::server::repository::ports as port_repo;
|
||||||
|
|
||||||
|
let pool = use_context::<AnyPool>()
|
||||||
|
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||||
|
|
||||||
|
if !(1..=65535).contains(&port_number) {
|
||||||
|
return Err(ServerFnError::new("Port number must be between 1 and 65535"));
|
||||||
|
}
|
||||||
|
|
||||||
|
port_repo::add_port_to_host(&pool, host_id, port_number as u16)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes a port on a host (removes the host-port association).
|
||||||
|
///
|
||||||
|
/// The port entry in the global catalog is not deleted.
|
||||||
|
/// If the port was not open on this host, the call is a no-op.
|
||||||
|
#[server]
|
||||||
|
pub async fn remove_host_port(host_id: i64, port_number: i64) -> Result<(), ServerFnError> {
|
||||||
|
use sqlx::AnyPool;
|
||||||
|
use crate::server::repository::ports as port_repo;
|
||||||
|
|
||||||
|
let pool = use_context::<AnyPool>()
|
||||||
|
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||||
|
|
||||||
|
port_repo::remove_port_from_host(&pool, host_id, port_number as u16)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,21 @@ pub async fn get_networks_with_counts() -> Result<Vec<NetworkWithCounts>, Server
|
|||||||
Ok(networks)
|
Ok(networks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a single network by id, or an error if it does not exist.
|
||||||
|
#[server]
|
||||||
|
pub async fn get_network(id: i64) -> Result<Network, ServerFnError> {
|
||||||
|
use sqlx::AnyPool;
|
||||||
|
use crate::server::repository::networks as repo;
|
||||||
|
|
||||||
|
let pool = use_context::<AnyPool>()
|
||||||
|
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||||
|
|
||||||
|
repo::find_network(&pool, id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||||
|
.ok_or_else(|| ServerFnError::new(format!("Network {id} not found")))
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Mutations ────────────────────────────────────────────────────────────────
|
// ─── Mutations ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Creates a new network with the given name and CIDR block.
|
/// Creates a new network with the given name and CIDR block.
|
||||||
|
|||||||
14
src/app.rs
14
src/app.rs
@@ -11,7 +11,15 @@ use leptos_router::{
|
|||||||
path,
|
path,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::client::{home::HomePage, hosts::HostsPage, networks::NetworksPage, theme::ThemeToggle};
|
use crate::client::{
|
||||||
|
applications::ApplicationsPage,
|
||||||
|
home::HomePage,
|
||||||
|
host_detail::HostDetailPage,
|
||||||
|
hosts::HostsPage,
|
||||||
|
network_detail::NetworkDetailPage,
|
||||||
|
networks::NetworksPage,
|
||||||
|
theme::ThemeToggle,
|
||||||
|
};
|
||||||
|
|
||||||
// Shell — full HTML document rendered by the Axum server.
|
// Shell — full HTML document rendered by the Axum server.
|
||||||
//
|
//
|
||||||
@@ -87,6 +95,7 @@ pub fn App() -> impl IntoView {
|
|||||||
<a href="/">"Rust IPAM"</a>
|
<a href="/">"Rust IPAM"</a>
|
||||||
<a href="/networks">"Networks"</a>
|
<a href="/networks">"Networks"</a>
|
||||||
<a href="/hosts">"Hosts"</a>
|
<a href="/hosts">"Hosts"</a>
|
||||||
|
<a href="/applications">"Applications"</a>
|
||||||
<span class="nav-spacer"/>
|
<span class="nav-spacer"/>
|
||||||
<ThemeToggle/>
|
<ThemeToggle/>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -102,7 +111,10 @@ pub fn App() -> impl IntoView {
|
|||||||
}>
|
}>
|
||||||
<Route path=path!("/") view=HomePage/>
|
<Route path=path!("/") view=HomePage/>
|
||||||
<Route path=path!("/networks") view=NetworksPage/>
|
<Route path=path!("/networks") view=NetworksPage/>
|
||||||
|
<Route path=path!("/networks/:id") view=NetworkDetailPage/>
|
||||||
<Route path=path!("/hosts") view=HostsPage/>
|
<Route path=path!("/hosts") view=HostsPage/>
|
||||||
|
<Route path=path!("/hosts/:id") view=HostDetailPage/>
|
||||||
|
<Route path=path!("/applications") view=ApplicationsPage/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
251
src/client/applications.rs
Normal file
251
src/client/applications.rs
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
// client/applications.rs — Applications list page
|
||||||
|
//
|
||||||
|
// Displays all applications with:
|
||||||
|
// - Add button : opens a modal to create an application by name
|
||||||
|
// - Filter bar : name substring filter (client-side)
|
||||||
|
// - Table : application name + number of associated hosts
|
||||||
|
// - Delete : confirmation modal before deletion
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos::form::ActionForm;
|
||||||
|
|
||||||
|
use crate::api::applications::{
|
||||||
|
ApplicationWithCounts, CreateApplication, DeleteApplication,
|
||||||
|
get_applications_with_counts,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Add application modal ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn AddApplicationModal(
|
||||||
|
create_action: ServerAction<CreateApplication>,
|
||||||
|
show_modal: RwSignal<bool>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if let Some(Ok(_)) = create_action.value().get() {
|
||||||
|
show_modal.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 an application"</h2>
|
||||||
|
<button class="modal__close" type="button" aria-label="Close"
|
||||||
|
on:click=move |_| show_modal.set(false)>
|
||||||
|
"×"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ActionForm action=create_action>
|
||||||
|
<div class="add-form__fields">
|
||||||
|
<label>
|
||||||
|
"Name"
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
placeholder="e.g. Nginx, PostgreSQL, Prometheus"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal__actions">
|
||||||
|
<button class="btn-secondary" type="button"
|
||||||
|
on:click=move |_| show_modal.set(false)>
|
||||||
|
"Cancel"
|
||||||
|
</button>
|
||||||
|
<button type="submit">"Add application"</button>
|
||||||
|
</div>
|
||||||
|
</ActionForm>
|
||||||
|
|
||||||
|
{move || create_action.value().get()
|
||||||
|
.and_then(|r| r.err())
|
||||||
|
.map(|e| view! { <p class="error">{e.to_string()}</p> })
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Delete confirmation modal ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn DeleteAppModal(
|
||||||
|
app: ApplicationWithCounts,
|
||||||
|
delete_action: ServerAction<DeleteApplication>,
|
||||||
|
pending_delete: RwSignal<Option<ApplicationWithCounts>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let id = app.id;
|
||||||
|
let label = app.name.clone();
|
||||||
|
let host_count = app.host_count;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="modal-backdrop" on:click=move |_| pending_delete.set(None)>
|
||||||
|
<div class="modal" on:click=move |e| e.stop_propagation()>
|
||||||
|
<div class="modal__header">
|
||||||
|
<h2>"Delete application"</h2>
|
||||||
|
<button class="modal__close" type="button" aria-label="Close"
|
||||||
|
on:click=move |_| pending_delete.set(None)>
|
||||||
|
"×"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal__body">
|
||||||
|
<p>"Delete application " <strong>{label}</strong> "?"</p>
|
||||||
|
{(host_count > 0).then(|| view! {
|
||||||
|
<p class="warning">
|
||||||
|
"This application is linked to "
|
||||||
|
{host_count}
|
||||||
|
{if host_count == 1 { " host" } else { " hosts" }}
|
||||||
|
" via shared ports. The port associations will be removed."
|
||||||
|
</p>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div class="modal__actions">
|
||||||
|
<button class="btn-secondary" type="button"
|
||||||
|
on:click=move |_| pending_delete.set(None)>
|
||||||
|
"Cancel"
|
||||||
|
</button>
|
||||||
|
<button class="btn-danger" type="button"
|
||||||
|
on:click=move |_| { delete_action.dispatch(DeleteApplication { id }); }>
|
||||||
|
"Delete"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ApplicationsPage() -> impl IntoView {
|
||||||
|
let create_action = ServerAction::<CreateApplication>::new();
|
||||||
|
let delete_action = ServerAction::<DeleteApplication>::new();
|
||||||
|
|
||||||
|
let show_modal = RwSignal::new(false);
|
||||||
|
|
||||||
|
// Some(app) = delete modal open for that app; None = closed.
|
||||||
|
let pending_delete: RwSignal<Option<ApplicationWithCounts>> = RwSignal::new(None);
|
||||||
|
|
||||||
|
// Name filter (client-side — list is typically small)
|
||||||
|
let name_filter = RwSignal::new(String::new());
|
||||||
|
|
||||||
|
// Close the delete modal automatically after a successful deletion.
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if let Some(Ok(_)) = delete_action.value().get() {
|
||||||
|
pending_delete.set(None);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let applications = Resource::new(
|
||||||
|
move || (create_action.version().get(), delete_action.version().get()),
|
||||||
|
|_| get_applications_with_counts(),
|
||||||
|
);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="applications-page">
|
||||||
|
|
||||||
|
// ── Page header ───────────────────────────────────────────────────
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>"Applications"</h1>
|
||||||
|
<button class="btn-primary" on:click=move |_| show_modal.set(true)>
|
||||||
|
"+ Add application"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// ── Add modal ─────────────────────────────────────────────────────
|
||||||
|
{move || show_modal.get().then(|| view! {
|
||||||
|
<AddApplicationModal
|
||||||
|
create_action=create_action
|
||||||
|
show_modal=show_modal
|
||||||
|
/>
|
||||||
|
})}
|
||||||
|
|
||||||
|
// ── Delete modal ──────────────────────────────────────────────────
|
||||||
|
{move || pending_delete.get().map(|app| view! {
|
||||||
|
<DeleteAppModal
|
||||||
|
app=app
|
||||||
|
delete_action=delete_action
|
||||||
|
pending_delete=pending_delete
|
||||||
|
/>
|
||||||
|
})}
|
||||||
|
|
||||||
|
// ── Filter bar ────────────────────────────────────────────────────
|
||||||
|
<section class="filter-bar">
|
||||||
|
<div class="filter-bar__fields">
|
||||||
|
<label class="filter-field">
|
||||||
|
"Name"
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search…"
|
||||||
|
on:input=move |e| name_filter.set(event_target_value(&e))
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
// ── Application list ──────────────────────────────────────────────
|
||||||
|
<section class="list">
|
||||||
|
{move || delete_action.value().get()
|
||||||
|
.and_then(|r| r.err())
|
||||||
|
.map(|e| view! { <p class="error">"Delete failed: " {e.to_string()}</p> })
|
||||||
|
}
|
||||||
|
|
||||||
|
<Suspense fallback=|| view! { <p>"Loading applications…"</p> }>
|
||||||
|
{move || applications.get().map(|result| match result {
|
||||||
|
Err(e) => view! {
|
||||||
|
<p class="error">"Could not load applications: " {e.to_string()}</p>
|
||||||
|
}.into_any(),
|
||||||
|
|
||||||
|
Ok(list) => {
|
||||||
|
let filter = name_filter.get().to_lowercase();
|
||||||
|
let filtered: Vec<_> = list.into_iter()
|
||||||
|
.filter(|app| filter.is_empty() || app.name.to_lowercase().contains(&filter))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if filtered.is_empty() {
|
||||||
|
view! {
|
||||||
|
<p class="empty">"No applications match the current filter."</p>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>"Name"</th>
|
||||||
|
<th class="col-count">"Hosts"</th>
|
||||||
|
<th class="col-actions">"Actions"</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.into_iter().map(|app| {
|
||||||
|
let app_clone = app.clone();
|
||||||
|
view! {
|
||||||
|
<tr>
|
||||||
|
<td>{app.name}</td>
|
||||||
|
<td class="col-count">{app.host_count}</td>
|
||||||
|
<td class="col-actions">
|
||||||
|
<button on:click=move |_| {
|
||||||
|
pending_delete.set(Some(app_clone.clone()));
|
||||||
|
}>
|
||||||
|
"Delete"
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</Suspense>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
339
src/client/host_detail.rs
Normal file
339
src/client/host_detail.rs
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
// client/host_detail.rs — Host detail page
|
||||||
|
//
|
||||||
|
// Shows all information for a single host:
|
||||||
|
// - Identity form : name, IP, network dropdown — editable, saved with "Save changes"
|
||||||
|
// - Ports section : full list with Remove per port + Add port input
|
||||||
|
// - Delete button : opens a confirmation modal, then navigates back to /hosts
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::hooks::{use_navigate, use_params_map, use_query_map};
|
||||||
|
|
||||||
|
use crate::api::{
|
||||||
|
hosts::{AddHostPort, DeleteHost, RemoveHostPort, UpdateHost, get_host_detail},
|
||||||
|
networks::get_networks,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Delete confirmation modal ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn DeleteModal(
|
||||||
|
host_name: String,
|
||||||
|
delete_action: ServerAction<DeleteHost>,
|
||||||
|
host_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 host"</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>{host_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(DeleteHost { id: host_id }); }>
|
||||||
|
"Delete"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main page component ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
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 host_id = move || {
|
||||||
|
params.read().get("id")
|
||||||
|
.and_then(|s| s.parse::<i64>().ok())
|
||||||
|
.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 back_url = move || {
|
||||||
|
query.read().get("back")
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| "/hosts".to_string())
|
||||||
|
};
|
||||||
|
let back_label = move || {
|
||||||
|
if back_url().starts_with("/networks/") { "← Network" } else { "← Hosts" }
|
||||||
|
};
|
||||||
|
|
||||||
|
let update_action = ServerAction::<UpdateHost>::new();
|
||||||
|
let add_port_action = ServerAction::<AddHostPort>::new();
|
||||||
|
let remove_port_action = ServerAction::<RemoveHostPort>::new();
|
||||||
|
let delete_action = ServerAction::<DeleteHost>::new();
|
||||||
|
|
||||||
|
let show_delete_modal = RwSignal::new(false);
|
||||||
|
|
||||||
|
// Reload detail after any mutation that touches this host.
|
||||||
|
// The resource key includes action versions so it invalidates automatically.
|
||||||
|
let host = Resource::new(
|
||||||
|
move || (
|
||||||
|
host_id(),
|
||||||
|
update_action.version().get(),
|
||||||
|
add_port_action.version().get(),
|
||||||
|
remove_port_action.version().get(),
|
||||||
|
),
|
||||||
|
|(id, _, _, _)| get_host_detail(id),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Networks dropdown — LocalResource avoids SSR/hydration mismatch.
|
||||||
|
let networks_res = LocalResource::new(|| get_networks());
|
||||||
|
|
||||||
|
// Edit-field signals, populated once by the Effect below.
|
||||||
|
// Using signals (rather than local variables) keeps them stable across
|
||||||
|
// re-renders and lets the user edit without triggering a resource reload.
|
||||||
|
let name_sig = RwSignal::new(String::new());
|
||||||
|
let ip_sig = RwSignal::new(String::new());
|
||||||
|
let net_id_sig = RwSignal::new(0i64);
|
||||||
|
|
||||||
|
// Input value for the "add port" row.
|
||||||
|
let new_port = RwSignal::new(String::new());
|
||||||
|
|
||||||
|
// Sync edit signals whenever the host resource delivers fresh data.
|
||||||
|
// This runs on initial load and after every successful mutation.
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if let Some(Ok(ref detail)) = host.get() {
|
||||||
|
name_sig.set(detail.name.clone());
|
||||||
|
ip_sig.set(detail.ip.clone());
|
||||||
|
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();
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if let Some(Ok(true)) = delete_action.value().get() {
|
||||||
|
navigate("/hosts", Default::default());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="host-detail-page">
|
||||||
|
<Suspense fallback=|| view! { <p class="empty">"Loading host…"</p> }>
|
||||||
|
{move || host.get().map(|result| match result {
|
||||||
|
Err(e) => view! {
|
||||||
|
<p class="error">"Could not load host: " {e.to_string()}</p>
|
||||||
|
}.into_any(),
|
||||||
|
|
||||||
|
Ok(detail) => {
|
||||||
|
let id = detail.id;
|
||||||
|
let modal_name = detail.name.clone();
|
||||||
|
let port_count = detail.ports.len();
|
||||||
|
let ports = detail.ports;
|
||||||
|
|
||||||
|
// Pre-built ports view — consumes `ports` once, not reactively.
|
||||||
|
let ports_list = if ports.is_empty() {
|
||||||
|
view! {
|
||||||
|
<p class="empty">"No ports open on this host."</p>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
<div class="port-list">
|
||||||
|
{ports.into_iter().map(|port| {
|
||||||
|
let num = port.number;
|
||||||
|
view! {
|
||||||
|
<div class="port-row">
|
||||||
|
<span class="port-row__number">{num}</span>
|
||||||
|
<span class="port-row__desc">
|
||||||
|
{port.description.unwrap_or_default()}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="btn-danger"
|
||||||
|
type="button"
|
||||||
|
on:click=move |_| {
|
||||||
|
remove_port_action.dispatch(
|
||||||
|
RemoveHostPort { host_id: id, port_number: num as i64 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"Remove"
|
||||||
|
</button>
|
||||||
|
</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>
|
||||||
|
<label class="detail-field">
|
||||||
|
"IP address"
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
prop:value=move || ip_sig.get()
|
||||||
|
on:input=move |e| ip_sig.set(event_target_value(&e))
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="detail-field">
|
||||||
|
"Network"
|
||||||
|
<select on:change=move |e| {
|
||||||
|
net_id_sig.set(
|
||||||
|
event_target_value(&e).parse().unwrap_or(0)
|
||||||
|
);
|
||||||
|
}>
|
||||||
|
{move || networks_res.get()
|
||||||
|
.and_then(|r| (*r).clone().ok())
|
||||||
|
.map(|nets| {
|
||||||
|
let current = net_id_sig.get();
|
||||||
|
nets.into_iter().map(|n| {
|
||||||
|
let label = format!("{} - {}", n.name, n.cidr);
|
||||||
|
view! {
|
||||||
|
<option
|
||||||
|
value=n.id.to_string()
|
||||||
|
selected=(n.id == current)
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
}).collect_view()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</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(UpdateHost {
|
||||||
|
id,
|
||||||
|
name: name_sig.get_untracked(),
|
||||||
|
ip: ip_sig.get_untracked(),
|
||||||
|
network_id: net_id_sig.get_untracked(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"Save changes"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
// ── Ports section ─────────────────────────────────
|
||||||
|
<section class="detail-section">
|
||||||
|
<h2 class="detail-section__title">
|
||||||
|
{format!("Open 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>
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add port row
|
||||||
|
<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(AddHostPort {
|
||||||
|
host_id: id,
|
||||||
|
port_number: n,
|
||||||
|
});
|
||||||
|
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>
|
||||||
|
|
||||||
|
// ── Danger zone ──────────────────────────────────
|
||||||
|
<div class="danger-zone">
|
||||||
|
<button
|
||||||
|
class="btn-danger-solid"
|
||||||
|
type="button"
|
||||||
|
on:click=move |_| show_delete_modal.set(true)
|
||||||
|
>
|
||||||
|
"Delete host"
|
||||||
|
</button>
|
||||||
|
</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()
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
//
|
//
|
||||||
// Displays all hosts across every network with:
|
// Displays all hosts across every network with:
|
||||||
// - Add button : opens a modal form to create a host inside a chosen network
|
// - Add button : opens a modal form to create a host inside a chosen network
|
||||||
// - Filter bar : name (substring), network, open port, application
|
// - Filter bar : name (substring), network, open ports (CSV), application
|
||||||
// - Table : name, IP, network, port count, application count, delete
|
// - Table : name, IP, network, port count, application count, delete
|
||||||
// - Pagination : configurable page size (15 / 25 / 50 / 100 / All)
|
// - Pagination : configurable page size (15 / 25 / 50 / 100 / All)
|
||||||
//
|
//
|
||||||
@@ -27,12 +27,53 @@ const PER_PAGE_OPTIONS: &[(i64, &str)] = &[
|
|||||||
(0, "All"),
|
(0, "All"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ─── Delete host modal ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn DeleteHostModal(
|
||||||
|
host_name: String,
|
||||||
|
host_id: i64,
|
||||||
|
delete_action: ServerAction<DeleteHost>,
|
||||||
|
pending_delete: RwSignal<Option<(i64, String)>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="modal-backdrop" on:click=move |_| pending_delete.set(None)>
|
||||||
|
<div class="modal" on:click=move |e| e.stop_propagation()>
|
||||||
|
<div class="modal__header">
|
||||||
|
<h2>"Delete host"</h2>
|
||||||
|
<button class="modal__close" type="button" aria-label="Close"
|
||||||
|
on:click=move |_| pending_delete.set(None)>
|
||||||
|
"×"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal__body">
|
||||||
|
<p class="warning">
|
||||||
|
"Are you sure you want to delete "
|
||||||
|
<strong>{host_name}</strong>
|
||||||
|
"? All port associations will also be removed."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal__actions">
|
||||||
|
<button class="btn-secondary" type="button"
|
||||||
|
on:click=move |_| pending_delete.set(None)>
|
||||||
|
"Cancel"
|
||||||
|
</button>
|
||||||
|
<button class="btn-danger" type="button"
|
||||||
|
on:click=move |_| { delete_action.dispatch(DeleteHost { id: host_id }); }>
|
||||||
|
"Delete"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Add host modal ───────────────────────────────────────────────────────────
|
// ─── Add host modal ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
fn AddHostModal(
|
fn AddHostModal(
|
||||||
create_action: ServerAction<CreateHost>,
|
create_action: ServerAction<CreateHost>,
|
||||||
networks_res: Resource<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.
|
// Close the modal automatically after a successful creation.
|
||||||
@@ -43,9 +84,7 @@ fn AddHostModal(
|
|||||||
});
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
// Backdrop — click outside the card to close
|
|
||||||
<div class="modal-backdrop" on:click=move |_| show_modal.set(false)>
|
<div class="modal-backdrop" on:click=move |_| show_modal.set(false)>
|
||||||
// stop_propagation keeps clicks inside the card from closing the modal
|
|
||||||
<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>
|
||||||
@@ -72,13 +111,23 @@ fn AddHostModal(
|
|||||||
<select name="network_id" required>
|
<select name="network_id" required>
|
||||||
<option value="">"— choose —"</option>
|
<option value="">"— choose —"</option>
|
||||||
{move || networks_res.get()
|
{move || networks_res.get()
|
||||||
.and_then(|r| r.ok())
|
.and_then(|r| (*r).clone().ok())
|
||||||
.map(|nets| nets.into_iter().map(|n| {
|
.map(|nets| nets.into_iter().map(|n| {
|
||||||
view! { <option value=n.id.to_string()>{n.name}</option> }
|
let label = format!("{} - {}", n.name, n.cidr);
|
||||||
|
view! { <option value=n.id.to_string()>{label}</option> }
|
||||||
}).collect_view())
|
}).collect_view())
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
"Open 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">
|
||||||
@@ -104,11 +153,11 @@ fn AddHostModal(
|
|||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
fn FilterBar(
|
fn FilterBar(
|
||||||
networks_res: Resource<Result<Vec<crate::models::Network>, ServerFnError>>,
|
networks_res: LocalResource<Result<Vec<crate::models::Network>, ServerFnError>>,
|
||||||
applications_res: Resource<Result<Vec<crate::models::Application>, ServerFnError>>,
|
applications_res: LocalResource<Result<Vec<crate::models::Application>, ServerFnError>>,
|
||||||
name_filter: RwSignal<String>,
|
name_filter: RwSignal<String>,
|
||||||
network_id_filter: RwSignal<i64>,
|
network_id_filter: RwSignal<i64>,
|
||||||
port_filter: RwSignal<i64>,
|
port_filter: RwSignal<String>,
|
||||||
app_id_filter: RwSignal<i64>,
|
app_id_filter: RwSignal<i64>,
|
||||||
page: RwSignal<i64>,
|
page: RwSignal<i64>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
@@ -135,23 +184,22 @@ fn FilterBar(
|
|||||||
}>
|
}>
|
||||||
<option value="0">"All networks"</option>
|
<option value="0">"All networks"</option>
|
||||||
{move || networks_res.get()
|
{move || networks_res.get()
|
||||||
.and_then(|r| r.ok())
|
.and_then(|r| (*r).clone().ok())
|
||||||
.map(|nets| nets.into_iter().map(|n| {
|
.map(|nets| nets.into_iter().map(|n| {
|
||||||
view! { <option value=n.id.to_string()>{n.name}</option> }
|
let label = format!("{} - {}", n.name, n.cidr);
|
||||||
|
view! { <option value=n.id.to_string()>{label}</option> }
|
||||||
}).collect_view())
|
}).collect_view())
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="filter-field">
|
<label class="filter-field">
|
||||||
"Open port"
|
"Open ports"
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="text"
|
||||||
min="1"
|
placeholder="e.g. 80, 443 (all required)"
|
||||||
max="65535"
|
|
||||||
placeholder="e.g. 443"
|
|
||||||
on:change=move |e| {
|
on:change=move |e| {
|
||||||
port_filter.set(event_target_value(&e).parse().unwrap_or(0));
|
port_filter.set(event_target_value(&e));
|
||||||
page.set(1);
|
page.set(1);
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -165,7 +213,7 @@ fn FilterBar(
|
|||||||
}>
|
}>
|
||||||
<option value="0">"All applications"</option>
|
<option value="0">"All applications"</option>
|
||||||
{move || applications_res.get()
|
{move || applications_res.get()
|
||||||
.and_then(|r| r.ok())
|
.and_then(|r| (*r).clone().ok())
|
||||||
.map(|apps| apps.into_iter().map(|a| {
|
.map(|apps| apps.into_iter().map(|a| {
|
||||||
view! { <option value=a.id.to_string()>{a.name}</option> }
|
view! { <option value=a.id.to_string()>{a.name}</option> }
|
||||||
}).collect_view())
|
}).collect_view())
|
||||||
@@ -212,7 +260,6 @@ fn PaginationBar(
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
// Page navigation — hidden when showing all results (per_page == 0)
|
|
||||||
{move || (per_page.get() > 0).then(|| view! {
|
{move || (per_page.get() > 0).then(|| view! {
|
||||||
<div class="pagination-nav">
|
<div class="pagination-nav">
|
||||||
<button
|
<button
|
||||||
@@ -241,7 +288,7 @@ fn PaginationBar(
|
|||||||
#[component]
|
#[component]
|
||||||
fn HostTable(
|
fn HostTable(
|
||||||
hosts: Resource<Result<HostsPageData, ServerFnError>>,
|
hosts: Resource<Result<HostsPageData, ServerFnError>>,
|
||||||
delete_action: ServerAction<DeleteHost>,
|
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> }>
|
||||||
@@ -270,6 +317,7 @@ fn HostTable(
|
|||||||
<tbody>
|
<tbody>
|
||||||
{rows.into_iter().map(|host| {
|
{rows.into_iter().map(|host| {
|
||||||
let id = host.id;
|
let id = host.id;
|
||||||
|
let delete_name = host.name.clone();
|
||||||
view! {
|
view! {
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
@@ -287,7 +335,7 @@ fn HostTable(
|
|||||||
<td class="col-count">{host.application_count}</td>
|
<td class="col-count">{host.application_count}</td>
|
||||||
<td class="col-actions">
|
<td class="col-actions">
|
||||||
<button on:click=move |_| {
|
<button on:click=move |_| {
|
||||||
delete_action.dispatch(DeleteHost { id });
|
pending_delete.set(Some((id, delete_name.clone())));
|
||||||
}>
|
}>
|
||||||
"Delete"
|
"Delete"
|
||||||
</button>
|
</button>
|
||||||
@@ -308,24 +356,30 @@ fn HostTable(
|
|||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn HostsPage() -> impl IntoView {
|
pub fn HostsPage() -> impl IntoView {
|
||||||
// Actions
|
|
||||||
let create_action = ServerAction::<CreateHost>::new();
|
let create_action = ServerAction::<CreateHost>::new();
|
||||||
let delete_action = ServerAction::<DeleteHost>::new();
|
let delete_action = ServerAction::<DeleteHost>::new();
|
||||||
|
|
||||||
// Controls the add-host modal
|
|
||||||
let show_modal = RwSignal::new(false);
|
let show_modal = RwSignal::new(false);
|
||||||
|
|
||||||
// Filter signals (0 / "" = no filter)
|
// None = no modal, Some((id, name)) = delete confirmation open.
|
||||||
|
let pending_delete: RwSignal<Option<(i64, String)>> = RwSignal::new(None);
|
||||||
|
|
||||||
|
// Close the delete modal automatically after a successful deletion.
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if let Some(Ok(_)) = delete_action.value().get() {
|
||||||
|
pending_delete.set(None);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter signals ("" / 0 = no filter)
|
||||||
let name_filter = RwSignal::new(String::new());
|
let name_filter = RwSignal::new(String::new());
|
||||||
let network_id_filter = RwSignal::new(0i64);
|
let network_id_filter = RwSignal::new(0i64);
|
||||||
let port_filter = RwSignal::new(0i64);
|
let port_filter = RwSignal::new(String::new()); // CSV of port numbers
|
||||||
let app_id_filter = RwSignal::new(0i64);
|
let app_id_filter = RwSignal::new(0i64);
|
||||||
|
|
||||||
// Pagination signals
|
|
||||||
let page = RwSignal::new(1i64);
|
let page = RwSignal::new(1i64);
|
||||||
let per_page = RwSignal::new(15i64);
|
let per_page = RwSignal::new(15i64);
|
||||||
|
|
||||||
// Hosts resource — refetches whenever any filter/pagination/action changes
|
|
||||||
let hosts = Resource::new(
|
let hosts = Resource::new(
|
||||||
move || (
|
move || (
|
||||||
name_filter.get(),
|
name_filter.get(),
|
||||||
@@ -340,11 +394,9 @@ pub fn HostsPage() -> impl IntoView {
|
|||||||
|(name, net, port, app, p, pp, _, _)| get_hosts_page(name, net, port, app, p, pp),
|
|(name, net, port, app, p, pp, _, _)| get_hosts_page(name, net, port, app, p, pp),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Dropdown resources (fetched once on mount)
|
let networks_res = LocalResource::new(|| get_networks());
|
||||||
let networks_res = Resource::new(|| (), |_| get_networks());
|
let applications_res = LocalResource::new(|| get_applications());
|
||||||
let applications_res = Resource::new(|| (), |_| get_applications());
|
|
||||||
|
|
||||||
// Derived pagination signals
|
|
||||||
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.ok()).map(|p| p.total_pages).unwrap_or(1)
|
||||||
});
|
});
|
||||||
@@ -361,7 +413,6 @@ pub fn HostsPage() -> impl IntoView {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
// Modal — only rendered when show_modal is true
|
|
||||||
{move || show_modal.get().then(|| view! {
|
{move || show_modal.get().then(|| view! {
|
||||||
<AddHostModal
|
<AddHostModal
|
||||||
create_action=create_action
|
create_action=create_action
|
||||||
@@ -370,6 +421,15 @@ pub fn HostsPage() -> impl IntoView {
|
|||||||
/>
|
/>
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{move || pending_delete.get().map(|(host_id, host_name)| view! {
|
||||||
|
<DeleteHostModal
|
||||||
|
host_name=host_name
|
||||||
|
host_id=host_id
|
||||||
|
delete_action=delete_action
|
||||||
|
pending_delete=pending_delete
|
||||||
|
/>
|
||||||
|
})}
|
||||||
|
|
||||||
<FilterBar
|
<FilterBar
|
||||||
networks_res=networks_res
|
networks_res=networks_res
|
||||||
applications_res=applications_res
|
applications_res=applications_res
|
||||||
@@ -388,7 +448,7 @@ pub fn HostsPage() -> impl IntoView {
|
|||||||
|
|
||||||
<PaginationBar total=total page=page per_page=per_page total_pages=total_pages/>
|
<PaginationBar total=total page=page per_page=per_page total_pages=total_pages/>
|
||||||
|
|
||||||
<HostTable hosts=hosts delete_action=delete_action/>
|
<HostTable hosts=hosts pending_delete=pending_delete/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
}.into_any()
|
}.into_any()
|
||||||
|
|||||||
@@ -9,7 +9,10 @@
|
|||||||
// 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 home; // Home page
|
pub mod applications; // Applications list and creation
|
||||||
pub mod hosts; // Hosts list with filters and pagination
|
pub mod home; // Home page
|
||||||
pub mod networks; // Networks list and creation
|
pub mod host_detail; // Host detail: identity, ports, edit, delete
|
||||||
pub mod theme; // Theme toggle component (light / dark / system)
|
pub mod hosts; // Hosts list with filters and pagination
|
||||||
|
pub mod network_detail; // Network detail: info + paginated host list
|
||||||
|
pub mod networks; // Networks list and creation
|
||||||
|
pub mod theme; // Theme toggle component (light / dark / system)
|
||||||
|
|||||||
200
src/client/network_detail.rs
Normal file
200
src/client/network_detail.rs
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
// client/network_detail.rs — Network detail page
|
||||||
|
//
|
||||||
|
// Displays a single network (name + CIDR) with a paginated list of its hosts.
|
||||||
|
// Each host name links to /hosts/:id?back=/networks/:network_id so that the
|
||||||
|
// host detail page can offer a contextual "back to network" button.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::hooks::use_params_map;
|
||||||
|
|
||||||
|
use crate::api::{
|
||||||
|
hosts::{get_hosts_page, HostsPage as HostsPageData},
|
||||||
|
networks::get_network,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PER_PAGE_OPTIONS: &[(i64, &str)] = &[
|
||||||
|
(15, "15"),
|
||||||
|
(25, "25"),
|
||||||
|
(50, "50"),
|
||||||
|
(100, "100"),
|
||||||
|
(0, "All"),
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Main page component ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn NetworkDetailPage() -> impl IntoView {
|
||||||
|
let params = use_params_map();
|
||||||
|
let network_id = move || {
|
||||||
|
params.read().get("id")
|
||||||
|
.and_then(|s| s.parse::<i64>().ok())
|
||||||
|
.unwrap_or(0)
|
||||||
|
};
|
||||||
|
|
||||||
|
let page = RwSignal::new(1i64);
|
||||||
|
let per_page = RwSignal::new(15i64);
|
||||||
|
|
||||||
|
// Network metadata — reloads only when the ID changes.
|
||||||
|
let network = Resource::new(
|
||||||
|
move || network_id(),
|
||||||
|
|id| get_network(id),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Paginated host list for this network.
|
||||||
|
// Guards against network_id = 0 to avoid fetching all hosts.
|
||||||
|
let hosts = Resource::new(
|
||||||
|
move || (network_id(), page.get(), per_page.get()),
|
||||||
|
|(net_id, p, pp)| async move {
|
||||||
|
if net_id == 0 {
|
||||||
|
return Err(ServerFnError::new("Invalid network ID"));
|
||||||
|
}
|
||||||
|
get_hosts_page(String::new(), net_id, String::new(), 0, p, pp).await
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let total_pages = Signal::derive(move || {
|
||||||
|
hosts.get().and_then(|r| r.ok()).map(|d| d.total_pages).unwrap_or(1)
|
||||||
|
});
|
||||||
|
let total = Signal::derive(move || {
|
||||||
|
hosts.get().and_then(|r| r.ok()).map(|d| d.total).unwrap_or(0)
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="network-detail-page">
|
||||||
|
<Suspense fallback=|| view! { <p class="empty">"Loading network…"</p> }>
|
||||||
|
{move || network.get().map(|result| match result {
|
||||||
|
Err(e) => view! {
|
||||||
|
<p class="error">"Could not load network: " {e.to_string()}</p>
|
||||||
|
}.into_any(),
|
||||||
|
|
||||||
|
Ok(net) => {
|
||||||
|
let net_id = net.id;
|
||||||
|
view! {
|
||||||
|
// ── Header ────────────────────────────────────────
|
||||||
|
<div class="page-header detail-page-header">
|
||||||
|
<a class="back-btn" href="/networks">"← Networks"</a>
|
||||||
|
<h1 class="detail-page-title">{net.name}</h1>
|
||||||
|
<p class="network-detail-cidr">{net.cidr}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// ── Hosts section ────────────────────────────────
|
||||||
|
<section class="detail-section">
|
||||||
|
<h2 class="detail-section__title">"Hosts"</h2>
|
||||||
|
|
||||||
|
// Pagination bar
|
||||||
|
<div class="pagination-bar">
|
||||||
|
<div class="pagination-bar__info">
|
||||||
|
{move || {
|
||||||
|
let t = total.get();
|
||||||
|
if t == 0 { "No hosts".to_string() }
|
||||||
|
else { format!("{} host{}", t, if t == 1 { "" } else { "s" }) }
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="pagination-bar__controls">
|
||||||
|
<label class="pagination-per-page">
|
||||||
|
"Per page "
|
||||||
|
<select on:change=move |e| {
|
||||||
|
per_page.set(
|
||||||
|
event_target_value(&e).parse().unwrap_or(15)
|
||||||
|
);
|
||||||
|
page.set(1);
|
||||||
|
}>
|
||||||
|
{PER_PAGE_OPTIONS.iter().map(|(v, label)| {
|
||||||
|
view! {
|
||||||
|
<option value=v.to_string() selected=*v == 15>
|
||||||
|
{*label}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{move || (per_page.get() > 0).then(|| view! {
|
||||||
|
<div class="pagination-nav">
|
||||||
|
<button
|
||||||
|
disabled=move || page.get() <= 1
|
||||||
|
on:click=move |_| page.update(|p| *p = (*p - 1).max(1))
|
||||||
|
>"‹"</button>
|
||||||
|
<span class="pagination-nav__label">
|
||||||
|
{move || format!(
|
||||||
|
"Page {} of {}",
|
||||||
|
page.get(),
|
||||||
|
total_pages.get().max(1)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
disabled={move || page.get() >= total_pages.get()}
|
||||||
|
on:click=move |_| {
|
||||||
|
let max = total_pages.get_untracked();
|
||||||
|
page.update(|p| *p = (*p + 1).min(max));
|
||||||
|
}
|
||||||
|
>"›"</button>
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Host table
|
||||||
|
<Suspense fallback=|| view! {
|
||||||
|
<p class="empty">"Loading hosts…"</p>
|
||||||
|
}>
|
||||||
|
{move || hosts.get().map(|result| match result {
|
||||||
|
Err(e) => view! {
|
||||||
|
<p class="error">
|
||||||
|
"Could not load hosts: " {e.to_string()}
|
||||||
|
</p>
|
||||||
|
}.into_any(),
|
||||||
|
|
||||||
|
Ok(HostsPageData { rows, .. }) if rows.is_empty() => view! {
|
||||||
|
<p class="empty">"No hosts in this network."</p>
|
||||||
|
}.into_any(),
|
||||||
|
|
||||||
|
Ok(HostsPageData { rows, .. }) => view! {
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>"Name"</th>
|
||||||
|
<th>"IP"</th>
|
||||||
|
<th class="col-count">"Ports"</th>
|
||||||
|
<th class="col-count">"Apps"</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.into_iter().map(|host| {
|
||||||
|
// Pass the current network as the back destination
|
||||||
|
// so the host detail page can link back here.
|
||||||
|
let href = format!(
|
||||||
|
"/hosts/{}?back=/networks/{}",
|
||||||
|
host.id, net_id
|
||||||
|
);
|
||||||
|
view! {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a class="table-link" href=href>
|
||||||
|
{host.name}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="cell-mono">{host.ip}</td>
|
||||||
|
<td class="col-count">
|
||||||
|
{host.port_count}
|
||||||
|
</td>
|
||||||
|
<td class="col-count">
|
||||||
|
{host.application_count}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}.into_any(),
|
||||||
|
})}
|
||||||
|
</Suspense>
|
||||||
|
</section>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
@@ -166,9 +166,15 @@ pub fn NetworksPage() -> impl IntoView {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|network| {
|
.map(|network| {
|
||||||
let network_clone = network.clone();
|
let network_clone = network.clone();
|
||||||
|
let net_id = network.id;
|
||||||
view! {
|
view! {
|
||||||
<tr>
|
<tr>
|
||||||
<td>{network.name}</td>
|
<td>
|
||||||
|
<a class="table-link"
|
||||||
|
href=format!("/networks/{}", net_id)>
|
||||||
|
{network.name}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
<td class="cell-mono">{network.cidr}</td>
|
<td class="cell-mono">{network.cidr}</td>
|
||||||
<td class="col-count">{network.host_count}</td>
|
<td class="col-count">{network.host_count}</td>
|
||||||
<td class="col-count">{network.application_count}</td>
|
<td class="col-count">{network.application_count}</td>
|
||||||
|
|||||||
@@ -62,6 +62,33 @@ pub async fn create_host(
|
|||||||
Ok(row_to_host(&row))
|
Ok(row_to_host(&row))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Updates a host's name, IP address, and network assignment.
|
||||||
|
///
|
||||||
|
/// Returns the updated host, or `None` if the id does not exist.
|
||||||
|
/// The caller must validate that `ip` falls within the CIDR range of the
|
||||||
|
/// new `network_id` before calling this function.
|
||||||
|
pub async fn update_host(
|
||||||
|
pool: &AnyPool,
|
||||||
|
id: i64,
|
||||||
|
name: &str,
|
||||||
|
ip: &str,
|
||||||
|
network_id: i64,
|
||||||
|
) -> Result<Option<Host>, DbError> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"UPDATE hosts SET name = $1, ip = $2, network_id = $3
|
||||||
|
WHERE id = $4
|
||||||
|
RETURNING id, name, ip, network_id",
|
||||||
|
)
|
||||||
|
.bind(name)
|
||||||
|
.bind(ip)
|
||||||
|
.bind(network_id)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(row.as_ref().map(row_to_host))
|
||||||
|
}
|
||||||
|
|
||||||
/// Deletes a host and all its port associations (via `ON DELETE CASCADE`).
|
/// Deletes a host and all 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.
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ pub async fn list_networks(pool: &AnyPool) -> Result<Vec<Network>, DbError> {
|
|||||||
pub async fn find_network(pool: &AnyPool, id: i64) -> Result<Option<Network>, DbError> {
|
pub async fn find_network(pool: &AnyPool, id: i64) -> Result<Option<Network>, DbError> {
|
||||||
// `fetch_optional` returns `Ok(None)` when no row matches — unlike
|
// `fetch_optional` returns `Ok(None)` when no row matches — unlike
|
||||||
// `fetch_one`, which returns an error when nothing is found.
|
// `fetch_one`, which returns an error when nothing is found.
|
||||||
let row = sqlx::query("SELECT id, cidr FROM networks WHERE id = $1")
|
let row = sqlx::query("SELECT id, name, cidr FROM networks WHERE id = $1")
|
||||||
.bind(id) // `$1` is replaced with the value of `id` at runtime
|
.bind(id) // `$1` is replaced with the value of `id` at runtime
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -784,6 +784,13 @@ td.col-actions {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
font-size: var(--font-xs, 0.72rem);
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-muted, var(--text-secondary));
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
PAGINATION BAR
|
PAGINATION BAR
|
||||||
============================================================ */
|
============================================================ */
|
||||||
@@ -1014,6 +1021,67 @@ td.col-actions {
|
|||||||
background: var(--accent-hover);
|
background: var(--accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Solid danger button — used for prominent destructive actions (page header) */
|
||||||
|
.btn-danger-solid {
|
||||||
|
background: var(--danger);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--danger);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
padding: 8px var(--size-md);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger-solid:hover {
|
||||||
|
filter: brightness(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Left cluster inside page header: back button stacked above title */
|
||||||
|
.page-header__left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detail page header: back button then centred title */
|
||||||
|
.detail-page-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Self-align the back button to the left while the title stays centred */
|
||||||
|
.detail-page-header .back-btn {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-page-title {
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: var(--font-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 3px 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
/* Delete button inside hosts table */
|
/* Delete button inside hosts table */
|
||||||
.hosts-page td button {
|
.hosts-page td button {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -1030,3 +1098,128 @@ td.col-actions {
|
|||||||
background: var(--danger-light);
|
background: var(--danger-light);
|
||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
HOST DETAIL PAGE
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.host-detail-page {
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card-like section grouping related fields */
|
||||||
|
.detail-section {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--size-lg);
|
||||||
|
margin-bottom: var(--size-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section__title {
|
||||||
|
font-size: var(--font-base);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin: 0 0 var(--size-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stack of label + input pairs */
|
||||||
|
.detail-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--size-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--size-xs);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-field input,
|
||||||
|
.detail-field select {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Save button aligned to the right */
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: var(--size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Ports list ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.port-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: var(--size-md);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--size-md);
|
||||||
|
padding: var(--size-sm) var(--size-xs);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-row__number {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 4ch;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-row__desc {
|
||||||
|
flex: 1;
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add port row: input + button side by side */
|
||||||
|
.port-add-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--size-sm);
|
||||||
|
padding-top: var(--size-sm);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin-top: var(--size-sm);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-add-row input[type="number"] {
|
||||||
|
width: 200px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Delete button anchored to the bottom-right of the detail page */
|
||||||
|
.danger-zone {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: var(--size-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
NETWORK DETAIL PAGE
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.network-detail-page {
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CIDR displayed below the network name in the header */
|
||||||
|
.network-detail-cidr {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user