Compare commits

..

11 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
11 changed files with 786 additions and 109 deletions

View File

@@ -64,31 +64,41 @@ WHERE NOT EXISTS (SELECT 1 FROM hosts WHERE hosts.name = t.name AND hosts.ip = t
-- ── Ports catalog ───────────────────────────────────────────────────────────── -- ── Ports catalog ─────────────────────────────────────────────────────────────
INSERT INTO ports (number, description) VALUES INSERT INTO ports (number, description) VALUES
(22, 'SSH'), (22, 'SSH'),
(25, 'SMTP'), (25, 'SMTP'),
(53, 'DNS'), (53, 'DNS'),
(80, 'HTTP'), (80, 'HTTP'),
(143, 'IMAP'), (143, 'IMAP'),
(161, 'SNMP'), (161, 'SNMP'),
(443, 'HTTPS'), (443, 'HTTPS'),
(445, 'SMB'), (445, 'SMB'),
(465, 'SMTPS'), (465, 'SMTPS'),
(500, 'IKE / IPSec'), (500, 'IKE / IPSec'),
(514, 'Syslog'), (514, 'Syslog'),
(587, 'SMTP Submission'), (587, 'SMTP Submission'),
(873, 'rsync'), (873, 'rsync'),
(993, 'IMAPS'), (993, 'IMAPS'),
(1194, 'OpenVPN'), (1194, 'OpenVPN'),
(2049, 'NFS'), (2049, 'NFS'),
(3000, 'Grafana'), (3000, 'Grafana / Gitea'),
(3389, 'RDP'), (3306, 'MariaDB / MySQL'),
(4500, 'IPSec NAT-T'), (3389, 'RDP'),
(5044, 'Logstash Beats'), (4500, 'IPSec NAT-T'),
(5432, 'PostgreSQL'), (5044, 'Logstash Beats'),
(5601, 'Kibana'), (5432, 'PostgreSQL'),
(9090, 'Prometheus'), (5601, 'Kibana'),
(9100, 'JetDirect'), (6379, 'Redis'),
(9200, 'Elasticsearch') (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; ON CONFLICT (number) DO NOTHING;
-- ── Host ports ──────────────────────────────────────────────────────────────── -- ── Host ports ────────────────────────────────────────────────────────────────
@@ -193,3 +203,84 @@ INSERT INTO host_ports (host_id, port_number)
SELECT h.id, 22 FROM hosts h SELECT h.id, 22 FROM hosts h
WHERE h.name = 'vpn-client-02' AND h.ip = '172.16.1.11' WHERE h.name = 'vpn-client-02' AND h.ip = '172.16.1.11'
ON CONFLICT DO NOTHING; 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 ───────────────────────────────────────────────────────────── -- ── Ports catalog ─────────────────────────────────────────────────────────────
INSERT OR IGNORE INTO ports (number, description) VALUES INSERT OR IGNORE INTO ports (number, description) VALUES
(22, 'SSH'), (22, 'SSH'),
(25, 'SMTP'), (25, 'SMTP'),
(53, 'DNS'), (53, 'DNS'),
(80, 'HTTP'), (80, 'HTTP'),
(143, 'IMAP'), (143, 'IMAP'),
(161, 'SNMP'), (161, 'SNMP'),
(443, 'HTTPS'), (443, 'HTTPS'),
(445, 'SMB'), (445, 'SMB'),
(465, 'SMTPS'), (465, 'SMTPS'),
(500, 'IKE / IPSec'), (500, 'IKE / IPSec'),
(514, 'Syslog'), (514, 'Syslog'),
(587, 'SMTP Submission'), (587, 'SMTP Submission'),
(873, 'rsync'), (873, 'rsync'),
(993, 'IMAPS'), (993, 'IMAPS'),
(1194, 'OpenVPN'), (1194, 'OpenVPN'),
(2049, 'NFS'), (2049, 'NFS'),
(3000, 'Grafana'), (3000, 'Grafana / Gitea'),
(3389, 'RDP'), (3306, 'MariaDB / MySQL'),
(4500, 'IPSec NAT-T'), (3389, 'RDP'),
(5044, 'Logstash Beats'), (4500, 'IPSec NAT-T'),
(5432, 'PostgreSQL'), (5044, 'Logstash Beats'),
(5601, 'Kibana'), (5432, 'PostgreSQL'),
(9090, 'Prometheus'), (5601, 'Kibana'),
(9100, 'JetDirect'), (6379, 'Redis'),
(9200, 'Elasticsearch'); (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 ──────────────────────────────────────────────────────────────── -- ── Host ports ────────────────────────────────────────────────────────────────
-- INSERT OR IGNORE is safe: host_ports has a composite PRIMARY KEY (host_id, port_number). -- 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, 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'; 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, 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, 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, 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 -- 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-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'; 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; 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 // Application row enriched with the number of hosts that use at least one of
// its registered ports. Host count is computed via the join: // its registered ports. Host count is computed via the join:
// application_ports → host_ports (matched on port_number) → hosts // 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())) .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. /// Returns the port numbers associated with an application.
#[server] #[server]
pub async fn get_ports_for_application( pub async fn get_ports_for_application(
@@ -83,20 +147,55 @@ pub async fn get_ports_for_application(
// ─── Mutations ──────────────────────────────────────────────────────────────── // ─── Mutations ────────────────────────────────────────────────────────────────
/// Creates a new application and returns the created record. /// Updates the name of an application and returns the updated record.
#[server] #[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 sqlx::AnyPool;
use crate::server::repository::applications as repo; use crate::server::repository::applications as repo;
let pool = use_context::<AnyPool>() let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
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 .await
.map_err(|e| ServerFnError::new(e.to_string())) .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. /// Deletes an application and all its port associations.
/// ///
/// Returns `true` if the application existed and was deleted. /// Returns `true` if the application existed and was deleted.

View File

@@ -12,6 +12,7 @@ use leptos_router::{
}; };
use crate::client::{ use crate::client::{
application_detail::ApplicationDetailPage,
applications::ApplicationsPage, applications::ApplicationsPage,
home::HomePage, home::HomePage,
host_detail::HostDetailPage, host_detail::HostDetailPage,
@@ -115,6 +116,7 @@ pub fn App() -> impl IntoView {
<Route path=path!("/hosts") view=HostsPage/> <Route path=path!("/hosts") view=HostsPage/>
<Route path=path!("/hosts/:id") view=HostDetailPage/> <Route path=path!("/hosts/:id") view=HostDetailPage/>
<Route path=path!("/applications") view=ApplicationsPage/> <Route path=path!("/applications") view=ApplicationsPage/>
<Route path=path!("/applications/:id") view=ApplicationDetailPage/>
</Routes> </Routes>
</main> </main>
</Router> </Router>

View File

@@ -85,5 +85,13 @@ async fn main() {
.await .await
.unwrap_or(0); .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::prelude::*;
use leptos::form::ActionForm; use leptos::form::ActionForm;
use leptos::html::Input;
use crate::api::applications::{ use crate::api::applications::{
ApplicationWithCounts, CreateApplication, DeleteApplication, ApplicationWithCounts, CreateApplication, DeleteApplication,
@@ -21,19 +22,33 @@ fn AddApplicationModal(
create_action: ServerAction<CreateApplication>, create_action: ServerAction<CreateApplication>,
show_modal: RwSignal<bool>, show_modal: RwSignal<bool>,
) -> impl IntoView { ) -> impl IntoView {
Effect::new(move |_| { use leptos::task::spawn_local;
if let Some(Ok(_)) = create_action.value().get() {
show_modal.set(false); 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! { 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" on:click=move |e| e.stop_propagation()>
<div class="modal__header"> <div class="modal__header">
<h2>"Add an application"</h2> <h2>"Add an application"</h2>
<button class="modal__close" type="button" aria-label="Close" <button class="modal__close" type="button" aria-label="Close"
on:click=move |_| show_modal.set(false)> on:click=move |_| close()>
"×" "×"
</button> </button>
</div> </div>
@@ -43,17 +58,27 @@ fn AddApplicationModal(
<label> <label>
"Name" "Name"
<input <input
node_ref=name_ref
type="text" type="text"
name="name" name="name"
placeholder="e.g. Nginx, PostgreSQL, Prometheus" placeholder="e.g. Nginx, PostgreSQL, Prometheus"
required required
/> />
</label> </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>
<div class="modal__actions"> <div class="modal__actions">
<button class="btn-secondary" type="button" <button class="btn-secondary" type="button"
on:click=move |_| show_modal.set(false)> on:click=move |_| close()>
"Cancel" "Cancel"
</button> </button>
<button type="submit">"Add application"</button> <button type="submit">"Add application"</button>
@@ -132,6 +157,19 @@ pub fn ApplicationsPage() -> impl IntoView {
// Name filter (client-side — list is typically small) // Name filter (client-side — list is typically small)
let name_filter = RwSignal::new(String::new()); 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. // Close the delete modal automatically after a successful deletion.
Effect::new(move |_| { Effect::new(move |_| {
if let Some(Ok(_)) = delete_action.value().get() { if let Some(Ok(_)) = delete_action.value().get() {
@@ -225,10 +263,15 @@ pub fn ApplicationsPage() -> impl IntoView {
let app_clone = app.clone(); let app_clone = app.clone();
view! { view! {
<tr> <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-count">{app.host_count}</td>
<td class="col-actions"> <td class="col-actions">
<button on:click=move |_| { <button class="btn-danger" on:click=move |_| {
pending_delete.set(Some(app_clone.clone())); pending_delete.set(Some(app_clone.clone()));
}> }>
"Delete" "Delete"

View File

@@ -232,7 +232,9 @@ pub fn HostDetailPage() -> impl IntoView {
.unwrap_or_else(|| "/hosts".to_string()) .unwrap_or_else(|| "/hosts".to_string())
}; };
let back_label = move || { 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 update_action = ServerAction::<UpdateHost>::new();
@@ -245,14 +247,16 @@ pub fn HostDetailPage() -> impl IntoView {
let show_delete_modal = RwSignal::new(false); let show_delete_modal = RwSignal::new(false);
let show_add_app_modal = RwSignal::new(false); let show_add_app_modal = RwSignal::new(false);
// Auto-close the add-app modal after a successful addition. // Auto-close the add-app modal when the action completes successfully.
// Keeping this Effect in the parent avoids the re-trigger bug that would // Lives here (not inside AddAppModal) so it is never recreated across modal open/close cycles.
// occur if the Effect were inside AddAppModal (it would fire on mount Effect::new(move |was_pending: Option<bool>| {
// if the action already held a previous Ok value). let is_pending = add_app_action.pending().get();
Effect::new(move |_| { if was_pending == Some(true) && !is_pending {
if let Some(Ok(_)) = add_app_action.value().get() { if let Some(Ok(_)) = add_app_action.value().get() {
show_add_app_modal.set(false); show_add_app_modal.set(false);
}
} }
is_pending
}); });
// LocalResource avoids reading the resource outside <Suspense> during hydration, // LocalResource avoids reading the resource outside <Suspense> during hydration,
@@ -305,6 +309,25 @@ pub fn HostDetailPage() -> impl IntoView {
view! { view! {
<div class="host-detail-page"> <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> }> <Suspense fallback=|| view! { <p class="empty">"Loading host…"</p> }>
{move || host.get().map(|r| match (*r).clone() { {move || host.get().map(|r| match (*r).clone() {
Err(e) => view! { Err(e) => view! {
@@ -313,7 +336,6 @@ pub fn HostDetailPage() -> impl IntoView {
Ok(detail) => { Ok(detail) => {
let id = detail.id; let id = detail.id;
let modal_name = detail.name.clone();
let port_count = detail.ports.len(); let port_count = detail.ports.len();
let app_count = detail.applications.len(); let app_count = detail.applications.len();
let ports = detail.ports; let ports = detail.ports;
@@ -365,7 +387,10 @@ pub fn HostDetailPage() -> impl IntoView {
let app_id = app.id; let app_id = app.id;
view! { view! {
<div class="app-row"> <div class="app-row">
<span class="app-row__name">{app.name}</span> <a class="table-link app-row__name"
href=format!("/applications/{}?back=/hosts/{}", app_id, id)>
{app.name}
</a>
<button <button
class="btn-danger" class="btn-danger"
type="button" type="button"
@@ -429,7 +454,7 @@ pub fn HostDetailPage() -> impl IntoView {
view! { view! {
<option <option
value=n.id.to_string() value=n.id.to_string()
selected=(n.id == current) selected=n.id == current
> >
{label} {label}
</option> </option>
@@ -541,16 +566,6 @@ pub fn HostDetailPage() -> impl IntoView {
</div> </div>
</section> </section>
// ── Add applications modal ────────────────────────
{move || show_add_app_modal.get().then(|| view! {
<AddAppModal
host_id=id
available_apps_res=available_apps_res
add_action=add_app_action
show_modal=show_add_app_modal
/>
})}
// ── Danger zone ─────────────────────────────────── // ── Danger zone ───────────────────────────────────
<div class="danger-zone"> <div class="danger-zone">
<button <button
@@ -561,16 +576,6 @@ pub fn HostDetailPage() -> impl IntoView {
"Delete host" "Delete host"
</button> </button>
</div> </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() }.into_any()
} }
})} })}

View File

@@ -12,6 +12,7 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos::form::ActionForm; use leptos::form::ActionForm;
use leptos::html::Input;
use crate::api::{ use crate::api::{
applications::get_applications, applications::get_applications,
@@ -76,15 +77,20 @@ fn AddHostModal(
networks_res: LocalResource<Result<Vec<crate::models::Network>, ServerFnError>>, networks_res: LocalResource<Result<Vec<crate::models::Network>, ServerFnError>>,
show_modal: RwSignal<bool>, show_modal: RwSignal<bool>,
) -> impl IntoView { ) -> impl IntoView {
// Close the modal automatically after a successful creation. use leptos::task::spawn_local;
Effect::new(move |_| {
if let Some(Ok(_)) = create_action.value().get() { let name_ref = NodeRef::<Input>::new();
show_modal.set(false);
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! { 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" on:click=move |e| e.stop_propagation()>
<div class="modal__header"> <div class="modal__header">
<h2>"Add a host"</h2> <h2>"Add a host"</h2>
@@ -92,7 +98,7 @@ fn AddHostModal(
class="modal__close" class="modal__close"
type="button" type="button"
aria-label="Close" aria-label="Close"
on:click=move |_| show_modal.set(false) on:click=move |_| close()
>"×"</button> >"×"</button>
</div> </div>
@@ -100,7 +106,7 @@ fn AddHostModal(
<div class="add-form__fields"> <div class="add-form__fields">
<label> <label>
"Name" "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>
<label> <label>
"IP address" "IP address"
@@ -134,7 +140,7 @@ fn AddHostModal(
<button <button
class="btn-secondary" class="btn-secondary"
type="button" type="button"
on:click=move |_| show_modal.set(false) on:click=move |_| close()
>"Cancel"</button> >"Cancel"</button>
<button type="submit">"Add host"</button> <button type="submit">"Add host"</button>
</div> </div>
@@ -364,6 +370,17 @@ pub fn HostsPage() -> impl IntoView {
// None = no modal, Some((id, name)) = delete confirmation open. // None = no modal, Some((id, name)) = delete confirmation open.
let pending_delete: RwSignal<Option<(i64, String)>> = RwSignal::new(None); 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. // Close the delete modal automatically after a successful deletion.
Effect::new(move |_| { Effect::new(move |_| {
if let Some(Ok(_)) = delete_action.value().get() { if let Some(Ok(_)) = delete_action.value().get() {

View File

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