Compare commits

..

36 Commits

Author SHA1 Message Date
e68845a2ce fix(navigation): back button on host detail returns to application when coming from there
- application_detail: host links now include ?back=/applications/:id
- host_detail: back_label handles /applications/ prefix → "← Application"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 23:01:05 +02:00
5b3170c6d1 feat(seed): add homelab applications with associated ports
Add 16 common homelab applications (Nginx, Pi-hole, WireGuard, OpenVPN,
PostgreSQL, MariaDB, Redis, Grafana, Prometheus, Elasticsearch, Kibana,
Portainer, Jellyfin, Home Assistant, Syncthing, Vaultwarden) with their
standard port associations.

Also extends the ports catalog with 10 new entries (WireGuard, Syncthing,
Jellyfin, Home Assistant, Portainer, MariaDB, Redis, etc.) and logs the
application count in the seed binary output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 22:51:46 +02:00
624839849f fix(host-detail): restore flex-grow on app link to right-align remove button
The <span class="app-row__name"> (flex: 1) was replaced by an <a> without
that class, so the Remove button lost its right-alignment. Adding
app-row__name to the <a> restores the layout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 22:43:55 +02:00
255f20cda4 feat(navigation): link app names on host detail to app detail with back button
- Host detail: application names are now clickable links to
  /applications/:id?back=/hosts/:host_id
- Application detail: back button reads the ?back query param and
  returns to the originating host page (label "← Host") or falls
  back to /applications ("← Applications")

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 22:38:39 +02:00
a8d98aeee2 style(applications): use btn-danger class on table delete button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 22:22:35 +02:00
bef28f44a1 feat(applications): add application detail page
- New page /applications/:id with identity (editable name), associated
  ports (add/remove), linked hosts (read-only via shared ports), and
  delete with confirmation modal
- Add get_application_detail and update_application server functions
- Add ApplicationDetail and HostRef types in api/applications
- Add update_application to the repository layer
- Application names in the list are now clickable links

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 22:17:49 +02:00
cf0a095ada feat(applications): add ports field to create application form
The add-application modal now accepts a comma-separated list of port
numbers (same UX as the add-host form). Ports are associated with the
new application atomically in create_application on the server side.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 22:06:24 +02:00
353fe09a99 fix(hosts,applications): fix wasm-bindgen panic when closing modal
Closing a modal by clicking the backdrop, Cancel, or × called
show_modal.set(false) synchronously inside a wasm-bindgen closure.
Leptos immediately unmounts the modal, freeing all its closures
while the click handler is still on the call stack, which causes
wasm-bindgen to panic with "closure invoked after being dropped".

Fix: introduce a close() helper that defers set(false) to the next
microtask via spawn_local, so the closure returns to wasm-bindgen
before the modal is unmounted.

Also switch autofocus from Effect+get() to spawn_local+get_untracked()
to avoid subscribing NodeRef as a reactive dependency, which would
re-trigger during unmount and risk the same panic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:49:33 +02:00
5228a76468 fix(hosts,applications): fix modal re-open bug and autofocus first field
Move the add-modal auto-close Effect from each modal component to its
parent page component. This prevents the stale-value re-trigger bug
where the Effect would immediately close the modal on second open
because action.value() still held the previous Ok result.

Also add autofocus on the first input field of each add modal using
NodeRef<Input> so the user can start typing immediately on open.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:33:33 +02:00
60e02ca453 fix(host-detail): move modals outside Suspense and auto-close Effect to parent
Modals rendered inside <Suspense> were unmounted each time the host
resource re-fetched, killing their reactive subscriptions and preventing
them from reopening. Moving them to the <div> level above <Suspense>
keeps them alive across re-fetches.

The auto-close Effect for the add-app modal is also moved from
AddAppModal to HostDetailPage so it is never recreated across
open/close cycles, avoiding the stale-value re-trigger bug.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:33:28 +02:00
052711b720 fix(host-detail): fix add-app modal not reopening after successful addition
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:19:04 +02:00
f5058bd54a fix(host-detail): switch host resource to LocalResource to fix hydration mismatch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:12:30 +02:00
54a5c2525f feat(host-detail): replace checkboxes with pick list + selected tags in add-app modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:07:58 +02:00
359d67fabc fix(hosts): switch hosts resource to LocalResource to fix hydration warning
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 03:12:14 +02:00
5789aba86b feat(host-detail): add direct host-application association with modal multi-select
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 03:08:58 +02:00
a6ce382eb5 chore(build): exclude seed binary from WASM build via required-features
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:58:35 +02:00
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
e0ddf58a17 docs(claude): fix network architecture description to include name field
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:40:49 +02:00
d9ee121fbb feat(networks): add name field to networks
- Migration 0007: ALTER TABLE networks ADD COLUMN name TEXT NOT NULL DEFAULT ''
- Network model, repository, and API updated to include name
- Networks page: name input in the add form, Name column as first column in table
- Delete modal now shows "Name (CIDR)" for clarity
- Hosts page: network dropdowns now show network name instead of CIDR
- Seeds updated with names (LAN, DMZ, Corporate, VPN)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:38:40 +02:00
e17b8ee722 feat(seed): add dev seed binary with networks and hosts
Creates a self-contained `seed` binary (cargo run --features ssr --bin seed)
that loads realistic test data into the database. Idempotent: safe to run
multiple times without creating duplicates.

Data: 4 networks (LAN, DMZ, corporate, VPN) and 17 hosts spread across them.
Both SQLite and PostgreSQL seed files are provided.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:33:21 +02:00
55d8ed9f72 feat(networks): add delete confirmation modal with host count warning
Show a modal before deleting a network. If the network has hosts,
display a warning with the exact count since they will be cascade-deleted.
Host count comes from the existing NetworkWithCounts data (no extra query).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:28:12 +02:00
30dd1ad0b0 fix(config): wire cargo-leptos features and CSS source file
- Add bin-features/lib-features so cargo-leptos enables ssr/hydrate
  correctly (server was exiting immediately with empty main otherwise)
- Add style-file so the CSS bundle is no longer empty
- Replace #[cfg(target_arch = "wasm32")] with #[cfg(feature = "hydrate")]
  in theme.rs to match when web-sys is actually available

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:25:10 +02:00
a4fc5b176f feat(hosts): replace inline add form with modal dialog
The add-host form is now opened via a "+ Add host" button in the page
header. The modal closes on Cancel, backdrop click, × button, or
automatically after a successful creation.

Adds modal CSS with backdrop blur and entry animation, .btn-primary /
.btn-secondary shared button styles, and a .page-header flex layout
reusable across list pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 23:57:08 +02:00
26 changed files with 3399 additions and 228 deletions

View File

@@ -46,6 +46,6 @@
- Les ports peuvent avoir une description pour indiquer quel est le protocole le plus probable d'être utiliser sur ce port (ex: 22 - SSH, 53 - DNS, 80 - HTTP, 443 - HTTPS)
- Un port peut être associé à une application, l'association n'est pas strict car un port peut être utilisé par plusieurs applications.
- Une application possede un nom, un ou plusieurs ports.
- Un réseaux et définit par son CIDR (ex: 192.168.1.0/24)
- Un réseaux est définit par son nom et son CIDR (ex: 192.168.1.0/24)
- L'application peut gérer plusieurs réseaux distinct.
- Chaques hôtes doit appartenir au réseaux dans lequel il est définit.

View File

@@ -6,6 +6,15 @@ edition = "2021"
# Leptos nécessite deux formats de compilation :
# - rlib : bibliothèque normale, utilisée par le serveur Axum
# - cdylib : bibliothèque dynamique compilée en WebAssembly pour le navigateur
[[bin]]
name = "rust-ipam"
path = "src/main.rs"
[[bin]]
name = "seed"
path = "src/bin/seed.rs"
required-features = ["ssr"]
[lib]
crate-type = ["cdylib", "rlib"]
@@ -91,6 +100,11 @@ site-root = "target/site" # Dossier racine des fichiers compilés par trunk
site-pkg-dir = "pkg" # Sous-dossier des assets WASM/JS dans site-root
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
style-file = "style/rust-ipam.css" # Source CSS compilé dans pkg/rust-ipam.css
# 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)
lib-features = ["hydrate"] # WASM bundle (browser)
# Profil de compilation WASM optimisé pour réduire la taille du fichier .wasm
# Un fichier WASM plus petit = page qui charge plus vite

View File

@@ -0,0 +1,3 @@
-- Add a human-readable name to networks.
-- DEFAULT '' allows the migration to run on databases that already have rows.
ALTER TABLE networks ADD COLUMN name TEXT NOT NULL DEFAULT '';

View File

@@ -0,0 +1,8 @@
-- host_applications: direct association between a host and an application.
-- Allows explicitly tagging a host with an application regardless of ports.
-- One application can only be linked once to a given host.
CREATE TABLE IF NOT EXISTS host_applications (
host_id BIGINT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
application_id BIGINT NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
PRIMARY KEY (host_id, application_id)
);

View File

@@ -0,0 +1,3 @@
-- Add a human-readable name to networks.
-- DEFAULT '' allows the migration to run on databases that already have rows.
ALTER TABLE networks ADD COLUMN name TEXT NOT NULL DEFAULT '';

View File

@@ -0,0 +1,8 @@
-- host_applications: direct association between a host and an application.
-- Allows explicitly tagging a host with an application regardless of ports.
-- One application can only be linked once to a given host.
CREATE TABLE IF NOT EXISTS host_applications (
host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
application_id INTEGER NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
PRIMARY KEY (host_id, application_id)
);

286
seeds/postgres/dev_seed.sql Normal file
View File

