Compare commits

..

14 Commits

Author SHA1 Message Date
1b55b13541 feat(applications): replace inline add form with modal and add name filter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:53:40 +02:00
4d0be98160 feat(applications): add applications list page with host count and delete modal
- API: ApplicationWithCounts struct + get_applications_with_counts() — counts
  distinct hosts linked via matching ports (application_ports ↔ host_ports)
- ApplicationsPage at /applications: inline add form, table with Name and
  Hosts columns, delete confirmation modal showing affected host count
- Nav: add Applications link

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:48:53 +02:00
62e9609fe8 feat(hosts): add delete confirmation modal on hosts list page
Replace the direct dispatch on the Delete button with a pending_delete
signal (id + name). A DeleteHostModal identical to the one in host detail
opens for confirmation before the action is dispatched. The modal closes
automatically after a successful deletion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:45:33 +02:00
7274157a80 fix(network-detail): wrap disabled attr in braces to fix pagination next button
The view! macro misparses `disabled=move || expr >= other` because >= without
braces is ambiguous — the rest of the expression renders as text content.
Fix: `disabled={move || ...}`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:42:29 +02:00
c3e2d5dcf6 feat(networks): add network detail page with paginated host list and contextual back button
- API: add get_network(id) server function
- NetworkDetailPage at /networks/:id — network name + CIDR header, paginated
  host table (Name, IP, Ports, Apps) linking to /hosts/:id?back=/networks/:id
- Networks list: make network name a link to its detail page
- HostDetailPage: read ?back= query param to show "← Network" or "← Hosts"
  and navigate to the correct destination

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:37:06 +02:00
ba4d2a60c6 style(host-detail): centre host title, move delete button to bottom-right
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:30:19 +02:00
577a655aee style(host-detail): polish buttons, back link and port list
- Save changes / Add port: add btn-primary class for consistent blue accent
- Back button: stacked above page title, styled as a small bordered button
- Port list: remove row background, replace full border with bottom separator only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:26:40 +02:00
2a6d925e59 feat(hosts): add host detail page with identity edit, port management and delete
- Repository: add update_host (name, IP, network reassignment with CIDR validation)
- API: get_host_detail (host + resolved network + ports), update_host,
  add_host_port, remove_host_port server functions
- Client: HostDetailPage at /hosts/:id — identity form, ports list with
  per-port Remove button, Add port input, delete confirmation modal with
  navigation back to /hosts on success
- CSS: detail-section cards, port-row list, btn-danger-solid, back-link

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:21:00 +02:00
0221ce26f9 feat(seeds): add port catalog and host port assignments to dev seed
Adds 25 common ports (SSH, HTTP/S, SMTP, PostgreSQL, etc.) to the ports
catalog and assigns realistic open ports to each seeded host based on its
role (web server, database, NAS, VPN gateway, etc.).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:11:52 +02:00
d2284727a2 fix(hosts): use LocalResource for network/app dropdowns to fix hydration blank
Resource::new() with SSR returns None during hydration outside <Suspense>,
causing dropdowns to stay empty on direct page load. LocalResource fetches
client-side only, bypassing the hydration mismatch entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:08:53 +02:00
eef0ae0b54 fix(hosts): remove port filter hint to fix filter bar alignment
The field-hint span made the port field taller than others; with
align-items: end on the grid, the input was offset upward.
The placeholder now carries the same information.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:58:39 +02:00
19dda00c17 fix(config): add bin-target to avoid cargo-leptos/seed binary conflict
Without bin-target, cargo-leptos fails when multiple binaries exist
(rust-ipam + seed). Specifying the main server binary fixes the issue.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:57:26 +02:00
df6aecef51 fix(repository): add missing name column in find_network query
The sed replacement during the network name feature didn't update
find_network's SELECT, causing a ColumnNotFound panic on host creation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:55:35 +02:00
6018874aa4 feat(hosts): multi-port filter and port list on host creation
- Network dropdowns now show "Name - CIDR" in both filter bar and add modal
- Port filter accepts comma-separated ports (e.g. "80, 443"); a host must
  have ALL listed ports open to match (AND semantics)
- Add host modal has a new "Open ports" field (comma-separated); ports are
  registered in the catalog and linked to the host on creation
- Port conditions are inlined as validated integers in SQL (no injection risk)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:50:26 +02:00
16 changed files with 1623 additions and 87 deletions

View File

@@ -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)

View File

@@ -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;

View File

@@ -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';

View File

@@ -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> {

View File

@@ -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(',')
.filter_map(|s| s.trim().parse::<i64>().ok())
.filter(|&p| p >= 1 && p <= 65535)
.collect();
// One EXISTS clause per required port (AND semantics: host must have ALL ports).
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 host_ports hp ON hp.host_id = h.id
LEFT JOIN application_ports ap ON ap.port_number = hp.port_number LEFT JOIN application_ports ap ON ap.port_number = hp.port_number
WHERE ($1 IS NULL OR LOWER(h.name) LIKE LOWER($1)) WHERE ($1 IS NULL OR LOWER(h.name) LIKE LOWER($1))
AND ($2 IS NULL OR h.network_id = $2) AND ($2 IS NULL OR h.network_id = $2)
AND ($3 IS NULL OR EXISTS ( 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 SELECT 1 FROM host_ports hp2
JOIN application_ports ap2 ON ap2.port_number = hp2.port_number JOIN application_ports ap2 ON ap2.port_number = hp2.port_number
WHERE hp2.host_id = h.id AND ap2.application_id = $4 WHERE hp2.host_id = h.id AND ap2.application_id = $3
))"; ))
{port_conditions}"
);
// Count matching hosts (ignoring pagination). let count_sql = format!("SELECT COUNT(DISTINCT h.id) FROM hosts h {where_clause}");
let count_sql = format!("SELECT COUNT(DISTINCT h.id) FROM hosts h {WHERE}");
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()))
} }

View File

@@ -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.

View File

@@ -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
View 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
View 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 (165535)"
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()
}

View File

@@ -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()

View File

@@ -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 applications; // Applications list and creation
pub mod home; // Home page pub mod home; // Home page
pub mod host_detail; // Host detail: identity, ports, edit, delete
pub mod hosts; // Hosts list with filters and pagination 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 networks; // Networks list and creation
pub mod theme; // Theme toggle component (light / dark / system) pub mod theme; // Theme toggle component (light / dark / system)

View 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()
}

View File

@@ -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>

View File

@@ -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.

View File

@@ -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?;

View File

@@ -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;
}