Compare commits

...

16 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
16 changed files with 1375 additions and 155 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"]

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

@@ -64,31 +64,41 @@ WHERE NOT EXISTS (SELECT 1 FROM hosts WHERE hosts.name = t.name AND hosts.ip = t
-- ── Ports catalog ─────────────────────────────────────────────────────────────
INSERT INTO ports (number, description) VALUES
(22, 'SSH'),
(25, 'SMTP'),
(53, 'DNS'),
(80, 'HTTP'),
(143, 'IMAP'),
(161, 'SNMP'),
(443, 'HTTPS'),
(445, 'SMB'),
(465, 'SMTPS'),
(500, 'IKE / IPSec'),
(514, 'Syslog'),
(587, 'SMTP Submission'),
(873, 'rsync'),
(993, 'IMAPS'),
(1194, 'OpenVPN'),
(2049, 'NFS'),
(3000, 'Grafana'),
(3389, 'RDP'),
(4500, 'IPSec NAT-T'),
(5044, 'Logstash Beats'),
(5432, 'PostgreSQL'),
(5601, 'Kibana'),
(9090, 'Prometheus'),
(9100, 'JetDirect'),
(9200, 'Elasticsearch')
(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 ────────────────────────────────────────────────────────────────
@@ -193,3 +203,84 @@ 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

@@ -44,31 +44,41 @@ INSERT INTO hosts (name, ip, network_id) SELECT 'vpn-client-02', '172.16.1.11',
-- ── Ports catalog ─────────────────────────────────────────────────────────────
INSERT OR IGNORE INTO ports (number, description) VALUES
(22, 'SSH'),
(25, 'SMTP'),
(53, 'DNS'),
(80, 'HTTP'),
(143, 'IMAP'),
(161, 'SNMP'),
(443, 'HTTPS'),
(445, 'SMB'),
(465, 'SMTPS'),
(500, 'IKE / IPSec'),
(514, 'Syslog'),
(587, 'SMTP Submission'),
(873, 'rsync'),
(993, 'IMAPS'),
(1194, 'OpenVPN'),
(2049, 'NFS'),
(3000, 'Grafana'),
(3389, 'RDP'),
(4500, 'IPSec NAT-T'),
(5044, 'Logstash Beats'),
(5432, 'PostgreSQL'),
(5601, 'Kibana'),
(9090, 'Prometheus'),
(9100, 'JetDirect'),
(9200, 'Elasticsearch');
(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).
@@ -124,7 +134,7 @@ INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 993 FROM host
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'core-switch-01' AND ip = '10.0.0.1';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 161 FROM hosts WHERE name = 'core-switch-01' AND ip = '10.0.0.1';
-- monitoring-01: SSH, HTTP, HTTPS, Prometheus, Grafana
-- monitoring-01: SSH, HTTP, HTTPS, Grafana, Prometheus
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'monitoring-01' AND ip = '10.0.1.10';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 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';
@@ -152,3 +162,81 @@ INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 4500 FROM hos
-- 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

@@ -5,6 +5,24 @@ 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
@@ -65,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(
@@ -83,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,9 +3,7 @@
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use crate::models::Host;
use crate::models::Port;
use crate::models::{Application, Host, Port};
// ─── Presentation types ───────────────────────────────────────────────────────
@@ -32,7 +30,7 @@ pub struct HostsPage {
pub total_pages: i64, // ceil(total / per_page); always ≥ 1
}
// Full host detail: identity fields + resolved network + open ports.
// Full host detail: identity fields + resolved network + open ports + linked applications.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HostDetail {
pub id: i64,
@@ -42,6 +40,7 @@ pub struct HostDetail {
pub network_name: String,
pub network_cidr: String,
pub ports: Vec<Port>,
pub applications: Vec<Application>,
}
// ─── Queries ──────────────────────────────────────────────────────────────────
@@ -50,7 +49,12 @@ pub struct HostDetail {
#[server]
pub async fn get_host_detail(id: i64) -> Result<HostDetail, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::{hosts as host_repo, networks, ports as port_repo};
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"))?;
@@ -69,6 +73,10 @@ pub async fn get_host_detail(id: i64) -> Result<HostDetail, ServerFnError> {
.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,
@@ -77,6 +85,7 @@ pub async fn get_host_detail(id: i64) -> Result<HostDetail, ServerFnError> {
network_name: network.name,
network_cidr: network.cidr,
ports,
applications,
})
}
@@ -343,6 +352,72 @@ pub async fn remove_host_port(host_id: i64, port_number: i64) -> Result<(), Serv
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Returns all applications not yet directly linked to a host.
///
/// Used to populate the "add applications" modal on the host detail page.
#[server]
pub async fn get_applications_not_on_host(
host_id: i64,
) -> Result<Vec<Application>, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::applications as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
repo::list_applications_not_on_host(&pool, host_id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Links one or more applications to a host.
///
/// `application_ids` is a comma-separated string of application IDs (e.g. "1,3,7").
/// Already-linked applications are silently skipped (no-op).
#[server]
pub async fn add_host_applications(
host_id: i64,
application_ids: String,
) -> Result<(), ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::applications as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
let ids: Vec<i64> = application_ids
.split(',')
.filter_map(|s| s.trim().parse::<i64>().ok())
.collect();
for application_id in ids {
repo::add_application_to_host(&pool, host_id, application_id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
}
Ok(())
}
/// Removes the direct link between a host and an application.
///
/// Returns `true` if the association existed and was removed.
#[server]
pub async fn remove_host_application(
host_id: i64,
application_id: i64,
) -> Result<bool, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::applications as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
repo::remove_application_from_host(&pool, host_id, application_id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Deletes a host by id.
///
/// Also removes all its port associations (via `ON DELETE CASCADE`).

View File

@@ -12,6 +12,7 @@ use leptos_router::{
};
use crate::client::{
application_detail::ApplicationDetailPage,
applications::ApplicationsPage,
home::HomePage,
host_detail::HostDetailPage,
@@ -115,6 +116,7 @@ pub fn App() -> impl IntoView {
<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()
}

View File

@@ -8,6 +8,7 @@
use leptos::prelude::*;
use leptos::form::ActionForm;
use leptos::html::Input;
use crate::api::applications::{
ApplicationWithCounts, CreateApplication, DeleteApplication,
@@ -21,19 +22,33 @@ fn AddApplicationModal(
create_action: ServerAction<CreateApplication>,
show_modal: RwSignal<bool>,
) -> impl IntoView {
Effect::new(move |_| {
if let Some(Ok(_)) = create_action.value().get() {
show_modal.set(false);
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 |_| show_modal.set(false)>
<div class="modal-backdrop" on:click=move |_| close()>
<div class="modal" on:click=move |e| e.stop_propagation()>
<div class="modal__header">
<h2>"Add an application"</h2>
<button class="modal__close" type="button" aria-label="Close"
on:click=move |_| show_modal.set(false)>
on:click=move |_| close()>
"×"
</button>
</div>
@@ -43,17 +58,27 @@ fn AddApplicationModal(
<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 |_| show_modal.set(false)>
on:click=move |_| close()>
"Cancel"
</button>
<button type="submit">"Add application"</button>
@@ -132,6 +157,19 @@ pub fn ApplicationsPage() -> impl IntoView {
// 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() {
@@ -225,10 +263,15 @@ pub fn ApplicationsPage() -> impl IntoView {
let app_clone = app.clone();
view! {
<tr>
<td>{app.name}</td>
<td>
<a class="table-link"
href=format!("/applications/{}", app.id)>
{app.name}
</a>
</td>
<td class="col-count">{app.host_count}</td>
<td class="col-actions">
<button on:click=move |_| {
<button class="btn-danger" on:click=move |_| {
pending_delete.set(Some(app_clone.clone()));
}>
"Delete"

View File

@@ -1,17 +1,177 @@
// client/host_detail.rs — Host detail page
//
// Shows all information for a single host:
// - Identity form : name, IP, network dropdown — editable, saved with "Save changes"
// - Ports section : full list with Remove per port + Add port input
// - Delete button : opens a confirmation modal, then navigates back to /hosts
// - 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::{AddHostPort, DeleteHost, RemoveHostPort, UpdateHost, get_host_detail},
hosts::{
AddHostApplications, AddHostPort, DeleteHost, RemoveHostApplication,
RemoveHostPort, UpdateHost, get_applications_not_on_host, get_host_detail,
},
networks::get_networks,
};
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 ────────────────────────────────────────────────
@@ -36,7 +196,7 @@ fn DeleteModal(
<p class="warning">
"Are you sure you want to delete "
<strong>{host_name}</strong>
"? All port associations will also be removed."
"? All port and application associations will also be removed."
</p>
</div>
<div class="modal__actions">
@@ -58,8 +218,6 @@ fn DeleteModal(
#[component]
pub fn HostDetailPage() -> impl IntoView {
// Read the `:id` segment from the URL.
// `use_params_map()` returns a reactive map of all URL path parameters.
let params = use_params_map();
let host_id = move || {
params.read().get("id")
@@ -67,8 +225,6 @@ pub fn HostDetailPage() -> impl IntoView {
.unwrap_or(0)
};
// Optional `?back=<url>` query parameter — used when arriving from a network
// detail page so the back button returns there instead of the hosts list.
let query = use_query_map();
let back_url = move || {
query.read().get("back")
@@ -76,53 +232,74 @@ pub fn HostDetailPage() -> impl IntoView {
.unwrap_or_else(|| "/hosts".to_string())
};
let back_label = move || {
if back_url().starts_with("/networks/") { "← Network" } else { "← Hosts" }
if back_url().starts_with("/networks/") { "← Network" }
else if back_url().starts_with("/applications/") { "← Application" }
else { "← Hosts" }
};
let update_action = ServerAction::<UpdateHost>::new();
let 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_delete_modal = RwSignal::new(false);
let show_add_app_modal = RwSignal::new(false);
// Reload detail after any mutation that touches this host.
// The resource key includes action versions so it invalidates automatically.
let host = Resource::new(
move || (
host_id(),
update_action.version().get(),
add_port_action.version().get(),
remove_port_action.version().get(),
),
|(id, _, _, _)| get_host_detail(id),
);
// 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());
// Edit-field signals, populated once by the Effect below.
// Using signals (rather than local variables) keeps them stable across
// re-renders and lets the user edit without triggering a resource reload.
// 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());
// Input value for the "add port" row.
let new_port = RwSignal::new(String::new());
// Sync edit signals whenever the host resource delivers fresh data.
// This runs on initial load and after every successful mutation.
// 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(Ok(ref detail)) = host.get() {
name_sig.set(detail.name.clone());
ip_sig.set(detail.ip.clone());
net_id_sig.set(detail.network_id);
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);
}
}
});
// Navigate back to the list after a successful delete.
// `use_navigate()` must be called during component setup (not inside a closure).
let navigate = use_navigate();
Effect::new(move |_| {
if let Some(Ok(true)) = delete_action.value().get() {
@@ -132,17 +309,37 @@ pub fn HostDetailPage() -> impl IntoView {
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(|result| match result {
{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 modal_name = detail.name.clone();
let port_count = detail.ports.len();
let ports = detail.ports;
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() {
@@ -178,6 +375,40 @@ pub fn HostDetailPage() -> impl IntoView {
}.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">
@@ -223,7 +454,7 @@ pub fn HostDetailPage() -> impl IntoView {
view! {
<option
value=n.id.to_string()
selected=(n.id == current)
selected=n.id == current
>
{label}
</option>
@@ -244,13 +475,13 @@ pub fn HostDetailPage() -> impl IntoView {
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(),
});
}
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>
@@ -273,7 +504,6 @@ pub fn HostDetailPage() -> impl IntoView {
})
}
// Add port row
<div class="port-add-row">
<input
type="number"
@@ -310,7 +540,33 @@ pub fn HostDetailPage() -> impl IntoView {
</div>
</section>
// ── Danger zone ──────────────────────────────────
// ── Applications section ──────────────────────────
<section class="detail-section">
<h2 class="detail-section__title">
{format!("Applications ({})", app_count)}
</h2>
{apps_list}
{move || remove_app_action.value().get()
.and_then(|r| r.err())
.map(|e| view! {
<p class="error">"Remove failed: " {e.to_string()}</p>
})
}
<div class="port-add-row">
<button
class="btn-primary"
type="button"
on:click=move |_| show_add_app_modal.set(true)
>
"+ Add applications"
</button>
</div>
</section>
// ── Danger zone ───────────────────────────────────
<div class="danger-zone">
<button
class="btn-danger-solid"
@@ -320,16 +576,6 @@ pub fn HostDetailPage() -> impl IntoView {
"Delete host"
</button>
</div>
// ── Delete modal (conditional) ────────────────────
{move || show_delete_modal.get().then(|| view! {
<DeleteModal
host_name=modal_name.clone()
delete_action=delete_action
host_id=id
show_modal=show_delete_modal
/>
})}
}.into_any()
}
})}

View File

@@ -12,6 +12,7 @@
use leptos::prelude::*;
use leptos::form::ActionForm;
use leptos::html::Input;
use crate::api::{
applications::get_applications,
@@ -76,15 +77,20 @@ fn AddHostModal(
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! {
<div class="modal-backdrop" on:click=move |_| show_modal.set(false)>
<div class="modal-backdrop" on:click=move |_| close()>
<div class="modal" on:click=move |e| e.stop_propagation()>
<div class="modal__header">
<h2>"Add a host"</h2>
@@ -92,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>
@@ -100,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"
@@ -134,7 +140,7 @@ fn AddHostModal(
<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>
@@ -287,12 +293,12 @@ fn PaginationBar(
#[component]
fn HostTable(
hosts: Resource<Result<HostsPageData, ServerFnError>>,
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(),
@@ -364,6 +370,17 @@ pub fn HostsPage() -> impl IntoView {
// 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() {
@@ -380,28 +397,30 @@ pub fn HostsPage() -> impl IntoView {
let page = RwSignal::new(1i64);
let per_page = RwSignal::new(15i64);
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),
);
)
});
let networks_res = LocalResource::new(|| get_networks());
let applications_res = LocalResource::new(|| get_applications());
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! {

View File

@@ -9,10 +9,11 @@
// Do not place code here that requires browser-only APIs (window, document...)
// without guarding it with `#[cfg(target_arch = "wasm32")]`.
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)
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

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

@@ -1223,3 +1223,125 @@ td.col-actions {
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;
}