@@ -0,0 +1,286 @@
-- dev_seed.sql (PostgreSQL) — development test data
--
-- Running this script is idempotent: existing rows are left untouched
-- and missing rows are inserted. Safe to run multiple times.
--
-- Load with: cargo run --features ssr --bin seed
-- ── Networks ──────────────────────────────────────────────────────────────────
INSERT INTO networks (name, cidr) VALUES
('LAN', '192.168.1.0/24'),
('DMZ', '192.168.10.0/24'),
('Corporate', '10.0.0.0/8'),
('VPN', '172.16.0.0/16')
ON CONFLICT (cidr) DO NOTHING;
-- ── Hosts ─────────────────────────────────────────────────────────────────────
-- LAN — 192.168.1.0/24
INSERT INTO hosts (name, ip, network_id)
SELECT name, ip, (SELECT id FROM networks WHERE cidr = '192.168.1.0/24')
FROM (VALUES
('gateway', '192.168.1.1'),
('workstation-01', '192.168.1.10'),
('workstation-02', '192.168.1.11'),
('workstation-03', '192.168.1.12'),
('nas-01', '192.168.1.20'),
('printer-01', '192.168.1.50')
) AS t(name, ip)
WHERE NOT EXISTS (SELECT 1 FROM hosts WHERE hosts.name = t.name AND hosts.ip = t.ip);
-- DMZ — 192.168.10.0/24
INSERT INTO hosts (name, ip, network_id)
SELECT name, ip, (SELECT id FROM networks WHERE cidr = '192.168.10.0/24')
FROM (VALUES
('web-server-01', '192.168.10.10'),
('web-server-02', '192.168.10.11'),
('db-server-01', '192.168.10.20'),
('mail-server-01', '192.168.10.30')
) AS t(name, ip)
WHERE NOT EXISTS (SELECT 1 FROM hosts WHERE hosts.name = t.name AND hosts.ip = t.ip);
-- Corporate backbone — 10.0.0.0/8
INSERT INTO hosts (name, ip, network_id)
SELECT name, ip, (SELECT id FROM networks WHERE cidr = '10.0.0.0/8')
FROM (VALUES
('core-switch-01', '10.0.0.1'),
('monitoring-01', '10.0.1.10'),
('backup-server-01', '10.0.1.20'),
('log-server-01', '10.0.1.30')
) AS t(name, ip)
WHERE NOT EXISTS (SELECT 1 FROM hosts WHERE hosts.name = t.name AND hosts.ip = t.ip);
-- VPN — 172.16.0.0/16
INSERT INTO hosts (name, ip, network_id)
SELECT name, ip, (SELECT id FROM networks WHERE cidr = '172.16.0.0/16')
FROM (VALUES
('vpn-gateway-01', '172.16.0.1'),
('vpn-client-01', '172.16.1.10'),
('vpn-client-02', '172.16.1.11')
) AS t(name, 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 / Gitea'),
(3306, 'MariaDB / MySQL'),
(3389, 'RDP'),
(4500, 'IPSec NAT-T'),
(5044, 'Logstash Beats'),
(5432, 'PostgreSQL'),
(5601, 'Kibana'),
(6379, 'Redis'),
(8096, 'Jellyfin'),
(8123, 'Home Assistant'),
(8384, 'Syncthing UI'),
(8920, 'Jellyfin HTTPS'),
(9000, 'Portainer'),
(9090, 'Prometheus'),
(9100, 'node_exporter / JetDirect'),
(9200, 'Elasticsearch'),
(9443, 'Portainer HTTPS'),
(22000, 'Syncthing'),
(51820, 'WireGuard')
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;
-- ── Applications ──────────────────────────────────────────────────────────────
-- applications has no UNIQUE constraint on name, so we use WHERE NOT EXISTS.
INSERT INTO applications (name)
SELECT v.name FROM (VALUES
('Nginx'),
('Pi-hole'),
('WireGuard'),
('OpenVPN'),
('PostgreSQL'),
('MariaDB'),
('Redis'),
('Grafana'),
('Prometheus'),
('Elasticsearch'),
('Kibana'),
('Portainer'),
('Jellyfin'),
('Home Assistant'),
('Syncthing'),
('Vaultwarden')
) AS v(name)
WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = v.name);
-- ── Application ports ─────────────────────────────────────────────────────────
-- Nginx: HTTP, HTTPS
INSERT INTO application_ports (application_id, port_number) SELECT id, 80 FROM applications WHERE name = 'Nginx' ON CONFLICT DO NOTHING;
INSERT INTO application_ports (application_id, port_number) SELECT id, 443 FROM applications WHERE name = 'Nginx' ON CONFLICT DO NOTHING;
-- Pi-hole: DNS, HTTP (admin UI), HTTPS
INSERT INTO application_ports (application_id, port_number) SELECT id, 53 FROM applications WHERE name = 'Pi-hole' ON CONFLICT DO NOTHING;
INSERT INTO application_ports (application_id, port_number) SELECT id, 80 FROM applications WHERE name = 'Pi-hole' ON CONFLICT DO NOTHING;
INSERT INTO application_ports (application_id, port_number) SELECT id, 443 FROM applications WHERE name = 'Pi-hole' ON CONFLICT DO NOTHING;
-- WireGuard
INSERT INTO application_ports (application_id, port_number) SELECT id, 51820 FROM applications WHERE name = 'WireGuard' ON CONFLICT DO NOTHING;
-- OpenVPN
INSERT INTO application_ports (application_id, port_number) SELECT id, 1194 FROM applications WHERE name = 'OpenVPN' ON CONFLICT DO NOTHING;
-- PostgreSQL
INSERT INTO application_ports (application_id, port_number) SELECT id, 5432 FROM applications WHERE name = 'PostgreSQL' ON CONFLICT DO NOTHING;
-- MariaDB
INSERT INTO application_ports (application_id, port_number) SELECT id, 3306 FROM applications WHERE name = 'MariaDB' ON CONFLICT DO NOTHING;
-- Redis
INSERT INTO application_ports (application_id, port_number) SELECT id, 6379 FROM applications WHERE name = 'Redis' ON CONFLICT DO NOTHING;
-- Grafana
INSERT INTO application_ports (application_id, port_number) SELECT id, 3000 FROM applications WHERE name = 'Grafana' ON CONFLICT DO NOTHING;
-- Prometheus
INSERT INTO application_ports (application_id, port_number) SELECT id, 9090 FROM applications WHERE name = 'Prometheus' ON CONFLICT DO NOTHING;
-- Elasticsearch
INSERT INTO application_ports (application_id, port_number) SELECT id, 9200 FROM applications WHERE name = 'Elasticsearch' ON CONFLICT DO NOTHING;
-- Kibana
INSERT INTO application_ports (application_id, port_number) SELECT id, 5601 FROM applications WHERE name = 'Kibana' ON CONFLICT DO NOTHING;
-- Portainer: HTTP, HTTPS
INSERT INTO application_ports (application_id, port_number) SELECT id, 9000 FROM applications WHERE name = 'Portainer' ON CONFLICT DO NOTHING;
INSERT INTO application_ports (application_id, port_number) SELECT id, 9443 FROM applications WHERE name = 'Portainer' ON CONFLICT DO NOTHING;
-- Jellyfin: HTTP, HTTPS
INSERT INTO application_ports (application_id, port_number) SELECT id, 8096 FROM applications WHERE name = 'Jellyfin' ON CONFLICT DO NOTHING;
INSERT INTO application_ports (application_id, port_number) SELECT id, 8920 FROM applications WHERE name = 'Jellyfin' ON CONFLICT DO NOTHING;
-- Home Assistant
INSERT INTO application_ports (application_id, port_number) SELECT id, 8123 FROM applications WHERE name = 'Home Assistant' ON CONFLICT DO NOTHING;
-- Syncthing: UI, data sync
INSERT INTO application_ports (application_id, port_number) SELECT id, 8384 FROM applications WHERE name = 'Syncthing' ON CONFLICT DO NOTHING;
INSERT INTO application_ports (application_id, port_number) SELECT id, 22000 FROM applications WHERE name = 'Syncthing' ON CONFLICT DO NOTHING;
-- Vaultwarden: HTTP, HTTPS
INSERT INTO application_ports (application_id, port_number) SELECT id, 80 FROM applications WHERE name = 'Vaultwarden' ON CONFLICT DO NOTHING;
INSERT INTO application_ports (application_id, port_number) SELECT id, 443 FROM applications WHERE name = 'Vaultwarden' ON CONFLICT DO NOTHING;

242
seeds/sqlite/dev_seed.sql Normal file
View File

@@ -0,0 +1,242 @@
-- dev_seed.sql (SQLite) — development test data
--
-- Running this script is idempotent: existing rows are left untouched
-- and missing rows are inserted. Safe to run multiple times.
--
-- Load with: cargo run --features ssr --bin seed
-- ── Networks ──────────────────────────────────────────────────────────────────
INSERT OR IGNORE INTO networks (name, cidr) VALUES ('LAN', '192.168.1.0/24');
INSERT OR IGNORE INTO networks (name, cidr) VALUES ('DMZ', '192.168.10.0/24');
INSERT OR IGNORE INTO networks (name, cidr) VALUES ('Corporate', '10.0.0.0/8');
INSERT OR IGNORE INTO networks (name, cidr) VALUES ('VPN', '172.16.0.0/16');
-- ── Hosts ─────────────────────────────────────────────────────────────────────
-- Hosts have no UNIQUE constraint, so we guard each insert with WHERE NOT EXISTS.
-- Network IDs are resolved by subquery on cidr for portability.
-- LAN — 192.168.1.0/24
INSERT INTO hosts (name, ip, network_id) SELECT 'gateway', '192.168.1.1', id FROM networks WHERE cidr = '192.168.1.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'gateway' AND ip = '192.168.1.1');
INSERT INTO hosts (name, ip, network_id) SELECT 'workstation-01', '192.168.1.10', id FROM networks WHERE cidr = '192.168.1.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'workstation-01' AND ip = '192.168.1.10');
INSERT INTO hosts (name, ip, network_id) SELECT 'workstation-02', '192.168.1.11', id FROM networks WHERE cidr = '192.168.1.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'workstation-02' AND ip = '192.168.1.11');
INSERT INTO hosts (name, ip, network_id) SELECT 'workstation-03', '192.168.1.12', id FROM networks WHERE cidr = '192.168.1.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'workstation-03' AND ip = '192.168.1.12');
INSERT INTO hosts (name, ip, network_id) SELECT 'nas-01', '192.168.1.20', id FROM networks WHERE cidr = '192.168.1.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'nas-01' AND ip = '192.168.1.20');
INSERT INTO hosts (name, ip, network_id) SELECT 'printer-01', '192.168.1.50', id FROM networks WHERE cidr = '192.168.1.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'printer-01' AND ip = '192.168.1.50');
-- DMZ — 192.168.10.0/24
INSERT INTO hosts (name, ip, network_id) SELECT 'web-server-01', '192.168.10.10', id FROM networks WHERE cidr = '192.168.10.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'web-server-01' AND ip = '192.168.10.10');
INSERT INTO hosts (name, ip, network_id) SELECT 'web-server-02', '192.168.10.11', id FROM networks WHERE cidr = '192.168.10.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'web-server-02' AND ip = '192.168.10.11');
INSERT INTO hosts (name, ip, network_id) SELECT 'db-server-01', '192.168.10.20', id FROM networks WHERE cidr = '192.168.10.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'db-server-01' AND ip = '192.168.10.20');
INSERT INTO hosts (name, ip, network_id) SELECT 'mail-server-01', '192.168.10.30', id FROM networks WHERE cidr = '192.168.10.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'mail-server-01' AND ip = '192.168.10.30');
-- Corporate backbone — 10.0.0.0/8
INSERT INTO hosts (name, ip, network_id) SELECT 'core-switch-01', '10.0.0.1', id FROM networks WHERE cidr = '10.0.0.0/8' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'core-switch-01' AND ip = '10.0.0.1');
INSERT INTO hosts (name, ip, network_id) SELECT 'monitoring-01', '10.0.1.10', id FROM networks WHERE cidr = '10.0.0.0/8' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'monitoring-01' AND ip = '10.0.1.10');
INSERT INTO hosts (name, ip, network_id) SELECT 'backup-server-01', '10.0.1.20', id FROM networks WHERE cidr = '10.0.0.0/8' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'backup-server-01' AND ip = '10.0.1.20');
INSERT INTO hosts (name, ip, network_id) SELECT 'log-server-01', '10.0.1.30', id FROM networks WHERE cidr = '10.0.0.0/8' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'log-server-01' AND ip = '10.0.1.30');
-- VPN — 172.16.0.0/16
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-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 / Gitea'),
(3306, 'MariaDB / MySQL'),
(3389, 'RDP'),
(4500, 'IPSec NAT-T'),
(5044, 'Logstash Beats'),
(5432, 'PostgreSQL'),
(5601, 'Kibana'),
(6379, 'Redis'),
(8096, 'Jellyfin'),
(8123, 'Home Assistant'),
(8384, 'Syncthing UI'),
(8920, 'Jellyfin HTTPS'),
(9000, 'Portainer'),
(9090, 'Prometheus'),
(9100, 'node_exporter / JetDirect'),
(9200, 'Elasticsearch'),
(9443, 'Portainer HTTPS'),
(22000, 'Syncthing'),
(51820, 'WireGuard');
-- ── 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, Grafana, Prometheus
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'monitoring-01' AND ip = '10.0.1.10';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 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';
-- ── Applications ──────────────────────────────────────────────────────────────
-- applications has no UNIQUE constraint on name, so we use WHERE NOT EXISTS.
INSERT INTO applications (name) SELECT 'Nginx' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Nginx');
INSERT INTO applications (name) SELECT 'Pi-hole' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Pi-hole');
INSERT INTO applications (name) SELECT 'WireGuard' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'WireGuard');
INSERT INTO applications (name) SELECT 'OpenVPN' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'OpenVPN');
INSERT INTO applications (name) SELECT 'PostgreSQL' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'PostgreSQL');
INSERT INTO applications (name) SELECT 'MariaDB' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'MariaDB');
INSERT INTO applications (name) SELECT 'Redis' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Redis');
INSERT INTO applications (name) SELECT 'Grafana' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Grafana');
INSERT INTO applications (name) SELECT 'Prometheus' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Prometheus');
INSERT INTO applications (name) SELECT 'Elasticsearch' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Elasticsearch');
INSERT INTO applications (name) SELECT 'Kibana' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Kibana');
INSERT INTO applications (name) SELECT 'Portainer' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Portainer');
INSERT INTO applications (name) SELECT 'Jellyfin' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Jellyfin');
INSERT INTO applications (name) SELECT 'Home Assistant' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Home Assistant');
INSERT INTO applications (name) SELECT 'Syncthing' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Syncthing');
INSERT INTO applications (name) SELECT 'Vaultwarden' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Vaultwarden');
-- ── Application ports ─────────────────────────────────────────────────────────
-- application_ports has a composite PRIMARY KEY, so INSERT OR IGNORE is safe.
-- Nginx: HTTP, HTTPS
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 80 FROM applications WHERE name = 'Nginx';
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 443 FROM applications WHERE name = 'Nginx';
-- Pi-hole: DNS, HTTP (admin UI), HTTPS
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 53 FROM applications WHERE name = 'Pi-hole';
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 80 FROM applications WHERE name = 'Pi-hole';
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 443 FROM applications WHERE name = 'Pi-hole';
-- WireGuard
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 51820 FROM applications WHERE name = 'WireGuard';
-- OpenVPN
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 1194 FROM applications WHERE name = 'OpenVPN';
-- PostgreSQL
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 5432 FROM applications WHERE name = 'PostgreSQL';
-- MariaDB
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 3306 FROM applications WHERE name = 'MariaDB';
-- Redis
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 6379 FROM applications WHERE name = 'Redis';
-- Grafana
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 3000 FROM applications WHERE name = 'Grafana';
-- Prometheus
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 9090 FROM applications WHERE name = 'Prometheus';
-- Elasticsearch
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 9200 FROM applications WHERE name = 'Elasticsearch';
-- Kibana
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 5601 FROM applications WHERE name = 'Kibana';
-- Portainer: HTTP, HTTPS
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 9000 FROM applications WHERE name = 'Portainer';
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 9443 FROM applications WHERE name = 'Portainer';
-- Jellyfin: HTTP, HTTPS
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 8096 FROM applications WHERE name = 'Jellyfin';
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 8920 FROM applications WHERE name = 'Jellyfin';
-- Home Assistant
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 8123 FROM applications WHERE name = 'Home Assistant';
-- Syncthing: UI, data sync
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 8384 FROM applications WHERE name = 'Syncthing';
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 22000 FROM applications WHERE name = 'Syncthing';
-- Vaultwarden: HTTP, HTTPS
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 80 FROM applications WHERE name = 'Vaultwarden';
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 443 FROM applications WHERE name = 'Vaultwarden';

