Compare commits

...

30 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
21 changed files with 2867 additions and 111 deletions

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"]
@@ -93,6 +102,7 @@ site-addr = "127.0.0.1:3000" # Adresse d'écoute du serveur Axum
reload-port = 3001 # Port WebSocket pour le hot-reload en développement
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)

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,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)
);

View File

@@ -60,3 +60,227 @@ FROM (VALUES
('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;

View File

@@ -40,3 +40,203 @@ INSERT INTO hosts (name, ip, network_id) SELECT 'log-server-01', '10.0.1.30',
INSERT INTO hosts (name, ip, network_id) SELECT 'vpn-gateway-01', '172.16.0.1', id FROM networks WHERE cidr = '172.16.0.0/16' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'vpn-gateway-01' AND ip = '172.16.0.1');
INSERT INTO hosts (name, ip, network_id) SELECT 'vpn-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

@@ -77,6 +77,21 @@ 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 name and CIDR block.

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>

View File

@@ -85,5 +85,13 @@ async fn main() {
.await
.unwrap_or(0);
tracing::info!("Database now contains {} network(s) and {} host(s).", network_count, host_count);
let application_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM applications")
.fetch_one(&pool)
.await
.unwrap_or(0);
tracing::info!(
"Database now contains {} network(s), {} host(s) and {} application(s).",
network_count, host_count, application_count
);
}

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

@@ -2,7 +2,7 @@
//
// Displays all hosts across every network with:
// - Add button : opens a modal form to create a host inside a chosen network
// - Filter bar : name (substring), network, open port, application
// - Filter bar : name (substring), network, open ports (CSV), application
// - Table : name, IP, network, port count, application count, delete
// - Pagination : configurable page size (15 / 25 / 50 / 100 / All)
//
@@ -12,6 +12,7 @@
use leptos::prelude::*;
use leptos::form::ActionForm;
use leptos::html::Input;
use crate::api::{
applications::get_applications,
@@ -27,25 +28,69 @@ const PER_PAGE_OPTIONS: &[(i64, &str)] = &[
(0, "All"),
];
// ─── Delete host modal ────────────────────────────────────────────────────────
#[component]
fn DeleteHostModal(
host_name: String,
host_id: i64,
delete_action: ServerAction<DeleteHost>,
pending_delete: RwSignal<Option<(i64, String)>>,
) -> impl IntoView {
view! {
<div class="modal-backdrop" on:click=move |_| pending_delete.set(None)>
<div class="modal" on:click=move |e| e.stop_propagation()>
<div class="modal__header">
<h2>"Delete host"</h2>
<button class="modal__close" type="button" aria-label="Close"
on:click=move |_| pending_delete.set(None)>
"×"
</button>
</div>
<div class="modal__body">
<p class="warning">
"Are you sure you want to delete "
<strong>{host_name}</strong>
"? All port associations will also be removed."
</p>
</div>
<div class="modal__actions">
<button class="btn-secondary" type="button"
on:click=move |_| pending_delete.set(None)>
"Cancel"
</button>
<button class="btn-danger" type="button"
on:click=move |_| { delete_action.dispatch(DeleteHost { id: host_id }); }>
"Delete"
</button>
</div>
</div>
</div>
}.into_any()
}
// ─── Add host modal ───────────────────────────────────────────────────────────
#[component]
fn AddHostModal(
create_action: ServerAction<CreateHost>,
networks_res: Resource<Result<Vec<crate::models::Network>, ServerFnError>>,
networks_res: LocalResource<Result<Vec<crate::models::Network>, ServerFnError>>,
show_modal: RwSignal<bool>,
) -> impl IntoView {
// Close the modal automatically after a successful creation.
Effect::new(move |_| {
if let Some(Ok(_)) = create_action.value().get() {
show_modal.set(false);
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! {
// Backdrop — click outside the card to close
<div class="modal-backdrop" on:click=move |_| show_modal.set(false)>
// stop_propagation keeps clicks inside the card from closing the modal
<div class="modal-backdrop" on:click=move |_| close()>
<div class="modal" on:click=move |e| e.stop_propagation()>
<div class="modal__header">
<h2>"Add a host"</h2>
@@ -53,7 +98,7 @@ fn AddHostModal(
class="modal__close"
type="button"
aria-label="Close"
on:click=move |_| show_modal.set(false)
on:click=move |_| close()
>"×"</button>
</div>
@@ -61,7 +106,7 @@ fn AddHostModal(
<div class="add-form__fields">
<label>
"Name"
<input type="text" name="name" placeholder="e.g. web-server-01" required/>
<input node_ref=name_ref type="text" name="name" placeholder="e.g. web-server-01" required/>
</label>
<label>
"IP address"
@@ -72,20 +117,30 @@ fn AddHostModal(
<select name="network_id" required>
<option value="">"— choose —"</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.name}</option> }
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 |_| show_modal.set(false)
on:click=move |_| close()
>"Cancel"</button>
<button type="submit">"Add host"</button>
</div>
@@ -104,11 +159,11 @@ fn AddHostModal(
#[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 {
@@ -135,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.name}</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);
}
/>
@@ -165,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())
@@ -212,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
@@ -240,12 +293,12 @@ fn PaginationBar(
#[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(),
@@ -270,6 +323,7 @@ fn HostTable(
<tbody>
{rows.into_iter().map(|host| {
let id = host.id;
let delete_name = host.name.clone();
view! {
<tr>
<td>
@@ -287,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>
@@ -308,48 +362,65 @@ fn HostTable(
#[component]
pub fn HostsPage() -> impl IntoView {
// Actions
let create_action = ServerAction::<CreateHost>::new();
let delete_action = ServerAction::<DeleteHost>::new();
// Controls the add-host modal
let show_modal = RwSignal::new(false);
// Filter signals (0 / "" = no filter)
// None = no modal, Some((id, name)) = delete confirmation open.
let pending_delete: RwSignal<Option<(i64, String)>> = RwSignal::new(None);
// Close the 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! {
@@ -361,7 +432,6 @@ pub fn HostsPage() -> impl IntoView {
</button>
</div>
// Modal — only rendered when show_modal is true
{move || show_modal.get().then(|| view! {
<AddHostModal
create_action=create_action
@@ -370,6 +440,15 @@ pub fn HostsPage() -> impl IntoView {
/>
})}
{move || pending_delete.get().map(|(host_id, host_name)| view! {
<DeleteHostModal
host_name=host_name
host_id=host_id
delete_action=delete_action
pending_delete=pending_delete
/>
})}
<FilterBar
networks_res=networks_res
applications_res=applications_res
@@ -388,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

@@ -166,9 +166,15 @@ pub fn NetworksPage() -> impl IntoView {
.into_iter()
.map(|network| {
let network_clone = network.clone();
let net_id = network.id;
view! {
<tr>
<td>{network.name}</td>
<td>
<a class="table-link"
href=format!("/networks/{}", net_id)>
{network.name}
</a>
</td>
<td class="cell-mono">{network.cidr}</td>
<td class="col-count">{network.host_count}</td>
<td class="col-count">{network.application_count}</td>

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

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

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
============================================================ */
@@ -1014,6 +1021,67 @@ td.col-actions {
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 */
.hosts-page td button {
background: transparent;
@@ -1030,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;
}