View File

@@ -1,11 +1,74 @@
// api/applications.rs — Server functions for applications and their port associations
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use crate::models::Application;
// Minimal host reference used by ApplicationDetail.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HostRef {
pub id: i64,
pub name: String,
pub ip: String,
}
// Full detail for a single application: identity, associated ports, and linked hosts.
// Linked hosts are those that have at least one port matching an application_ports entry.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ApplicationDetail {
pub id: i64,
pub name: String,
pub ports: Vec<u16>,
pub hosts: Vec<HostRef>,
}
// Application row enriched with the number of hosts that use at least one of
// its registered ports. Host count is computed via the join:
// application_ports → host_ports (matched on port_number) → hosts
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ApplicationWithCounts {
pub id: i64,
pub name: String,
/// Distinct hosts that have at least one port matching this application.
pub host_count: i64,
}
// ─── Queries ──────────────────────────────────────────────────────────────────
/// Returns all applications enriched with their associated host count.
#[server]
pub async fn get_applications_with_counts() -> Result<Vec<ApplicationWithCounts>, ServerFnError> {
use sqlx::{AnyPool, Row};
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
let rows = sqlx::query(
"SELECT
a.id,
a.name,
COUNT(DISTINCT hp.host_id) AS host_count
FROM applications a
LEFT JOIN application_ports ap ON ap.application_id = a.id
LEFT JOIN host_ports hp ON hp.port_number = ap.port_number
GROUP BY a.id, a.name
ORDER BY a.name",
)
.fetch_all(&pool)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(rows
.into_iter()
.map(|row| ApplicationWithCounts {
id: row.get("id"),
name: row.get("name"),
host_count: row.get("host_count"),
})
.collect())
}
/// Returns all applications ordered by name.
#[server]
pub async fn get_applications() -> Result<Vec<Application>, ServerFnError> {
@@ -20,6 +83,52 @@ pub async fn get_applications() -> Result<Vec<Application>, ServerFnError> {
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Returns full detail for a single application: identity, ports, and linked hosts.
///
/// Linked hosts are hosts that have at least one open port matching one of
/// the application's registered port numbers (via application_ports ↔ host_ports).
#[server]
pub async fn get_application_detail(id: i64) -> Result<ApplicationDetail, ServerFnError> {
use sqlx::{AnyPool, Row};
use crate::server::repository::applications as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
let app = repo::find_application(&pool, id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
.ok_or_else(|| ServerFnError::new(format!("Application {id} not found")))?;
let ports = repo::list_ports_for_application(&pool, id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let rows = sqlx::query(
"SELECT DISTINCT h.id, h.name, h.ip
FROM hosts h
JOIN host_ports hp ON hp.host_id = h.id
JOIN application_ports ap ON ap.port_number = hp.port_number
WHERE ap.application_id = $1
ORDER BY h.name",
)
.bind(id)
.fetch_all(&pool)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let hosts = rows
.iter()
.map(|row| HostRef {
id: row.get("id"),
name: row.get("name"),
ip: row.get("ip"),
})
.collect();
Ok(ApplicationDetail { id: app.id, name: app.name, ports, hosts })
}
/// Returns the port numbers associated with an application.
#[server]
pub async fn get_ports_for_application(
@@ -38,20 +147,55 @@ pub async fn get_ports_for_application(
// ─── Mutations ────────────────────────────────────────────────────────────────
/// Creates a new application and returns the created record.
/// Updates the name of an application and returns the updated record.
#[server]
pub async fn create_application(name: String) -> Result<Application, ServerFnError> {
pub async fn update_application(id: i64, name: String) -> Result<Application, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::applications as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
repo::create_application(&pool, &name)
if name.trim().is_empty() {
return Err(ServerFnError::new("Application name cannot be empty"));
}
repo::update_application(&pool, id, name.trim())
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Creates a new application, then associates the given port numbers.
///
/// `ports` is a comma-separated list of port numbers (e.g. "80,443").
/// An empty string means no ports are associated.
#[server]
pub async fn create_application(name: String, ports: String) -> Result<Application, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::applications as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
let app = repo::create_application(&pool, &name)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let port_numbers: Vec<u16> = ports
.split(',')
.filter_map(|s| s.trim().parse::<u16>().ok())
.filter(|&p| p >= 1)
.collect();
for port_number in port_numbers {
repo::add_port_to_application(&pool, app.id, port_number)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
}
Ok(app)
}
/// Deletes an application and all its port associations.
///
/// Returns `true` if the application existed and was deleted.

View File

@@ -3,7 +3,7 @@
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use crate::models::Host;
use crate::models::{Application, Host, Port};
// ─── Presentation types ───────────────────────────────────────────────────────
@@ -30,8 +30,65 @@ pub struct HostsPage {
pub total_pages: i64, // ceil(total / per_page); always ≥ 1
}
// Full host detail: identity fields + resolved network + open ports + linked applications.
#[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>,
pub applications: Vec<Application>,
}
// ─── 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::{
applications as app_repo,
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()))?;
let applications = app_repo::list_applications_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,
applications,
})
}
/// Returns all hosts belonging to a given network.
#[server]
pub async fn get_hosts_by_network(network_id: i64) -> Result<Vec<Host>, ServerFnError> {
@@ -48,22 +105,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.
///
/// Filter parameters use sentinel values (0 / empty string) to mean "no filter":
/// - `name_filter` : substring match on host name (case-insensitive); "" = all
/// - `network_id_filter` : exact network id; 0 = all
/// - `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
/// `port_filter` is a comma-separated list of port numbers (e.g. "80,443").
/// A host matches only if it has ALL the specified ports open.
/// An empty string means no port filter.
///
/// The SQL uses each bind parameter twice in the WHERE clause
/// (once for the IS NULL guard, once for the actual comparison).
/// Each $N placeholder refers to the N-th bound argument by index.
/// Port conditions are inlined in the SQL as integer literals (safe: values
/// are parsed and range-checked before use — no raw user strings are injected).
#[server]
pub async fn get_hosts_page(
name_filter: String,
network_id_filter: i64,
port_filter: i64,
port_filter: String,
application_id_filter: i64,
page: i64,
per_page: i64,
@@ -73,47 +125,55 @@ pub async fn get_hosts_page(
let pool = use_context::<AnyPool>()
.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() {
None
} else {
Some(format!("%{}%", name_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) };
// Each filter param is bound twice so the same $N can appear in both
// the IS NULL guard and the comparison without re-declaring parameters.
const WHERE: &str = "
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
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
))";
// Parse and validate port numbers from the CSV string.
// Inlined as integer literals in SQL — safe because they are range-checked i64s.
let ports: Vec<i64> = port_filter
.split(',')
.filter_map(|s| s.trim().parse::<i64>().ok())
.filter(|&p| p >= 1 && p <= 65535)
.collect();
// Count matching hosts (ignoring pagination).
let count_sql = format!("SELECT COUNT(DISTINCT h.id) FROM hosts h {WHERE}");
// 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 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)
.bind(name_like.as_deref())
.bind(network_id)
.bind(port)
.bind(app_id)
.fetch_one(&pool)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
// Compute pagination bounds.
let safe_page = page.max(1);
let (limit, offset, total_pages) = if per_page <= 0 {
(1_000_000_000i64, 0i64, 1i64)
@@ -122,23 +182,21 @@ pub async fn get_hosts_page(
(per_page, (safe_page - 1) * per_page, tp)
};
// Fetch the page of hosts with enriched columns.
let data_sql = format!(
"SELECT h.id, h.name, h.ip, h.network_id,
n.cidr AS network_cidr,
COUNT(DISTINCT hp.port_number) AS port_count,
COUNT(DISTINCT ap.application_id) AS application_count
FROM hosts h
{WHERE}
{where_clause}
GROUP BY h.id, h.name, h.ip, h.network_id, n.cidr
ORDER BY h.name, h.id
LIMIT $5 OFFSET $6"
LIMIT $4 OFFSET $5"
);
let rows = sqlx::query(&data_sql)
.bind(name_like.as_deref())
.bind(network_id)
.bind(port)
.bind(app_id)
.bind(limit)
.bind(offset)
@@ -170,19 +228,21 @@ pub async fn get_hosts_page(
// ─── 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`.
/// Returns an error if the network does not exist or the IP is out of range.
/// `ports` is a comma-separated list of port numbers (e.g. "22,80,443").
/// Ports are auto-registered in the global catalog if not already present.
/// An empty string means no ports are opened.
#[server]
pub async fn create_host(
name: String,
ip: String,
network_id: i64,
ports: String,
) -> Result<Host, ServerFnError> {
use sqlx::AnyPool;
use crate::server::{
repository::{hosts, networks},
repository::{hosts, networks, ports as port_repo},
validation::validate_ip_in_network,
};
@@ -192,14 +252,168 @@ pub async fn create_host(
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"))
})?;
.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()))?;
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
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Returns all applications not yet directly linked to a host.
///
/// Used to populate the "add applications" modal on the host detail page.
#[server]
pub async fn get_applications_not_on_host(
host_id: i64,
) -> Result<Vec<Application>, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::applications as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
repo::list_applications_not_on_host(&pool, host_id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Links one or more applications to a host.
///
/// `application_ids` is a comma-separated string of application IDs (e.g. "1,3,7").
/// Already-linked applications are silently skipped (no-op).
#[server]
pub async fn add_host_applications(
host_id: i64,
application_ids: String,
) -> Result<(), ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::applications as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
let ids: Vec<i64> = application_ids
.split(',')
.filter_map(|s| s.trim().parse::<i64>().ok())
.collect();
for application_id in ids {
repo::add_application_to_host(&pool, host_id, application_id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
}
Ok(())
}
/// Removes the direct link between a host and an application.
///
/// Returns `true` if the association existed and was removed.
#[server]
pub async fn remove_host_application(
host_id: i64,
application_id: i64,
) -> Result<bool, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::applications as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
repo::remove_application_from_host(&pool, host_id, application_id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}

View File

@@ -8,9 +8,10 @@ use crate::models::Network;
// Network row augmented with pre-computed counts.
// Defined here (not in models.rs) because it is a presentation model
// specific to the Networks page, not a pure domain entity.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct NetworkWithCounts {
pub id: i64,
pub name: String,
pub cidr: String,
/// Number of hosts whose IP falls within this network's CIDR range.
pub host_count: i64,
@@ -28,25 +29,15 @@ pub async fn get_networks() -> Result<Vec<Network>, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::networks as repo;
// `use_context` retrieves a value previously registered with `provide_context`.
// The pool was injected in main.rs before every request.
// `ok_or_else` converts `None` into an error (defensive: should never happen).
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
// Propagate any DB error as a ServerFnError so the client sees a clean message.
repo::list_networks(&pool)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Returns all networks enriched with host and application counts.
///
/// A single SQL query fetches everything at once using correlated subqueries,
/// avoiding N+1 round-trips regardless of the number of networks.
///
/// `application_count` = distinct applications whose registered ports appear
/// among the ports open on hosts in each network (via host_ports → application_ports).
#[server]
pub async fn get_networks_with_counts() -> Result<Vec<NetworkWithCounts>, ServerFnError> {
use sqlx::{AnyPool, Row};
@@ -57,6 +48,7 @@ pub async fn get_networks_with_counts() -> Result<Vec<NetworkWithCounts>, Server
let rows = sqlx::query(
"SELECT
n.id,
n.name,
n.cidr,
(SELECT COUNT(*) FROM hosts WHERE network_id = n.id) AS host_count,
(SELECT COUNT(DISTINCT ap.application_id)
@@ -75,6 +67,7 @@ pub async fn get_networks_with_counts() -> Result<Vec<NetworkWithCounts>, Server
.into_iter()
.map(|row| NetworkWithCounts {
id: row.get("id"),
name: row.get("name"),
cidr: row.get("cidr"),
host_count: row.get("host_count"),
application_count: row.get("application_count"),
@@ -84,25 +77,41 @@ pub async fn get_networks_with_counts() -> Result<Vec<NetworkWithCounts>, Server
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 ────────────────────────────────────────────────────────────────
/// Creates a new network with the given CIDR block.
/// Creates a new network with the given name and CIDR block.
///
/// Returns the created record (with its auto-generated id).
/// Returns an error if the CIDR is malformed or already exists.
#[server]
pub async fn create_network(cidr: String) -> Result<Network, ServerFnError> {
pub async fn create_network(name: String, cidr: String) -> Result<Network, ServerFnError> {
use sqlx::AnyPool;
use crate::server::{repository::networks as repo, validation::validate_cidr};
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
// Validate the CIDR before touching the database.
// Example of a valid CIDR: "192.168.1.0/24"
if name.trim().is_empty() {
return Err(ServerFnError::new("Network name cannot be empty"));
}
validate_cidr(&cidr).map_err(|e| ServerFnError::new(e.to_string()))?;
repo::create_network(&pool, &cidr)
repo::create_network(&pool, name.trim(), &cidr)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}

View File

@@ -11,7 +11,16 @@ use leptos_router::{
path,
};
use crate::client::{home::HomePage, hosts::HostsPage, networks::NetworksPage, theme::ThemeToggle};
use crate::client::{
application_detail::ApplicationDetailPage,
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.
//
@@ -87,6 +96,7 @@ pub fn App() -> impl IntoView {
<a href="/">"Rust IPAM"</a>
<a href="/networks">"Networks"</a>
<a href="/hosts">"Hosts"</a>
<a href="/applications">"Applications"</a>
<span class="nav-spacer"/>
<ThemeToggle/>
</nav>
@@ -102,7 +112,11 @@ pub fn App() -> impl IntoView {
}>
<Route path=path!("/") view=HomePage/>
<Route path=path!("/networks") view=NetworksPage/>
<Route path=path!("/networks/:id") view=NetworkDetailPage/>
<Route path=path!("/hosts") view=HostsPage/>
<Route path=path!("/hosts/:id") view=HostDetailPage/>
<Route path=path!("/applications") view=ApplicationsPage/>
<Route path=path!("/applications/:id") view=ApplicationDetailPage/>
</Routes>
</main>
</Router>

97
src/bin/seed.rs Normal file
View File

@@ -0,0 +1,97 @@
// bin/seed.rs — Development seed loader
//
// Inserts a realistic set of networks and hosts into the database so the UI
// can be tested without manual data entry.
//
// The seed is idempotent: running it multiple times never duplicates rows.
//
// Usage:
// cargo run --features ssr --bin seed
//
// The DATABASE_URL is read from the .env file (or environment variable),
// exactly like the main server.
#[tokio::main]
async fn main() {
use rust_ipam::server::{
config::{AppConfig, DatabaseBackend},
db::{create_pool, run_migrations},
};
// Load .env so DATABASE_URL is available without exporting it manually.
// Errors are ignored: the variable may already be set in the environment.
let _ = dotenvy::dotenv();
tracing_subscriber::fmt()
.with_env_filter(
std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()),
)
.init();
let config = AppConfig::from_env()
.expect("Configuration error — check DATABASE_URL in your .env file");
tracing::info!("Connecting to {} ({})", config.backend, config.database_url);
let pool = create_pool(&config)
.await
.expect("Failed to connect to database");
run_migrations(&pool, &config.backend)
.await
.expect("Migration failed");
// Pick the seed file that matches the active backend.
// `include_str!` embeds the SQL at compile time so the binary is self-contained.
let sql = match config.backend {
DatabaseBackend::Sqlite => include_str!("../../seeds/sqlite/dev_seed.sql"),
DatabaseBackend::Postgres => include_str!("../../seeds/postgres/dev_seed.sql"),
};
// Strip comment lines first, then split on ';'.
// sqlx does not support multiple statements in a single `query()` call.
// Without pre-stripping comments, a block like "-- section\nINSERT …"
// would start with "--" and get incorrectly discarded.
let sql_stripped: String = sql
.lines()
.filter(|line| !line.trim().starts_with("--"))
.collect::<Vec<_>>()
.join("\n");
let statements: Vec<String> = sql_stripped
.split(';')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let total = statements.len();
for (i, stmt) in statements.iter().enumerate() {
sqlx::query(stmt)
.execute(&pool)
.await
.unwrap_or_else(|e| panic!("Statement {}/{} failed: {}\nSQL: {}", i + 1, total, e, stmt));
}
tracing::info!("Seed complete — {} statement(s) executed.", total);
// Count what was inserted so the operator can confirm at a glance.
let network_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM networks")
.fetch_one(&pool)
.await
.unwrap_or(0);
let host_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM hosts")
.fetch_one(&pool)
.await
.unwrap_or(0);
let application_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM applications")
.fetch_one(&pool)
.await
.unwrap_or(0);
tracing::info!(
"Database now contains {} network(s), {} host(s) and {} application(s).",
network_count, host_count, application_count
);
}

View File

@@ -0,0 +1,311 @@
// client/application_detail.rs — Application detail page
//
// Shows all information for a single application:
// - Identity form : name — editable, saved with "Save changes"
// - Ports section : ports associated with this application + Add/Remove per port
// - Hosts section : hosts sharing at least one port with this application (read-only)
// - Delete button : confirmation modal, then navigates back to /applications
use leptos::prelude::*;
use leptos_router::hooks::{use_navigate, use_params_map, use_query_map};
use crate::api::applications::{
AddPortToApplication, DeleteApplication, RemovePortFromApplication,
UpdateApplication, get_application_detail,
};
// ─── Delete confirmation modal ────────────────────────────────────────────────
#[component]
fn DeleteModal(
app_name: String,
delete_action: ServerAction<DeleteApplication>,
app_id: i64,
show_modal: RwSignal<bool>,
) -> impl IntoView {
view! {
<div class="modal-backdrop" on:click=move |_| show_modal.set(false)>
<div class="modal" on:click=move |e| e.stop_propagation()>
<div class="modal__header">
<h2>"Delete application"</h2>
<button class="modal__close" type="button" aria-label="Close"
on:click=move |_| show_modal.set(false)>
"×"
</button>
</div>
<div class="modal__body">
<p class="warning">
"Are you sure you want to delete "
<strong>{app_name}</strong>
"? All port associations will also be removed."
</p>
</div>
<div class="modal__actions">
<button class="btn-secondary" type="button"
on:click=move |_| show_modal.set(false)>
"Cancel"
</button>
<button class="btn-danger" type="button"
on:click=move |_| { delete_action.dispatch(DeleteApplication { id: app_id }); }>
"Delete"
</button>
</div>
</div>
</div>
}.into_any()
}
// ─── Main page component ──────────────────────────────────────────────────────
#[component]
pub fn ApplicationDetailPage() -> impl IntoView {
let params = use_params_map();
let app_id = move || {
params.read().get("id")
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0)
};
let query = use_query_map();
let back_url = move || {
query.read().get("back")
.map(|s| s.to_string())
.unwrap_or_else(|| "/applications".to_string())
};
let back_label = move || {
if back_url().starts_with("/hosts/") { "← Host" } else { "← Applications" }
};
let update_action = ServerAction::<UpdateApplication>::new();
let add_port_action = ServerAction::<AddPortToApplication>::new();
let remove_port_action = ServerAction::<RemovePortFromApplication>::new();
let delete_action = ServerAction::<DeleteApplication>::new();
let show_delete_modal = RwSignal::new(false);
let app = LocalResource::new(move || {
let _ = update_action.version().get();
let _ = add_port_action.version().get();
let _ = remove_port_action.version().get();
get_application_detail(app_id())
});
let name_sig = RwSignal::new(String::new());
let new_port = RwSignal::new(String::new());
// Sync the editable name whenever fresh data arrives.
Effect::new(move |_| {
if let Some(r) = app.get() {
if let Ok(ref detail) = *r {
name_sig.set(detail.name.clone());
}
}
});
let navigate = use_navigate();
Effect::new(move |_| {
if let Some(Ok(true)) = delete_action.value().get() {
navigate("/applications", Default::default());
}
});
view! {
<div class="application-detail-page">
// Delete modal lives OUTSIDE <Suspense> so it is not unmounted when
// the application resource re-fetches.
{move || show_delete_modal.get().then(|| view! {
<DeleteModal
app_name=name_sig.get()
delete_action=delete_action
app_id=app_id()
show_modal=show_delete_modal
/>
})}
<Suspense fallback=|| view! { <p class="empty">"Loading application…"</p> }>
{move || app.get().map(|r| match (*r).clone() {
Err(e) => view! {
<p class="error">"Could not load application: " {e.to_string()}</p>
}.into_any(),
Ok(detail) => {
let id = detail.id;
let port_count = detail.ports.len();
let host_count = detail.hosts.len();
let ports = detail.ports;
let hosts = detail.hosts;
let ports_list = if ports.is_empty() {
view! {
<p class="empty">"No ports associated with this application."</p>
}.into_any()
} else {
view! {
<div class="port-list">
{ports.into_iter().map(|num| {
view! {
<div class="port-row">
<span class="port-row__number">{num}</span>
<button
class="btn-danger"
type="button"
on:click=move |_| {
remove_port_action.dispatch(
RemovePortFromApplication {
application_id: id,
port_number: num,
}
);
}
>
"Remove"
</button>
</div>
}
}).collect_view()}
</div>
}.into_any()
};
let hosts_list = if hosts.is_empty() {
view! {
<p class="empty">"No hosts share a port with this application."</p>
}.into_any()
} else {
view! {
<div class="app-list">
{hosts.into_iter().map(|host| {
view! {
<div class="app-row">
<a class="table-link"
href=format!("/hosts/{}?back=/applications/{}", host.id, id)>
{host.name}
</a>
<span class="cell-mono">{host.ip}</span>
</div>
}
}).collect_view()}
</div>
}.into_any()
};
view! {
// ── Page header ───────────────────────────────────
<div class="page-header detail-page-header">
<a class="back-btn" href=move || back_url()>
{move || back_label()}
</a>
<h1 class="detail-page-title">{move || name_sig.get()}</h1>
</div>
// ── Identity form ─────────────────────────────────
<section class="detail-section">
<h2 class="detail-section__title">"Identity"</h2>
<div class="detail-form">
<label class="detail-field">
"Name"
<input
type="text"
prop:value=move || name_sig.get()
on:input=move |e| name_sig.set(event_target_value(&e))
/>
</label>
{move || update_action.value().get()
.and_then(|r| r.err())
.map(|e| view! { <p class="error">{e.to_string()}</p> })
}
<div class="form-actions">
<button
class="btn-primary"
type="button"
on:click=move |_| {
update_action.dispatch(UpdateApplication {
id,
name: name_sig.get_untracked(),
});
}
>
"Save changes"
</button>
</div>
</div>
</section>
// ── Ports section ─────────────────────────────────
<section class="detail-section">
<h2 class="detail-section__title">
{format!("Associated ports ({})", port_count)}
</h2>
{ports_list}
{move || remove_port_action.value().get()
.and_then(|r| r.err())
.map(|e| view! {
<p class="error">"Remove failed: " {e.to_string()}</p>
})
}
<div class="port-add-row">
<input
type="number"
min="1"
max="65535"
placeholder="Port number (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(AddPortToApplication {
application_id: id,
port_number: n as u16,
});
new_port.set(String::new());
}
}
}
>
"Add port"
</button>
{move || add_port_action.value().get()
.and_then(|r| r.err())
.map(|e| view! {
<p class="error">"Add failed: " {e.to_string()}</p>
})
}
</div>
</section>
// ── Hosts section (read-only — linked via shared ports) ──
<section class="detail-section">
<h2 class="detail-section__title">
{format!("Linked hosts ({})", host_count)}
</h2>
{hosts_list}
</section>
// ── Danger zone ───────────────────────────────────
<div class="danger-zone">
<button
class="btn-danger-solid"
type="button"
on:click=move |_| show_delete_modal.set(true)
>
"Delete application"
</button>
</div>
}.into_any()
}
})}
</Suspense>
</div>
}.into_any()
}

294
src/client/applications.rs Normal file
View File

@@ -0,0 +1,294 @@
// 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 leptos::html::Input;
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 {
use leptos::task::spawn_local;
let name_ref = NodeRef::<Input>::new();
// Defer focus to the next microtask so the element is in the DOM.
// Using get_untracked() avoids subscribing to NodeRef's reactive signal,
// which would otherwise re-trigger during modal unmount and cause
// "closure invoked after being dropped" in wasm-bindgen.
spawn_local(async move {
if let Some(el) = name_ref.get_untracked() {
let _ = el.focus();
}
});
// close() defers show_modal.set(false) to the next microtask.
// Without this, setting the signal synchronously inside a click handler
// unmounts the modal (and frees its closures) while the handler is still
// on the call stack, causing wasm-bindgen to panic.
let close = move || spawn_local(async move { show_modal.set(false) });
view! {
<div class="modal-backdrop" on:click=move |_| close()>
<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 |_| close()>
"×"
</button>
</div>
<ActionForm action=create_action>
<div class="add-form__fields">
<label>
"Name"
<input
node_ref=name_ref
type="text"
name="name"
placeholder="e.g. Nginx, PostgreSQL, Prometheus"
required
/>
</label>
<label>
"Associated ports"
<input
type="text"
name="ports"
placeholder="e.g. 22, 80, 443"
/>
<span class="field-hint">"Comma-separated port numbers"</span>
</label>
</div>
<div class="modal__actions">
<button class="btn-secondary" type="button"
on:click=move |_| close()>
"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 add modal when the action transitions pending→done with Ok.
// Lives in the parent so it is never recreated across modal open/close cycles,
// which avoids the stale-value re-trigger bug.
Effect::new(move |was_pending: Option<bool>| {
let is_pending = create_action.pending().get();
if was_pending == Some(true) && !is_pending {
if let Some(Ok(_)) = create_action.value().get() {
show_modal.set(false);
}
}
is_pending
});
// Close the delete modal automatically after a successful deletion.
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>
<a class="table-link"
href=format!("/applications/{}", app.id)>
{app.name}
</a>
</td>
<td class="col-count">{app.host_count}</td>
<td class="col-actions">
<button class="btn-danger" on:click=move |_| {
pending_delete.set(Some(app_clone.clone()));
}>
"Delete"
</button>
</td>
</tr>
}
}).collect_view()}
</tbody>
</table>
</div>
}.into_any()
}
}
})}
</Suspense>
</section>
</div>
}
}

585
src/client/host_detail.rs Normal file
View File

@@ -0,0 +1,585 @@
// 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
// - Applications : directly linked apps with Remove + modal multi-select to add
// - 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::{
AddHostApplications, AddHostPort, DeleteHost, RemoveHostApplication,
RemoveHostPort, UpdateHost, get_applications_not_on_host, get_host_detail,
},
networks::get_networks,
};
use crate::models::Application;
// ─── Add applications modal ───────────────────────────────────────────────────
// Scrollable pick list + selected tags:
// - Top: scrollable list of available apps; clicking one moves it to the
// selected section and removes it from the list.
// - Bottom: selected apps shown as removable tags; clicking × puts the app
// back in the list.
//
// The auto-close Effect lives in the PARENT to avoid the re-trigger bug
// (an Effect inside a conditionally-rendered component fires on mount and
// would immediately close the modal if the action already held a past Ok value).
#[component]
fn AddAppModal(
host_id: i64,
available_apps_res: LocalResource<Result<Vec<Application>, ServerFnError>>,
add_action: ServerAction<AddHostApplications>,
show_modal: RwSignal<bool>,
) -> impl IntoView {
// Full Application structs so names are available in the selected tag list.
let selected: RwSignal<Vec<Application>> = RwSignal::new(vec![]);
view! {
<div class="modal-backdrop" on:click=move |_| show_modal.set(false)>
<div class="modal" on:click=move |e| e.stop_propagation()>
<div class="modal__header">
<h2>"Add applications"</h2>
<button class="modal__close" type="button" aria-label="Close"
on:click=move |_| show_modal.set(false)>
"×"
</button>
</div>
<div class="modal__body">
// ── Scrollable pick list ──────────────────────────────────
{move || match available_apps_res.get() {
None => view! { <p class="empty">"Loading…"</p> }.into_any(),
Some(r) => match (*r).clone() {
Err(e) => view! {
<p class="error">
"Could not load applications: " {e.to_string()}
</p>
}.into_any(),
Ok(apps) => {
// Exclude already-selected apps from the displayed list.
let sel_ids: Vec<i64> = selected.get()
.iter().map(|a| a.id).collect();
let displayed: Vec<Application> = apps.into_iter()
.filter(|a| !sel_ids.contains(&a.id))
.collect();
if displayed.is_empty() && sel_ids.is_empty() {
view! {
<p class="empty">
"All applications are already linked to this host."
</p>
}.into_any()
} else if displayed.is_empty() {
view! {
<p class="empty">
"All available applications have been selected."
</p>
}.into_any()
} else {
view! {
<ul class="app-pick-list">
{displayed.into_iter().map(|app| {
let app_clone = app.clone();
view! {
<li class="app-pick-item"
on:click=move |_| {
selected.update(|v| {
v.push(app_clone.clone());
});
}
>
<span>{app.name}</span>
<span class="app-pick-item__add">"+"</span>
</li>
}
}).collect_view()}
</ul>
}.into_any()
}
}
}
}}
// ── Selected tags (shown once at least one app is chosen) ─
{move || (!selected.get().is_empty()).then(|| {
let sel = selected.get();
view! {
<div class="app-selected-section">
<span class="app-selected-label">"Selected:"</span>
<div class="app-selected-list">
{sel.into_iter().map(|app| {
let app_id = app.id;
view! {
<span class="app-selected-tag">
{app.name}
<button
class="app-selected-tag__remove"
type="button"
aria-label="Remove"
on:click=move |_| {
selected.update(|v| {
v.retain(|x| x.id != app_id);
});
}
>"×"</button>
</span>
}
}).collect_view()}
</div>
</div>
}
})}
{move || add_action.value().get()
.and_then(|r| r.err())
.map(|e| view! { <p class="error">{e.to_string()}</p> })
}
</div>
<div class="modal__actions">
<button class="btn-secondary" type="button"
on:click=move |_| show_modal.set(false)>
"Cancel"
</button>
<button
class="btn-primary"
type="button"
disabled={move || selected.get().is_empty()}
on:click=move |_| {
let ids_str = selected.get_untracked()
.iter()
.map(|a| a.id.to_string())
.collect::<Vec<_>>()
.join(",");
if !ids_str.is_empty() {
add_action.dispatch(AddHostApplications {
host_id,
application_ids: ids_str,
});
}
}
>
"Add selected"
</button>
</div>
</div>
</div>
}.into_any()
}
// ─── Delete confirmation modal ────────────────────────────────────────────────
#[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 and application 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 {
let params = use_params_map();
let host_id = move || {
params.read().get("id")
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0)
};
let query = use_query_map();
let back_url = move || {
query.read().get("back")
.map(|s| s.to_string())
.unwrap_or_else(|| "/hosts".to_string())
};
let back_label = move || {
if back_url().starts_with("/networks/") { "← Network" }
else if back_url().starts_with("/applications/") { "← Application" }
else { "← Hosts" }
};
let update_action = ServerAction::<UpdateHost>::new();
let add_port_action = ServerAction::<AddHostPort>::new();
let remove_port_action = ServerAction::<RemoveHostPort>::new();
let add_app_action = ServerAction::<AddHostApplications>::new();
let remove_app_action = ServerAction::<RemoveHostApplication>::new();
let delete_action = ServerAction::<DeleteHost>::new();
let show_delete_modal = RwSignal::new(false);
let show_add_app_modal = RwSignal::new(false);
// Auto-close the add-app modal when the action completes successfully.
// Lives here (not inside AddAppModal) so it is never recreated across modal open/close cycles.
Effect::new(move |was_pending: Option<bool>| {
let is_pending = add_app_action.pending().get();
if was_pending == Some(true) && !is_pending {
if let Some(Ok(_)) = add_app_action.value().get() {
show_add_app_modal.set(false);
}
}
is_pending
});
// LocalResource avoids reading the resource outside <Suspense> during hydration,
// which would cause a mismatch between the SSR-rendered fallback and the content
// the WASM expects to find after the resource resolves.
let host = LocalResource::new(move || {
let _ = update_action.version().get();
let _ = add_port_action.version().get();
let _ = remove_port_action.version().get();
let _ = add_app_action.version().get();
let _ = remove_app_action.version().get();
get_host_detail(host_id())
});
// Networks dropdown — LocalResource avoids SSR/hydration mismatch.
let networks_res = LocalResource::new(|| get_networks());
// Available apps for the modal: re-fetched whenever add/remove completes.
let add_app_ver = add_app_action.version();
let remove_app_ver = remove_app_action.version();
let available_apps_res = LocalResource::new(move || {
let _ = add_app_ver.get();
let _ = remove_app_ver.get();
get_applications_not_on_host(host_id())
});
let name_sig = RwSignal::new(String::new());
let ip_sig = RwSignal::new(String::new());
let net_id_sig = RwSignal::new(0i64);
let new_port = RwSignal::new(String::new());
// Sync edit signals whenever fresh host data arrives.
// LocalResource wraps its value in SendWrapper, so we dereference with `*r`.
Effect::new(move |_| {
if let Some(r) = host.get() {
if let Ok(ref detail) = *r {
name_sig.set(detail.name.clone());
ip_sig.set(detail.ip.clone());
net_id_sig.set(detail.network_id);
}
}
});
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">
// Modals live OUTSIDE <Suspense> so they are not unmounted when the
// host resource re-fetches (which would kill their reactive subscriptions).
{move || show_add_app_modal.get().then(|| view! {
<AddAppModal
host_id=host_id()
available_apps_res=available_apps_res
add_action=add_app_action
show_modal=show_add_app_modal
/>
})}
{move || show_delete_modal.get().then(|| view! {
<DeleteModal
host_name=name_sig.get()
delete_action=delete_action
host_id=host_id()
show_modal=show_delete_modal
/>
})}
<Suspense fallback=|| view! { <p class="empty">"Loading host…"</p> }>
{move || host.get().map(|r| match (*r).clone() {
Err(e) => view! {
<p class="error">"Could not load host: " {e.to_string()}</p>
}.into_any(),
Ok(detail) => {
let id = detail.id;
let port_count = detail.ports.len();
let app_count = detail.applications.len();
let ports = detail.ports;
let applications = detail.applications;
// 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()
};
// Pre-built applications view.
let apps_list = if applications.is_empty() {
view! {
<p class="empty">"No applications linked to this host."</p>
}.into_any()
} else {
view! {
<div class="app-list">
{applications.into_iter().map(|app| {
let app_id = app.id;
view! {
<div class="app-row">
<a class="table-link app-row__name"
href=format!("/applications/{}?back=/hosts/{}", app_id, id)>
{app.name}
</a>
<button
class="btn-danger"
type="button"
on:click=move |_| {
remove_app_action.dispatch(
RemoveHostApplication { host_id: id, application_id: app_id }
);
}
>
"Remove"
</button>
</div>
}
}).collect_view()}
</div>
}.into_any()
};
view! {
// ── 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>
})
}
<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>
// ── Applications section ──────────────────────────
<section class="detail-section">
<h2 class="detail-section__title">
{format!("Applications ({})", app_count)}
</h2>
{apps_list}
{move || remove_app_action.value().get()
.and_then(|r| r.err())
.map(|e| view! {
<p class="error">"Remove failed: " {e.to_string()}</p>
})
}
<div class="port-add-row">
<button
class="btn-primary"
type="button"
on:click=move |_| show_add_app_modal.set(true)
>
"+ Add applications"
</button>
</div>
</section>
// ── Danger zone ───────────────────────────────────
<div class="danger-zone">
<button
class="btn-danger-solid"
type="button"
on:click=move |_| show_delete_modal.set(true)
>
"Delete host"
</button>
</div>
}.into_any()
}
})}
</Suspense>
</div>
}.into_any()
}

View File

@@ -1,18 +1,18 @@
// client/hosts.rs — Hosts list page
//
// Displays all hosts across every network with:
// - Add form : create a host inside a chosen network
// - Filter bar : name (substring), network, open port, application
// - Table : name, IP, network, port count, application count, delete
// - Pagination : configurable page size (15 / 25 / 50 / 100 / All)
// - Add button : opens a modal form to create a host inside a chosen network
// - Filter bar : name (substring), network, open ports (CSV), application
// - Table : name, IP, network, port count, application count, delete
// - Pagination : configurable page size (15 / 25 / 50 / 100 / All)
//
// Each sub-component calls `.into_any()` on its view to return `AnyView`
// (a type-erased wrapper). This prevents Rust from composing all the nested
// generic types into a single enormous type in the parent's monomorphization,
// which would otherwise overflow the compiler's query depth limit.
// Sub-components call `.into_any()` on their views to erase the concrete
// Leptos type, preventing the parent from accumulating a deeply-nested
// generic type that overflows the compiler's query depth limit.
use leptos::prelude::*;
use leptos::form::ActionForm;
use leptos::html::Input;
use crate::api::{
applications::get_applications,
@@ -28,46 +28,130 @@ const PER_PAGE_OPTIONS: &[(i64, &str)] = &[
(0, "All"),
];
// ─── Add host form ────────────────────────────────────────────────────────────
// ─── Delete host modal ────────────────────────────────────────────────────────
#[component]
fn AddHostForm(
create_action: ServerAction<CreateHost>,
networks_res: Resource<Result<Vec<crate::models::Network>, ServerFnError>>,
fn DeleteHostModal(
host_name: String,
host_id: i64,
delete_action: ServerAction<DeleteHost>,
pending_delete: RwSignal<Option<(i64, String)>>,
) -> impl IntoView {
view! {
<section class="add-form">
<h2>"Add a host"</h2>
<ActionForm action=create_action>
<div class="add-form__fields">
<label>
"Name"
<input type="text" name="name" placeholder="e.g. web-server-01" required/>
</label>
<label>
"IP address"
<input type="text" name="ip" placeholder="e.g. 192.168.1.10" required/>
</label>
<label>
"Network"
<select name="network_id" required>
<option value="">"— choose —"</option>
{move || networks_res.get()
.and_then(|r| r.ok())
.map(|nets| nets.into_iter().map(|n| {
view! { <option value=n.id.to_string()>{n.cidr}</option> }
}).collect_view())
}
</select>
</label>
<button type="submit">"Add"</button>
<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>
</ActionForm>
{move || create_action.value().get()
.and_then(|r| r.err())
.map(|e| view! { <p class="error">{e.to_string()}</p> })
}
</section>
<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 ───────────────────────────────────────────────────────────
#[component]
fn AddHostModal(
create_action: ServerAction<CreateHost>,
networks_res: LocalResource<Result<Vec<crate::models::Network>, ServerFnError>>,
show_modal: RwSignal<bool>,
) -> impl IntoView {
use leptos::task::spawn_local;
let name_ref = NodeRef::<Input>::new();
spawn_local(async move {
if let Some(el) = name_ref.get_untracked() {
let _ = el.focus();
}
});
let close = move || spawn_local(async move { show_modal.set(false) });
view! {
<div class="modal-backdrop" on:click=move |_| close()>
<div class="modal" on:click=move |e| e.stop_propagation()>
<div class="modal__header">
<h2>"Add a host"</h2>
<button
class="modal__close"
type="button"
aria-label="Close"
on:click=move |_| close()
>"×"</button>
</div>
<ActionForm action=create_action>
<div class="add-form__fields">
<label>
"Name"
<input node_ref=name_ref type="text" name="name" placeholder="e.g. web-server-01" required/>
</label>
<label>
"IP address"
<input type="text" name="ip" placeholder="e.g. 192.168.1.10" required/>
</label>
<label>
"Network"
<select name="network_id" required>
<option value="">"— choose —"</option>
{move || networks_res.get()
.and_then(|r| (*r).clone().ok())
.map(|nets| nets.into_iter().map(|n| {
let label = format!("{} - {}", n.name, n.cidr);
view! { <option value=n.id.to_string()>{label}</option> }
}).collect_view())
}
</select>
</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 class="modal__actions">
<button
class="btn-secondary"
type="button"
on:click=move |_| close()
>"Cancel"</button>
<button type="submit">"Add host"</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()
}
@@ -75,11 +159,11 @@ fn AddHostForm(
#[component]
fn FilterBar(
networks_res: Resource<Result<Vec<crate::models::Network>, ServerFnError>>,
applications_res: Resource<Result<Vec<crate::models::Application>, ServerFnError>>,
networks_res: LocalResource<Result<Vec<crate::models::Network>, ServerFnError>>,
applications_res: LocalResource<Result<Vec<crate::models::Application>, ServerFnError>>,
name_filter: RwSignal<String>,
network_id_filter: RwSignal<i64>,
port_filter: RwSignal<i64>,
port_filter: RwSignal<String>,
app_id_filter: RwSignal<i64>,
page: RwSignal<i64>,
) -> impl IntoView {
@@ -106,23 +190,22 @@ fn FilterBar(
}>
<option value="0">"All networks"</option>
{move || networks_res.get()
.and_then(|r| r.ok())
.and_then(|r| (*r).clone().ok())
.map(|nets| nets.into_iter().map(|n| {
view! { <option value=n.id.to_string()>{n.cidr}</option> }
let label = format!("{} - {}", n.name, n.cidr);
view! { <option value=n.id.to_string()>{label}</option> }
}).collect_view())
}
</select>
</label>
<label class="filter-field">
"Open port"
"Open ports"
<input
type="number"
min="1"
max="65535"
placeholder="e.g. 443"
type="text"
placeholder="e.g. 80, 443 (all required)"
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);
}
/>
@@ -136,7 +219,7 @@ fn FilterBar(
}>
<option value="0">"All applications"</option>
{move || applications_res.get()
.and_then(|r| r.ok())
.and_then(|r| (*r).clone().ok())
.map(|apps| apps.into_iter().map(|a| {
view! { <option value=a.id.to_string()>{a.name}</option> }
}).collect_view())
@@ -183,7 +266,6 @@ fn PaginationBar(
</select>
</label>
// Page navigation — hidden when showing all results (per_page == 0)
{move || (per_page.get() > 0).then(|| view! {
<div class="pagination-nav">
<button
@@ -194,7 +276,7 @@ fn PaginationBar(
{move || format!("Page {} of {}", page.get(), total_pages.get().max(1))}
</span>
<button
disabled=move || page.get() >= total_pages.get()
disabled={move || page.get() >= total_pages.get()}
on:click=move |_| {
let max = total_pages.get_untracked();
page.update(|p| *p = (*p + 1).min(max));
@@ -209,15 +291,14 @@ fn PaginationBar(
// ─── Host table ───────────────────────────────────────────────────────────────
// Separate component for the table body to further reduce type depth in HostsPage.
#[component]
fn HostTable(
hosts: Resource<Result<HostsPageData, ServerFnError>>,
delete_action: ServerAction<DeleteHost>,
hosts: LocalResource<Result<HostsPageData, ServerFnError>>,
pending_delete: RwSignal<Option<(i64, String)>>,
) -> impl IntoView {
view! {
<Suspense fallback=|| view! { <p class="empty">"Loading hosts…"</p> }>
{move || hosts.get().map(|result| match result {
{move || hosts.get().map(|r| match (*r).clone() {
Err(e) => view! {
<p class="error">"Could not load hosts: " {e.to_string()}</p>
}.into_any(),
@@ -242,6 +323,7 @@ fn HostTable(
<tbody>
{rows.into_iter().map(|host| {
let id = host.id;
let delete_name = host.name.clone();
view! {
<tr>
<td>
@@ -259,7 +341,7 @@ fn HostTable(
<td class="col-count">{host.application_count}</td>
<td class="col-actions">
<button on:click=move |_| {
delete_action.dispatch(DeleteHost { id });
pending_delete.set(Some((id, delete_name.clone())));
}>
"Delete"
</button>
@@ -280,52 +362,92 @@ fn HostTable(
#[component]
pub fn HostsPage() -> impl IntoView {
// Actions
let create_action = ServerAction::<CreateHost>::new();
let delete_action = ServerAction::<DeleteHost>::new();
// Filter signals (0 / "" = no filter)
let show_modal = RwSignal::new(false);
// None = no modal, Some((id, name)) = delete confirmation open.
let pending_delete: RwSignal<Option<(i64, String)>> = RwSignal::new(None);
// Close the add modal on pending→done with Ok (lives in parent to avoid stale-value re-trigger).
Effect::new(move |was_pending: Option<bool>| {
let is_pending = create_action.pending().get();
if was_pending == Some(true) && !is_pending {
if let Some(Ok(_)) = create_action.value().get() {
show_modal.set(false);
}
}
is_pending
});
// Close the delete modal automatically after a successful deletion.
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 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);
// Pagination signals
let page = RwSignal::new(1i64);
let per_page = RwSignal::new(15i64);
// Hosts resource — refetches whenever any filter/pagination/action changes
let hosts = Resource::new(
move || (
// LocalResource avoids reading a resource outside <Suspense> during hydration.
// All dependencies (filters, pagination, action versions) are client-side only,
// so there is no benefit to SSR for this resource.
let hosts = LocalResource::new(move || {
let _ = create_action.version().get();
let _ = delete_action.version().get();
get_hosts_page(
name_filter.get(),
network_id_filter.get(),
port_filter.get(),
app_id_filter.get(),
page.get(),
per_page.get(),
create_action.version().get(),
delete_action.version().get(),
),
|(name, net, port, app, p, pp, _, _)| get_hosts_page(name, net, port, app, p, pp),
);
)
});
// Dropdown resources (fetched once on mount)
let networks_res = Resource::new(|| (), |_| get_networks());
let applications_res = Resource::new(|| (), |_| get_applications());
let networks_res = LocalResource::new(|| get_networks());
let applications_res = LocalResource::new(|| get_applications());
// Derived pagination signals
let total_pages = Signal::derive(move || {
hosts.get().and_then(|r| r.ok()).map(|p| p.total_pages).unwrap_or(1)
hosts.get().and_then(|r| (*r).clone().ok()).map(|p| p.total_pages).unwrap_or(1)
});
let total = Signal::derive(move || {
hosts.get().and_then(|r| r.ok()).map(|p| p.total).unwrap_or(0)
hosts.get().and_then(|r| (*r).clone().ok()).map(|p| p.total).unwrap_or(0)
});
view! {
<div class="hosts-page">
<h1>"Hosts"</h1>
<div class="page-header">
<h1>"Hosts"</h1>
<button class="btn-primary" on:click=move |_| show_modal.set(true)>
"+ Add host"
</button>
</div>
<AddHostForm create_action=create_action networks_res=networks_res/>
{move || show_modal.get().then(|| view! {
<AddHostModal
create_action=create_action
networks_res=networks_res
show_modal=show_modal
/>
})}
{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
networks_res=networks_res
@@ -345,7 +467,7 @@ pub fn HostsPage() -> impl IntoView {
<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>
</div>
}.into_any()

View File

@@ -9,7 +9,11 @@
// Do not place code here that requires browser-only APIs (window, document...)
// without guarding it with `#[cfg(target_arch = "wasm32")]`.
pub mod home; // Home page
pub mod hosts; // Hosts list with filters and pagination
pub mod networks; // Networks list and creation
pub mod theme; // Theme toggle component (light / dark / system)
pub mod application_detail; // Application detail: identity, ports, linked hosts, delete
pub mod applications; // Applications list and creation
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 network_detail; // Network detail: info + paginated host list
pub mod networks; // Networks list and creation
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

@@ -1,41 +1,77 @@
// client/networks.rs — Networks page
//
// Displays all CIDR networks managed by the IPAM and lets the user add or
// delete them. All data operations go through Leptos server functions
// (api/networks.rs), which run on the server and are called via HTTP
// from the browser after hydration.
//
// Key Leptos 0.7 concepts used here:
// - `ServerAction<F>` : wraps a `#[server]` function for use with forms / buttons
// - `Resource::new` : async data that re-fetches when its source signal changes
// - `action.version() : a Signal<usize> that increments after each dispatch,
// used here as a dependency to trigger list re-fetches
// - `<ActionForm>` : a form that submits to a ServerAction (no JS needed)
// - `<Suspense>` : shows a fallback while the Resource is loading
use leptos::prelude::*;
use leptos::form::ActionForm;
use crate::api::networks::{CreateNetwork, DeleteNetwork, get_networks_with_counts};
use crate::api::networks::{CreateNetwork, DeleteNetwork, NetworkWithCounts, get_networks_with_counts};
// ─── Delete confirmation modal ────────────────────────────────────────────────
#[component]
fn DeleteConfirmModal(
network: NetworkWithCounts,
delete_action: ServerAction<DeleteNetwork>,
pending_delete: RwSignal<Option<NetworkWithCounts>>,
) -> impl IntoView {
let id = network.id;
let label = format!("{} ({})", network.name, network.cidr);
let host_count = network.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 network"</h2>
</div>
<div class="modal__body">
<p>"Delete network " <strong>{label}</strong> "?"</p>
{(host_count > 0).then(|| view! {
<p class="warning">
"Warning: "
{host_count}
{if host_count == 1 { " host" } else { " hosts" }}
" belonging to this network will also be deleted."
</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(DeleteNetwork { id });
}
>"Delete"</button>
</div>
</div>
</div>
}.into_any()
}
// ─── Page ─────────────────────────────────────────────────────────────────────
#[component]
pub fn NetworksPage() -> impl IntoView {
// ── Actions ───────────────────────────────────────────────────────────────
//
// `ServerAction<F>` binds a `#[server]` function to a reactive action.
// Under the hood it posts to `/api/<fn-name>` and updates its signals
// (.pending(), .value(), .version()) when the call completes.
let create_action = ServerAction::<CreateNetwork>::new();
let delete_action = ServerAction::<DeleteNetwork>::new();
// ── Data resource ─────────────────────────────────────────────────────────
//
// `Resource::new(source, fetcher)`:
// - source : a closure whose return value Leptos tracks reactively
// - fetcher : an async closure called whenever the source changes
//
// By reading `.version()` from both actions, the list automatically
// re-fetches after any create or delete, keeping the view in sync.
// Stores the network pending deletion; Some = modal open, None = closed.
let pending_delete: RwSignal<Option<NetworkWithCounts>> = RwSignal::new(None);
// Close the modal automatically after a successful deletion.
Effect::new(move |_| {
if let Some(Ok(_)) = delete_action.value().get() {
pending_delete.set(None);
}
});
let networks = Resource::new(
move || (create_action.version().get(), delete_action.version().get()),
|_| get_networks_with_counts(),
@@ -45,15 +81,28 @@ pub fn NetworksPage() -> impl IntoView {
<div class="networks-page">
<h1>"Networks"</h1>
// ── Delete confirmation modal ──────────────────────────────────────
{move || pending_delete.get().map(|network| view! {
<DeleteConfirmModal
network=network
delete_action=delete_action
pending_delete=pending_delete
/>
})}
// ── Add form ──────────────────────────────────────────────────────
//
// `<ActionForm action=create_action>` submits the form to the server
// function registered in `create_action`. The `name` attribute on
// each input must match the parameter name in `create_network(cidr: String)`.
// After submission the form clears itself automatically.
<section class="add-form">
<h2>"Add a network"</h2>
<ActionForm action=create_action>
<label>
"Name"
<input
type="text"
name="name"
placeholder="e.g. LAN, DMZ, VPN"
required
/>
</label>
<label>
"CIDR block"
<input
@@ -66,9 +115,6 @@ pub fn NetworksPage() -> impl IntoView {
<button type="submit">"Add"</button>
</ActionForm>
// Show the error from the last create attempt, if any.
// `action.value().get()` → Option<Result<Network, ServerFnError>>
// `.and_then(|r| r.err())` extracts the error when present.
{move || {
create_action
.value()
@@ -82,7 +128,6 @@ pub fn NetworksPage() -> impl IntoView {
<section class="list">
<h2>"All networks"</h2>
// Show delete errors above the list.
{move || {
delete_action
.value()
@@ -91,12 +136,8 @@ pub fn NetworksPage() -> impl IntoView {
.map(|e| view! { <p class="error">"Delete failed: " {e.to_string()}</p> })
}}
// `<Suspense>` shows `fallback` while the Resource is loading,
// then switches to the children once data is available.
<Suspense fallback=|| view! { <p>"Loading networks…"</p> }>
{move || {
// `networks.get()` → None while loading, Some(result) once done.
// Returning None here keeps <Suspense> in its fallback state.
networks.get().map(|result| match result {
Err(e) => view! {
<p class="error">"Could not load networks: " {e.to_string()}</p>
@@ -113,6 +154,7 @@ pub fn NetworksPage() -> impl IntoView {
<table>
<thead>
<tr>
<th>"Name"</th>
<th>"CIDR"</th>
<th class="col-count">"Hosts"</th>
<th class="col-count">"Applications"</th>
@@ -123,16 +165,22 @@ pub fn NetworksPage() -> impl IntoView {
{list
.into_iter()
.map(|network| {
let id = network.id;
let network_clone = network.clone();
let net_id = network.id;
view! {
<tr>
<td>{network.cidr}</td>
<td>
<a class="table-link"
href=format!("/networks/{}", net_id)>
{network.name}
</a>
</td>
<td class="cell-mono">{network.cidr}</td>
<td class="col-count">{network.host_count}</td>
<td class="col-count">{network.application_count}</td>
<td class="col-actions">
<button on:click=move |_| {
delete_action
.dispatch(DeleteNetwork { id });
pending_delete.set(Some(network_clone.clone()));
}>
"Delete"
</button>

View File

@@ -71,7 +71,9 @@ impl ThemeChoice {
// ─── DOM helpers (WASM only) ─────────────────────────────────────────────────
// Reads the stored theme name from localStorage.
#[cfg(target_arch = "wasm32")]
// Guard on `hydrate` feature rather than `target_arch` because web-sys is
// only activated by that feature in Cargo.toml.
#[cfg(feature = "hydrate")]
fn load_stored_theme() -> Option<ThemeChoice> {
let storage = web_sys::window()?.local_storage().ok()??;
let value = storage.get_item(STORAGE_KEY).ok()??;
@@ -79,7 +81,7 @@ fn load_stored_theme() -> Option<ThemeChoice> {
}
// Applies `data-theme` attribute to <html> and persists to localStorage.
#[cfg(target_arch = "wasm32")]
#[cfg(feature = "hydrate")]
fn apply_and_persist(choice: &ThemeChoice) {
let Some(window) = web_sys::window() else { return };
let Some(document) = window.document() else { return };
@@ -108,7 +110,7 @@ pub fn ThemeToggle() -> impl IntoView {
// Does NOT track `theme`, so it never re-runs after the initial mount.
// Setting the signal here triggers Effect 2 below.
Effect::new(move |_| {
#[cfg(target_arch = "wasm32")]
#[cfg(feature = "hydrate")]
if let Some(stored) = load_stored_theme() {
theme.set(stored);
}
@@ -118,7 +120,7 @@ pub fn ThemeToggle() -> impl IntoView {
// whenever the signal changes (both on init and after user clicks).
Effect::new(move |_| {
let current = theme.get(); // tracked — re-runs when theme changes
#[cfg(target_arch = "wasm32")]
#[cfg(feature = "hydrate")]
apply_and_persist(&current);
// Suppress unused variable warning when compiling for SSR
let _ = current;

View File

@@ -28,6 +28,9 @@ pub struct Network {
/// `i64` is a signed 64-bit integer — maps to `BIGINT` in SQL.
pub id: i64,
/// Human-readable name. Examples: "LAN", "DMZ", "VPN"
pub name: String,
/// Address range in CIDR notation.
/// Examples: "10.0.0.0/8", "172.16.0.0/12", "192.168.1.0/24"
pub cidr: String,

View File

@@ -42,6 +42,18 @@ pub async fn create_application(pool: &AnyPool, name: &str) -> Result<Applicatio
Ok(row_to_application(&row))
}
/// Updates the name of an application. Returns the updated record.
pub async fn update_application(pool: &AnyPool, id: i64, name: &str) -> Result<Application, DbError> {
let row = sqlx::query(
"UPDATE applications SET name = $1 WHERE id = $2 RETURNING id, name",
)
.bind(name)
.bind(id)
.fetch_one(pool)
.await?;
Ok(row_to_application(&row))
}
/// Deletes an application and its port associations (via `ON DELETE CASCADE`).
///
/// Returns `true` if a row was deleted, `false` if the id did not exist.
@@ -54,6 +66,84 @@ pub async fn delete_application(pool: &AnyPool, id: i64) -> Result<bool, DbError
Ok(result.rows_affected() > 0)
}
// ─── Host-application associations ───────────────────────────────────────────
/// Returns all applications linked directly to a host, ordered by name.
pub async fn list_applications_for_host(
pool: &AnyPool,
host_id: i64,
) -> Result<Vec<Application>, DbError> {
let rows = sqlx::query(
"SELECT a.id, a.name
FROM applications a
JOIN host_applications ha ON ha.application_id = a.id
WHERE ha.host_id = $1
ORDER BY a.name",
)
.bind(host_id)
.fetch_all(pool)
.await?;
Ok(rows.iter().map(row_to_application).collect())
}
/// Returns all applications NOT yet linked to a host, ordered by name.
pub async fn list_applications_not_on_host(
pool: &AnyPool,
host_id: i64,
) -> Result<Vec<Application>, DbError> {
let rows = sqlx::query(
"SELECT id, name FROM applications
WHERE id NOT IN (
SELECT application_id FROM host_applications WHERE host_id = $1
)
ORDER BY name",
)
.bind(host_id)
.fetch_all(pool)
.await?;
Ok(rows.iter().map(row_to_application).collect())
}
/// Links an application directly to a host.
///
/// If the link already exists, this is a no-op (not an error).
pub async fn add_application_to_host(
pool: &AnyPool,
host_id: i64,
application_id: i64,
) -> Result<(), DbError> {
let result = sqlx::query(
"INSERT INTO host_applications (host_id, application_id) VALUES ($1, $2)",
)
.bind(host_id)
.bind(application_id)
.execute(pool)
.await;
match result {
Ok(_) => Ok(()),
Err(sqlx::Error::Database(ref e)) if e.is_unique_violation() => Ok(()),
Err(e) => Err(DbError::Connection(e)),
}
}
/// Removes the direct link between a host and an application.
///
/// Returns `true` if the association existed and was removed.
pub async fn remove_application_from_host(
pool: &AnyPool,
host_id: i64,
application_id: i64,
) -> Result<bool, DbError> {
let result = sqlx::query(
"DELETE FROM host_applications WHERE host_id = $1 AND application_id = $2",
)
.bind(host_id)
.bind(application_id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
// ─── Application-port associations ───────────────────────────────────────────
/// Returns all port numbers associated with an application, sorted numerically.

View File

@@ -62,6 +62,33 @@ pub async fn create_host(
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`).
///
/// Returns `true` if a row was deleted, `false` if the id did not exist.

View File

@@ -11,7 +11,7 @@ use crate::server::db::DbError;
pub async fn list_networks(pool: &AnyPool) -> Result<Vec<Network>, DbError> {
// `fetch_all` runs the query and collects every row into a Vec.
// It returns an error if the query fails; an empty table returns Ok(vec![]).
let rows = sqlx::query("SELECT id, cidr FROM networks ORDER BY id")
let rows = sqlx::query("SELECT id, name, cidr FROM networks ORDER BY id")
.fetch_all(pool)
.await?;
@@ -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> {
// `fetch_optional` returns `Ok(None)` when no row matches — unlike
// `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
.fetch_optional(pool)
.await?;
@@ -42,11 +42,14 @@ pub async fn find_network(pool: &AnyPool, id: i64) -> Result<Option<Network>, Db
/// `RETURNING id, cidr` reads back the inserted row in a single round-trip,
/// avoiding a separate SELECT after the INSERT.
/// Requires SQLite ≥ 3.35 (2021) and any PostgreSQL version.
pub async fn create_network(pool: &AnyPool, cidr: &str) -> Result<Network, DbError> {
let row = sqlx::query("INSERT INTO networks (cidr) VALUES ($1) RETURNING id, cidr")
.bind(cidr)
.fetch_one(pool) // exactly one row is returned by RETURNING
.await?;
pub async fn create_network(pool: &AnyPool, name: &str, cidr: &str) -> Result<Network, DbError> {
let row = sqlx::query(
"INSERT INTO networks (name, cidr) VALUES ($1, $2) RETURNING id, name, cidr",
)
.bind(name)
.bind(cidr)
.fetch_one(pool)
.await?;
Ok(row_to_network(&row))
}
@@ -73,7 +76,8 @@ pub async fn delete_network(pool: &AnyPool, id: i64) -> Result<bool, DbError> {
/// The type must implement `sqlx::Decode` for the `Any` backend.
fn row_to_network(row: &sqlx::any::AnyRow) -> Network {
Network {
id: row.get("id"),
id: row.get("id"),
name: row.get("name"),
cidr: row.get("cidr"),
}
}

View File

@@ -784,6 +784,13 @@ td.col-actions {
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
============================================================ */
@@ -859,35 +866,220 @@ td.col-actions {
}
/* ============================================================
HOSTS PAGE
MODAL
============================================================ */
.hosts-page h1 {
margin-bottom: var(--size-lg);
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(3px);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
animation: backdrop-in var(--transition-base) both;
}
.hosts-page .add-form {
@keyframes backdrop-in {
from { opacity: 0; }
to { opacity: 1; }
}
.modal {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: var(--size-lg);
margin-bottom: var(--size-md);
box-shadow: var(--shadow-lg);
padding: var(--size-lg) var(--size-xl);
width: 90%;
max-width: 440px;
animation: modal-in var(--transition-base) both;
}
.hosts-page .add-form h2 {
margin-bottom: var(--size-md);
@keyframes modal-in {
from { opacity: 0; transform: translateY(-12px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.add-form__fields {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
.modal__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--size-lg);
}
.modal__header h2 {
margin: 0;
font-size: var(--font-lg);
}
.modal__close {
background: none;
border: none;
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
color: var(--text-secondary);
padding: 2px 6px;
border-radius: var(--radius-sm);
transition: color var(--transition-fast), background var(--transition-fast);
}
.modal__close:hover {
color: var(--text);
background: var(--bg-hover);
}
.modal__body {
margin-bottom: var(--size-lg);
}
.modal__body p {
margin: 0 0 var(--size-sm);
}
.warning {
color: var(--color-warning, #b45309);
background: var(--color-warning-bg, #fef3c7);
border: 1px solid var(--color-warning-border, #fcd34d);
border-radius: var(--radius-sm);
padding: var(--size-sm) var(--size-md);
font-size: var(--font-sm);
}
/* Form fields inside modal — single column stack */
.modal .add-form__fields {
display: flex;
flex-direction: column;
gap: var(--size-md);
align-items: end;
}
.add-form__fields button[type="submit"] {
align-self: end;
.modal .add-form__fields label {
display: flex;
flex-direction: column;
gap: var(--size-xs);
font-size: var(--font-sm);
font-weight: 500;
color: var(--text-secondary);
}
/* Cancel / Submit button row */
.modal__actions {
display: flex;
justify-content: flex-end;
gap: var(--size-sm);
margin-top: var(--size-lg);
}
.btn-secondary {
background: var(--bg-hover);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 7px var(--size-md);
font-size: var(--font-sm);
font-weight: 500;
cursor: pointer;
transition: background var(--transition-fast);
}
.btn-secondary:hover {
background: var(--bg-surface2);
}
/* ============================================================
HOSTS PAGE
============================================================ */
/* Header row: title on the left, "Add host" button on the right */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--size-lg);
}
.page-header h1 {
margin: 0;
}
.btn-primary {
background: var(--accent);
color: var(--text-on-accent);
border: none;
border-radius: var(--radius-sm);
padding: 8px var(--size-md);
font-size: var(--font-sm);
font-weight: 500;
cursor: pointer;
transition: background var(--transition-fast);
white-space: nowrap;
}
.btn-primary: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 */
@@ -906,3 +1098,250 @@ td.col-actions {
background: var(--danger-light);
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;
}
/* ============================================================
HOST DETAIL — APPLICATIONS SECTION
============================================================ */
/* List of applications linked to a host (mirrors .port-list) */
.app-list {
display: flex;
flex-direction: column;
margin-bottom: var(--size-md);
border-top: 1px solid var(--border);
}
.app-row {
display: flex;
align-items: center;
gap: var(--size-md);
padding: var(--size-sm) var(--size-xs);
border-bottom: 1px solid var(--border);
}
.app-row__name {
flex: 1;
font-size: var(--font-sm);
}
/* ── Pick list (scrollable, click to select) ── */
.app-pick-list {
list-style: none;
margin: 0 0 var(--size-sm);
padding: 0;
max-height: 220px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 6px;
}
.app-pick-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--size-sm) var(--size-md);
cursor: pointer;
font-size: var(--font-sm);
border-bottom: 1px solid var(--border);
transition: background 0.1s;
}
.app-pick-item:last-child {
border-bottom: none;
}
.app-pick-item:hover {
background: color-mix(in srgb, var(--accent) 10%, transparent);
color: var(--accent);
}
.app-pick-item__add {
font-size: 1.1rem;
font-weight: 600;
color: var(--accent);
opacity: 0.7;
}
.app-pick-item:hover .app-pick-item__add {
opacity: 1;
}
/* ── Selected tags (shown below the pick list) ── */
.app-selected-section {
display: flex;
flex-direction: column;
gap: var(--size-xs);
padding-top: var(--size-sm);
border-top: 1px solid var(--border);
margin-top: var(--size-xs);
}
.app-selected-label {
font-size: var(--font-xs, 0.75rem);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
}
.app-selected-list {
display: flex;
flex-wrap: wrap;
gap: var(--size-xs);
}
.app-selected-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px 2px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 15%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
color: var(--accent);
font-size: var(--font-sm);
font-weight: 500;
}
.app-selected-tag__remove {
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: none;
padding: 0 2px;
cursor: pointer;
font-size: 0.9rem;
color: var(--accent);
opacity: 0.7;
line-height: 1;
}
.app-selected-tag__remove:hover {
opacity: 1;
}