Compare commits
53 Commits
11b0f60892
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e68845a2ce | |||
| 5b3170c6d1 | |||
| 624839849f | |||
| 255f20cda4 | |||
| a8d98aeee2 | |||
| bef28f44a1 | |||
| cf0a095ada | |||
| 353fe09a99 | |||
| 5228a76468 | |||
| 60e02ca453 | |||
| 052711b720 | |||
| f5058bd54a | |||
| 54a5c2525f | |||
| 359d67fabc | |||
| 5789aba86b | |||
| a6ce382eb5 | |||
| 1b55b13541 | |||
| 4d0be98160 | |||
| 62e9609fe8 | |||
| 7274157a80 | |||
| c3e2d5dcf6 | |||
| ba4d2a60c6 | |||
| 577a655aee | |||
| 2a6d925e59 | |||
| 0221ce26f9 | |||
| d2284727a2 | |||
| eef0ae0b54 | |||
| 19dda00c17 | |||
| df6aecef51 | |||
| 6018874aa4 | |||
| e0ddf58a17 | |||
| d9ee121fbb | |||
| e17b8ee722 | |||
| 55d8ed9f72 | |||
| 30dd1ad0b0 | |||
| a4fc5b176f | |||
| 042793f385 | |||
| 5b1f30fe24 | |||
| 3ee39b96bb | |||
| 589aab7e3d | |||
| 3aeb74e5bc | |||
| e902efc04f | |||
| 75c13b261b | |||
| a352a8edfd | |||
| f13097591c | |||
| 18804e740c | |||
| 4c11a8608b | |||
| 4e9eab0450 | |||
| b6d1e22d25 | |||
| 18134d6f4b | |||
| acaa121658 | |||
| 1746d9d922 | |||
| efad573c3b |
18
.env.example
Normal file
18
.env.example
Normal file
@@ -0,0 +1,18 @@
|
||||
# .env.example — Copier ce fichier en .env et adapter les valeurs
|
||||
#
|
||||
# Ce fichier est versionné dans git pour servir de référence.
|
||||
# Le fichier .env réel contient des secrets et ne doit JAMAIS être commité.
|
||||
|
||||
# --- Base de données ---
|
||||
# Choisir UNE des deux options ci-dessous :
|
||||
|
||||
# Option 1 : SQLite (recommandé pour le développement — aucun serveur requis)
|
||||
# Le fichier sera créé automatiquement s'il n'existe pas.
|
||||
DATABASE_URL=sqlite://data/ipam.db
|
||||
|
||||
# Option 2 : PostgreSQL (recommandé pour la production)
|
||||
# DATABASE_URL=postgresql://utilisateur:motdepasse@localhost:5432/ipam
|
||||
|
||||
# --- Logs ---
|
||||
# Niveau de log : error | warn | info | debug | trace
|
||||
RUST_LOG=info
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1 +1,4 @@
|
||||
/target
|
||||
.env
|
||||
data/
|
||||
.DS_Store
|
||||
13
CLAUDE.md
13
CLAUDE.md
@@ -26,6 +26,7 @@
|
||||
- Ne jamais utiliser `unwrap()` en production — toujours `?` ou `.expect("message clair")`
|
||||
- Toujours expliquer les lifetimes si elles apparaissent
|
||||
- Préférer les types idiomatiques Rust (`Option`, `Result`)
|
||||
- Le code généré devra suivre le conventions standard, le code et les commentaires sont rédigés en anglais.
|
||||
|
||||
## Git & Commits
|
||||
- Committer chaque changement fonctionnel terminé (ne pas accumuler)
|
||||
@@ -37,4 +38,14 @@
|
||||
- `chore(deps): update axum to 0.8`
|
||||
- Ne jamais committer du code qui ne compile pas (`cargo check` avant)
|
||||
- Une fonctionnalité = une branche = une PR
|
||||
- Branches : `feature/<nom>`, `fix/<nom>`, `chore/<nom>`
|
||||
- Branches : `feature/<nom>`, `fix/<nom>`, `chore/<nom>`
|
||||
|
||||
## Architecture
|
||||
- Deux databases possible, postgresql ou sqlite. Le choix dois se faire par la configuration de l'application.
|
||||
- Un hote possede un nom, une IP, des ports ouverts
|
||||
- Les ports peuvent avoir une description pour indiquer quel est le protocole le plus probable d'être utiliser sur ce port (ex: 22 - SSH, 53 - DNS, 80 - HTTP, 443 - HTTPS)
|
||||
- Un port peut être associé à une application, l'association n'est pas strict car un port peut être utilisé par plusieurs applications.
|
||||
- Une application possede un nom, un ou plusieurs ports.
|
||||
- Un réseaux est définit par son nom et son CIDR (ex: 192.168.1.0/24)
|
||||
- L'application peut gérer plusieurs réseaux distinct.
|
||||
- Chaques hôtes doit appartenir au réseaux dans lequel il est définit.
|
||||
|
||||
3646
Cargo.lock
generated
3646
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
112
Cargo.toml
112
Cargo.toml
@@ -1,6 +1,116 @@
|
||||
[package]
|
||||
name = "rust-ipam"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
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"]
|
||||
|
||||
# Les "features" permettent d'activer du code conditionnellement selon le contexte.
|
||||
# On compile deux fois le même code : une fois en mode "ssr" (serveur), une fois "hydrate" (WASM).
|
||||
[features]
|
||||
# Mode serveur : active Axum, Tokio, et le rendu HTML côté serveur
|
||||
ssr = [
|
||||
"dep:axum",
|
||||
"dep:tokio",
|
||||
"dep:tower-http",
|
||||
"dep:leptos_axum",
|
||||
"dep:tracing-subscriber",
|
||||
"dep:dotenvy",
|
||||
"dep:ipnetwork",
|
||||
"dep:sqlx",
|
||||
"leptos/ssr",
|
||||
"leptos_meta/ssr",
|
||||
"leptos_router/ssr",
|
||||
]
|
||||
# Mode client : compile l'application en WebAssembly pour le navigateur
|
||||
# Note : seul `leptos` expose une feature "hydrate" ; leptos_meta et leptos_router
|
||||
# n'en ont pas besoin — ils s'adaptent automatiquement au mode de compilation.
|
||||
hydrate = [
|
||||
"dep:console_error_panic_hook",
|
||||
"dep:wasm-bindgen",
|
||||
"dep:web-sys",
|
||||
"leptos/hydrate",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
# --- Dépendances partagées (compilées côté serveur ET client) ---
|
||||
|
||||
# Framework UI réactif full-stack : le cœur du projet
|
||||
leptos = { version = "0.7", features = [] }
|
||||
# Gestion des balises HTML <head> (title, meta tags, liens CSS...)
|
||||
leptos_meta = { version = "0.7", features = [] }
|
||||
# Routeur : associe des URLs à des composants, côté serveur et client
|
||||
leptos_router = { version = "0.7", features = [] }
|
||||
|
||||
# Dérive automatiquement des types d'erreurs idiomatiques Rust
|
||||
thiserror = "1"
|
||||
# Macros pour les logs : tracing::info!(), tracing::error!()...
|
||||
tracing = "0.1"
|
||||
# Sérialisation/désérialisation — nécessaire pour les server functions Leptos
|
||||
# (les types de retour doivent traverser la frontière server ↔ client)
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
# --- Dépendances serveur uniquement (activées par la feature "ssr") ---
|
||||
|
||||
# Serveur HTTP asynchrone rapide et ergonomique
|
||||
axum = { version = "0.7", optional = true }
|
||||
# Runtime asynchrone Rust (nécessaire pour `async fn main()` et les Futures)
|
||||
tokio = { version = "1", features = ["full"], optional = true }
|
||||
# Pont entre Leptos et Axum : SSR, server functions, streaming...
|
||||
leptos_axum = { version = "0.7", optional = true }
|
||||
# Middleware HTTP : sert les fichiers statiques (CSS, WASM compilé, images...)
|
||||
tower-http = { version = "0.5", features = ["fs"], optional = true }
|
||||
# Formateur de logs pour le terminal (affiche les messages tracing::info!...)
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
|
||||
# Charge automatiquement le fichier .env au démarrage du serveur
|
||||
dotenvy = { version = "0.15", optional = true }
|
||||
# Parsing et calcul de plages d'adresses IP (CIDR) — ex: 192.168.1.0/24
|
||||
ipnetwork = { version = "0.20", optional = true }
|
||||
# Database access: connection pools, queries, migrations — SQLite + PostgreSQL
|
||||
# "any" = runtime-dispatched driver (same code works with both backends)
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "sqlite", "postgres", "migrate", "any"], optional = true }
|
||||
|
||||
# --- Dépendances client uniquement (activées par la feature "hydrate") ---
|
||||
|
||||
# Affiche les panics Rust dans la console du navigateur (indispensable pour déboguer)
|
||||
console_error_panic_hook = { version = "0.1", optional = true }
|
||||
# Pont entre Rust/WASM et JavaScript : permet d'appeler du JS depuis Rust
|
||||
wasm-bindgen = { version = "0.2", optional = true }
|
||||
# Bindings aux APIs du navigateur : window, document, localStorage, Element...
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Storage"], optional = true }
|
||||
|
||||
# Configuration Leptos lue par get_configuration(Some("Cargo.toml"))
|
||||
# Définit les chemins des fichiers compilés et l'adresse du serveur.
|
||||
[package.metadata.leptos]
|
||||
output-name = "rust-ipam" # Nom de base des fichiers .wasm et .js générés
|
||||
site-root = "target/site" # Dossier racine des fichiers compilés par trunk
|
||||
site-pkg-dir = "pkg" # Sous-dossier des assets WASM/JS dans site-root
|
||||
site-addr = "127.0.0.1:3000" # Adresse d'écoute du serveur Axum
|
||||
reload-port = 3001 # Port WebSocket pour le hot-reload en développement
|
||||
style-file = "style/rust-ipam.css" # Source CSS compilé dans pkg/rust-ipam.css
|
||||
# Features activées par cargo-leptos lors du build
|
||||
bin-target = "rust-ipam" # Main server binary (excludes src/bin/seed.rs)
|
||||
bin-features = ["ssr"] # SSR binary (Axum server)
|
||||
lib-features = ["hydrate"] # WASM bundle (browser)
|
||||
|
||||
# Profil de compilation WASM optimisé pour réduire la taille du fichier .wasm
|
||||
# Un fichier WASM plus petit = page qui charge plus vite
|
||||
[profile.wasm-release]
|
||||
inherits = "release"
|
||||
opt-level = "z" # Optimise pour la taille (z) plutôt que la vitesse (3)
|
||||
lto = true # Link-Time Optimization : élimine le code mort entre crates
|
||||
codegen-units = 1 # Un seul thread de codegen = meilleure optimisation globale
|
||||
panic = "abort" # Pas de stack unwinding = binaire plus petit
|
||||
|
||||
29
Trunk.toml
Normal file
29
Trunk.toml
Normal file
@@ -0,0 +1,29 @@
|
||||
# Trunk.toml — Configuration de trunk
|
||||
# trunk est l'outil de build pour les applications Rust/WASM.
|
||||
#
|
||||
# Commandes principales :
|
||||
# trunk build --features hydrate → compilation dev
|
||||
# trunk build --features hydrate --release → compilation prod optimisée
|
||||
# trunk serve --features hydrate → serveur de dev avec hot-reload
|
||||
|
||||
[build]
|
||||
# Feature à activer lors de la compilation WASM
|
||||
features = ["hydrate"]
|
||||
|
||||
# Dossier de sortie des fichiers WASM/JS.
|
||||
# Doit correspondre à site-pkg-dir dans [package.metadata.leptos] du Cargo.toml,
|
||||
# car Axum sert ce dossier sur le chemin "/pkg/".
|
||||
dist = "target/site/pkg"
|
||||
|
||||
# Désactive les hashes dans les noms de fichiers (ex: rust-ipam-abc123.js → rust-ipam.js).
|
||||
# Sans ça, HydrationScripts (Leptos) ne peut pas trouver les bons fichiers WASM
|
||||
# car il génère des URLs sans hash (/pkg/rust-ipam.js).
|
||||
filehash = false
|
||||
|
||||
[watch]
|
||||
# Ne pas surveiller ces dossiers pour éviter les boucles infinies
|
||||
ignore = ["./target"]
|
||||
|
||||
[serve]
|
||||
port = 3000
|
||||
open = false
|
||||
37
index.html
Normal file
37
index.html
Normal file
@@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<!--
|
||||
trunk complète automatiquement ce fichier lors de `trunk build` ou `trunk serve` :
|
||||
- Il injecte les balises <link> pour charger le CSS compilé
|
||||
- Il injecte les balises <script> pour charger le bundle WebAssembly
|
||||
Ne pas ajouter ces balises manuellement ici — trunk le fait pour vous.
|
||||
-->
|
||||
<!--
|
||||
Indique à trunk de compiler le target "lib" (cdylib → WASM)
|
||||
et non le binaire "rust-ipam" (qui lui est le serveur Axum).
|
||||
data-target-name correspond au nom du crate avec underscores (convention Rust).
|
||||
-->
|
||||
<link data-trunk rel="rust" data-target-name="rust_ipam" />
|
||||
<!--
|
||||
Compile style/rust-ipam.css → target/site/pkg/rust-ipam.css
|
||||
Served by Axum at /pkg/rust-ipam.css and loaded by the <Stylesheet>
|
||||
component in app.rs.
|
||||
-->
|
||||
<link data-trunk rel="css" href="style/rust-ipam.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
Leptos monte l'application ici via mount_to_body() (défini dans lib.rs).
|
||||
|
||||
Flux SSR + Hydration :
|
||||
1. Le navigateur demande la page au serveur Axum
|
||||
2. Axum rend le composant App() en HTML (SSR) et l'envoie
|
||||
3. Le navigateur affiche le HTML instantanément (pas d'écran blanc)
|
||||
4. Le bundle WASM se charge en arrière-plan
|
||||
5. Leptos "hydrate" le HTML : attache les event listeners pour le rendre interactif
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
6
migrations/postgres/0001_create_networks.sql
Normal file
6
migrations/postgres/0001_create_networks.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- networks: IP address ranges managed by the IPAM.
|
||||
-- BIGSERIAL: auto-incrementing 64-bit integer (PostgreSQL's equivalent of AUTOINCREMENT).
|
||||
CREATE TABLE IF NOT EXISTS networks (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
cidr TEXT NOT NULL UNIQUE
|
||||
);
|
||||
7
migrations/postgres/0002_create_hosts.sql
Normal file
7
migrations/postgres/0002_create_hosts.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- hosts: physical or virtual machines belonging to a network.
|
||||
CREATE TABLE IF NOT EXISTS hosts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
ip TEXT NOT NULL,
|
||||
network_id BIGINT NOT NULL REFERENCES networks(id) ON DELETE CASCADE
|
||||
);
|
||||
5
migrations/postgres/0003_create_ports.sql
Normal file
5
migrations/postgres/0003_create_ports.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- ports: global catalog of TCP/UDP port numbers and their known protocol.
|
||||
CREATE TABLE IF NOT EXISTS ports (
|
||||
number INTEGER PRIMARY KEY,
|
||||
description TEXT
|
||||
);
|
||||
5
migrations/postgres/0004_create_applications.sql
Normal file
5
migrations/postgres/0004_create_applications.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- applications: software stacks that use one or more ports.
|
||||
CREATE TABLE IF NOT EXISTS applications (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
6
migrations/postgres/0005_create_host_ports.sql
Normal file
6
migrations/postgres/0005_create_host_ports.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- host_ports: which ports are open on which host (many-to-many).
|
||||
CREATE TABLE IF NOT EXISTS host_ports (
|
||||
host_id BIGINT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
||||
port_number INTEGER NOT NULL REFERENCES ports(number) ON DELETE CASCADE,
|
||||
PRIMARY KEY (host_id, port_number)
|
||||
);
|
||||
6
migrations/postgres/0006_create_application_ports.sql
Normal file
6
migrations/postgres/0006_create_application_ports.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- application_ports: which ports an application typically uses (many-to-many).
|
||||
CREATE TABLE IF NOT EXISTS application_ports (
|
||||
application_id BIGINT NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
|
||||
port_number INTEGER NOT NULL,
|
||||
PRIMARY KEY (application_id, port_number)
|
||||
);
|
||||
3
migrations/postgres/0007_add_network_name.sql
Normal file
3
migrations/postgres/0007_add_network_name.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Add a human-readable name to networks.
|
||||
-- DEFAULT '' allows the migration to run on databases that already have rows.
|
||||
ALTER TABLE networks ADD COLUMN name TEXT NOT NULL DEFAULT '';
|
||||
8
migrations/postgres/0008_create_host_applications.sql
Normal file
8
migrations/postgres/0008_create_host_applications.sql
Normal 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)
|
||||
);
|
||||
6
migrations/sqlite/0001_create_networks.sql
Normal file
6
migrations/sqlite/0001_create_networks.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- networks: IP address ranges managed by the IPAM.
|
||||
-- Each network has a unique CIDR block (e.g. "192.168.1.0/24").
|
||||
CREATE TABLE IF NOT EXISTS networks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cidr TEXT NOT NULL UNIQUE
|
||||
);
|
||||
9
migrations/sqlite/0002_create_hosts.sql
Normal file
9
migrations/sqlite/0002_create_hosts.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- hosts: physical or virtual machines belonging to a network.
|
||||
-- The ip field must fall within the CIDR of the parent network
|
||||
-- (enforced in application code, not at the DB level).
|
||||
CREATE TABLE IF NOT EXISTS hosts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
ip TEXT NOT NULL,
|
||||
network_id INTEGER NOT NULL REFERENCES networks(id) ON DELETE CASCADE
|
||||
);
|
||||
7
migrations/sqlite/0003_create_ports.sql
Normal file
7
migrations/sqlite/0003_create_ports.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- ports: global catalog of TCP/UDP port numbers and their known protocol.
|
||||
-- Ports are not tied to a specific host here — host_ports handles that link.
|
||||
-- Port numbers range from 0 to 65535 (fits in INTEGER).
|
||||
CREATE TABLE IF NOT EXISTS ports (
|
||||
number INTEGER PRIMARY KEY,
|
||||
description TEXT
|
||||
);
|
||||
6
migrations/sqlite/0004_create_applications.sql
Normal file
6
migrations/sqlite/0004_create_applications.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- applications: software stacks that use one or more ports.
|
||||
-- Examples: "Nginx", "PostgreSQL", "Prometheus".
|
||||
CREATE TABLE IF NOT EXISTS applications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
7
migrations/sqlite/0005_create_host_ports.sql
Normal file
7
migrations/sqlite/0005_create_host_ports.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- host_ports: which ports are open on which host (many-to-many).
|
||||
-- Composite primary key prevents duplicate (host, port) pairs.
|
||||
CREATE TABLE IF NOT EXISTS host_ports (
|
||||
host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
||||
port_number INTEGER NOT NULL REFERENCES ports(number) ON DELETE CASCADE,
|
||||
PRIMARY KEY (host_id, port_number)
|
||||
);
|
||||
8
migrations/sqlite/0006_create_application_ports.sql
Normal file
8
migrations/sqlite/0006_create_application_ports.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- application_ports: which ports an application typically uses (many-to-many).
|
||||
-- port_number is not a strict FK to ports to allow registering an application
|
||||
-- before its port entry exists in the catalog.
|
||||
CREATE TABLE IF NOT EXISTS application_ports (
|
||||
application_id INTEGER NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
|
||||
port_number INTEGER NOT NULL,
|
||||
PRIMARY KEY (application_id, port_number)
|
||||
);
|
||||
3
migrations/sqlite/0007_add_network_name.sql
Normal file
3
migrations/sqlite/0007_add_network_name.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Add a human-readable name to networks.
|
||||
-- DEFAULT '' allows the migration to run on databases that already have rows.
|
||||
ALTER TABLE networks ADD COLUMN name TEXT NOT NULL DEFAULT '';
|
||||
8
migrations/sqlite/0008_create_host_applications.sql
Normal file
8
migrations/sqlite/0008_create_host_applications.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- host_applications: direct association between a host and an application.
|
||||
-- Allows explicitly tagging a host with an application regardless of ports.
|
||||
-- One application can only be linked once to a given host.
|
||||
CREATE TABLE IF NOT EXISTS host_applications (
|
||||
host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
|
||||
application_id INTEGER NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (host_id, application_id)
|
||||
);
|
||||
286
seeds/postgres/dev_seed.sql
Normal file
286
seeds/postgres/dev_seed.sql
Normal file
@@ -0,0 +1,286 @@
|
||||
-- dev_seed.sql (PostgreSQL) — development test data
|
||||
--
|
||||
-- Running this script is idempotent: existing rows are left untouched
|
||||
-- and missing rows are inserted. Safe to run multiple times.
|
||||
--
|
||||
-- Load with: cargo run --features ssr --bin seed
|
||||
|
||||
-- ── Networks ──────────────────────────────────────────────────────────────────
|
||||
|
||||
INSERT INTO networks (name, cidr) VALUES
|
||||
('LAN', '192.168.1.0/24'),
|
||||
('DMZ', '192.168.10.0/24'),
|
||||
('Corporate', '10.0.0.0/8'),
|
||||
('VPN', '172.16.0.0/16')
|
||||
ON CONFLICT (cidr) DO NOTHING;
|
||||
|
||||
-- ── Hosts ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
-- LAN — 192.168.1.0/24
|
||||
INSERT INTO hosts (name, ip, network_id)
|
||||
SELECT name, ip, (SELECT id FROM networks WHERE cidr = '192.168.1.0/24')
|
||||
FROM (VALUES
|
||||
('gateway', '192.168.1.1'),
|
||||
('workstation-01', '192.168.1.10'),
|
||||
('workstation-02', '192.168.1.11'),
|
||||
('workstation-03', '192.168.1.12'),
|
||||
('nas-01', '192.168.1.20'),
|
||||
('printer-01', '192.168.1.50')
|
||||
) AS t(name, ip)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM hosts WHERE hosts.name = t.name AND hosts.ip = t.ip);
|
||||
|
||||
-- DMZ — 192.168.10.0/24
|
||||
INSERT INTO hosts (name, ip, network_id)
|
||||
SELECT name, ip, (SELECT id FROM networks WHERE cidr = '192.168.10.0/24')
|
||||
FROM (VALUES
|
||||
('web-server-01', '192.168.10.10'),
|
||||
('web-server-02', '192.168.10.11'),
|
||||
('db-server-01', '192.168.10.20'),
|
||||
('mail-server-01', '192.168.10.30')
|
||||
) AS t(name, ip)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM hosts WHERE hosts.name = t.name AND hosts.ip = t.ip);
|
||||
|
||||
-- Corporate backbone — 10.0.0.0/8
|
||||
INSERT INTO hosts (name, ip, network_id)
|
||||
SELECT name, ip, (SELECT id FROM networks WHERE cidr = '10.0.0.0/8')
|
||||
FROM (VALUES
|
||||
('core-switch-01', '10.0.0.1'),
|
||||
('monitoring-01', '10.0.1.10'),
|
||||
('backup-server-01', '10.0.1.20'),
|
||||
('log-server-01', '10.0.1.30')
|
||||
) AS t(name, ip)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM hosts WHERE hosts.name = t.name AND hosts.ip = t.ip);
|
||||
|
||||
-- VPN — 172.16.0.0/16
|
||||
INSERT INTO hosts (name, ip, network_id)
|
||||
SELECT name, ip, (SELECT id FROM networks WHERE cidr = '172.16.0.0/16')
|
||||
FROM (VALUES
|
||||
('vpn-gateway-01', '172.16.0.1'),
|
||||
('vpn-client-01', '172.16.1.10'),
|
||||
('vpn-client-02', '172.16.1.11')
|
||||
) AS t(name, ip)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM hosts WHERE hosts.name = t.name AND hosts.ip = t.ip);
|
||||
|
||||
-- ── Ports catalog ─────────────────────────────────────────────────────────────
|
||||
|
||||
INSERT INTO ports (number, description) VALUES
|
||||
(22, 'SSH'),
|
||||
(25, 'SMTP'),
|
||||
(53, 'DNS'),
|
||||
(80, 'HTTP'),
|
||||
(143, 'IMAP'),
|
||||
(161, 'SNMP'),
|
||||
(443, 'HTTPS'),
|
||||
(445, 'SMB'),
|
||||
(465, 'SMTPS'),
|
||||
(500, 'IKE / IPSec'),
|
||||
(514, 'Syslog'),
|
||||
(587, 'SMTP Submission'),
|
||||
(873, 'rsync'),
|
||||
(993, 'IMAPS'),
|
||||
(1194, 'OpenVPN'),
|
||||
(2049, 'NFS'),
|
||||
(3000, 'Grafana / Gitea'),
|
||||
(3306, 'MariaDB / MySQL'),
|
||||
(3389, 'RDP'),
|
||||
(4500, 'IPSec NAT-T'),
|
||||
(5044, 'Logstash Beats'),
|
||||
(5432, 'PostgreSQL'),
|
||||
(5601, 'Kibana'),
|
||||
(6379, 'Redis'),
|
||||
(8096, 'Jellyfin'),
|
||||
(8123, 'Home Assistant'),
|
||||
(8384, 'Syncthing UI'),
|
||||
(8920, 'Jellyfin HTTPS'),
|
||||
(9000, 'Portainer'),
|
||||
(9090, 'Prometheus'),
|
||||
(9100, 'node_exporter / JetDirect'),
|
||||
(9200, 'Elasticsearch'),
|
||||
(9443, 'Portainer HTTPS'),
|
||||
(22000, 'Syncthing'),
|
||||
(51820, 'WireGuard')
|
||||
ON CONFLICT (number) DO NOTHING;
|
||||
|
||||
-- ── Host ports ────────────────────────────────────────────────────────────────
|
||||
|
||||
-- gateway: SSH, DNS, HTTP, HTTPS
|
||||
INSERT INTO host_ports (host_id, port_number)
|
||||
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 53, 80, 443]) p
|
||||
WHERE h.name = 'gateway' AND h.ip = '192.168.1.1'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- workstation-01: SSH, RDP
|
||||
INSERT INTO host_ports (host_id, port_number)
|
||||
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 3389]) p
|
||||
WHERE h.name = 'workstation-01' AND h.ip = '192.168.1.10'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- workstation-02: SSH, RDP
|
||||
INSERT INTO host_ports (host_id, port_number)
|
||||
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 3389]) p
|
||||
WHERE h.name = 'workstation-02' AND h.ip = '192.168.1.11'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- workstation-03: SSH, RDP
|
||||
INSERT INTO host_ports (host_id, port_number)
|
||||
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 3389]) p
|
||||
WHERE h.name = 'workstation-03' AND h.ip = '192.168.1.12'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- nas-01: SSH, HTTP, HTTPS, SMB, NFS
|
||||
INSERT INTO host_ports (host_id, port_number)
|
||||
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 80, 443, 445, 2049]) p
|
||||
WHERE h.name = 'nas-01' AND h.ip = '192.168.1.20'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- printer-01: HTTP, HTTPS, JetDirect
|
||||
INSERT INTO host_ports (host_id, port_number)
|
||||
SELECT h.id, p FROM hosts h, unnest(ARRAY[80, 443, 9100]) p
|
||||
WHERE h.name = 'printer-01' AND h.ip = '192.168.1.50'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- web-server-01: SSH, HTTP, HTTPS
|
||||
INSERT INTO host_ports (host_id, port_number)
|
||||
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 80, 443]) p
|
||||
WHERE h.name = 'web-server-01' AND h.ip = '192.168.10.10'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- web-server-02: SSH, HTTP, HTTPS
|
||||
INSERT INTO host_ports (host_id, port_number)
|
||||
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 80, 443]) p
|
||||
WHERE h.name = 'web-server-02' AND h.ip = '192.168.10.11'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- db-server-01: SSH, PostgreSQL
|
||||
INSERT INTO host_ports (host_id, port_number)
|
||||
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 5432]) p
|
||||
WHERE h.name = 'db-server-01' AND h.ip = '192.168.10.20'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- mail-server-01: SSH, SMTP, IMAP, SMTPS, Submission, IMAPS
|
||||
INSERT INTO host_ports (host_id, port_number)
|
||||
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 25, 143, 465, 587, 993]) p
|
||||
WHERE h.name = 'mail-server-01' AND h.ip = '192.168.10.30'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- core-switch-01: SSH, SNMP
|
||||
INSERT INTO host_ports (host_id, port_number)
|
||||
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 161]) p
|
||||
WHERE h.name = 'core-switch-01' AND h.ip = '10.0.0.1'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- monitoring-01: SSH, HTTP, HTTPS, Grafana, Prometheus
|
||||
INSERT INTO host_ports (host_id, port_number)
|
||||
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 80, 443, 3000, 9090]) p
|
||||
WHERE h.name = 'monitoring-01' AND h.ip = '10.0.1.10'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- backup-server-01: SSH, SMB, rsync
|
||||
INSERT INTO host_ports (host_id, port_number)
|
||||
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 445, 873]) p
|
||||
WHERE h.name = 'backup-server-01' AND h.ip = '10.0.1.20'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- log-server-01: SSH, Syslog, Logstash Beats, Elasticsearch, Kibana
|
||||
INSERT INTO host_ports (host_id, port_number)
|
||||
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 514, 5044, 9200, 5601]) p
|
||||
WHERE h.name = 'log-server-01' AND h.ip = '10.0.1.30'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- vpn-gateway-01: SSH, IKE, OpenVPN, IPSec NAT-T
|
||||
INSERT INTO host_ports (host_id, port_number)
|
||||
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 500, 1194, 4500]) p
|
||||
WHERE h.name = 'vpn-gateway-01' AND h.ip = '172.16.0.1'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- vpn clients: SSH only
|
||||
INSERT INTO host_ports (host_id, port_number)
|
||||
SELECT h.id, 22 FROM hosts h
|
||||
WHERE h.name = 'vpn-client-01' AND h.ip = '172.16.1.10'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO host_ports (host_id, port_number)
|
||||
SELECT h.id, 22 FROM hosts h
|
||||
WHERE h.name = 'vpn-client-02' AND h.ip = '172.16.1.11'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ── Applications ──────────────────────────────────────────────────────────────
|
||||
-- applications has no UNIQUE constraint on name, so we use WHERE NOT EXISTS.
|
||||
|
||||
INSERT INTO applications (name)
|
||||
SELECT v.name FROM (VALUES
|
||||
('Nginx'),
|
||||
('Pi-hole'),
|
||||
('WireGuard'),
|
||||
('OpenVPN'),
|
||||
('PostgreSQL'),
|
||||
('MariaDB'),
|
||||
('Redis'),
|
||||
('Grafana'),
|
||||
('Prometheus'),
|
||||
('Elasticsearch'),
|
||||
('Kibana'),
|
||||
('Portainer'),
|
||||
('Jellyfin'),
|
||||
('Home Assistant'),
|
||||
('Syncthing'),
|
||||
('Vaultwarden')
|
||||
) AS v(name)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = v.name);
|
||||
|
||||
-- ── Application ports ─────────────────────────────────────────────────────────
|
||||
|
||||
-- Nginx: HTTP, HTTPS
|
||||
INSERT INTO application_ports (application_id, port_number) SELECT id, 80 FROM applications WHERE name = 'Nginx' ON CONFLICT DO NOTHING;
|
||||
INSERT INTO application_ports (application_id, port_number) SELECT id, 443 FROM applications WHERE name = 'Nginx' ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Pi-hole: DNS, HTTP (admin UI), HTTPS
|
||||
INSERT INTO application_ports (application_id, port_number) SELECT id, 53 FROM applications WHERE name = 'Pi-hole' ON CONFLICT DO NOTHING;
|
||||
INSERT INTO application_ports (application_id, port_number) SELECT id, 80 FROM applications WHERE name = 'Pi-hole' ON CONFLICT DO NOTHING;
|
||||
INSERT INTO application_ports (application_id, port_number) SELECT id, 443 FROM applications WHERE name = 'Pi-hole' ON CONFLICT DO NOTHING;
|
||||
|
||||
-- WireGuard
|
||||
INSERT INTO application_ports (application_id, port_number) SELECT id, 51820 FROM applications WHERE name = 'WireGuard' ON CONFLICT DO NOTHING;
|
||||
|
||||
-- OpenVPN
|
||||
INSERT INTO application_ports (application_id, port_number) SELECT id, 1194 FROM applications WHERE name = 'OpenVPN' ON CONFLICT DO NOTHING;
|
||||
|
||||
-- PostgreSQL
|
||||
INSERT INTO application_ports (application_id, port_number) SELECT id, 5432 FROM applications WHERE name = 'PostgreSQL' ON CONFLICT DO NOTHING;
|
||||
|
||||
-- MariaDB
|
||||
INSERT INTO application_ports (application_id, port_number) SELECT id, 3306 FROM applications WHERE name = 'MariaDB' ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Redis
|
||||
INSERT INTO application_ports (application_id, port_number) SELECT id, 6379 FROM applications WHERE name = 'Redis' ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Grafana
|
||||
INSERT INTO application_ports (application_id, port_number) SELECT id, 3000 FROM applications WHERE name = 'Grafana' ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Prometheus
|
||||
INSERT INTO application_ports (application_id, port_number) SELECT id, 9090 FROM applications WHERE name = 'Prometheus' ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Elasticsearch
|
||||
INSERT INTO application_ports (application_id, port_number) SELECT id, 9200 FROM applications WHERE name = 'Elasticsearch' ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Kibana
|
||||
INSERT INTO application_ports (application_id, port_number) SELECT id, 5601 FROM applications WHERE name = 'Kibana' ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Portainer: HTTP, HTTPS
|
||||
INSERT INTO application_ports (application_id, port_number) SELECT id, 9000 FROM applications WHERE name = 'Portainer' ON CONFLICT DO NOTHING;
|
||||
INSERT INTO application_ports (application_id, port_number) SELECT id, 9443 FROM applications WHERE name = 'Portainer' ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Jellyfin: HTTP, HTTPS
|
||||
INSERT INTO application_ports (application_id, port_number) SELECT id, 8096 FROM applications WHERE name = 'Jellyfin' ON CONFLICT DO NOTHING;
|
||||
INSERT INTO application_ports (application_id, port_number) SELECT id, 8920 FROM applications WHERE name = 'Jellyfin' ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Home Assistant
|
||||
INSERT INTO application_ports (application_id, port_number) SELECT id, 8123 FROM applications WHERE name = 'Home Assistant' ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Syncthing: UI, data sync
|
||||
INSERT INTO application_ports (application_id, port_number) SELECT id, 8384 FROM applications WHERE name = 'Syncthing' ON CONFLICT DO NOTHING;
|
||||
INSERT INTO application_ports (application_id, port_number) SELECT id, 22000 FROM applications WHERE name = 'Syncthing' ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Vaultwarden: HTTP, HTTPS
|
||||
INSERT INTO application_ports (application_id, port_number) SELECT id, 80 FROM applications WHERE name = 'Vaultwarden' ON CONFLICT DO NOTHING;
|
||||
INSERT INTO application_ports (application_id, port_number) SELECT id, 443 FROM applications WHERE name = 'Vaultwarden' ON CONFLICT DO NOTHING;
|
||||
242
seeds/sqlite/dev_seed.sql
Normal file
242
seeds/sqlite/dev_seed.sql
Normal file
@@ -0,0 +1,242 @@
|
||||
-- dev_seed.sql (SQLite) — development test data
|
||||
--
|
||||
-- Running this script is idempotent: existing rows are left untouched
|
||||
-- and missing rows are inserted. Safe to run multiple times.
|
||||
--
|
||||
-- Load with: cargo run --features ssr --bin seed
|
||||
|
||||
-- ── Networks ──────────────────────────────────────────────────────────────────
|
||||
|
||||
INSERT OR IGNORE INTO networks (name, cidr) VALUES ('LAN', '192.168.1.0/24');
|
||||
INSERT OR IGNORE INTO networks (name, cidr) VALUES ('DMZ', '192.168.10.0/24');
|
||||
INSERT OR IGNORE INTO networks (name, cidr) VALUES ('Corporate', '10.0.0.0/8');
|
||||
INSERT OR IGNORE INTO networks (name, cidr) VALUES ('VPN', '172.16.0.0/16');
|
||||
|
||||
-- ── Hosts ─────────────────────────────────────────────────────────────────────
|
||||
-- Hosts have no UNIQUE constraint, so we guard each insert with WHERE NOT EXISTS.
|
||||
-- Network IDs are resolved by subquery on cidr for portability.
|
||||
|
||||
-- LAN — 192.168.1.0/24
|
||||
INSERT INTO hosts (name, ip, network_id) SELECT 'gateway', '192.168.1.1', id FROM networks WHERE cidr = '192.168.1.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'gateway' AND ip = '192.168.1.1');
|
||||
INSERT INTO hosts (name, ip, network_id) SELECT 'workstation-01', '192.168.1.10', id FROM networks WHERE cidr = '192.168.1.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'workstation-01' AND ip = '192.168.1.10');
|
||||
INSERT INTO hosts (name, ip, network_id) SELECT 'workstation-02', '192.168.1.11', id FROM networks WHERE cidr = '192.168.1.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'workstation-02' AND ip = '192.168.1.11');
|
||||
INSERT INTO hosts (name, ip, network_id) SELECT 'workstation-03', '192.168.1.12', id FROM networks WHERE cidr = '192.168.1.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'workstation-03' AND ip = '192.168.1.12');
|
||||
INSERT INTO hosts (name, ip, network_id) SELECT 'nas-01', '192.168.1.20', id FROM networks WHERE cidr = '192.168.1.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'nas-01' AND ip = '192.168.1.20');
|
||||
INSERT INTO hosts (name, ip, network_id) SELECT 'printer-01', '192.168.1.50', id FROM networks WHERE cidr = '192.168.1.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'printer-01' AND ip = '192.168.1.50');
|
||||
|
||||
-- DMZ — 192.168.10.0/24
|
||||
INSERT INTO hosts (name, ip, network_id) SELECT 'web-server-01', '192.168.10.10', id FROM networks WHERE cidr = '192.168.10.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'web-server-01' AND ip = '192.168.10.10');
|
||||
INSERT INTO hosts (name, ip, network_id) SELECT 'web-server-02', '192.168.10.11', id FROM networks WHERE cidr = '192.168.10.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'web-server-02' AND ip = '192.168.10.11');
|
||||
INSERT INTO hosts (name, ip, network_id) SELECT 'db-server-01', '192.168.10.20', id FROM networks WHERE cidr = '192.168.10.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'db-server-01' AND ip = '192.168.10.20');
|
||||
INSERT INTO hosts (name, ip, network_id) SELECT 'mail-server-01', '192.168.10.30', id FROM networks WHERE cidr = '192.168.10.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'mail-server-01' AND ip = '192.168.10.30');
|
||||
|
||||
-- Corporate backbone — 10.0.0.0/8
|
||||
INSERT INTO hosts (name, ip, network_id) SELECT 'core-switch-01', '10.0.0.1', id FROM networks WHERE cidr = '10.0.0.0/8' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'core-switch-01' AND ip = '10.0.0.1');
|
||||
INSERT INTO hosts (name, ip, network_id) SELECT 'monitoring-01', '10.0.1.10', id FROM networks WHERE cidr = '10.0.0.0/8' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'monitoring-01' AND ip = '10.0.1.10');
|
||||
INSERT INTO hosts (name, ip, network_id) SELECT 'backup-server-01', '10.0.1.20', id FROM networks WHERE cidr = '10.0.0.0/8' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'backup-server-01' AND ip = '10.0.1.20');
|
||||
INSERT INTO hosts (name, ip, network_id) SELECT 'log-server-01', '10.0.1.30', id FROM networks WHERE cidr = '10.0.0.0/8' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'log-server-01' AND ip = '10.0.1.30');
|
||||
|
||||
-- VPN — 172.16.0.0/16
|
||||
INSERT INTO hosts (name, ip, network_id) SELECT 'vpn-gateway-01', '172.16.0.1', id FROM networks WHERE cidr = '172.16.0.0/16' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'vpn-gateway-01' AND ip = '172.16.0.1');
|
||||
INSERT INTO hosts (name, ip, network_id) SELECT 'vpn-client-01', '172.16.1.10', id FROM networks WHERE cidr = '172.16.0.0/16' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'vpn-client-01' AND ip = '172.16.1.10');
|
||||
INSERT INTO hosts (name, ip, network_id) SELECT 'vpn-client-02', '172.16.1.11', id FROM networks WHERE cidr = '172.16.0.0/16' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'vpn-client-02' AND ip = '172.16.1.11');
|
||||
|
||||
-- ── Ports catalog ─────────────────────────────────────────────────────────────
|
||||
|
||||
INSERT OR IGNORE INTO ports (number, description) VALUES
|
||||
(22, 'SSH'),
|
||||
(25, 'SMTP'),
|
||||
(53, 'DNS'),
|
||||
(80, 'HTTP'),
|
||||
(143, 'IMAP'),
|
||||
(161, 'SNMP'),
|
||||
(443, 'HTTPS'),
|
||||
(445, 'SMB'),
|
||||
(465, 'SMTPS'),
|
||||
(500, 'IKE / IPSec'),
|
||||
(514, 'Syslog'),
|
||||
(587, 'SMTP Submission'),
|
||||
(873, 'rsync'),
|
||||
(993, 'IMAPS'),
|
||||
(1194, 'OpenVPN'),
|
||||
(2049, 'NFS'),
|
||||
(3000, 'Grafana / Gitea'),
|
||||
(3306, 'MariaDB / MySQL'),
|
||||
(3389, 'RDP'),
|
||||
(4500, 'IPSec NAT-T'),
|
||||
(5044, 'Logstash Beats'),
|
||||
(5432, 'PostgreSQL'),
|
||||
(5601, 'Kibana'),
|
||||
(6379, 'Redis'),
|
||||
(8096, 'Jellyfin'),
|
||||
(8123, 'Home Assistant'),
|
||||
(8384, 'Syncthing UI'),
|
||||
(8920, 'Jellyfin HTTPS'),
|
||||
(9000, 'Portainer'),
|
||||
(9090, 'Prometheus'),
|
||||
(9100, 'node_exporter / JetDirect'),
|
||||
(9200, 'Elasticsearch'),
|
||||
(9443, 'Portainer HTTPS'),
|
||||
(22000, 'Syncthing'),
|
||||
(51820, 'WireGuard');
|
||||
|
||||
-- ── Host ports ────────────────────────────────────────────────────────────────
|
||||
-- INSERT OR IGNORE is safe: host_ports has a composite PRIMARY KEY (host_id, port_number).
|
||||
-- Host IDs are resolved by subquery on (name, ip) to stay independent of auto-increment values.
|
||||
|
||||
-- gateway: SSH, DNS, HTTP (admin UI), HTTPS (admin UI)
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'gateway' AND ip = '192.168.1.1';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 53 FROM hosts WHERE name = 'gateway' AND ip = '192.168.1.1';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 80 FROM hosts WHERE name = 'gateway' AND ip = '192.168.1.1';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 443 FROM hosts WHERE name = 'gateway' AND ip = '192.168.1.1';
|
||||
|
||||
-- workstations: SSH, RDP
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'workstation-01' AND ip = '192.168.1.10';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 3389 FROM hosts WHERE name = 'workstation-01' AND ip = '192.168.1.10';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'workstation-02' AND ip = '192.168.1.11';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 3389 FROM hosts WHERE name = 'workstation-02' AND ip = '192.168.1.11';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'workstation-03' AND ip = '192.168.1.12';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 3389 FROM hosts WHERE name = 'workstation-03' AND ip = '192.168.1.12';
|
||||
|
||||
-- nas-01: SSH, HTTP (web UI), HTTPS, SMB, NFS
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'nas-01' AND ip = '192.168.1.20';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 80 FROM hosts WHERE name = 'nas-01' AND ip = '192.168.1.20';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 443 FROM hosts WHERE name = 'nas-01' AND ip = '192.168.1.20';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 445 FROM hosts WHERE name = 'nas-01' AND ip = '192.168.1.20';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 2049 FROM hosts WHERE name = 'nas-01' AND ip = '192.168.1.20';
|
||||
|
||||
-- printer-01: HTTP (web UI), HTTPS, JetDirect
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 80 FROM hosts WHERE name = 'printer-01' AND ip = '192.168.1.50';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 443 FROM hosts WHERE name = 'printer-01' AND ip = '192.168.1.50';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 9100 FROM hosts WHERE name = 'printer-01' AND ip = '192.168.1.50';
|
||||
|
||||
-- web servers: SSH, HTTP, HTTPS
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'web-server-01' AND ip = '192.168.10.10';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 80 FROM hosts WHERE name = 'web-server-01' AND ip = '192.168.10.10';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 443 FROM hosts WHERE name = 'web-server-01' AND ip = '192.168.10.10';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'web-server-02' AND ip = '192.168.10.11';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 80 FROM hosts WHERE name = 'web-server-02' AND ip = '192.168.10.11';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 443 FROM hosts WHERE name = 'web-server-02' AND ip = '192.168.10.11';
|
||||
|
||||
-- db-server-01: SSH, PostgreSQL
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'db-server-01' AND ip = '192.168.10.20';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 5432 FROM hosts WHERE name = 'db-server-01' AND ip = '192.168.10.20';
|
||||
|
||||
-- mail-server-01: SSH, SMTP, IMAP, SMTPS, SMTP Submission, IMAPS
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'mail-server-01' AND ip = '192.168.10.30';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 25 FROM hosts WHERE name = 'mail-server-01' AND ip = '192.168.10.30';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 143 FROM hosts WHERE name = 'mail-server-01' AND ip = '192.168.10.30';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 465 FROM hosts WHERE name = 'mail-server-01' AND ip = '192.168.10.30';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 587 FROM hosts WHERE name = 'mail-server-01' AND ip = '192.168.10.30';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 993 FROM hosts WHERE name = 'mail-server-01' AND ip = '192.168.10.30';
|
||||
|
||||
-- core-switch-01: SSH, SNMP
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'core-switch-01' AND ip = '10.0.0.1';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 161 FROM hosts WHERE name = 'core-switch-01' AND ip = '10.0.0.1';
|
||||
|
||||
-- monitoring-01: SSH, HTTP, HTTPS, Grafana, Prometheus
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'monitoring-01' AND ip = '10.0.1.10';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 80 FROM hosts WHERE name = 'monitoring-01' AND ip = '10.0.1.10';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 443 FROM hosts WHERE name = 'monitoring-01' AND ip = '10.0.1.10';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 3000 FROM hosts WHERE name = 'monitoring-01' AND ip = '10.0.1.10';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 9090 FROM hosts WHERE name = 'monitoring-01' AND ip = '10.0.1.10';
|
||||
|
||||
-- backup-server-01: SSH, SMB, rsync
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'backup-server-01' AND ip = '10.0.1.20';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 445 FROM hosts WHERE name = 'backup-server-01' AND ip = '10.0.1.20';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 873 FROM hosts WHERE name = 'backup-server-01' AND ip = '10.0.1.20';
|
||||
|
||||
-- log-server-01: SSH, Syslog, Logstash Beats, Elasticsearch, Kibana
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'log-server-01' AND ip = '10.0.1.30';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 514 FROM hosts WHERE name = 'log-server-01' AND ip = '10.0.1.30';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 5044 FROM hosts WHERE name = 'log-server-01' AND ip = '10.0.1.30';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 9200 FROM hosts WHERE name = 'log-server-01' AND ip = '10.0.1.30';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 5601 FROM hosts WHERE name = 'log-server-01' AND ip = '10.0.1.30';
|
||||
|
||||
-- vpn-gateway-01: SSH, IKE, IPSec NAT-T, OpenVPN
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'vpn-gateway-01' AND ip = '172.16.0.1';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 500 FROM hosts WHERE name = 'vpn-gateway-01' AND ip = '172.16.0.1';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 1194 FROM hosts WHERE name = 'vpn-gateway-01' AND ip = '172.16.0.1';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 4500 FROM hosts WHERE name = 'vpn-gateway-01' AND ip = '172.16.0.1';
|
||||
|
||||
-- vpn clients: SSH only
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'vpn-client-01' AND ip = '172.16.1.10';
|
||||
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'vpn-client-02' AND ip = '172.16.1.11';
|
||||
|
||||
-- ── Applications ──────────────────────────────────────────────────────────────
|
||||
-- applications has no UNIQUE constraint on name, so we use WHERE NOT EXISTS.
|
||||
|
||||
INSERT INTO applications (name) SELECT 'Nginx' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Nginx');
|
||||
INSERT INTO applications (name) SELECT 'Pi-hole' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Pi-hole');
|
||||
INSERT INTO applications (name) SELECT 'WireGuard' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'WireGuard');
|
||||
INSERT INTO applications (name) SELECT 'OpenVPN' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'OpenVPN');
|
||||
INSERT INTO applications (name) SELECT 'PostgreSQL' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'PostgreSQL');
|
||||
INSERT INTO applications (name) SELECT 'MariaDB' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'MariaDB');
|
||||
INSERT INTO applications (name) SELECT 'Redis' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Redis');
|
||||
INSERT INTO applications (name) SELECT 'Grafana' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Grafana');
|
||||
INSERT INTO applications (name) SELECT 'Prometheus' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Prometheus');
|
||||
INSERT INTO applications (name) SELECT 'Elasticsearch' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Elasticsearch');
|
||||
INSERT INTO applications (name) SELECT 'Kibana' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Kibana');
|
||||
INSERT INTO applications (name) SELECT 'Portainer' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Portainer');
|
||||
INSERT INTO applications (name) SELECT 'Jellyfin' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Jellyfin');
|
||||
INSERT INTO applications (name) SELECT 'Home Assistant' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Home Assistant');
|
||||
INSERT INTO applications (name) SELECT 'Syncthing' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Syncthing');
|
||||
INSERT INTO applications (name) SELECT 'Vaultwarden' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Vaultwarden');
|
||||
|
||||
-- ── Application ports ─────────────────────────────────────────────────────────
|
||||
-- application_ports has a composite PRIMARY KEY, so INSERT OR IGNORE is safe.
|
||||
|
||||
-- Nginx: HTTP, HTTPS
|
||||
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 80 FROM applications WHERE name = 'Nginx';
|
||||
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 443 FROM applications WHERE name = 'Nginx';
|
||||
|
||||
-- Pi-hole: DNS, HTTP (admin UI), HTTPS
|
||||
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 53 FROM applications WHERE name = 'Pi-hole';
|
||||
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 80 FROM applications WHERE name = 'Pi-hole';
|
||||
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 443 FROM applications WHERE name = 'Pi-hole';
|
||||
|
||||
-- WireGuard
|
||||
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 51820 FROM applications WHERE name = 'WireGuard';
|
||||
|
||||
-- OpenVPN
|
||||
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 1194 FROM applications WHERE name = 'OpenVPN';
|
||||
|
||||
-- PostgreSQL
|
||||
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 5432 FROM applications WHERE name = 'PostgreSQL';
|
||||
|
||||
-- MariaDB
|
||||
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 3306 FROM applications WHERE name = 'MariaDB';
|
||||
|
||||
-- Redis
|
||||
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 6379 FROM applications WHERE name = 'Redis';
|
||||
|
||||
-- Grafana
|
||||
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 3000 FROM applications WHERE name = 'Grafana';
|
||||
|
||||
-- Prometheus
|
||||
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 9090 FROM applications WHERE name = 'Prometheus';
|
||||
|
||||
-- Elasticsearch
|
||||
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 9200 FROM applications WHERE name = 'Elasticsearch';
|
||||
|
||||
-- Kibana
|
||||
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 5601 FROM applications WHERE name = 'Kibana';
|
||||
|
||||
-- Portainer: HTTP, HTTPS
|
||||
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 9000 FROM applications WHERE name = 'Portainer';
|
||||
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 9443 FROM applications WHERE name = 'Portainer';
|
||||
|
||||
-- Jellyfin: HTTP, HTTPS
|
||||
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 8096 FROM applications WHERE name = 'Jellyfin';
|
||||
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 8920 FROM applications WHERE name = 'Jellyfin';
|
||||
|
||||
-- Home Assistant
|
||||
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 8123 FROM applications WHERE name = 'Home Assistant';
|
||||
|
||||
-- Syncthing: UI, data sync
|
||||
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 8384 FROM applications WHERE name = 'Syncthing';
|
||||
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 22000 FROM applications WHERE name = 'Syncthing';
|
||||
|
||||
-- Vaultwarden: HTTP, HTTPS
|
||||
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 80 FROM applications WHERE name = 'Vaultwarden';
|
||||
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 443 FROM applications WHERE name = 'Vaultwarden';
|
||||
251
src/api/applications.rs
Normal file
251
src/api/applications.rs
Normal file
@@ -0,0 +1,251 @@
|
||||
// api/applications.rs — Server functions for applications and their port associations
|
||||
|
||||
use leptos::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::Application;
|
||||
|
||||
// Minimal host reference used by ApplicationDetail.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct HostRef {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub ip: String,
|
||||
}
|
||||
|
||||
// Full detail for a single application: identity, associated ports, and linked hosts.
|
||||
// Linked hosts are those that have at least one port matching an application_ports entry.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ApplicationDetail {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub ports: Vec<u16>,
|
||||
pub hosts: Vec<HostRef>,
|
||||
}
|
||||
|
||||
// Application row enriched with the number of hosts that use at least one of
|
||||
// its registered ports. Host count is computed via the join:
|
||||
// application_ports → host_ports (matched on port_number) → hosts
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ApplicationWithCounts {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
/// Distinct hosts that have at least one port matching this application.
|
||||
pub host_count: i64,
|
||||
}
|
||||
|
||||
// ─── Queries ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns all applications enriched with their associated host count.
|
||||
#[server]
|
||||
pub async fn get_applications_with_counts() -> Result<Vec<ApplicationWithCounts>, ServerFnError> {
|
||||
use sqlx::{AnyPool, Row};
|
||||
|
||||
let pool = use_context::<AnyPool>()
|
||||
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT
|
||||
a.id,
|
||||
a.name,
|
||||
COUNT(DISTINCT hp.host_id) AS host_count
|
||||
FROM applications a
|
||||
LEFT JOIN application_ports ap ON ap.application_id = a.id
|
||||
LEFT JOIN host_ports hp ON hp.port_number = ap.port_number
|
||||
GROUP BY a.id, a.name
|
||||
ORDER BY a.name",
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| ApplicationWithCounts {
|
||||
id: row.get("id"),
|
||||
name: row.get("name"),
|
||||
host_count: row.get("host_count"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Returns all applications ordered by name.
|
||||
#[server]
|
||||
pub async fn get_applications() -> Result<Vec<Application>, ServerFnError> {
|
||||
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(&pool)
|
||||
.await
|
||||
.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(
|
||||
application_id: i64,
|
||||
) -> Result<Vec<u16>, 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_ports_for_application(&pool, application_id)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||
}
|
||||
|
||||
// ─── Mutations ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Updates the name of an application and returns the updated record.
|
||||
#[server]
|
||||
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"))?;
|
||||
|
||||
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.
|
||||
#[server]
|
||||
pub async fn delete_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::delete_application(&pool, id)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||
}
|
||||
|
||||
/// Associates a port number with an application.
|
||||
///
|
||||
/// If the association already exists, this is a no-op.
|
||||
#[server]
|
||||
pub async fn add_port_to_application(
|
||||
application_id: i64,
|
||||
port_number: u16,
|
||||
) -> 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"))?;
|
||||
|
||||
repo::add_port_to_application(&pool, application_id, port_number)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||
}
|
||||
|
||||
/// Removes a port association from an application.
|
||||
///
|
||||
/// If the association does not exist, this is a no-op.
|
||||
#[server]
|
||||
pub async fn remove_port_from_application(
|
||||
application_id: i64,
|
||||
port_number: u16,
|
||||
) -> 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"))?;
|
||||
|
||||
repo::remove_port_from_application(&pool, application_id, port_number)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||
}
|
||||
436
src/api/hosts.rs
Normal file
436
src/api/hosts.rs
Normal file
@@ -0,0 +1,436 @@
|
||||
// api/hosts.rs — Server functions for hosts
|
||||
|
||||
use leptos::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::{Application, Host, Port};
|
||||
|
||||
// ─── Presentation types ───────────────────────────────────────────────────────
|
||||
|
||||
// A host row enriched with its network CIDR and pre-computed counts.
|
||||
// Used by the paginated hosts list (get_hosts_page).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct HostRow {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub ip: String,
|
||||
pub network_id: i64,
|
||||
pub network_cidr: String,
|
||||
pub port_count: i64,
|
||||
pub application_count: i64,
|
||||
}
|
||||
|
||||
// Result of a paginated, filtered host query.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
||||
pub struct HostsPage {
|
||||
pub rows: Vec<HostRow>,
|
||||
pub total: i64, // total rows matching the filters (ignoring pagination)
|
||||
pub page: i64, // current page (1-indexed)
|
||||
pub per_page: i64, // items per page; 0 = all
|
||||
pub total_pages: i64, // ceil(total / per_page); always ≥ 1
|
||||
}
|
||||
|
||||
// Full host detail: identity fields + resolved network + open ports + linked applications.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct HostDetail {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub ip: String,
|
||||
pub network_id: i64,
|
||||
pub network_name: String,
|
||||
pub network_cidr: String,
|
||||
pub ports: Vec<Port>,
|
||||
pub applications: Vec<Application>,
|
||||
}
|
||||
|
||||
// ─── Queries ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns full detail for a single host: identity, network, and open ports.
|
||||
#[server]
|
||||
pub async fn get_host_detail(id: i64) -> Result<HostDetail, ServerFnError> {
|
||||
use sqlx::AnyPool;
|
||||
use crate::server::repository::{
|
||||
applications as app_repo,
|
||||
hosts as host_repo,
|
||||
networks,
|
||||
ports as port_repo,
|
||||
};
|
||||
|
||||
let pool = use_context::<AnyPool>()
|
||||
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||
|
||||
let host = host_repo::find_host(&pool, id)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||
.ok_or_else(|| ServerFnError::new(format!("Host {id} not found")))?;
|
||||
|
||||
let network = networks::find_network(&pool, host.network_id)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||
.ok_or_else(|| ServerFnError::new(format!("Network {} not found", host.network_id)))?;
|
||||
|
||||
let ports = port_repo::list_ports_for_host(&pool, id)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
let applications = app_repo::list_applications_for_host(&pool, id)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
Ok(HostDetail {
|
||||
id: host.id,
|
||||
name: host.name,
|
||||
ip: host.ip,
|
||||
network_id: host.network_id,
|
||||
network_name: network.name,
|
||||
network_cidr: network.cidr,
|
||||
ports,
|
||||
applications,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns all hosts belonging to a given network.
|
||||
#[server]
|
||||
pub async fn get_hosts_by_network(network_id: i64) -> Result<Vec<Host>, ServerFnError> {
|
||||
use sqlx::AnyPool;
|
||||
use crate::server::repository::hosts as repo;
|
||||
|
||||
let pool = use_context::<AnyPool>()
|
||||
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||
|
||||
repo::list_hosts_by_network(&pool, network_id)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||
}
|
||||
|
||||
/// Returns a filtered and paginated list of hosts across all networks.
|
||||
///
|
||||
/// `port_filter` is a comma-separated list of port numbers (e.g. "80,443").
|
||||
/// A host matches only if it has ALL the specified ports open.
|
||||
/// An empty string means no port filter.
|
||||
///
|
||||
/// Port conditions are inlined in the SQL as integer literals (safe: values
|
||||
/// are parsed and range-checked before use — no raw user strings are injected).
|
||||
#[server]
|
||||
pub async fn get_hosts_page(
|
||||
name_filter: String,
|
||||
network_id_filter: i64,
|
||||
port_filter: String,
|
||||
application_id_filter: i64,
|
||||
page: i64,
|
||||
per_page: i64,
|
||||
) -> Result<HostsPage, ServerFnError> {
|
||||
use sqlx::{AnyPool, Row};
|
||||
|
||||
let pool = use_context::<AnyPool>()
|
||||
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||
|
||||
let name_like: Option<String> = if name_filter.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(format!("%{}%", name_filter))
|
||||
};
|
||||
let network_id: Option<i64> = if network_id_filter == 0 { None } else { Some(network_id_filter) };
|
||||
let app_id: Option<i64> = if application_id_filter == 0 { None } else { Some(application_id_filter) };
|
||||
|
||||
// Parse and validate port numbers from the CSV string.
|
||||
// Inlined as integer literals in SQL — safe because they are range-checked i64s.
|
||||
let ports: Vec<i64> = port_filter
|
||||
.split(',')
|
||||
.filter_map(|s| s.trim().parse::<i64>().ok())
|
||||
.filter(|&p| p >= 1 && p <= 65535)
|
||||
.collect();
|
||||
|
||||
// One EXISTS clause per required port (AND semantics: host must have ALL ports).
|
||||
let port_conditions: String = ports
|
||||
.iter()
|
||||
.map(|p| format!(
|
||||
" AND EXISTS (SELECT 1 FROM host_ports WHERE host_id = h.id AND port_number = {p})"
|
||||
))
|
||||
.collect();
|
||||
|
||||
// $1 = name_like, $2 = network_id, $3 = app_id
|
||||
// Pagination: $4 = limit, $5 = offset
|
||||
let where_clause = format!(
|
||||
"JOIN networks n ON n.id = h.network_id
|
||||
LEFT JOIN host_ports hp ON hp.host_id = h.id
|
||||
LEFT JOIN application_ports ap ON ap.port_number = hp.port_number
|
||||
WHERE ($1 IS NULL OR LOWER(h.name) LIKE LOWER($1))
|
||||
AND ($2 IS NULL OR h.network_id = $2)
|
||||
AND ($3 IS NULL OR EXISTS (
|
||||
SELECT 1 FROM host_ports hp2
|
||||
JOIN application_ports ap2 ON ap2.port_number = hp2.port_number
|
||||
WHERE hp2.host_id = h.id AND ap2.application_id = $3
|
||||
))
|
||||
{port_conditions}"
|
||||
);
|
||||
|
||||
let count_sql = format!("SELECT COUNT(DISTINCT h.id) FROM hosts h {where_clause}");
|
||||
let total: i64 = sqlx::query_scalar(&count_sql)
|
||||
.bind(name_like.as_deref())
|
||||
.bind(network_id)
|
||||
.bind(app_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
let safe_page = page.max(1);
|
||||
let (limit, offset, total_pages) = if per_page <= 0 {
|
||||
(1_000_000_000i64, 0i64, 1i64)
|
||||
} else {
|
||||
let tp = ((total + per_page - 1) / per_page).max(1);
|
||||
(per_page, (safe_page - 1) * per_page, tp)
|
||||
};
|
||||
|
||||
let data_sql = format!(
|
||||
"SELECT h.id, h.name, h.ip, h.network_id,
|
||||
n.cidr AS network_cidr,
|
||||
COUNT(DISTINCT hp.port_number) AS port_count,
|
||||
COUNT(DISTINCT ap.application_id) AS application_count
|
||||
FROM hosts h
|
||||
{where_clause}
|
||||
GROUP BY h.id, h.name, h.ip, h.network_id, n.cidr
|
||||
ORDER BY h.name, h.id
|
||||
LIMIT $4 OFFSET $5"
|
||||
);
|
||||
|
||||
let rows = sqlx::query(&data_sql)
|
||||
.bind(name_like.as_deref())
|
||||
.bind(network_id)
|
||||
.bind(app_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
let host_rows = rows
|
||||
.into_iter()
|
||||
.map(|row| HostRow {
|
||||
id: row.get("id"),
|
||||
name: row.get("name"),
|
||||
ip: row.get("ip"),
|
||||
network_id: row.get("network_id"),
|
||||
network_cidr: row.get("network_cidr"),
|
||||
port_count: row.get("port_count"),
|
||||
application_count: row.get("application_count"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(HostsPage {
|
||||
rows: host_rows,
|
||||
total,
|
||||
page: safe_page,
|
||||
per_page,
|
||||
total_pages,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Mutations ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Creates a new host inside the specified network, then opens the given ports.
|
||||
///
|
||||
/// `ports` is a comma-separated list of port numbers (e.g. "22,80,443").
|
||||
/// Ports are auto-registered in the global catalog if not already present.
|
||||
/// An empty string means no ports are opened.
|
||||
#[server]
|
||||
pub async fn create_host(
|
||||
name: String,
|
||||
ip: String,
|
||||
network_id: i64,
|
||||
ports: String,
|
||||
) -> Result<Host, ServerFnError> {
|
||||
use sqlx::AnyPool;
|
||||
use crate::server::{
|
||||
repository::{hosts, networks, ports as port_repo},
|
||||
validation::validate_ip_in_network,
|
||||
};
|
||||
|
||||
let pool = use_context::<AnyPool>()
|
||||
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||
|
||||
let network = networks::find_network(&pool, network_id)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||
.ok_or_else(|| ServerFnError::new(format!("Network {network_id} not found")))?;
|
||||
|
||||
validate_ip_in_network(&ip, &network.cidr)
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
let host = hosts::create_host(&pool, &name, &ip, network_id)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
// Parse, validate, and open each port on the new host.
|
||||
let port_numbers: Vec<u16> = ports
|
||||
.split(',')
|
||||
.filter_map(|s| s.trim().parse::<u16>().ok())
|
||||
.filter(|&p| p >= 1)
|
||||
.collect();
|
||||
|
||||
for port_number in port_numbers {
|
||||
port_repo::add_port_to_host(&pool, host.id, port_number)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
}
|
||||
|
||||
Ok(host)
|
||||
}
|
||||
|
||||
/// Updates a host's name, IP address, and network assignment.
|
||||
///
|
||||
/// Validates that the new IP falls within the CIDR of the new network.
|
||||
#[server]
|
||||
pub async fn update_host(
|
||||
id: i64,
|
||||
name: String,
|
||||
ip: String,
|
||||
network_id: i64,
|
||||
) -> Result<Host, ServerFnError> {
|
||||
use sqlx::AnyPool;
|
||||
use crate::server::{
|
||||
repository::{hosts as host_repo, networks},
|
||||
validation::validate_ip_in_network,
|
||||
};
|
||||
|
||||
let pool = use_context::<AnyPool>()
|
||||
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||
|
||||
if name.trim().is_empty() {
|
||||
return Err(ServerFnError::new("Name must not be empty"));
|
||||
}
|
||||
|
||||
let network = networks::find_network(&pool, network_id)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||
.ok_or_else(|| ServerFnError::new(format!("Network {network_id} not found")))?;
|
||||
|
||||
validate_ip_in_network(&ip, &network.cidr)
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
host_repo::update_host(&pool, id, name.trim(), ip.trim(), network_id)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||
.ok_or_else(|| ServerFnError::new(format!("Host {id} not found")))
|
||||
}
|
||||
|
||||
/// Opens a single port on a host.
|
||||
///
|
||||
/// Auto-registers the port in the global catalog if not already present.
|
||||
/// If the port is already open on this host, the call is a no-op.
|
||||
#[server]
|
||||
pub async fn add_host_port(host_id: i64, port_number: i64) -> Result<(), ServerFnError> {
|
||||
use sqlx::AnyPool;
|
||||
use crate::server::repository::ports as port_repo;
|
||||
|
||||
let pool = use_context::<AnyPool>()
|
||||
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||
|
||||
if !(1..=65535).contains(&port_number) {
|
||||
return Err(ServerFnError::new("Port number must be between 1 and 65535"));
|
||||
}
|
||||
|
||||
port_repo::add_port_to_host(&pool, host_id, port_number as u16)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||
}
|
||||
|
||||
/// Closes a port on a host (removes the host-port association).
|
||||
///
|
||||
/// The port entry in the global catalog is not deleted.
|
||||
/// If the port was not open on this host, the call is a no-op.
|
||||
#[server]
|
||||
pub async fn remove_host_port(host_id: i64, port_number: i64) -> Result<(), ServerFnError> {
|
||||
use sqlx::AnyPool;
|
||||
use crate::server::repository::ports as port_repo;
|
||||
|
||||
let pool = use_context::<AnyPool>()
|
||||
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||
|
||||
port_repo::remove_port_from_host(&pool, host_id, port_number as u16)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||
}
|
||||
|
||||
/// Returns all applications not yet directly linked to a host.
|
||||
///
|
||||
/// Used to populate the "add applications" modal on the host detail page.
|
||||
#[server]
|
||||
pub async fn get_applications_not_on_host(
|
||||
host_id: i64,
|
||||
) -> Result<Vec<Application>, ServerFnError> {
|
||||
use sqlx::AnyPool;
|
||||
use crate::server::repository::applications as repo;
|
||||
|
||||
let pool = use_context::<AnyPool>()
|
||||
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||
|
||||
repo::list_applications_not_on_host(&pool, host_id)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||
}
|
||||
|
||||
/// Links one or more applications to a host.
|
||||
///
|
||||
/// `application_ids` is a comma-separated string of application IDs (e.g. "1,3,7").
|
||||
/// Already-linked applications are silently skipped (no-op).
|
||||
#[server]
|
||||
pub async fn add_host_applications(
|
||||
host_id: i64,
|
||||
application_ids: String,
|
||||
) -> Result<(), ServerFnError> {
|
||||
use sqlx::AnyPool;
|
||||
use crate::server::repository::applications as repo;
|
||||
|
||||
let pool = use_context::<AnyPool>()
|
||||
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||
|
||||
let ids: Vec<i64> = application_ids
|
||||
.split(',')
|
||||
.filter_map(|s| s.trim().parse::<i64>().ok())
|
||||
.collect();
|
||||
|
||||
for application_id in ids {
|
||||
repo::add_application_to_host(&pool, host_id, application_id)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the direct link between a host and an application.
|
||||
///
|
||||
/// Returns `true` if the association existed and was removed.
|
||||
#[server]
|
||||
pub async fn remove_host_application(
|
||||
host_id: i64,
|
||||
application_id: i64,
|
||||
) -> Result<bool, ServerFnError> {
|
||||
use sqlx::AnyPool;
|
||||
use crate::server::repository::applications as repo;
|
||||
|
||||
let pool = use_context::<AnyPool>()
|
||||
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||
|
||||
repo::remove_application_from_host(&pool, host_id, application_id)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||
}
|
||||
|
||||
/// Deletes a host by id.
|
||||
///
|
||||
/// Also removes all its port associations (via `ON DELETE CASCADE`).
|
||||
/// Returns `true` if the host existed and was deleted.
|
||||
#[server]
|
||||
pub async fn delete_host(id: i64) -> Result<bool, ServerFnError> {
|
||||
use sqlx::AnyPool;
|
||||
use crate::server::repository::hosts as repo;
|
||||
|
||||
let pool = use_context::<AnyPool>()
|
||||
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||
|
||||
repo::delete_host(&pool, id)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||
}
|
||||
20
src/api/mod.rs
Normal file
20
src/api/mod.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
// api/ — Leptos server functions (the bridge between client and server)
|
||||
//
|
||||
// Server functions are annotated with `#[server]`.
|
||||
// Leptos compiles them differently depending on the active feature:
|
||||
//
|
||||
// ssr → the function body runs on the server (normal async Rust code)
|
||||
// hydrate → the body is replaced by an HTTP POST call to /api/<fn-name>
|
||||
//
|
||||
// This means:
|
||||
// - Function signatures (arguments + return types) must compile for BOTH targets.
|
||||
// All types used here must also be in `models.rs` (shared code).
|
||||
// - Imports inside the function body are only compiled for `ssr`,
|
||||
// so ssr-only modules (server::repository, sqlx) can be used freely there.
|
||||
// - The pool is retrieved via `use_context::<AnyPool>()` — it is injected
|
||||
// into the Leptos context by `main.rs` for every request.
|
||||
|
||||
pub mod applications;
|
||||
pub mod hosts;
|
||||
pub mod networks;
|
||||
pub mod summary;
|
||||
134
src/api/networks.rs
Normal file
134
src/api/networks.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
// api/networks.rs — Server functions for networks
|
||||
|
||||
use leptos::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::Network;
|
||||
|
||||
// Network row augmented with pre-computed counts.
|
||||
// Defined here (not in models.rs) because it is a presentation model
|
||||
// specific to the Networks page, not a pure domain entity.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct NetworkWithCounts {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub cidr: String,
|
||||
/// Number of hosts whose IP falls within this network's CIDR range.
|
||||
pub host_count: i64,
|
||||
/// Number of distinct applications linked via ports open on hosts in this network.
|
||||
pub application_count: i64,
|
||||
}
|
||||
|
||||
// ─── Queries ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns all networks from the database.
|
||||
///
|
||||
/// Called by the Networks page to populate the list.
|
||||
#[server]
|
||||
pub async fn get_networks() -> Result<Vec<Network>, ServerFnError> {
|
||||
use sqlx::AnyPool;
|
||||
use crate::server::repository::networks as repo;
|
||||
|
||||
let pool = use_context::<AnyPool>()
|
||||
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||
|
||||
repo::list_networks(&pool)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||
}
|
||||
|
||||
/// Returns all networks enriched with host and application counts.
|
||||
#[server]
|
||||
pub async fn get_networks_with_counts() -> Result<Vec<NetworkWithCounts>, ServerFnError> {
|
||||
use sqlx::{AnyPool, Row};
|
||||
|
||||
let pool = use_context::<AnyPool>()
|
||||
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT
|
||||
n.id,
|
||||
n.name,
|
||||
n.cidr,
|
||||
(SELECT COUNT(*) FROM hosts WHERE network_id = n.id) AS host_count,
|
||||
(SELECT COUNT(DISTINCT ap.application_id)
|
||||
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 h.network_id = n.id) AS application_count
|
||||
FROM networks n
|
||||
ORDER BY n.id",
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
let networks = rows
|
||||
.into_iter()
|
||||
.map(|row| NetworkWithCounts {
|
||||
id: row.get("id"),
|
||||
name: row.get("name"),
|
||||
cidr: row.get("cidr"),
|
||||
host_count: row.get("host_count"),
|
||||
application_count: row.get("application_count"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(networks)
|
||||
}
|
||||
|
||||
/// Returns a single network by id, or an error if it does not exist.
|
||||
#[server]
|
||||
pub async fn get_network(id: i64) -> Result<Network, ServerFnError> {
|
||||
use sqlx::AnyPool;
|
||||
use crate::server::repository::networks as repo;
|
||||
|
||||
let pool = use_context::<AnyPool>()
|
||||
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||
|
||||
repo::find_network(&pool, id)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||
.ok_or_else(|| ServerFnError::new(format!("Network {id} not found")))
|
||||
}
|
||||
|
||||
// ─── Mutations ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Creates a new network with the given name and CIDR block.
|
||||
///
|
||||
/// Returns an error if the CIDR is malformed or already exists.
|
||||
#[server]
|
||||
pub async fn create_network(name: String, cidr: String) -> Result<Network, ServerFnError> {
|
||||
use sqlx::AnyPool;
|
||||
use crate::server::{repository::networks as repo, validation::validate_cidr};
|
||||
|
||||
let pool = use_context::<AnyPool>()
|
||||
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||
|
||||
if name.trim().is_empty() {
|
||||
return Err(ServerFnError::new("Network name cannot be empty"));
|
||||
}
|
||||
|
||||
validate_cidr(&cidr).map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
repo::create_network(&pool, name.trim(), &cidr)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||
}
|
||||
|
||||
/// Deletes a network by id.
|
||||
///
|
||||
/// Also deletes all hosts in that network (via `ON DELETE CASCADE`).
|
||||
/// Returns `true` if the network existed and was deleted.
|
||||
#[server]
|
||||
pub async fn delete_network(id: i64) -> Result<bool, ServerFnError> {
|
||||
use sqlx::AnyPool;
|
||||
use crate::server::repository::networks as repo;
|
||||
|
||||
let pool = use_context::<AnyPool>()
|
||||
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||
|
||||
repo::delete_network(&pool, id)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||
}
|
||||
45
src/api/summary.rs
Normal file
45
src/api/summary.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
// api/summary.rs — Dashboard summary server function
|
||||
//
|
||||
// Returns the count of each main entity in a single server round-trip.
|
||||
// The home page uses this to display a quick-glance dashboard without
|
||||
// loading the full list of each entity.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// Counts for every top-level entity shown on the dashboard.
|
||||
// Add a field here when a new entity type is introduced.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
||||
pub struct Summary {
|
||||
pub network_count: i64,
|
||||
pub host_count: i64,
|
||||
pub application_count: i64,
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn get_summary() -> Result<Summary, ServerFnError> {
|
||||
use sqlx::AnyPool;
|
||||
use leptos::prelude::use_context;
|
||||
|
||||
let pool = use_context::<AnyPool>()
|
||||
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||
|
||||
// Three lightweight COUNT queries — no full table scans on the payload side.
|
||||
// sqlx returns COUNT(*) as i64 for both SQLite and PostgreSQL.
|
||||
let network_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM networks")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
let host_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM hosts")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
let application_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM applications")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
Ok(Summary { network_count, host_count, application_count })
|
||||
}
|
||||
124
src/app.rs
Normal file
124
src/app.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
// app.rs — Root Leptos components
|
||||
//
|
||||
// This file contains two components:
|
||||
// - `Shell` : full HTML document (head + body) — SSR only
|
||||
// - `App` : page content and router — shared between SSR and WASM
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos_meta::*;
|
||||
use leptos_router::{
|
||||
components::{Route, Router, Routes},
|
||||
path,
|
||||
};
|
||||
|
||||
use crate::client::{
|
||||
application_detail::ApplicationDetailPage,
|
||||
applications::ApplicationsPage,
|
||||
home::HomePage,
|
||||
host_detail::HostDetailPage,
|
||||
hosts::HostsPage,
|
||||
network_detail::NetworkDetailPage,
|
||||
networks::NetworksPage,
|
||||
theme::ThemeToggle,
|
||||
};
|
||||
|
||||
// Shell — full HTML document rendered by the Axum server.
|
||||
//
|
||||
// This component only exists in SSR mode (`#[cfg(feature = "ssr")]`).
|
||||
// It provides the HTML skeleton that leptos_meta requires:
|
||||
// a valid <head> and <body>. Without it, <Title> and <Stylesheet>
|
||||
// components have nowhere to inject their output.
|
||||
//
|
||||
// SSR rendering flow:
|
||||
// 1. Axum calls Shell() for each incoming request
|
||||
// 2. Shell renders <head> with MetaTags (a placeholder filled by App)
|
||||
// 3. Shell renders <body> containing App()
|
||||
// 4. App() calls provide_meta_context() and registers metadata
|
||||
// 5. Leptos retroactively injects that metadata into MetaTags
|
||||
// 6. HydrationScripts generates <script> tags to load the WASM bundle
|
||||
#[cfg(feature = "ssr")]
|
||||
#[component]
|
||||
pub fn Shell(
|
||||
// LeptosOptions holds project configuration (paths, file names, ports...).
|
||||
// Used by HydrationScripts to build the WASM bundle URLs.
|
||||
options: leptos::config::LeptosOptions,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
// MetaTags: placeholder where leptos_meta injects tags collected
|
||||
// from <Title>, <Stylesheet>, <Meta>... defined inside App().
|
||||
<MetaTags/>
|
||||
// HydrationScripts: generates <link> and <script> tags that load
|
||||
// the trunk-compiled WASM bundle and call hydrate() from lib.rs.
|
||||
<HydrationScripts options=options.clone()/>
|
||||
// AutoReload: hot-reload during development (no-op in production).
|
||||
// Only activates when the LEPTOS_WATCH environment variable is set.
|
||||
<AutoReload options/>
|
||||
</head>
|
||||
<body>
|
||||
// App() runs here, provides the meta context, and renders page content.
|
||||
<App/>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
}
|
||||
|
||||
// App — root component shared between the server (SSR) and the browser (WASM).
|
||||
//
|
||||
// This component is rendered:
|
||||
// - server-side : inside the Shell <body>, to generate HTML
|
||||
// - client-side : via hydrate() in lib.rs, to attach reactivity
|
||||
//
|
||||
// `-> impl IntoView` : returns "something displayable". The concrete type is
|
||||
// opaque because Leptos's `view!` macro generates a complex internal type.
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
// Initialize the Leptos metadata context.
|
||||
// Without this call, <Title>, <Stylesheet>, and <Meta> in child components
|
||||
// would have no context to store their metadata in.
|
||||
provide_meta_context();
|
||||
|
||||
view! {
|
||||
<Title text="Rust IPAM — IP Address Manager"/>
|
||||
|
||||
// Load the global CSS from /pkg/rust-ipam.css.
|
||||
// This file is generated by trunk from style.css (to be added later).
|
||||
<Stylesheet id="main" href="/pkg/rust-ipam.css"/>
|
||||
|
||||
// Router handles client-side navigation without full page reloads.
|
||||
// On the server, it determines which component to render for the requested URL.
|
||||
<Router>
|
||||
<nav>
|
||||
<a href="/">"Rust IPAM"</a>
|
||||
<a href="/networks">"Networks"</a>
|
||||
<a href="/hosts">"Hosts"</a>
|
||||
<a href="/applications">"Applications"</a>
|
||||
<span class="nav-spacer"/>
|
||||
<ThemeToggle/>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
// <Routes> is the container for all route definitions.
|
||||
// `fallback` is displayed when no route matches the current URL.
|
||||
<Routes fallback=|| view! {
|
||||
<div class="not-found">
|
||||
<h1>"404 — Page not found"</h1>
|
||||
<a href="/">"← Back to home"</a>
|
||||
</div>
|
||||
}>
|
||||
<Route path=path!("/") view=HomePage/>
|
||||
<Route path=path!("/networks") view=NetworksPage/>
|
||||
<Route path=path!("/networks/:id") view=NetworkDetailPage/>
|
||||
<Route path=path!("/hosts") view=HostsPage/>
|
||||
<Route path=path!("/hosts/:id") view=HostDetailPage/>
|
||||
<Route path=path!("/applications") view=ApplicationsPage/>
|
||||
<Route path=path!("/applications/:id") view=ApplicationDetailPage/>
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
97
src/bin/seed.rs
Normal file
97
src/bin/seed.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
// bin/seed.rs — Development seed loader
|
||||
//
|
||||
// Inserts a realistic set of networks and hosts into the database so the UI
|
||||
// can be tested without manual data entry.
|
||||
//
|
||||
// The seed is idempotent: running it multiple times never duplicates rows.
|
||||
//
|
||||
// Usage:
|
||||
// cargo run --features ssr --bin seed
|
||||
//
|
||||
// The DATABASE_URL is read from the .env file (or environment variable),
|
||||
// exactly like the main server.
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
use rust_ipam::server::{
|
||||
config::{AppConfig, DatabaseBackend},
|
||||
db::{create_pool, run_migrations},
|
||||
};
|
||||
|
||||
// Load .env so DATABASE_URL is available without exporting it manually.
|
||||
// Errors are ignored: the variable may already be set in the environment.
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let config = AppConfig::from_env()
|
||||
.expect("Configuration error — check DATABASE_URL in your .env file");
|
||||
|
||||
tracing::info!("Connecting to {} ({})", config.backend, config.database_url);
|
||||
|
||||
let pool = create_pool(&config)
|
||||
.await
|
||||
.expect("Failed to connect to database");
|
||||
|
||||
run_migrations(&pool, &config.backend)
|
||||
.await
|
||||
.expect("Migration failed");
|
||||
|
||||
// Pick the seed file that matches the active backend.
|
||||
// `include_str!` embeds the SQL at compile time so the binary is self-contained.
|
||||
let sql = match config.backend {
|
||||
DatabaseBackend::Sqlite => include_str!("../../seeds/sqlite/dev_seed.sql"),
|
||||
DatabaseBackend::Postgres => include_str!("../../seeds/postgres/dev_seed.sql"),
|
||||
};
|
||||
|
||||
// Strip comment lines first, then split on ';'.
|
||||
// sqlx does not support multiple statements in a single `query()` call.
|
||||
// Without pre-stripping comments, a block like "-- section\nINSERT …"
|
||||
// would start with "--" and get incorrectly discarded.
|
||||
let sql_stripped: String = sql
|
||||
.lines()
|
||||
.filter(|line| !line.trim().starts_with("--"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let statements: Vec<String> = sql_stripped
|
||||
.split(';')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
let total = statements.len();
|
||||
for (i, stmt) in statements.iter().enumerate() {
|
||||
sqlx::query(stmt)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("Statement {}/{} failed: {}\nSQL: {}", i + 1, total, e, stmt));
|
||||
}
|
||||
|
||||
tracing::info!("Seed complete — {} statement(s) executed.", total);
|
||||
|
||||
// Count what was inserted so the operator can confirm at a glance.
|
||||
let network_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM networks")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let host_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM hosts")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let application_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM applications")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
tracing::info!(
|
||||
"Database now contains {} network(s), {} host(s) and {} application(s).",
|
||||
network_count, host_count, application_count
|
||||
);
|
||||
}
|
||||
311
src/client/application_detail.rs
Normal file
311
src/client/application_detail.rs
Normal 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 (1–65535)"
|
||||
prop:value=move || new_port.get()
|
||||
on:input=move |e| new_port.set(event_target_value(&e))
|
||||
/>
|
||||
<button
|
||||
class="btn-primary"
|
||||
type="button"
|
||||
on:click=move |_| {
|
||||
let raw = new_port.get_untracked();
|
||||
if let Ok(n) = raw.trim().parse::<i64>() {
|
||||
if (1..=65535).contains(&n) {
|
||||
add_port_action.dispatch(AddPortToApplication {
|
||||
application_id: id,
|
||||
port_number: n as u16,
|
||||
});
|
||||
new_port.set(String::new());
|
||||
}
|
||||
}
|
||||
}
|
||||
>
|
||||
"Add port"
|
||||
</button>
|
||||
{move || add_port_action.value().get()
|
||||
.and_then(|r| r.err())
|
||||
.map(|e| view! {
|
||||
<p class="error">"Add failed: " {e.to_string()}</p>
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
// ── Hosts section (read-only — linked via shared ports) ──
|
||||
<section class="detail-section">
|
||||
<h2 class="detail-section__title">
|
||||
{format!("Linked hosts ({})", host_count)}
|
||||
</h2>
|
||||
{hosts_list}
|
||||
</section>
|
||||
|
||||
// ── Danger zone ───────────────────────────────────
|
||||
<div class="danger-zone">
|
||||
<button
|
||||
class="btn-danger-solid"
|
||||
type="button"
|
||||
on:click=move |_| show_delete_modal.set(true)
|
||||
>
|
||||
"Delete application"
|
||||
</button>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
})}
|
||||
</Suspense>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
294
src/client/applications.rs
Normal file
294
src/client/applications.rs
Normal file
@@ -0,0 +1,294 @@
|
||||
// client/applications.rs — Applications list page
|
||||
//
|
||||
// Displays all applications with:
|
||||
// - Add button : opens a modal to create an application by name
|
||||
// - Filter bar : name substring filter (client-side)
|
||||
// - Table : application name + number of associated hosts
|
||||
// - Delete : confirmation modal before deletion
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos::form::ActionForm;
|
||||
use leptos::html::Input;
|
||||
|
||||
use crate::api::applications::{
|
||||
ApplicationWithCounts, CreateApplication, DeleteApplication,
|
||||
get_applications_with_counts,
|
||||
};
|
||||
|
||||
// ─── Add application modal ────────────────────────────────────────────────────
|
||||
|
||||
#[component]
|
||||
fn AddApplicationModal(
|
||||
create_action: ServerAction<CreateApplication>,
|
||||
show_modal: RwSignal<bool>,
|
||||
) -> impl IntoView {
|
||||
use leptos::task::spawn_local;
|
||||
|
||||
let name_ref = NodeRef::<Input>::new();
|
||||
|
||||
// Defer focus to the next microtask so the element is in the DOM.
|
||||
// Using get_untracked() avoids subscribing to NodeRef's reactive signal,
|
||||
// which would otherwise re-trigger during modal unmount and cause
|
||||
// "closure invoked after being dropped" in wasm-bindgen.
|
||||
spawn_local(async move {
|
||||
if let Some(el) = name_ref.get_untracked() {
|
||||
let _ = el.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// close() defers show_modal.set(false) to the next microtask.
|
||||
// Without this, setting the signal synchronously inside a click handler
|
||||
// unmounts the modal (and frees its closures) while the handler is still
|
||||
// on the call stack, causing wasm-bindgen to panic.
|
||||
let close = move || spawn_local(async move { show_modal.set(false) });
|
||||
|
||||
view! {
|
||||
<div class="modal-backdrop" on:click=move |_| close()>
|
||||
<div class="modal" on:click=move |e| e.stop_propagation()>
|
||||
<div class="modal__header">
|
||||
<h2>"Add an application"</h2>
|
||||
<button class="modal__close" type="button" aria-label="Close"
|
||||
on:click=move |_| close()>
|
||||
"×"
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ActionForm action=create_action>
|
||||
<div class="add-form__fields">
|
||||
<label>
|
||||
"Name"
|
||||
<input
|
||||
node_ref=name_ref
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="e.g. Nginx, PostgreSQL, Prometheus"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
"Associated ports"
|
||||
<input
|
||||
type="text"
|
||||
name="ports"
|
||||
placeholder="e.g. 22, 80, 443"
|
||||
/>
|
||||
<span class="field-hint">"Comma-separated port numbers"</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="modal__actions">
|
||||
<button class="btn-secondary" type="button"
|
||||
on:click=move |_| close()>
|
||||
"Cancel"
|
||||
</button>
|
||||
<button type="submit">"Add application"</button>
|
||||
</div>
|
||||
</ActionForm>
|
||||
|
||||
{move || create_action.value().get()
|
||||
.and_then(|r| r.err())
|
||||
.map(|e| view! { <p class="error">{e.to_string()}</p> })
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
|
||||
// ─── Delete confirmation modal ────────────────────────────────────────────────
|
||||
|
||||
#[component]
|
||||
fn DeleteAppModal(
|
||||
app: ApplicationWithCounts,
|
||||
delete_action: ServerAction<DeleteApplication>,
|
||||
pending_delete: RwSignal<Option<ApplicationWithCounts>>,
|
||||
) -> impl IntoView {
|
||||
let id = app.id;
|
||||
let label = app.name.clone();
|
||||
let host_count = app.host_count;
|
||||
|
||||
view! {
|
||||
<div class="modal-backdrop" on:click=move |_| pending_delete.set(None)>
|
||||
<div class="modal" on:click=move |e| e.stop_propagation()>
|
||||
<div class="modal__header">
|
||||
<h2>"Delete application"</h2>
|
||||
<button class="modal__close" type="button" aria-label="Close"
|
||||
on:click=move |_| pending_delete.set(None)>
|
||||
"×"
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal__body">
|
||||
<p>"Delete application " <strong>{label}</strong> "?"</p>
|
||||
{(host_count > 0).then(|| view! {
|
||||
<p class="warning">
|
||||
"This application is linked to "
|
||||
{host_count}
|
||||
{if host_count == 1 { " host" } else { " hosts" }}
|
||||
" via shared ports. The port associations will be removed."
|
||||
</p>
|
||||
})}
|
||||
</div>
|
||||
<div class="modal__actions">
|
||||
<button class="btn-secondary" type="button"
|
||||
on:click=move |_| pending_delete.set(None)>
|
||||
"Cancel"
|
||||
</button>
|
||||
<button class="btn-danger" type="button"
|
||||
on:click=move |_| { delete_action.dispatch(DeleteApplication { id }); }>
|
||||
"Delete"
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[component]
|
||||
pub fn ApplicationsPage() -> impl IntoView {
|
||||
let create_action = ServerAction::<CreateApplication>::new();
|
||||
let delete_action = ServerAction::<DeleteApplication>::new();
|
||||
|
||||
let show_modal = RwSignal::new(false);
|
||||
|
||||
// Some(app) = delete modal open for that app; None = closed.
|
||||
let pending_delete: RwSignal<Option<ApplicationWithCounts>> = RwSignal::new(None);
|
||||
|
||||
// Name filter (client-side — list is typically small)
|
||||
let name_filter = RwSignal::new(String::new());
|
||||
|
||||
// Close the add modal when the action transitions pending→done with Ok.
|
||||
// Lives in the parent so it is never recreated across modal open/close cycles,
|
||||
// which avoids the stale-value re-trigger bug.
|
||||
Effect::new(move |was_pending: Option<bool>| {
|
||||
let is_pending = create_action.pending().get();
|
||||
if was_pending == Some(true) && !is_pending {
|
||||
if let Some(Ok(_)) = create_action.value().get() {
|
||||
show_modal.set(false);
|
||||
}
|
||||
}
|
||||
is_pending
|
||||
});
|
||||
|
||||
// Close the delete modal automatically after a successful deletion.
|
||||
Effect::new(move |_| {
|
||||
if let Some(Ok(_)) = delete_action.value().get() {
|
||||
pending_delete.set(None);
|
||||
}
|
||||
});
|
||||
|
||||
let applications = Resource::new(
|
||||
move || (create_action.version().get(), delete_action.version().get()),
|
||||
|_| get_applications_with_counts(),
|
||||
);
|
||||
|
||||
view! {
|
||||
<div class="applications-page">
|
||||
|
||||
// ── Page header ───────────────────────────────────────────────────
|
||||
<div class="page-header">
|
||||
<h1>"Applications"</h1>
|
||||
<button class="btn-primary" on:click=move |_| show_modal.set(true)>
|
||||
"+ Add application"
|
||||
</button>
|
||||
</div>
|
||||
|
||||
// ── Add modal ─────────────────────────────────────────────────────
|
||||
{move || show_modal.get().then(|| view! {
|
||||
<AddApplicationModal
|
||||
create_action=create_action
|
||||
show_modal=show_modal
|
||||
/>
|
||||
})}
|
||||
|
||||
// ── Delete modal ──────────────────────────────────────────────────
|
||||
{move || pending_delete.get().map(|app| view! {
|
||||
<DeleteAppModal
|
||||
app=app
|
||||
delete_action=delete_action
|
||||
pending_delete=pending_delete
|
||||
/>
|
||||
})}
|
||||
|
||||
// ── Filter bar ────────────────────────────────────────────────────
|
||||
<section class="filter-bar">
|
||||
<div class="filter-bar__fields">
|
||||
<label class="filter-field">
|
||||
"Name"
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search…"
|
||||
on:input=move |e| name_filter.set(event_target_value(&e))
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
// ── Application list ──────────────────────────────────────────────
|
||||
<section class="list">
|
||||
{move || delete_action.value().get()
|
||||
.and_then(|r| r.err())
|
||||
.map(|e| view! { <p class="error">"Delete failed: " {e.to_string()}</p> })
|
||||
}
|
||||
|
||||
<Suspense fallback=|| view! { <p>"Loading applications…"</p> }>
|
||||
{move || applications.get().map(|result| match result {
|
||||
Err(e) => view! {
|
||||
<p class="error">"Could not load applications: " {e.to_string()}</p>
|
||||
}.into_any(),
|
||||
|
||||
Ok(list) => {
|
||||
let filter = name_filter.get().to_lowercase();
|
||||
let filtered: Vec<_> = list.into_iter()
|
||||
.filter(|app| filter.is_empty() || app.name.to_lowercase().contains(&filter))
|
||||
.collect();
|
||||
|
||||
if filtered.is_empty() {
|
||||
view! {
|
||||
<p class="empty">"No applications match the current filter."</p>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>"Name"</th>
|
||||
<th class="col-count">"Hosts"</th>
|
||||
<th class="col-actions">"Actions"</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.into_iter().map(|app| {
|
||||
let app_clone = app.clone();
|
||||
view! {
|
||||
<tr>
|
||||
<td>
|
||||
<a class="table-link"
|
||||
href=format!("/applications/{}", app.id)>
|
||||
{app.name}
|
||||
</a>
|
||||
</td>
|
||||
<td class="col-count">{app.host_count}</td>
|
||||
<td class="col-actions">
|
||||
<button class="btn-danger" on:click=move |_| {
|
||||
pending_delete.set(Some(app_clone.clone()));
|
||||
}>
|
||||
"Delete"
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}).collect_view()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
}
|
||||
})}
|
||||
</Suspense>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
72
src/client/home.rs
Normal file
72
src/client/home.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
// client/home.rs — Dashboard home page
|
||||
//
|
||||
// Shows a quick-glance summary card for each main entity (Networks, Hosts,
|
||||
// Applications). Each card displays the total count and navigates to the
|
||||
// corresponding page on click.
|
||||
//
|
||||
// The counts are fetched via a single `get_summary()` server function call
|
||||
// so the page only makes one round-trip to the server.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use crate::api::summary::get_summary;
|
||||
|
||||
// A single summary card — count + label + link.
|
||||
//
|
||||
// `href` : navigation target when the card is clicked
|
||||
// `label` : entity name displayed on the card
|
||||
// `count` : the number to show, or None while loading
|
||||
#[component]
|
||||
fn SummaryCard(href: &'static str, label: &'static str, count: Signal<Option<i64>>) -> impl IntoView {
|
||||
view! {
|
||||
<a href=href class="summary-card">
|
||||
<span class="summary-card__count">
|
||||
{move || match count.get() {
|
||||
Some(n) => n.to_string(),
|
||||
None => "—".to_string(),
|
||||
}}
|
||||
</span>
|
||||
<span class="summary-card__label">{label}</span>
|
||||
</a>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn HomePage() -> impl IntoView {
|
||||
// Fetch all counts in a single server call.
|
||||
// Source `|| ()` is constant — the fetcher runs exactly once on mount.
|
||||
let summary = Resource::new(|| (), |_| get_summary());
|
||||
|
||||
view! {
|
||||
<div class="home-page">
|
||||
<header class="home-header">
|
||||
<h1>"Rust IPAM"</h1>
|
||||
<p>"IP Address Manager"</p>
|
||||
</header>
|
||||
|
||||
// `<Suspense>` lets the SSR server wait for the resource before sending
|
||||
// HTML, so the counts are already filled in on first page load.
|
||||
// The fallback is only shown in the browser while the WASM loads
|
||||
// (for users with slow connections).
|
||||
<Suspense fallback=|| view! {
|
||||
<div class="summary-grid">
|
||||
<div class="summary-card"><span class="summary-card__count">"—"</span><span class="summary-card__label">"Networks"</span></div>
|
||||
<div class="summary-card"><span class="summary-card__count">"—"</span><span class="summary-card__label">"Hosts"</span></div>
|
||||
<div class="summary-card"><span class="summary-card__count">"—"</span><span class="summary-card__label">"Applications"</span></div>
|
||||
</div>
|
||||
}>
|
||||
{move || summary.get().map(|result| match result {
|
||||
Err(e) => view! {
|
||||
<p class="error">"Could not load summary: " {e.to_string()}</p>
|
||||
}.into_any(),
|
||||
Ok(s) => view! {
|
||||
<div class="summary-grid">
|
||||
<SummaryCard href="/networks" label="Networks" count=Signal::derive(move || Some(s.network_count))/>
|
||||
<SummaryCard href="/hosts" label="Hosts" count=Signal::derive(move || Some(s.host_count))/>
|
||||
<SummaryCard href="/applications" label="Applications" count=Signal::derive(move || Some(s.application_count))/>
|
||||
</div>
|
||||
}.into_any(),
|
||||
})}
|
||||
</Suspense>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
585
src/client/host_detail.rs
Normal file
585
src/client/host_detail.rs
Normal file
@@ -0,0 +1,585 @@
|
||||
// client/host_detail.rs — Host detail page
|
||||
//
|
||||
// Shows all information for a single host:
|
||||
// - Identity form : name, IP, network dropdown — editable, saved with "Save changes"
|
||||
// - Ports section : full list with Remove per port + Add port input
|
||||
// - Applications : directly linked apps with Remove + modal multi-select to add
|
||||
// - Delete button : opens a confirmation modal, then navigates back to /hosts
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::hooks::{use_navigate, use_params_map, use_query_map};
|
||||
|
||||
use crate::api::{
|
||||
hosts::{
|
||||
AddHostApplications, AddHostPort, DeleteHost, RemoveHostApplication,
|
||||
RemoveHostPort, UpdateHost, get_applications_not_on_host, get_host_detail,
|
||||
},
|
||||
networks::get_networks,
|
||||
};
|
||||
use crate::models::Application;
|
||||
|
||||
// ─── Add applications modal ───────────────────────────────────────────────────
|
||||
|
||||
// Scrollable pick list + selected tags:
|
||||
// - Top: scrollable list of available apps; clicking one moves it to the
|
||||
// selected section and removes it from the list.
|
||||
// - Bottom: selected apps shown as removable tags; clicking × puts the app
|
||||
// back in the list.
|
||||
//
|
||||
// The auto-close Effect lives in the PARENT to avoid the re-trigger bug
|
||||
// (an Effect inside a conditionally-rendered component fires on mount and
|
||||
// would immediately close the modal if the action already held a past Ok value).
|
||||
#[component]
|
||||
fn AddAppModal(
|
||||
host_id: i64,
|
||||
available_apps_res: LocalResource<Result<Vec<Application>, ServerFnError>>,
|
||||
add_action: ServerAction<AddHostApplications>,
|
||||
show_modal: RwSignal<bool>,
|
||||
) -> impl IntoView {
|
||||
// Full Application structs so names are available in the selected tag list.
|
||||
let selected: RwSignal<Vec<Application>> = RwSignal::new(vec![]);
|
||||
|
||||
view! {
|
||||
<div class="modal-backdrop" on:click=move |_| show_modal.set(false)>
|
||||
<div class="modal" on:click=move |e| e.stop_propagation()>
|
||||
<div class="modal__header">
|
||||
<h2>"Add applications"</h2>
|
||||
<button class="modal__close" type="button" aria-label="Close"
|
||||
on:click=move |_| show_modal.set(false)>
|
||||
"×"
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal__body">
|
||||
|
||||
// ── Scrollable pick list ──────────────────────────────────
|
||||
{move || match available_apps_res.get() {
|
||||
None => view! { <p class="empty">"Loading…"</p> }.into_any(),
|
||||
Some(r) => match (*r).clone() {
|
||||
Err(e) => view! {
|
||||
<p class="error">
|
||||
"Could not load applications: " {e.to_string()}
|
||||
</p>
|
||||
}.into_any(),
|
||||
Ok(apps) => {
|
||||
// Exclude already-selected apps from the displayed list.
|
||||
let sel_ids: Vec<i64> = selected.get()
|
||||
.iter().map(|a| a.id).collect();
|
||||
let displayed: Vec<Application> = apps.into_iter()
|
||||
.filter(|a| !sel_ids.contains(&a.id))
|
||||
.collect();
|
||||
|
||||
if displayed.is_empty() && sel_ids.is_empty() {
|
||||
view! {
|
||||
<p class="empty">
|
||||
"All applications are already linked to this host."
|
||||
</p>
|
||||
}.into_any()
|
||||
} else if displayed.is_empty() {
|
||||
view! {
|
||||
<p class="empty">
|
||||
"All available applications have been selected."
|
||||
</p>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<ul class="app-pick-list">
|
||||
{displayed.into_iter().map(|app| {
|
||||
let app_clone = app.clone();
|
||||
view! {
|
||||
<li class="app-pick-item"
|
||||
on:click=move |_| {
|
||||
selected.update(|v| {
|
||||
v.push(app_clone.clone());
|
||||
});
|
||||
}
|
||||
>
|
||||
<span>{app.name}</span>
|
||||
<span class="app-pick-item__add">"+"</span>
|
||||
</li>
|
||||
}
|
||||
}).collect_view()}
|
||||
</ul>
|
||||
}.into_any()
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
// ── Selected tags (shown once at least one app is chosen) ─
|
||||
{move || (!selected.get().is_empty()).then(|| {
|
||||
let sel = selected.get();
|
||||
view! {
|
||||
<div class="app-selected-section">
|
||||
<span class="app-selected-label">"Selected:"</span>
|
||||
<div class="app-selected-list">
|
||||
{sel.into_iter().map(|app| {
|
||||
let app_id = app.id;
|
||||
view! {
|
||||
<span class="app-selected-tag">
|
||||
{app.name}
|
||||
<button
|
||||
class="app-selected-tag__remove"
|
||||
type="button"
|
||||
aria-label="Remove"
|
||||
on:click=move |_| {
|
||||
selected.update(|v| {
|
||||
v.retain(|x| x.id != app_id);
|
||||
});
|
||||
}
|
||||
>"×"</button>
|
||||
</span>
|
||||
}
|
||||
}).collect_view()}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
|
||||
{move || add_action.value().get()
|
||||
.and_then(|r| r.err())
|
||||
.map(|e| view! { <p class="error">{e.to_string()}</p> })
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="modal__actions">
|
||||
<button class="btn-secondary" type="button"
|
||||
on:click=move |_| show_modal.set(false)>
|
||||
"Cancel"
|
||||
</button>
|
||||
<button
|
||||
class="btn-primary"
|
||||
type="button"
|
||||
disabled={move || selected.get().is_empty()}
|
||||
on:click=move |_| {
|
||||
let ids_str = selected.get_untracked()
|
||||
.iter()
|
||||
.map(|a| a.id.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
if !ids_str.is_empty() {
|
||||
add_action.dispatch(AddHostApplications {
|
||||
host_id,
|
||||
application_ids: ids_str,
|
||||
});
|
||||
}
|
||||
}
|
||||
>
|
||||
"Add selected"
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
|
||||
// ─── Delete confirmation modal ────────────────────────────────────────────────
|
||||
|
||||
#[component]
|
||||
fn DeleteModal(
|
||||
host_name: String,
|
||||
delete_action: ServerAction<DeleteHost>,
|
||||
host_id: i64,
|
||||
show_modal: RwSignal<bool>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class="modal-backdrop" on:click=move |_| show_modal.set(false)>
|
||||
<div class="modal" on:click=move |e| e.stop_propagation()>
|
||||
<div class="modal__header">
|
||||
<h2>"Delete host"</h2>
|
||||
<button class="modal__close" type="button" aria-label="Close"
|
||||
on:click=move |_| show_modal.set(false)>
|
||||
"×"
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal__body">
|
||||
<p class="warning">
|
||||
"Are you sure you want to delete "
|
||||
<strong>{host_name}</strong>
|
||||
"? All port and application associations will also be removed."
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal__actions">
|
||||
<button class="btn-secondary" type="button"
|
||||
on:click=move |_| show_modal.set(false)>
|
||||
"Cancel"
|
||||
</button>
|
||||
<button class="btn-danger" type="button"
|
||||
on:click=move |_| { delete_action.dispatch(DeleteHost { id: host_id }); }>
|
||||
"Delete"
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
|
||||
// ─── Main page component ──────────────────────────────────────────────────────
|
||||
|
||||
#[component]
|
||||
pub fn HostDetailPage() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
let host_id = move || {
|
||||
params.read().get("id")
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
let query = use_query_map();
|
||||
let back_url = move || {
|
||||
query.read().get("back")
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "/hosts".to_string())
|
||||
};
|
||||
let back_label = move || {
|
||||
if back_url().starts_with("/networks/") { "← Network" }
|
||||
else if back_url().starts_with("/applications/") { "← Application" }
|
||||
else { "← Hosts" }
|
||||
};
|
||||
|
||||
let update_action = ServerAction::<UpdateHost>::new();
|
||||
let add_port_action = ServerAction::<AddHostPort>::new();
|
||||
let remove_port_action = ServerAction::<RemoveHostPort>::new();
|
||||
let add_app_action = ServerAction::<AddHostApplications>::new();
|
||||
let remove_app_action = ServerAction::<RemoveHostApplication>::new();
|
||||
let delete_action = ServerAction::<DeleteHost>::new();
|
||||
|
||||
let show_delete_modal = RwSignal::new(false);
|
||||
let show_add_app_modal = RwSignal::new(false);
|
||||
|
||||
// Auto-close the add-app modal when the action completes successfully.
|
||||
// Lives here (not inside AddAppModal) so it is never recreated across modal open/close cycles.
|
||||
Effect::new(move |was_pending: Option<bool>| {
|
||||
let is_pending = add_app_action.pending().get();
|
||||
if was_pending == Some(true) && !is_pending {
|
||||
if let Some(Ok(_)) = add_app_action.value().get() {
|
||||
show_add_app_modal.set(false);
|
||||
}
|
||||
}
|
||||
is_pending
|
||||
});
|
||||
|
||||
// LocalResource avoids reading the resource outside <Suspense> during hydration,
|
||||
// which would cause a mismatch between the SSR-rendered fallback and the content
|
||||
// the WASM expects to find after the resource resolves.
|
||||
let host = LocalResource::new(move || {
|
||||
let _ = update_action.version().get();
|
||||
let _ = add_port_action.version().get();
|
||||
let _ = remove_port_action.version().get();
|
||||
let _ = add_app_action.version().get();
|
||||
let _ = remove_app_action.version().get();
|
||||
get_host_detail(host_id())
|
||||
});
|
||||
|
||||
// Networks dropdown — LocalResource avoids SSR/hydration mismatch.
|
||||
let networks_res = LocalResource::new(|| get_networks());
|
||||
|
||||
// Available apps for the modal: re-fetched whenever add/remove completes.
|
||||
let add_app_ver = add_app_action.version();
|
||||
let remove_app_ver = remove_app_action.version();
|
||||
let available_apps_res = LocalResource::new(move || {
|
||||
let _ = add_app_ver.get();
|
||||
let _ = remove_app_ver.get();
|
||||
get_applications_not_on_host(host_id())
|
||||
});
|
||||
|
||||
let name_sig = RwSignal::new(String::new());
|
||||
let ip_sig = RwSignal::new(String::new());
|
||||
let net_id_sig = RwSignal::new(0i64);
|
||||
let new_port = RwSignal::new(String::new());
|
||||
|
||||
// Sync edit signals whenever fresh host data arrives.
|
||||
// LocalResource wraps its value in SendWrapper, so we dereference with `*r`.
|
||||
Effect::new(move |_| {
|
||||
if let Some(r) = host.get() {
|
||||
if let Ok(ref detail) = *r {
|
||||
name_sig.set(detail.name.clone());
|
||||
ip_sig.set(detail.ip.clone());
|
||||
net_id_sig.set(detail.network_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let navigate = use_navigate();
|
||||
Effect::new(move |_| {
|
||||
if let Some(Ok(true)) = delete_action.value().get() {
|
||||
navigate("/hosts", Default::default());
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="host-detail-page">
|
||||
// Modals live OUTSIDE <Suspense> so they are not unmounted when the
|
||||
// host resource re-fetches (which would kill their reactive subscriptions).
|
||||
{move || show_add_app_modal.get().then(|| view! {
|
||||
<AddAppModal
|
||||
host_id=host_id()
|
||||
available_apps_res=available_apps_res
|
||||
add_action=add_app_action
|
||||
show_modal=show_add_app_modal
|
||||
/>
|
||||
})}
|
||||
{move || show_delete_modal.get().then(|| view! {
|
||||
<DeleteModal
|
||||
host_name=name_sig.get()
|
||||
delete_action=delete_action
|
||||
host_id=host_id()
|
||||
show_modal=show_delete_modal
|
||||
/>
|
||||
})}
|
||||
|
||||
<Suspense fallback=|| view! { <p class="empty">"Loading host…"</p> }>
|
||||
{move || host.get().map(|r| match (*r).clone() {
|
||||
Err(e) => view! {
|
||||
<p class="error">"Could not load host: " {e.to_string()}</p>
|
||||
}.into_any(),
|
||||
|
||||
Ok(detail) => {
|
||||
let id = detail.id;
|
||||
let port_count = detail.ports.len();
|
||||
let app_count = detail.applications.len();
|
||||
let ports = detail.ports;
|
||||
let applications = detail.applications;
|
||||
|
||||
// Pre-built ports view — consumes `ports` once, not reactively.
|
||||
let ports_list = if ports.is_empty() {
|
||||
view! {
|
||||
<p class="empty">"No ports open on this host."</p>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<div class="port-list">
|
||||
{ports.into_iter().map(|port| {
|
||||
let num = port.number;
|
||||
view! {
|
||||
<div class="port-row">
|
||||
<span class="port-row__number">{num}</span>
|
||||
<span class="port-row__desc">
|
||||
{port.description.unwrap_or_default()}
|
||||
</span>
|
||||
<button
|
||||
class="btn-danger"
|
||||
type="button"
|
||||
on:click=move |_| {
|
||||
remove_port_action.dispatch(
|
||||
RemoveHostPort { host_id: id, port_number: num as i64 }
|
||||
);
|
||||
}
|
||||
>
|
||||
"Remove"
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}).collect_view()}
|
||||
</div>
|
||||
}.into_any()
|
||||
};
|
||||
|
||||
// Pre-built applications view.
|
||||
let apps_list = if applications.is_empty() {
|
||||
view! {
|
||||
<p class="empty">"No applications linked to this host."</p>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<div class="app-list">
|
||||
{applications.into_iter().map(|app| {
|
||||
let app_id = app.id;
|
||||
view! {
|
||||
<div class="app-row">
|
||||
<a class="table-link app-row__name"
|
||||
href=format!("/applications/{}?back=/hosts/{}", app_id, id)>
|
||||
{app.name}
|
||||
</a>
|
||||
<button
|
||||
class="btn-danger"
|
||||
type="button"
|
||||
on:click=move |_| {
|
||||
remove_app_action.dispatch(
|
||||
RemoveHostApplication { host_id: id, application_id: app_id }
|
||||
);
|
||||
}
|
||||
>
|
||||
"Remove"
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}).collect_view()}
|
||||
</div>
|
||||
}.into_any()
|
||||
};
|
||||
|
||||
view! {
|
||||
// ── Page header ──────────────────────────────────
|
||||
<div class="page-header detail-page-header">
|
||||
<a class="back-btn" href=move || back_url()>
|
||||
{move || back_label()}
|
||||
</a>
|
||||
<h1 class="detail-page-title">{move || name_sig.get()}</h1>
|
||||
</div>
|
||||
|
||||
// ── Identity form ─────────────────────────────────
|
||||
<section class="detail-section">
|
||||
<h2 class="detail-section__title">"Identity"</h2>
|
||||
<div class="detail-form">
|
||||
<label class="detail-field">
|
||||
"Name"
|
||||
<input
|
||||
type="text"
|
||||
prop:value=move || name_sig.get()
|
||||
on:input=move |e| name_sig.set(event_target_value(&e))
|
||||
/>
|
||||
</label>
|
||||
<label class="detail-field">
|
||||
"IP address"
|
||||
<input
|
||||
type="text"
|
||||
prop:value=move || ip_sig.get()
|
||||
on:input=move |e| ip_sig.set(event_target_value(&e))
|
||||
/>
|
||||
</label>
|
||||
<label class="detail-field">
|
||||
"Network"
|
||||
<select on:change=move |e| {
|
||||
net_id_sig.set(
|
||||
event_target_value(&e).parse().unwrap_or(0)
|
||||
);
|
||||
}>
|
||||
{move || networks_res.get()
|
||||
.and_then(|r| (*r).clone().ok())
|
||||
.map(|nets| {
|
||||
let current = net_id_sig.get();
|
||||
nets.into_iter().map(|n| {
|
||||
let label = format!("{} - {}", n.name, n.cidr);
|
||||
view! {
|
||||
<option
|
||||
value=n.id.to_string()
|
||||
selected=n.id == current
|
||||
>
|
||||
{label}
|
||||
</option>
|
||||
}
|
||||
}).collect_view()
|
||||
})
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{move || update_action.value().get()
|
||||
.and_then(|r| r.err())
|
||||
.map(|e| view! { <p class="error">{e.to_string()}</p> })
|
||||
}
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="btn-primary"
|
||||
type="button"
|
||||
on:click=move |_| {
|
||||
update_action.dispatch(UpdateHost {
|
||||
id,
|
||||
name: name_sig.get_untracked(),
|
||||
ip: ip_sig.get_untracked(),
|
||||
network_id: net_id_sig.get_untracked(),
|
||||
});
|
||||
}
|
||||
>
|
||||
"Save changes"
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
// ── Ports section ─────────────────────────────────
|
||||
<section class="detail-section">
|
||||
<h2 class="detail-section__title">
|
||||
{format!("Open ports ({})", port_count)}
|
||||
</h2>
|
||||
|
||||
{ports_list}
|
||||
|
||||
{move || remove_port_action.value().get()
|
||||
.and_then(|r| r.err())
|
||||
.map(|e| view! {
|
||||
<p class="error">"Remove failed: " {e.to_string()}</p>
|
||||
})
|
||||
}
|
||||
|
||||
<div class="port-add-row">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
placeholder="Port number (1–65535)"
|
||||
prop:value=move || new_port.get()
|
||||
on:input=move |e| new_port.set(event_target_value(&e))
|
||||
/>
|
||||
<button
|
||||
class="btn-primary"
|
||||
type="button"
|
||||
on:click=move |_| {
|
||||
let raw = new_port.get_untracked();
|
||||
if let Ok(n) = raw.trim().parse::<i64>() {
|
||||
if (1..=65535).contains(&n) {
|
||||
add_port_action.dispatch(AddHostPort {
|
||||
host_id: id,
|
||||
port_number: n,
|
||||
});
|
||||
new_port.set(String::new());
|
||||
}
|
||||
}
|
||||
}
|
||||
>
|
||||
"Add port"
|
||||
</button>
|
||||
{move || add_port_action.value().get()
|
||||
.and_then(|r| r.err())
|
||||
.map(|e| view! {
|
||||
<p class="error">"Add failed: " {e.to_string()}</p>
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
// ── Applications section ──────────────────────────
|
||||
<section class="detail-section">
|
||||
<h2 class="detail-section__title">
|
||||
{format!("Applications ({})", app_count)}
|
||||
</h2>
|
||||
|
||||
{apps_list}
|
||||
|
||||
{move || remove_app_action.value().get()
|
||||
.and_then(|r| r.err())
|
||||
.map(|e| view! {
|
||||
<p class="error">"Remove failed: " {e.to_string()}</p>
|
||||
})
|
||||
}
|
||||
|
||||
<div class="port-add-row">
|
||||
<button
|
||||
class="btn-primary"
|
||||
type="button"
|
||||
on:click=move |_| show_add_app_modal.set(true)
|
||||
>
|
||||
"+ Add applications"
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
// ── Danger zone ───────────────────────────────────
|
||||
<div class="danger-zone">
|
||||
<button
|
||||
class="btn-danger-solid"
|
||||
type="button"
|
||||
on:click=move |_| show_delete_modal.set(true)
|
||||
>
|
||||
"Delete host"
|
||||
</button>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
})}
|
||||
</Suspense>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
474
src/client/hosts.rs
Normal file
474
src/client/hosts.rs
Normal file
@@ -0,0 +1,474 @@
|
||||
// client/hosts.rs — Hosts list page
|
||||
//
|
||||
// Displays all hosts across every network with:
|
||||
// - Add button : opens a modal form to create a host inside a chosen network
|
||||
// - Filter bar : name (substring), network, open ports (CSV), application
|
||||
// - Table : name, IP, network, port count, application count, delete
|
||||
// - Pagination : configurable page size (15 / 25 / 50 / 100 / All)
|
||||
//
|
||||
// Sub-components call `.into_any()` on their views to erase the concrete
|
||||
// Leptos type, preventing the parent from accumulating a deeply-nested
|
||||
// generic type that overflows the compiler's query depth limit.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos::form::ActionForm;
|
||||
use leptos::html::Input;
|
||||
|
||||
use crate::api::{
|
||||
applications::get_applications,
|
||||
hosts::{CreateHost, DeleteHost, get_hosts_page, HostsPage as HostsPageData},
|
||||
networks::get_networks,
|
||||
};
|
||||
|
||||
const PER_PAGE_OPTIONS: &[(i64, &str)] = &[
|
||||
(15, "15"),
|
||||
(25, "25"),
|
||||
(50, "50"),
|
||||
(100, "100"),
|
||||
(0, "All"),
|
||||
];
|
||||
|
||||
// ─── Delete host modal ────────────────────────────────────────────────────────
|
||||
|
||||
#[component]
|
||||
fn DeleteHostModal(
|
||||
host_name: String,
|
||||
host_id: i64,
|
||||
delete_action: ServerAction<DeleteHost>,
|
||||
pending_delete: RwSignal<Option<(i64, String)>>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class="modal-backdrop" on:click=move |_| pending_delete.set(None)>
|
||||
<div class="modal" on:click=move |e| e.stop_propagation()>
|
||||
<div class="modal__header">
|
||||
<h2>"Delete host"</h2>
|
||||
<button class="modal__close" type="button" aria-label="Close"
|
||||
on:click=move |_| pending_delete.set(None)>
|
||||
"×"
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal__body">
|
||||
<p class="warning">
|
||||
"Are you sure you want to delete "
|
||||
<strong>{host_name}</strong>
|
||||
"? All port associations will also be removed."
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal__actions">
|
||||
<button class="btn-secondary" type="button"
|
||||
on:click=move |_| pending_delete.set(None)>
|
||||
"Cancel"
|
||||
</button>
|
||||
<button class="btn-danger" type="button"
|
||||
on:click=move |_| { delete_action.dispatch(DeleteHost { id: host_id }); }>
|
||||
"Delete"
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
|
||||
// ─── Add host modal ───────────────────────────────────────────────────────────
|
||||
|
||||
#[component]
|
||||
fn AddHostModal(
|
||||
create_action: ServerAction<CreateHost>,
|
||||
networks_res: LocalResource<Result<Vec<crate::models::Network>, ServerFnError>>,
|
||||
show_modal: RwSignal<bool>,
|
||||
) -> impl IntoView {
|
||||
use leptos::task::spawn_local;
|
||||
|
||||
let name_ref = NodeRef::<Input>::new();
|
||||
|
||||
spawn_local(async move {
|
||||
if let Some(el) = name_ref.get_untracked() {
|
||||
let _ = el.focus();
|
||||
}
|
||||
});
|
||||
|
||||
let close = move || spawn_local(async move { show_modal.set(false) });
|
||||
|
||||
view! {
|
||||
<div class="modal-backdrop" on:click=move |_| close()>
|
||||
<div class="modal" on:click=move |e| e.stop_propagation()>
|
||||
<div class="modal__header">
|
||||
<h2>"Add a host"</h2>
|
||||
<button
|
||||
class="modal__close"
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
on:click=move |_| close()
|
||||
>"×"</button>
|
||||
</div>
|
||||
|
||||
<ActionForm action=create_action>
|
||||
<div class="add-form__fields">
|
||||
<label>
|
||||
"Name"
|
||||
<input node_ref=name_ref type="text" name="name" placeholder="e.g. web-server-01" required/>
|
||||
</label>
|
||||
<label>
|
||||
"IP address"
|
||||
<input type="text" name="ip" placeholder="e.g. 192.168.1.10" required/>
|
||||
</label>
|
||||
<label>
|
||||
"Network"
|
||||
<select name="network_id" required>
|
||||
<option value="">"— choose —"</option>
|
||||
{move || networks_res.get()
|
||||
.and_then(|r| (*r).clone().ok())
|
||||
.map(|nets| nets.into_iter().map(|n| {
|
||||
let label = format!("{} - {}", n.name, n.cidr);
|
||||
view! { <option value=n.id.to_string()>{label}</option> }
|
||||
}).collect_view())
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
"Open ports"
|
||||
<input
|
||||
type="text"
|
||||
name="ports"
|
||||
placeholder="e.g. 22, 80, 443"
|
||||
/>
|
||||
<span class="field-hint">"Comma-separated port numbers"</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="modal__actions">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
type="button"
|
||||
on:click=move |_| close()
|
||||
>"Cancel"</button>
|
||||
<button type="submit">"Add host"</button>
|
||||
</div>
|
||||
</ActionForm>
|
||||
|
||||
{move || create_action.value().get()
|
||||
.and_then(|r| r.err())
|
||||
.map(|e| view! { <p class="error">{e.to_string()}</p> })
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
|
||||
// ─── Filter bar ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[component]
|
||||
fn FilterBar(
|
||||
networks_res: LocalResource<Result<Vec<crate::models::Network>, ServerFnError>>,
|
||||
applications_res: LocalResource<Result<Vec<crate::models::Application>, ServerFnError>>,
|
||||
name_filter: RwSignal<String>,
|
||||
network_id_filter: RwSignal<i64>,
|
||||
port_filter: RwSignal<String>,
|
||||
app_id_filter: RwSignal<i64>,
|
||||
page: RwSignal<i64>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<section class="filter-bar">
|
||||
<div class="filter-bar__fields">
|
||||
<label class="filter-field">
|
||||
"Name"
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search…"
|
||||
on:change=move |e| {
|
||||
name_filter.set(event_target_value(&e));
|
||||
page.set(1);
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="filter-field">
|
||||
"Network"
|
||||
<select on:change=move |e| {
|
||||
network_id_filter.set(event_target_value(&e).parse().unwrap_or(0));
|
||||
page.set(1);
|
||||
}>
|
||||
<option value="0">"All networks"</option>
|
||||
{move || networks_res.get()
|
||||
.and_then(|r| (*r).clone().ok())
|
||||
.map(|nets| nets.into_iter().map(|n| {
|
||||
let label = format!("{} - {}", n.name, n.cidr);
|
||||
view! { <option value=n.id.to_string()>{label}</option> }
|
||||
}).collect_view())
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="filter-field">
|
||||
"Open ports"
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 80, 443 (all required)"
|
||||
on:change=move |e| {
|
||||
port_filter.set(event_target_value(&e));
|
||||
page.set(1);
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="filter-field">
|
||||
"Application"
|
||||
<select on:change=move |e| {
|
||||
app_id_filter.set(event_target_value(&e).parse().unwrap_or(0));
|
||||
page.set(1);
|
||||
}>
|
||||
<option value="0">"All applications"</option>
|
||||
{move || applications_res.get()
|
||||
.and_then(|r| (*r).clone().ok())
|
||||
.map(|apps| apps.into_iter().map(|a| {
|
||||
view! { <option value=a.id.to_string()>{a.name}</option> }
|
||||
}).collect_view())
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
}.into_any()
|
||||
}
|
||||
|
||||
// ─── Pagination bar ───────────────────────────────────────────────────────────
|
||||
|
||||
#[component]
|
||||
fn PaginationBar(
|
||||
total: Signal<i64>,
|
||||
page: RwSignal<i64>,
|
||||
per_page: RwSignal<i64>,
|
||||
total_pages: Signal<i64>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class="pagination-bar">
|
||||
<div class="pagination-bar__info">
|
||||
{move || {
|
||||
let t = total.get();
|
||||
if t == 0 { "No hosts found".to_string() }
|
||||
else { format!("{} host{}", t, if t == 1 { "" } else { "s" }) }
|
||||
}}
|
||||
</div>
|
||||
<div class="pagination-bar__controls">
|
||||
<label class="pagination-per-page">
|
||||
"Per page "
|
||||
<select on:change=move |e| {
|
||||
per_page.set(event_target_value(&e).parse().unwrap_or(15));
|
||||
page.set(1);
|
||||
}>
|
||||
{PER_PAGE_OPTIONS.iter().map(|(value, label)| {
|
||||
view! {
|
||||
<option value=value.to_string() selected=*value == 15>
|
||||
{*label}
|
||||
</option>
|
||||
}
|
||||
}).collect_view()}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{move || (per_page.get() > 0).then(|| view! {
|
||||
<div class="pagination-nav">
|
||||
<button
|
||||
disabled=move || page.get() <= 1
|
||||
on:click=move |_| page.update(|p| *p = (*p - 1).max(1))
|
||||
>"‹"</button>
|
||||
<span class="pagination-nav__label">
|
||||
{move || format!("Page {} of {}", page.get(), total_pages.get().max(1))}
|
||||
</span>
|
||||
<button
|
||||
disabled={move || page.get() >= total_pages.get()}
|
||||
on:click=move |_| {
|
||||
let max = total_pages.get_untracked();
|
||||
page.update(|p| *p = (*p + 1).min(max));
|
||||
}
|
||||
>"›"</button>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
|
||||
// ─── Host table ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[component]
|
||||
fn HostTable(
|
||||
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(|r| match (*r).clone() {
|
||||
Err(e) => view! {
|
||||
<p class="error">"Could not load hosts: " {e.to_string()}</p>
|
||||
}.into_any(),
|
||||
|
||||
Ok(HostsPageData { rows, .. }) if rows.is_empty() => view! {
|
||||
<p class="empty">"No hosts match the current filters."</p>
|
||||
}.into_any(),
|
||||
|
||||
Ok(HostsPageData { rows, .. }) => view! {
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>"Name"</th>
|
||||
<th>"IP"</th>
|
||||
<th>"Network"</th>
|
||||
<th class="col-count">"Ports"</th>
|
||||
<th class="col-count">"Applications"</th>
|
||||
<th class="col-actions">"Actions"</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.into_iter().map(|host| {
|
||||
let id = host.id;
|
||||
let delete_name = host.name.clone();
|
||||
view! {
|
||||
<tr>
|
||||
<td>
|
||||
<a class="table-link" href=format!("/hosts/{id}")>
|
||||
{host.name}
|
||||
</a>
|
||||
</td>
|
||||
<td class="cell-mono">{host.ip}</td>
|
||||
<td>
|
||||
<a class="table-link" href=format!("/networks/{}", host.network_id)>
|
||||
{host.network_cidr}
|
||||
</a>
|
||||
</td>
|
||||
<td class="col-count">{host.port_count}</td>
|
||||
<td class="col-count">{host.application_count}</td>
|
||||
<td class="col-actions">
|
||||
<button on:click=move |_| {
|
||||
pending_delete.set(Some((id, delete_name.clone())));
|
||||
}>
|
||||
"Delete"
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}).collect_view()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}.into_any(),
|
||||
})}
|
||||
</Suspense>
|
||||
}.into_any()
|
||||
}
|
||||
|
||||
// ─── Main page component ──────────────────────────────────────────────────────
|
||||
|
||||
#[component]
|
||||
pub fn HostsPage() -> impl IntoView {
|
||||
let create_action = ServerAction::<CreateHost>::new();
|
||||
let delete_action = ServerAction::<DeleteHost>::new();
|
||||
|
||||
let show_modal = RwSignal::new(false);
|
||||
|
||||
// None = no modal, Some((id, name)) = delete confirmation open.
|
||||
let pending_delete: RwSignal<Option<(i64, String)>> = RwSignal::new(None);
|
||||
|
||||
// Close the add modal on pending→done with Ok (lives in parent to avoid stale-value re-trigger).
|
||||
Effect::new(move |was_pending: Option<bool>| {
|
||||
let is_pending = create_action.pending().get();
|
||||
if was_pending == Some(true) && !is_pending {
|
||||
if let Some(Ok(_)) = create_action.value().get() {
|
||||
show_modal.set(false);
|
||||
}
|
||||
}
|
||||
is_pending
|
||||
});
|
||||
|
||||
// Close the delete modal automatically after a successful deletion.
|
||||
Effect::new(move |_| {
|
||||
if let Some(Ok(_)) = delete_action.value().get() {
|
||||
pending_delete.set(None);
|
||||
}
|
||||
});
|
||||
|
||||
// Filter signals ("" / 0 = no filter)
|
||||
let name_filter = RwSignal::new(String::new());
|
||||
let network_id_filter = RwSignal::new(0i64);
|
||||
let port_filter = RwSignal::new(String::new()); // CSV of port numbers
|
||||
let app_id_filter = RwSignal::new(0i64);
|
||||
|
||||
let page = RwSignal::new(1i64);
|
||||
let per_page = RwSignal::new(15i64);
|
||||
|
||||
// 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(),
|
||||
)
|
||||
});
|
||||
|
||||
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).clone().ok()).map(|p| p.total_pages).unwrap_or(1)
|
||||
});
|
||||
let total = Signal::derive(move || {
|
||||
hosts.get().and_then(|r| (*r).clone().ok()).map(|p| p.total).unwrap_or(0)
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="hosts-page">
|
||||
<div class="page-header">
|
||||
<h1>"Hosts"</h1>
|
||||
<button class="btn-primary" on:click=move |_| show_modal.set(true)>
|
||||
"+ Add host"
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{move || show_modal.get().then(|| view! {
|
||||
<AddHostModal
|
||||
create_action=create_action
|
||||
networks_res=networks_res
|
||||
show_modal=show_modal
|
||||
/>
|
||||
})}
|
||||
|
||||
{move || pending_delete.get().map(|(host_id, host_name)| view! {
|
||||
<DeleteHostModal
|
||||
host_name=host_name
|
||||
host_id=host_id
|
||||
delete_action=delete_action
|
||||
pending_delete=pending_delete
|
||||
/>
|
||||
})}
|
||||
|
||||
<FilterBar
|
||||
networks_res=networks_res
|
||||
applications_res=applications_res
|
||||
name_filter=name_filter
|
||||
network_id_filter=network_id_filter
|
||||
port_filter=port_filter
|
||||
app_id_filter=app_id_filter
|
||||
page=page
|
||||
/>
|
||||
|
||||
<section class="list">
|
||||
{move || delete_action.value().get()
|
||||
.and_then(|r| r.err())
|
||||
.map(|e| view! { <p class="error">"Delete failed: " {e.to_string()}</p> })
|
||||
}
|
||||
|
||||
<PaginationBar total=total page=page per_page=per_page total_pages=total_pages/>
|
||||
|
||||
<HostTable hosts=hosts pending_delete=pending_delete/>
|
||||
</section>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
19
src/client/mod.rs
Normal file
19
src/client/mod.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
// client/mod.rs — Client UI module
|
||||
//
|
||||
// Contains Leptos pages and components.
|
||||
//
|
||||
// Despite the name "client", this code runs on BOTH sides:
|
||||
// - Server-side : to generate the initial HTML (SSR)
|
||||
// - Browser : compiled to WASM to make the interface interactive
|
||||
//
|
||||
// Do not place code here that requires browser-only APIs (window, document...)
|
||||
// without guarding it with `#[cfg(target_arch = "wasm32")]`.
|
||||
|
||||
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)
|
||||
200
src/client/network_detail.rs
Normal file
200
src/client/network_detail.rs
Normal file
@@ -0,0 +1,200 @@
|
||||
// client/network_detail.rs — Network detail page
|
||||
//
|
||||
// Displays a single network (name + CIDR) with a paginated list of its hosts.
|
||||
// Each host name links to /hosts/:id?back=/networks/:network_id so that the
|
||||
// host detail page can offer a contextual "back to network" button.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::hooks::use_params_map;
|
||||
|
||||
use crate::api::{
|
||||
hosts::{get_hosts_page, HostsPage as HostsPageData},
|
||||
networks::get_network,
|
||||
};
|
||||
|
||||
const PER_PAGE_OPTIONS: &[(i64, &str)] = &[
|
||||
(15, "15"),
|
||||
(25, "25"),
|
||||
(50, "50"),
|
||||
(100, "100"),
|
||||
(0, "All"),
|
||||
];
|
||||
|
||||
// ─── Main page component ──────────────────────────────────────────────────────
|
||||
|
||||
#[component]
|
||||
pub fn NetworkDetailPage() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
let network_id = move || {
|
||||
params.read().get("id")
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
let page = RwSignal::new(1i64);
|
||||
let per_page = RwSignal::new(15i64);
|
||||
|
||||
// Network metadata — reloads only when the ID changes.
|
||||
let network = Resource::new(
|
||||
move || network_id(),
|
||||
|id| get_network(id),
|
||||
);
|
||||
|
||||
// Paginated host list for this network.
|
||||
// Guards against network_id = 0 to avoid fetching all hosts.
|
||||
let hosts = Resource::new(
|
||||
move || (network_id(), page.get(), per_page.get()),
|
||||
|(net_id, p, pp)| async move {
|
||||
if net_id == 0 {
|
||||
return Err(ServerFnError::new("Invalid network ID"));
|
||||
}
|
||||
get_hosts_page(String::new(), net_id, String::new(), 0, p, pp).await
|
||||
},
|
||||
);
|
||||
|
||||
let total_pages = Signal::derive(move || {
|
||||
hosts.get().and_then(|r| r.ok()).map(|d| d.total_pages).unwrap_or(1)
|
||||
});
|
||||
let total = Signal::derive(move || {
|
||||
hosts.get().and_then(|r| r.ok()).map(|d| d.total).unwrap_or(0)
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="network-detail-page">
|
||||
<Suspense fallback=|| view! { <p class="empty">"Loading network…"</p> }>
|
||||
{move || network.get().map(|result| match result {
|
||||
Err(e) => view! {
|
||||
<p class="error">"Could not load network: " {e.to_string()}</p>
|
||||
}.into_any(),
|
||||
|
||||
Ok(net) => {
|
||||
let net_id = net.id;
|
||||
view! {
|
||||
// ── Header ────────────────────────────────────────
|
||||
<div class="page-header detail-page-header">
|
||||
<a class="back-btn" href="/networks">"← Networks"</a>
|
||||
<h1 class="detail-page-title">{net.name}</h1>
|
||||
<p class="network-detail-cidr">{net.cidr}</p>
|
||||
</div>
|
||||
|
||||
// ── Hosts section ────────────────────────────────
|
||||
<section class="detail-section">
|
||||
<h2 class="detail-section__title">"Hosts"</h2>
|
||||
|
||||
// Pagination bar
|
||||
<div class="pagination-bar">
|
||||
<div class="pagination-bar__info">
|
||||
{move || {
|
||||
let t = total.get();
|
||||
if t == 0 { "No hosts".to_string() }
|
||||
else { format!("{} host{}", t, if t == 1 { "" } else { "s" }) }
|
||||
}}
|
||||
</div>
|
||||
<div class="pagination-bar__controls">
|
||||
<label class="pagination-per-page">
|
||||
"Per page "
|
||||
<select on:change=move |e| {
|
||||
per_page.set(
|
||||
event_target_value(&e).parse().unwrap_or(15)
|
||||
);
|
||||
page.set(1);
|
||||
}>
|
||||
{PER_PAGE_OPTIONS.iter().map(|(v, label)| {
|
||||
view! {
|
||||
<option value=v.to_string() selected=*v == 15>
|
||||
{*label}
|
||||
</option>
|
||||
}
|
||||
}).collect_view()}
|
||||
</select>
|
||||
</label>
|
||||
{move || (per_page.get() > 0).then(|| view! {
|
||||
<div class="pagination-nav">
|
||||
<button
|
||||
disabled=move || page.get() <= 1
|
||||
on:click=move |_| page.update(|p| *p = (*p - 1).max(1))
|
||||
>"‹"</button>
|
||||
<span class="pagination-nav__label">
|
||||
{move || format!(
|
||||
"Page {} of {}",
|
||||
page.get(),
|
||||
total_pages.get().max(1)
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
disabled={move || page.get() >= total_pages.get()}
|
||||
on:click=move |_| {
|
||||
let max = total_pages.get_untracked();
|
||||
page.update(|p| *p = (*p + 1).min(max));
|
||||
}
|
||||
>"›"</button>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Host table
|
||||
<Suspense fallback=|| view! {
|
||||
<p class="empty">"Loading hosts…"</p>
|
||||
}>
|
||||
{move || hosts.get().map(|result| match result {
|
||||
Err(e) => view! {
|
||||
<p class="error">
|
||||
"Could not load hosts: " {e.to_string()}
|
||||
</p>
|
||||
}.into_any(),
|
||||
|
||||
Ok(HostsPageData { rows, .. }) if rows.is_empty() => view! {
|
||||
<p class="empty">"No hosts in this network."</p>
|
||||
}.into_any(),
|
||||
|
||||
Ok(HostsPageData { rows, .. }) => view! {
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>"Name"</th>
|
||||
<th>"IP"</th>
|
||||
<th class="col-count">"Ports"</th>
|
||||
<th class="col-count">"Apps"</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.into_iter().map(|host| {
|
||||
// Pass the current network as the back destination
|
||||
// so the host detail page can link back here.
|
||||
let href = format!(
|
||||
"/hosts/{}?back=/networks/{}",
|
||||
host.id, net_id
|
||||
);
|
||||
view! {
|
||||
<tr>
|
||||
<td>
|
||||
<a class="table-link" href=href>
|
||||
{host.name}
|
||||
</a>
|
||||
</td>
|
||||
<td class="cell-mono">{host.ip}</td>
|
||||
<td class="col-count">
|
||||
{host.port_count}
|
||||
</td>
|
||||
<td class="col-count">
|
||||
{host.application_count}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}).collect_view()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}.into_any(),
|
||||
})}
|
||||
</Suspense>
|
||||
</section>
|
||||
}.into_any()
|
||||
}
|
||||
})}
|
||||
</Suspense>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
203
src/client/networks.rs
Normal file
203
src/client/networks.rs
Normal file
@@ -0,0 +1,203 @@
|
||||
// client/networks.rs — Networks page
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos::form::ActionForm;
|
||||
|
||||
use crate::api::networks::{CreateNetwork, DeleteNetwork, NetworkWithCounts, get_networks_with_counts};
|
||||
|
||||
// ─── Delete confirmation modal ────────────────────────────────────────────────
|
||||
|
||||
#[component]
|
||||
fn DeleteConfirmModal(
|
||||
network: NetworkWithCounts,
|
||||
delete_action: ServerAction<DeleteNetwork>,
|
||||
pending_delete: RwSignal<Option<NetworkWithCounts>>,
|
||||
) -> impl IntoView {
|
||||
let id = network.id;
|
||||
let label = format!("{} ({})", network.name, network.cidr);
|
||||
let host_count = network.host_count;
|
||||
|
||||
view! {
|
||||
<div class="modal-backdrop" on:click=move |_| pending_delete.set(None)>
|
||||
<div class="modal" on:click=move |e| e.stop_propagation()>
|
||||
<div class="modal__header">
|
||||
<h2>"Delete network"</h2>
|
||||
</div>
|
||||
|
||||
<div class="modal__body">
|
||||
<p>"Delete network " <strong>{label}</strong> "?"</p>
|
||||
{(host_count > 0).then(|| view! {
|
||||
<p class="warning">
|
||||
"Warning: "
|
||||
{host_count}
|
||||
{if host_count == 1 { " host" } else { " hosts" }}
|
||||
" belonging to this network will also be deleted."
|
||||
</p>
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div class="modal__actions">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
type="button"
|
||||
on:click=move |_| pending_delete.set(None)
|
||||
>"Cancel"</button>
|
||||
<button
|
||||
class="btn-danger"
|
||||
type="button"
|
||||
on:click=move |_| {
|
||||
delete_action.dispatch(DeleteNetwork { id });
|
||||
}
|
||||
>"Delete"</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[component]
|
||||
pub fn NetworksPage() -> impl IntoView {
|
||||
let create_action = ServerAction::<CreateNetwork>::new();
|
||||
let delete_action = ServerAction::<DeleteNetwork>::new();
|
||||
|
||||
// Stores the network pending deletion; Some = modal open, None = closed.
|
||||
let pending_delete: RwSignal<Option<NetworkWithCounts>> = RwSignal::new(None);
|
||||
|
||||
// Close the modal automatically after a successful deletion.
|
||||
Effect::new(move |_| {
|
||||
if let Some(Ok(_)) = delete_action.value().get() {
|
||||
pending_delete.set(None);
|
||||
}
|
||||
});
|
||||
|
||||
let networks = Resource::new(
|
||||
move || (create_action.version().get(), delete_action.version().get()),
|
||||
|_| get_networks_with_counts(),
|
||||
);
|
||||
|
||||
view! {
|
||||
<div class="networks-page">
|
||||
<h1>"Networks"</h1>
|
||||
|
||||
// ── Delete confirmation modal ──────────────────────────────────────
|
||||
{move || pending_delete.get().map(|network| view! {
|
||||
<DeleteConfirmModal
|
||||
network=network
|
||||
delete_action=delete_action
|
||||
pending_delete=pending_delete
|
||||
/>
|
||||
})}
|
||||
|
||||
// ── Add form ──────────────────────────────────────────────────────
|
||||
<section class="add-form">
|
||||
<h2>"Add a network"</h2>
|
||||
<ActionForm action=create_action>
|
||||
<label>
|
||||
"Name"
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="e.g. LAN, DMZ, VPN"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
"CIDR block"
|
||||
<input
|
||||
type="text"
|
||||
name="cidr"
|
||||
placeholder="e.g. 192.168.1.0/24"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit">"Add"</button>
|
||||
</ActionForm>
|
||||
|
||||
{move || {
|
||||
create_action
|
||||
.value()
|
||||
.get()
|
||||
.and_then(|r| r.err())
|
||||
.map(|e| view! { <p class="error">{e.to_string()}</p> })
|
||||
}}
|
||||
</section>
|
||||
|
||||
// ── Network list ──────────────────────────────────────────────────
|
||||
<section class="list">
|
||||
<h2>"All networks"</h2>
|
||||
|
||||
{move || {
|
||||
delete_action
|
||||
.value()
|
||||
.get()
|
||||
.and_then(|r| r.err())
|
||||
.map(|e| view! { <p class="error">"Delete failed: " {e.to_string()}</p> })
|
||||
}}
|
||||
|
||||
<Suspense fallback=|| view! { <p>"Loading networks…"</p> }>
|
||||
{move || {
|
||||
networks.get().map(|result| match result {
|
||||
Err(e) => view! {
|
||||
<p class="error">"Could not load networks: " {e.to_string()}</p>
|
||||
}
|
||||
.into_any(),
|
||||
|
||||
Ok(list) if list.is_empty() => view! {
|
||||
<p class="empty">"No networks yet. Add one above."</p>
|
||||
}
|
||||
.into_any(),
|
||||
|
||||
Ok(list) => view! {
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>"Name"</th>
|
||||
<th>"CIDR"</th>
|
||||
<th class="col-count">"Hosts"</th>
|
||||
<th class="col-count">"Applications"</th>
|
||||
<th class="col-actions">"Actions"</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list
|
||||
.into_iter()
|
||||
.map(|network| {
|
||||
let network_clone = network.clone();
|
||||
let net_id = network.id;
|
||||
view! {
|
||||
<tr>
|
||||
<td>
|
||||
<a class="table-link"
|
||||
href=format!("/networks/{}", net_id)>
|
||||
{network.name}
|
||||
</a>
|
||||
</td>
|
||||
<td class="cell-mono">{network.cidr}</td>
|
||||
<td class="col-count">{network.host_count}</td>
|
||||
<td class="col-count">{network.application_count}</td>
|
||||
<td class="col-actions">
|
||||
<button on:click=move |_| {
|
||||
pending_delete.set(Some(network_clone.clone()));
|
||||
}>
|
||||
"Delete"
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
})
|
||||
.collect_view()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
.into_any(),
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
138
src/client/theme.rs
Normal file
138
src/client/theme.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
// client/theme.rs — Theme toggle component
|
||||
//
|
||||
// Architecture for multi-theme support:
|
||||
// - ThemeChoice enum: each variant maps to a `data-theme` attribute value
|
||||
// (None for "System" = follow OS preference via CSS media query)
|
||||
// - Adding a future theme: add a variant here + a CSS [data-theme="..."] block
|
||||
// - The active theme is persisted in localStorage under "ipam-theme"
|
||||
// - When no value is stored, the CSS media query `prefers-color-scheme` decides
|
||||
//
|
||||
// SSR vs WASM:
|
||||
// - SSR renders the button with no data-theme on <html> (system default applies)
|
||||
// - On hydration, the WASM Effect reads localStorage and applies the stored choice
|
||||
// - The `#[cfg(target_arch = "wasm32")]` guards prevent DOM/localStorage calls
|
||||
// from being compiled into the server binary
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
// Used only in WASM builds — suppress false-positive dead_code warnings from SSR
|
||||
#[allow(dead_code)]
|
||||
const STORAGE_KEY: &str = "ipam-theme";
|
||||
|
||||
// Each variant corresponds to a CSS `[data-theme]` value (None = follow OS).
|
||||
// To add a theme: add a variant, implement the methods, add CSS variables.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, PartialEq, Debug, Default)]
|
||||
pub enum ThemeChoice {
|
||||
#[default]
|
||||
System, // No data-theme attribute; CSS prefers-color-scheme decides
|
||||
Light,
|
||||
Dark,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ThemeChoice {
|
||||
// The attribute value written to <html data-theme="...">, or None to remove it.
|
||||
fn attr_value(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::System => None,
|
||||
Self::Light => Some("light"),
|
||||
Self::Dark => Some("dark"),
|
||||
}
|
||||
}
|
||||
|
||||
// Label shown in the toggle button.
|
||||
fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "Auto",
|
||||
Self::Light => "Light",
|
||||
Self::Dark => "Dark",
|
||||
}
|
||||
}
|
||||
|
||||
// Cycles to the next theme. Extend this as new variants are added.
|
||||
fn next(&self) -> Self {
|
||||
match self {
|
||||
Self::System => Self::Light,
|
||||
Self::Light => Self::Dark,
|
||||
Self::Dark => Self::System,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_stored(s: &str) -> Self {
|
||||
match s {
|
||||
"light" => Self::Light,
|
||||
"dark" => Self::Dark,
|
||||
_ => Self::System,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DOM helpers (WASM only) ─────────────────────────────────────────────────
|
||||
|
||||
// Reads the stored theme name from localStorage.
|
||||
// Guard on `hydrate` feature rather than `target_arch` because web-sys is
|
||||
// only activated by that feature in Cargo.toml.
|
||||
#[cfg(feature = "hydrate")]
|
||||
fn load_stored_theme() -> Option<ThemeChoice> {
|
||||
let storage = web_sys::window()?.local_storage().ok()??;
|
||||
let value = storage.get_item(STORAGE_KEY).ok()??;
|
||||
Some(ThemeChoice::from_stored(&value))
|
||||
}
|
||||
|
||||
// Applies `data-theme` attribute to <html> and persists to localStorage.
|
||||
#[cfg(feature = "hydrate")]
|
||||
fn apply_and_persist(choice: &ThemeChoice) {
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
let Some(document) = window.document() else { return };
|
||||
let Some(root) = document.document_element() else { return };
|
||||
|
||||
match choice.attr_value() {
|
||||
Some(v) => { let _ = root.set_attribute("data-theme", v); }
|
||||
None => { let _ = root.remove_attribute("data-theme"); }
|
||||
}
|
||||
|
||||
if let Ok(Some(storage)) = window.local_storage() {
|
||||
match choice.attr_value() {
|
||||
Some(v) => { let _ = storage.set_item(STORAGE_KEY, v); }
|
||||
None => { let _ = storage.remove_item(STORAGE_KEY); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[component]
|
||||
pub fn ThemeToggle() -> impl IntoView {
|
||||
let theme = RwSignal::new(ThemeChoice::System);
|
||||
|
||||
// Effect 1: runs once on mount — reads localStorage and initializes the signal.
|
||||
// Does NOT track `theme`, so it never re-runs after the initial mount.
|
||||
// Setting the signal here triggers Effect 2 below.
|
||||
Effect::new(move |_| {
|
||||
#[cfg(feature = "hydrate")]
|
||||
if let Some(stored) = load_stored_theme() {
|
||||
theme.set(stored);
|
||||
}
|
||||
});
|
||||
|
||||
// Effect 2: tracks `theme` — applies the choice to the DOM and localStorage
|
||||
// whenever the signal changes (both on init and after user clicks).
|
||||
Effect::new(move |_| {
|
||||
let current = theme.get(); // tracked — re-runs when theme changes
|
||||
#[cfg(feature = "hydrate")]
|
||||
apply_and_persist(¤t);
|
||||
// Suppress unused variable warning when compiling for SSR
|
||||
let _ = current;
|
||||
});
|
||||
|
||||
view! {
|
||||
<button
|
||||
class="theme-toggle"
|
||||
title="Toggle color theme"
|
||||
on:click=move |_| theme.update(|t| *t = t.next())
|
||||
>
|
||||
{move || theme.get().label()}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
46
src/lib.rs
Normal file
46
src/lib.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
// Leptos view! macros generate deeply-nested generic types; the default
|
||||
// limit of 128 is not enough for pages with many components.
|
||||
#![recursion_limit = "512"]
|
||||
|
||||
// lib.rs — Shared library root
|
||||
//
|
||||
// This file is compiled in BOTH modes:
|
||||
// "ssr" → used by the Axum server to render HTML
|
||||
// "hydrate" → compiled by trunk into WebAssembly for the browser
|
||||
//
|
||||
// This code sharing is what makes Leptos "full-stack":
|
||||
// components are written once and run on both sides.
|
||||
|
||||
// Declare the sub-modules of this library.
|
||||
// `pub` makes them accessible from main.rs and other crates.
|
||||
pub mod api; // Leptos server functions — the HTTP API between client and server
|
||||
pub mod app; // Root App() component and router configuration
|
||||
pub mod client; // UI pages and Leptos components
|
||||
pub mod models; // Shared data structs: Network, Host, Port, Application
|
||||
pub mod server; // HTTP handlers and server-side business logic
|
||||
|
||||
// WebAssembly entry point — called by the browser when the .wasm bundle loads.
|
||||
//
|
||||
// `#[cfg(feature = "hydrate")]` : this code only exists in the WASM bundle.
|
||||
// `#[wasm_bindgen(start)]` : instructs wasm-bindgen to call this function
|
||||
// automatically, without any JavaScript glue.
|
||||
#[cfg(feature = "hydrate")]
|
||||
#[wasm_bindgen::prelude::wasm_bindgen(start)]
|
||||
pub fn hydrate() {
|
||||
use crate::app::App;
|
||||
|
||||
// Enable Rust panic messages in the browser console.
|
||||
// Without this, a Rust panic in WASM only shows "unreachable executed" — useless.
|
||||
// `set_once()` ensures the hook is registered at most once.
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
// Mount the Leptos application into the <body> of the HTML page.
|
||||
//
|
||||
// In "hydration" mode (SSR + WASM), Leptos does not rebuild the DOM from scratch.
|
||||
// It finds the HTML already rendered by the server and attaches event listeners
|
||||
// to make the interface interactive. This is faster than a classic SPA
|
||||
// that builds the entire DOM on the client side.
|
||||
//
|
||||
// `hydrate_body` (Leptos 0.7) = SSR + hydration mode (≠ `mount_to_body` which starts fresh)
|
||||
leptos::mount::hydrate_body(App);
|
||||
}
|
||||
152
src/main.rs
152
src/main.rs
@@ -1,3 +1,151 @@
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
// main.rs — Axum server entry point
|
||||
//
|
||||
// This file is compiled ONLY when the "ssr" feature is enabled.
|
||||
// `#[cfg(feature = "ssr")]` works like `#ifdef` in C:
|
||||
// the guarded code does not exist in the WASM bundle.
|
||||
//
|
||||
// Run the server:
|
||||
// cargo run --features ssr
|
||||
//
|
||||
// Run with verbose logs:
|
||||
// RUST_LOG=debug cargo run --features ssr
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
#[tokio::main]
|
||||
// `#[tokio::main]` turns the synchronous `fn main()` into an async function
|
||||
// managed by the Tokio runtime. Without it, Rust cannot execute `async` code.
|
||||
async fn main() {
|
||||
use axum::Router;
|
||||
use leptos::config::get_configuration;
|
||||
use leptos::prelude::provide_context;
|
||||
use leptos::view;
|
||||
use leptos_axum::{
|
||||
generate_route_list, handle_server_fns_with_context, LeptosRoutes,
|
||||
};
|
||||
use rust_ipam::{
|
||||
app::{App, Shell},
|
||||
server::{
|
||||
config::AppConfig,
|
||||
db::{create_pool, run_migrations},
|
||||
routes::not_found_handler,
|
||||
state::AppState,
|
||||
},
|
||||
};
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
// Initialize structured logging.
|
||||
// tracing::info!(), tracing::warn!(), etc. produce no output without this.
|
||||
// RUST_LOG=debug cargo run --features ssr → enables debug-level logs
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()),
|
||||
)
|
||||
.init();
|
||||
|
||||
tracing::info!("Starting Rust IPAM server...");
|
||||
|
||||
// Load configuration from environment variables / .env file.
|
||||
// The server cannot start without knowing which database to connect to,
|
||||
// so we abort immediately on any configuration error.
|
||||
let app_config = AppConfig::from_env()
|
||||
.expect("Configuration error — check your .env file");
|
||||
|
||||
tracing::info!("Database: {} ({})", app_config.backend, app_config.database_url);
|
||||
|
||||
// Connect to the database and apply any pending migrations.
|
||||
// The server cannot serve data without a working database, so we abort on error.
|
||||
let pool = create_pool(&app_config)
|
||||
.await
|
||||
.expect("Failed to connect to database");
|
||||
|
||||
run_migrations(&pool, &app_config.backend)
|
||||
.await
|
||||
.expect("Database migration failed");
|
||||
|
||||
tracing::info!("Database ready.");
|
||||
|
||||
// `Some("Cargo.toml")` tells Leptos to read the [package.metadata.leptos]
|
||||
// section from Cargo.toml (file paths, output names, server address...).
|
||||
let conf = get_configuration(Some("Cargo.toml"))
|
||||
.expect("Failed to load Leptos configuration from Cargo.toml");
|
||||
let leptos_options = conf.leptos_options;
|
||||
let addr = leptos_options.site_addr;
|
||||
|
||||
// Combine Leptos options and the database pool into a single shared state.
|
||||
// `AppState` implements `FromRef<AppState> for LeptosOptions` so Leptos
|
||||
// can still extract just what it needs from the full state.
|
||||
let state = AppState { leptos_options: leptos_options.clone(), db: pool };
|
||||
|
||||
// Walk all `<Route>` components inside `App` to build the list of URLs
|
||||
// that Leptos SSR must handle.
|
||||
let routes = generate_route_list(App);
|
||||
|
||||
// Clone the pool so we can inject it into two different contexts:
|
||||
// 1. `leptos_routes_with_context` — SSR rendering + server functions called during SSR
|
||||
// 2. `handle_server_fns_with_context` — server functions called from the WASM client
|
||||
let pool_for_routes = state.db.clone();
|
||||
let pool_for_fns = state.db.clone();
|
||||
|
||||
// Build the Axum router using the builder pattern (method chaining).
|
||||
//
|
||||
// `Router::<AppState>::new()` explicitly tells Rust the state type is `AppState`.
|
||||
// Without this annotation, type inference would default to `LeptosOptions`
|
||||
// (inferred from `leptos_routes`) and then reject `.with_state(state: AppState)`.
|
||||
let app = Router::<AppState>::new()
|
||||
// Serve static files compiled by trunk (WASM, JS...).
|
||||
// Trunk places them in target/site/pkg/ as configured in [package.metadata.leptos].
|
||||
.nest_service("/pkg", ServeDir::new("target/site/pkg"))
|
||||
// Handle server function HTTP calls from the WASM client.
|
||||
//
|
||||
// `#[server]` functions register themselves at "/api/<fn-name>".
|
||||
// `handle_server_fns_with_context` runs the server function body and injects
|
||||
// `additional_context` into the Leptos context before execution,
|
||||
// so server functions can call `use_context::<AnyPool>()` to get the pool.
|
||||
.route(
|
||||
"/api/*fn_name",
|
||||
axum::routing::post({
|
||||
let pool = pool_for_fns;
|
||||
move |req| {
|
||||
let pool = pool.clone();
|
||||
handle_server_fns_with_context(
|
||||
move || provide_context(pool.clone()),
|
||||
req,
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
// Mount all Leptos routes into Axum.
|
||||
// `leptos_routes_with_context` injects the pool into the Leptos context
|
||||
// for every SSR render — needed for server functions called during SSR
|
||||
// (e.g. when a `Resource` pre-fetches data on the server).
|
||||
.leptos_routes_with_context(
|
||||
&state,
|
||||
routes,
|
||||
{
|
||||
move || provide_context(pool_for_routes.clone())
|
||||
},
|
||||
{
|
||||
let leptos_options = state.leptos_options.clone();
|
||||
move || view! { <Shell options=leptos_options.clone()/> }
|
||||
},
|
||||
)
|
||||
.fallback(not_found_handler)
|
||||
// Share AppState (Leptos options + DB pool) with all handlers.
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&addr)
|
||||
.await
|
||||
.expect(&format!("Failed to bind to address {}", addr));
|
||||
|
||||
tracing::info!("Server listening on http://{}", addr);
|
||||
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("Fatal server error");
|
||||
}
|
||||
|
||||
// This empty block is required so the compiler finds a `fn main()`
|
||||
// when building in WASM mode (where the "ssr" feature is not enabled).
|
||||
// In WASM, the real entry point is `hydrate()` in lib.rs.
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
fn main() {}
|
||||
|
||||
144
src/models.rs
Normal file
144
src/models.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
// models.rs — Shared data models (server + client)
|
||||
//
|
||||
// This module defines the structs that represent the IPAM domain entities.
|
||||
// They are compiled for both the server and WASM, because Leptos needs them
|
||||
// on both sides:
|
||||
// - Server : to read/write the database and render HTML
|
||||
// - Client : to display data inside Leptos components
|
||||
//
|
||||
// Each struct derives `Serialize` and `Deserialize` from serde.
|
||||
// This is required for Leptos to transfer data between the server and the
|
||||
// browser through server functions (#[server]).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ─── Network ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// An IP network defined by its CIDR range.
|
||||
///
|
||||
/// Example: { id: 1, cidr: "192.168.1.0/24" }
|
||||
/// → covers 192.168.1.0 to 192.168.1.255 (254 usable hosts)
|
||||
///
|
||||
/// CIDR (Classless Inter-Domain Routing) combines the network address and
|
||||
/// the subnet mask into a single field: <address>/<prefix length>.
|
||||
/// /24 = 24-bit mask = 255.255.255.0
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Network {
|
||||
/// Unique identifier, auto-incremented by the database.
|
||||
/// `i64` is a signed 64-bit integer — maps to `BIGINT` in SQL.
|
||||
pub id: i64,
|
||||
|
||||
/// Human-readable name. Examples: "LAN", "DMZ", "VPN"
|
||||
pub name: String,
|
||||
|
||||
/// Address range in CIDR notation.
|
||||
/// Examples: "10.0.0.0/8", "172.16.0.0/12", "192.168.1.0/24"
|
||||
pub cidr: String,
|
||||
}
|
||||
|
||||
// ─── Host ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A host (server, workstation, network device) belonging to a network.
|
||||
///
|
||||
/// Constraint: the IP address must fall within the CIDR range of the network
|
||||
/// referenced by `network_id`. This is enforced on creation and update.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Host {
|
||||
pub id: i64,
|
||||
|
||||
/// Human-readable name. Examples: "web-server-01", "main-router"
|
||||
pub name: String,
|
||||
|
||||
/// IPv4 address stored as text. Example: "192.168.1.10"
|
||||
/// We use String instead of IpAddr to simplify serialization
|
||||
/// and database storage.
|
||||
pub ip: String,
|
||||
|
||||
/// Foreign key referencing the network this host belongs to.
|
||||
pub network_id: i64,
|
||||
}
|
||||
|
||||
// ─── Port ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A network port entry in the global port catalog.
|
||||
///
|
||||
/// Ports are defined once here; host_ports and application_ports link them
|
||||
/// to hosts and applications through separate join tables.
|
||||
/// Well-known ports (0–1023) have standardized protocol assignments.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Port {
|
||||
/// TCP/UDP port number (0–65535).
|
||||
/// `u16` is an unsigned 16-bit integer — the exact range for port numbers.
|
||||
pub number: u16,
|
||||
|
||||
/// Description of the protocol typically running on this port.
|
||||
/// `Option<String>`: absent (None) when the protocol is unknown.
|
||||
/// Examples: Some("SSH"), Some("HTTPS"), None
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
// ─── HostPort ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Join record representing a port open on a specific host.
|
||||
///
|
||||
/// Maps to the `host_ports` table (many-to-many between hosts and ports).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HostPort {
|
||||
pub host_id: i64,
|
||||
pub port_number: u16,
|
||||
}
|
||||
|
||||
impl Port {
|
||||
/// Returns the standard description for common well-known ports.
|
||||
/// Used to pre-fill the description field when adding a port.
|
||||
///
|
||||
/// `match` is Rust's exhaustive pattern-matching construct (like switch/case,
|
||||
/// but the compiler enforces that all cases are handled).
|
||||
pub fn known_protocol(number: u16) -> Option<&'static str> {
|
||||
// `&'static str`: a reference to a string that lives for the entire
|
||||
// program lifetime (string literals are stored in the compiled binary).
|
||||
match number {
|
||||
21 => Some("FTP"),
|
||||
22 => Some("SSH"),
|
||||
23 => Some("Telnet"),
|
||||
25 => Some("SMTP"),
|
||||
53 => Some("DNS"),
|
||||
80 => Some("HTTP"),
|
||||
110 => Some("POP3"),
|
||||
143 => Some("IMAP"),
|
||||
443 => Some("HTTPS"),
|
||||
3306 => Some("MySQL"),
|
||||
5432 => Some("PostgreSQL"),
|
||||
6379 => Some("Redis"),
|
||||
8080 => Some("HTTP (alternate)"),
|
||||
_ => None, // `_` is the wildcard pattern — matches everything else
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Application ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// An application that uses one or more ports.
|
||||
///
|
||||
/// The association between an application and a port is non-strict:
|
||||
/// the same port can be shared by multiple applications.
|
||||
/// Example: port 80 might be used by both Nginx and an application proxy.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Application {
|
||||
pub id: i64,
|
||||
|
||||
/// Application name. Examples: "Nginx", "PostgreSQL", "Prometheus"
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
// ─── ApplicationPort ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Join record linking an application to a port (many-to-many relationship).
|
||||
///
|
||||
/// A dedicated struct is used instead of Vec<Port> inside Application
|
||||
/// so it maps directly to the join table in the database.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ApplicationPort {
|
||||
pub application_id: i64,
|
||||
pub port_number: u16,
|
||||
}
|
||||
100
src/server/config.rs
Normal file
100
src/server/config.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
// server/config.rs — Application configuration
|
||||
//
|
||||
// This module loads and validates configuration at server startup.
|
||||
// All configuration is read from environment variables,
|
||||
// which can be populated from a `.env` file via dotenvy.
|
||||
//
|
||||
// A single variable is enough to select the database backend:
|
||||
// DATABASE_URL=sqlite://data/ipam.db → SQLite (dev, no server needed)
|
||||
// DATABASE_URL=postgresql://user:pw@host/db → PostgreSQL (production)
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
// ─── Configuration errors ─────────────────────────────────────────────────────
|
||||
|
||||
// `#[derive(Error)]` from thiserror automatically generates the impl for the
|
||||
// standard `std::error::Error` trait — no need to write it by hand.
|
||||
//
|
||||
// `#[error("...")]` defines the message shown by Display / println!("{}", err).
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConfigError {
|
||||
// `#[from]` auto-converts a VarError into ConfigError via the `?` operator.
|
||||
// Example: std::env::var("X")? in a fn -> Result<_, ConfigError>
|
||||
#[error("Missing environment variable: {0}")]
|
||||
MissingVar(#[from] std::env::VarError),
|
||||
|
||||
#[error("Unknown database URL '{0}' — must start with sqlite:// or postgresql://")]
|
||||
UnknownBackend(String),
|
||||
}
|
||||
|
||||
// ─── Database backend ─────────────────────────────────────────────────────────
|
||||
|
||||
// `#[derive(Debug, Clone)]` automatically generates:
|
||||
// - Debug : allows printing the value with {:?} in logs
|
||||
// - Clone : allows copying the value (required for Axum state)
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum DatabaseBackend {
|
||||
Postgres,
|
||||
Sqlite,
|
||||
}
|
||||
|
||||
// Display impl allows writing: tracing::info!("Backend: {}", backend)
|
||||
impl std::fmt::Display for DatabaseBackend {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DatabaseBackend::Postgres => write!(f, "PostgreSQL"),
|
||||
DatabaseBackend::Sqlite => write!(f, "SQLite"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Application configuration ────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppConfig {
|
||||
/// Full database connection URL.
|
||||
/// Examples: "sqlite://data/ipam.db" or "postgresql://user:pw@localhost/ipam"
|
||||
pub database_url: String,
|
||||
|
||||
/// Backend detected automatically from the DATABASE_URL prefix.
|
||||
pub backend: DatabaseBackend,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
/// Loads configuration from environment variables.
|
||||
///
|
||||
/// Variable priority order:
|
||||
/// 1. Variables already set in the shell (e.g. export DATABASE_URL=...)
|
||||
/// 2. `.env` file at the project root
|
||||
///
|
||||
/// Returns `ConfigError` if DATABASE_URL is missing or unrecognized.
|
||||
pub fn from_env() -> Result<Self, ConfigError> {
|
||||
// Load the .env file if it exists.
|
||||
// `let _ =` silently ignores the error when .env is absent —
|
||||
// this is intentional: in production, variables are injected directly.
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
// `std::env::var` returns Result<String, VarError>.
|
||||
// The `?` propagates VarError, converted to ConfigError::MissingVar
|
||||
// thanks to the `#[from]` attribute above.
|
||||
let database_url = std::env::var("DATABASE_URL")?;
|
||||
|
||||
let backend = Self::detect_backend(&database_url)?;
|
||||
|
||||
Ok(Self { database_url, backend })
|
||||
}
|
||||
|
||||
/// Infers the database backend from the URL prefix.
|
||||
///
|
||||
/// `&str`: a borrowed string reference — no copy, just a borrow.
|
||||
fn detect_backend(url: &str) -> Result<DatabaseBackend, ConfigError> {
|
||||
if url.starts_with("postgresql://") || url.starts_with("postgres://") {
|
||||
Ok(DatabaseBackend::Postgres)
|
||||
} else if url.starts_with("sqlite://") {
|
||||
Ok(DatabaseBackend::Sqlite)
|
||||
} else {
|
||||
// `to_string()` creates an owned String from a &str
|
||||
Err(ConfigError::UnknownBackend(url.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
110
src/server/db.rs
Normal file
110
src/server/db.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
// server/db.rs — Database connection pool and migrations
|
||||
//
|
||||
// This module provides two functions called once at server startup:
|
||||
// - `create_pool` : opens a connection pool to the database
|
||||
// - `run_migrations` : applies all pending SQL migrations
|
||||
//
|
||||
// `AnyPool` lets the same Rust code target both SQLite (dev) and
|
||||
// PostgreSQL (production) — only DATABASE_URL changes.
|
||||
|
||||
use sqlx::AnyPool;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::server::config::{AppConfig, DatabaseBackend};
|
||||
|
||||
// ─── Errors ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DbError {
|
||||
#[error("Database connection failed: {0}")]
|
||||
Connection(#[from] sqlx::Error),
|
||||
|
||||
#[error("Migration failed: {0}")]
|
||||
Migration(#[from] sqlx::migrate::MigrateError),
|
||||
|
||||
#[error("Record not found: {0}")]
|
||||
NotFound(String),
|
||||
}
|
||||
|
||||
// ─── Pool creation ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Opens a connection pool to the database specified in `config.database_url`.
|
||||
///
|
||||
/// A pool maintains multiple open connections so concurrent requests
|
||||
/// do not block each other waiting for a single connection.
|
||||
///
|
||||
/// `install_default_drivers()` must be called before `AnyPool::connect`
|
||||
/// to register both the SQLite and PostgreSQL drivers in the `Any` registry.
|
||||
///
|
||||
/// For SQLite, this function also creates the database file and its parent
|
||||
/// directory if they do not exist yet. The `AnyPool` driver cannot create
|
||||
/// a new SQLite file by itself — unlike the `SqlitePool` which has an
|
||||
/// explicit `create_if_missing` option.
|
||||
pub async fn create_pool(config: &AppConfig) -> Result<AnyPool, DbError> {
|
||||
// Register SQLite and PostgreSQL drivers so `AnyPool` can dispatch
|
||||
// to the correct one based on the URL scheme (sqlite:// vs postgres://).
|
||||
sqlx::any::install_default_drivers();
|
||||
|
||||
// SQLite-specific setup: ensure the file exists before connecting.
|
||||
//
|
||||
// `AnyPool` does not expose `create_if_missing` like `SqlitePool` does,
|
||||
// so we must touch the file ourselves.
|
||||
//
|
||||
// The URL is parsed the same way SQLx does it internally:
|
||||
// sqlite://data/ipam.db → strip sqlite:// → path = data/ipam.db
|
||||
if let DatabaseBackend::Sqlite = &config.backend {
|
||||
// Strip both possible prefixes (SQLx accepts both forms)
|
||||
let path_str = config
|
||||
.database_url
|
||||
.trim_start_matches("sqlite://")
|
||||
.trim_start_matches("sqlite:");
|
||||
|
||||
// Skip special filenames (in-memory, shared cache)
|
||||
if path_str != ":memory:" && !path_str.is_empty() {
|
||||
let path = std::path::Path::new(path_str);
|
||||
|
||||
// Create the parent directory if it does not exist
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| sqlx::Error::Io(e))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Create an empty file so SQLite can open it
|
||||
if !path.exists() {
|
||||
std::fs::File::create(path)
|
||||
.map_err(|e| sqlx::Error::Io(e))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pool = AnyPool::connect(&config.database_url).await?;
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
// ─── Migrations ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Applies all pending migrations from the directory matching the active backend.
|
||||
///
|
||||
/// Two separate directories handle SQL syntax differences:
|
||||
/// - `migrations/sqlite/` : uses `INTEGER PRIMARY KEY AUTOINCREMENT`
|
||||
/// - `migrations/postgres/` : uses `BIGSERIAL PRIMARY KEY`
|
||||
///
|
||||
/// SQLx tracks applied migrations in a `_sqlx_migrations` table, so running
|
||||
/// this function on an already-migrated database is always safe (idempotent).
|
||||
///
|
||||
/// `sqlx::migrate!("path")` is a compile-time macro: it embeds all `.sql`
|
||||
/// files from the given path directly into the binary. The path is relative
|
||||
/// to the project root (where Cargo.toml lives).
|
||||
pub async fn run_migrations(pool: &AnyPool, backend: &DatabaseBackend) -> Result<(), DbError> {
|
||||
match backend {
|
||||
DatabaseBackend::Sqlite => {
|
||||
sqlx::migrate!("migrations/sqlite").run(pool).await?;
|
||||
}
|
||||
DatabaseBackend::Postgres => {
|
||||
sqlx::migrate!("migrations/postgres").run(pool).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
24
src/server/mod.rs
Normal file
24
src/server/mod.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
// server/mod.rs — Server-side module
|
||||
//
|
||||
// Contains all code that runs on the server only.
|
||||
//
|
||||
// Some sub-modules depend on SSR-only crates (dotenvy, ipnetwork...)
|
||||
// and are therefore gated with `#[cfg(feature = "ssr")]` to prevent
|
||||
// them from being compiled into the WASM bundle.
|
||||
|
||||
pub mod routes;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod config;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod db;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod repository;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod state;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod validation;
|
||||
224
src/server/repository/applications.rs
Normal file
224
src/server/repository/applications.rs
Normal file
@@ -0,0 +1,224 @@
|
||||
// repository/applications.rs — CRUD for applications and their port associations
|
||||
|
||||
use sqlx::{AnyPool, Row};
|
||||
|
||||
use crate::models::Application;
|
||||
use crate::server::db::DbError;
|
||||
|
||||
// ─── Read ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns every application ordered by name.
|
||||
pub async fn list_applications(pool: &AnyPool) -> Result<Vec<Application>, DbError> {
|
||||
let rows = sqlx::query("SELECT id, name FROM applications ORDER BY name")
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows.iter().map(row_to_application).collect())
|
||||
}
|
||||
|
||||
/// Returns a single application by id, or `None` if it does not exist.
|
||||
pub async fn find_application(
|
||||
pool: &AnyPool,
|
||||
id: i64,
|
||||
) -> Result<Option<Application>, DbError> {
|
||||
let row = sqlx::query("SELECT id, name FROM applications WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.as_ref().map(row_to_application))
|
||||
}
|
||||
|
||||
// ─── Write ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Inserts a new application and returns the created record.
|
||||
pub async fn create_application(pool: &AnyPool, name: &str) -> Result<Application, DbError> {
|
||||
let row =
|
||||
sqlx::query("INSERT INTO applications (name) VALUES ($1) RETURNING id, name")
|
||||
.bind(name)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
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.
|
||||
pub async fn delete_application(pool: &AnyPool, id: i64) -> Result<bool, DbError> {
|
||||
let result = sqlx::query("DELETE FROM applications WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
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.
|
||||
///
|
||||
/// Note: the `application_ports` table does NOT have a FK to `ports`,
|
||||
/// so an application can reference a port number that is not in the catalog.
|
||||
/// Returns plain `u16` numbers, not full `Port` structs.
|
||||
pub async fn list_ports_for_application(
|
||||
pool: &AnyPool,
|
||||
application_id: i64,
|
||||
) -> Result<Vec<u16>, DbError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT port_number FROM application_ports
|
||||
WHERE application_id = $1
|
||||
ORDER BY port_number",
|
||||
)
|
||||
.bind(application_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| r.get::<i64, _>("port_number") as u16)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Associates a port number with an application.
|
||||
///
|
||||
/// If the association already exists, the call is a no-op (not an error).
|
||||
/// The port does not need to be in the `ports` catalog.
|
||||
pub async fn add_port_to_application(
|
||||
pool: &AnyPool,
|
||||
application_id: i64,
|
||||
port_number: u16,
|
||||
) -> Result<(), DbError> {
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO application_ports (application_id, port_number) VALUES ($1, $2)",
|
||||
)
|
||||
.bind(application_id)
|
||||
.bind(port_number as i64)
|
||||
.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 a port association from an application.
|
||||
///
|
||||
/// If the association did not exist, this is a no-op (not an error).
|
||||
pub async fn remove_port_from_application(
|
||||
pool: &AnyPool,
|
||||
application_id: i64,
|
||||
port_number: u16,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query(
|
||||
"DELETE FROM application_ports
|
||||
WHERE application_id = $1 AND port_number = $2",
|
||||
)
|
||||
.bind(application_id)
|
||||
.bind(port_number as i64)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Row mapping ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn row_to_application(row: &sqlx::any::AnyRow) -> Application {
|
||||
Application {
|
||||
id: row.get("id"),
|
||||
name: row.get("name"),
|
||||
}
|
||||
}
|
||||
113
src/server/repository/hosts.rs
Normal file
113
src/server/repository/hosts.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
// repository/hosts.rs — CRUD for the `hosts` table
|
||||
|
||||
use sqlx::{AnyPool, Row};
|
||||
|
||||
use crate::models::Host;
|
||||
use crate::server::db::DbError;
|
||||
|
||||
// ─── Read ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns every host belonging to a network, sorted by name.
|
||||
pub async fn list_hosts_by_network(
|
||||
pool: &AnyPool,
|
||||
network_id: i64,
|
||||
) -> Result<Vec<Host>, DbError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, name, ip, network_id FROM hosts
|
||||
WHERE network_id = $1
|
||||
ORDER BY name",
|
||||
)
|
||||
.bind(network_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows.iter().map(row_to_host).collect())
|
||||
}
|
||||
|
||||
/// Returns a single host by id, or `None` if it does not exist.
|
||||
pub async fn find_host(pool: &AnyPool, id: i64) -> Result<Option<Host>, DbError> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, name, ip, network_id FROM hosts WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.as_ref().map(row_to_host))
|
||||
}
|
||||
|
||||
// ─── Write ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Inserts a new host into the given network and returns the created record.
|
||||
///
|
||||
/// The caller must validate that `ip` falls within the CIDR range of `network_id`
|
||||
/// before calling this function — use `server::validation::validate_ip_in_network`.
|
||||
pub async fn create_host(
|
||||
pool: &AnyPool,
|
||||
name: &str,
|
||||
ip: &str,
|
||||
network_id: i64,
|
||||
) -> Result<Host, DbError> {
|
||||
let row = sqlx::query(
|
||||
"INSERT INTO hosts (name, ip, network_id)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, name, ip, network_id",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(ip)
|
||||
.bind(network_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row_to_host(&row))
|
||||
}
|
||||
|
||||
/// Updates a host's name, IP address, and network assignment.
|
||||
///
|
||||
/// Returns the updated host, or `None` if the id does not exist.
|
||||
/// The caller must validate that `ip` falls within the CIDR range of the
|
||||
/// new `network_id` before calling this function.
|
||||
pub async fn update_host(
|
||||
pool: &AnyPool,
|
||||
id: i64,
|
||||
name: &str,
|
||||
ip: &str,
|
||||
network_id: i64,
|
||||
) -> Result<Option<Host>, DbError> {
|
||||
let row = sqlx::query(
|
||||
"UPDATE hosts SET name = $1, ip = $2, network_id = $3
|
||||
WHERE id = $4
|
||||
RETURNING id, name, ip, network_id",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(ip)
|
||||
.bind(network_id)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.as_ref().map(row_to_host))
|
||||
}
|
||||
|
||||
/// Deletes a host and all its port associations (via `ON DELETE CASCADE`).
|
||||
///
|
||||
/// Returns `true` if a row was deleted, `false` if the id did not exist.
|
||||
pub async fn delete_host(pool: &AnyPool, id: i64) -> Result<bool, DbError> {
|
||||
let result = sqlx::query("DELETE FROM hosts WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
// ─── Row mapping ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn row_to_host(row: &sqlx::any::AnyRow) -> Host {
|
||||
Host {
|
||||
id: row.get("id"),
|
||||
name: row.get("name"),
|
||||
ip: row.get("ip"),
|
||||
network_id: row.get("network_id"),
|
||||
}
|
||||
}
|
||||
19
src/server/repository/mod.rs
Normal file
19
src/server/repository/mod.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
// server/repository — Database access layer (CRUD)
|
||||
//
|
||||
// Each sub-module owns the queries for one domain entity.
|
||||
// All functions accept `&AnyPool` and return `Result<_, DbError>`,
|
||||
// so the caller never has to think about raw SQL or connection management.
|
||||
//
|
||||
// Design choices:
|
||||
// - Free functions (not struct methods) keep the API simple.
|
||||
// - `sqlx::query()` is used instead of `sqlx::query!()` because the
|
||||
// compile-time macro requires a live database at build time.
|
||||
// - All integer columns are read as `i64` (the AnyPool normalizes
|
||||
// INTEGER/BIGINT to 64-bit integers internally).
|
||||
// - Port numbers are stored as i64 in the DB and cast to u16 in Rust
|
||||
// (safe because valid port numbers 0–65535 fit in u16).
|
||||
|
||||
pub mod applications;
|
||||
pub mod hosts;
|
||||
pub mod networks;
|
||||
pub mod ports;
|
||||
83
src/server/repository/networks.rs
Normal file
83
src/server/repository/networks.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
// repository/networks.rs — CRUD for the `networks` table
|
||||
|
||||
use sqlx::{AnyPool, Row};
|
||||
|
||||
use crate::models::Network;
|
||||
use crate::server::db::DbError;
|
||||
|
||||
// ─── Read ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns every network ordered by id.
|
||||
pub async fn list_networks(pool: &AnyPool) -> Result<Vec<Network>, DbError> {
|
||||
// `fetch_all` runs the query and collects every row into a Vec.
|
||||
// It returns an error if the query fails; an empty table returns Ok(vec![]).
|
||||
let rows = sqlx::query("SELECT id, name, cidr FROM networks ORDER BY id")
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
// `.iter().map(...).collect()` transforms each raw DB row into a Network struct.
|
||||
Ok(rows.iter().map(row_to_network).collect())
|
||||
}
|
||||
|
||||
/// Returns a single network by id, or `None` if it does not exist.
|
||||
pub async fn find_network(pool: &AnyPool, id: i64) -> Result<Option<Network>, DbError> {
|
||||
// `fetch_optional` returns `Ok(None)` when no row matches — unlike
|
||||
// `fetch_one`, which returns an error when nothing is found.
|
||||
let row = sqlx::query("SELECT id, name, cidr FROM networks WHERE id = $1")
|
||||
.bind(id) // `$1` is replaced with the value of `id` at runtime
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
// `Option::map` applies the conversion only if the row is Some.
|
||||
Ok(row.as_ref().map(row_to_network))
|
||||
}
|
||||
|
||||
// ─── Write ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Inserts a new network and returns the created record (with its auto-generated id).
|
||||
///
|
||||
/// Fails with `DbError::Connection` if the CIDR is already registered
|
||||
/// (the `cidr` column has a UNIQUE constraint).
|
||||
///
|
||||
/// `RETURNING id, cidr` reads back the inserted row in a single round-trip,
|
||||
/// avoiding a separate SELECT after the INSERT.
|
||||
/// Requires SQLite ≥ 3.35 (2021) and any PostgreSQL version.
|
||||
pub async fn create_network(pool: &AnyPool, name: &str, cidr: &str) -> Result<Network, DbError> {
|
||||
let row = sqlx::query(
|
||||
"INSERT INTO networks (name, cidr) VALUES ($1, $2) RETURNING id, name, cidr",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(cidr)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row_to_network(&row))
|
||||
}
|
||||
|
||||
/// Deletes a network by id and all its hosts (via `ON DELETE CASCADE`).
|
||||
///
|
||||
/// Returns `true` if a row was deleted, `false` if the id did not exist.
|
||||
pub async fn delete_network(pool: &AnyPool, id: i64) -> Result<bool, DbError> {
|
||||
// `execute` runs the query without fetching rows.
|
||||
// `rows_affected()` tells us how many rows were actually deleted.
|
||||
let result = sqlx::query("DELETE FROM networks WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
// ─── Row mapping ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Converts a raw database row into a `Network` struct.
|
||||
///
|
||||
/// `row.get("col")` extracts a typed value by column name.
|
||||
/// The type must implement `sqlx::Decode` for the `Any` backend.
|
||||
fn row_to_network(row: &sqlx::any::AnyRow) -> Network {
|
||||
Network {
|
||||
id: row.get("id"),
|
||||
name: row.get("name"),
|
||||
cidr: row.get("cidr"),
|
||||
}
|
||||
}
|
||||
156
src/server/repository/ports.rs
Normal file
156
src/server/repository/ports.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
// repository/ports.rs — Port catalog and host-port associations
|
||||
//
|
||||
// The `ports` table is a global catalog: a port number (e.g. 22) is defined
|
||||
// once with an optional description, then linked to hosts via `host_ports`.
|
||||
//
|
||||
// Port numbers in Rust are `u16` (0–65535).
|
||||
// In the database they are stored as INTEGER (i64 in SQLx/AnyPool).
|
||||
// `as u16` casts are safe because values above 65535 cannot be inserted
|
||||
// (the schema and application code enforce this).
|
||||
|
||||
use sqlx::{AnyPool, Row};
|
||||
|
||||
use crate::models::Port;
|
||||
use crate::server::db::DbError;
|
||||
|
||||
// ─── Port catalog ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Inserts or updates a port in the global catalog.
|
||||
///
|
||||
/// If the port number already exists, its description is updated.
|
||||
/// If it does not exist, a new row is inserted.
|
||||
///
|
||||
/// We use a try-INSERT then UPDATE pattern instead of database-specific
|
||||
/// upsert syntax (`INSERT OR REPLACE` for SQLite, `ON CONFLICT` for PostgreSQL)
|
||||
/// so the same code works with `AnyPool` on both backends.
|
||||
pub async fn upsert_port(
|
||||
pool: &AnyPool,
|
||||
number: u16,
|
||||
description: Option<&str>,
|
||||
) -> Result<Port, DbError> {
|
||||
let insert_result = sqlx::query(
|
||||
"INSERT INTO ports (number, description) VALUES ($1, $2)",
|
||||
)
|
||||
.bind(number as i64)
|
||||
.bind(description)
|
||||
.execute(pool)
|
||||
.await;
|
||||
|
||||
match insert_result {
|
||||
Ok(_) => {}
|
||||
Err(sqlx::Error::Database(ref db_err)) if db_err.is_unique_violation() => {
|
||||
// Port already exists: update the description instead.
|
||||
// `is_unique_violation()` works for both SQLite and PostgreSQL.
|
||||
sqlx::query("UPDATE ports SET description = $1 WHERE number = $2")
|
||||
.bind(description)
|
||||
.bind(number as i64)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
Err(e) => return Err(DbError::Connection(e)),
|
||||
}
|
||||
|
||||
// Return the current state of the port row.
|
||||
find_port(pool, number)
|
||||
.await?
|
||||
.ok_or_else(|| DbError::NotFound(format!("port {number}")))
|
||||
}
|
||||
|
||||
/// Returns a port from the catalog, or `None` if the number is not registered.
|
||||
pub async fn find_port(pool: &AnyPool, number: u16) -> Result<Option<Port>, DbError> {
|
||||
let row = sqlx::query(
|
||||
"SELECT number, description FROM ports WHERE number = $1",
|
||||
)
|
||||
.bind(number as i64)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.as_ref().map(row_to_port))
|
||||
}
|
||||
|
||||
// ─── Host-port associations ───────────────────────────────────────────────────
|
||||
|
||||
/// Returns all ports currently associated with a host, sorted by port number.
|
||||
///
|
||||
/// Uses a JOIN so callers get full `Port` structs (with descriptions), not just numbers.
|
||||
pub async fn list_ports_for_host(
|
||||
pool: &AnyPool,
|
||||
host_id: i64,
|
||||
) -> Result<Vec<Port>, DbError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT p.number, p.description
|
||||
FROM ports p
|
||||
JOIN host_ports hp ON hp.port_number = p.number
|
||||
WHERE hp.host_id = $1
|
||||
ORDER BY p.number",
|
||||
)
|
||||
.bind(host_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows.iter().map(row_to_port).collect())
|
||||
}
|
||||
|
||||
/// Adds a port to a host.
|
||||
///
|
||||
/// If the port is not yet in the global catalog, it is automatically registered
|
||||
/// with a description from `Port::known_protocol` (e.g. port 22 → "SSH").
|
||||
/// If the host already has this port, the call is a no-op (not an error).
|
||||
pub async fn add_port_to_host(
|
||||
pool: &AnyPool,
|
||||
host_id: i64,
|
||||
port_number: u16,
|
||||
) -> Result<(), DbError> {
|
||||
// Auto-register the port in the catalog if it is not already there.
|
||||
// The `host_ports` table has a FK to `ports`, so the port must exist first.
|
||||
let description = crate::models::Port::known_protocol(port_number);
|
||||
upsert_port(pool, port_number, description).await?;
|
||||
|
||||
// Insert the host-port association.
|
||||
// If it already exists, treat it as a no-op (unique violation is expected).
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO host_ports (host_id, port_number) VALUES ($1, $2)",
|
||||
)
|
||||
.bind(host_id)
|
||||
.bind(port_number as i64)
|
||||
.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 a port from a host.
|
||||
///
|
||||
/// If the association did not exist, this is a no-op (not an error).
|
||||
/// The port entry in the global catalog is NOT deleted.
|
||||
pub async fn remove_port_from_host(
|
||||
pool: &AnyPool,
|
||||
host_id: i64,
|
||||
port_number: u16,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query(
|
||||
"DELETE FROM host_ports WHERE host_id = $1 AND port_number = $2",
|
||||
)
|
||||
.bind(host_id)
|
||||
.bind(port_number as i64)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Row mapping ──────────────────────────────────────────────────────────────
|
||||
|
||||
fn row_to_port(row: &sqlx::any::AnyRow) -> Port {
|
||||
Port {
|
||||
// Port numbers are stored as INTEGER in the DB.
|
||||
// SQLx/AnyPool decodes all integers to i64; `as u16` is safe
|
||||
// because values above 65535 cannot reach the DB.
|
||||
number: row.get::<i64, _>("number") as u16,
|
||||
description: row.get("description"),
|
||||
}
|
||||
}
|
||||
26
src/server/routes.rs
Normal file
26
src/server/routes.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
// server/routes.rs — Additional Axum HTTP handlers
|
||||
//
|
||||
// These handlers complement the routes managed by Leptos.
|
||||
// Intended for future use:
|
||||
// - REST API endpoints (/api/...)
|
||||
// - File exports (CSV, PDF...)
|
||||
// - Incoming webhooks
|
||||
// - Health check endpoint (/health)
|
||||
//
|
||||
// The entire file is guarded by `#[cfg(feature = "ssr")]`:
|
||||
// Axum does not exist in the WASM bundle, so this code is server-only.
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
use axum::{http::StatusCode, response::IntoResponse};
|
||||
|
||||
// Fallback 404 handler — used in main.rs for any URL not matched by Leptos or Axum.
|
||||
//
|
||||
// `impl IntoResponse`: Axum accepts any type that implements this trait.
|
||||
// A `(StatusCode, &str)` tuple implements it automatically:
|
||||
// Axum turns it into an HTTP 404 response with the given body.
|
||||
//
|
||||
// `async fn` is required for Axum handlers, even without any async operations.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn not_found_handler() -> impl IntoResponse {
|
||||
(StatusCode::NOT_FOUND, "Not found")
|
||||
}
|
||||
42
src/server/state.rs
Normal file
42
src/server/state.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
// server/state.rs — Shared Axum application state
|
||||
//
|
||||
// Axum uses a typed state system: any handler can extract a specific type
|
||||
// from the shared state using the `State<T>` extractor.
|
||||
//
|
||||
// We store two pieces of state:
|
||||
// - `leptos_options` : required by Leptos SSR routes
|
||||
// - `db` : database pool shared across all requests
|
||||
|
||||
use axum::extract::FromRef;
|
||||
use leptos::config::LeptosOptions;
|
||||
use sqlx::AnyPool;
|
||||
|
||||
// ─── AppState ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Shared state available to every Axum handler and Leptos server function.
|
||||
///
|
||||
/// `#[derive(Clone)]` is required by Axum: each request receives its own clone.
|
||||
/// Both fields are cheap to clone — they hold reference-counted pointers
|
||||
/// under the hood, so cloning just increments an atomic counter.
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
/// Leptos configuration (output paths, site address, reload port…).
|
||||
pub leptos_options: LeptosOptions,
|
||||
|
||||
/// Shared connection pool to the database.
|
||||
/// Using a pool (not a single connection) allows concurrent requests
|
||||
/// to run their queries in parallel without contention.
|
||||
pub db: AnyPool,
|
||||
}
|
||||
|
||||
// ─── FromRef implementations ──────────────────────────────────────────────────
|
||||
|
||||
/// Tells Axum how to extract just `LeptosOptions` from the full `AppState`.
|
||||
///
|
||||
/// `.leptos_routes()` in main.rs requires this impl because it stores
|
||||
/// only `LeptosOptions` internally, not the full application state.
|
||||
impl FromRef<AppState> for LeptosOptions {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
state.leptos_options.clone()
|
||||
}
|
||||
}
|
||||
108
src/server/validation.rs
Normal file
108
src/server/validation.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
// server/validation.rs — Business rule validation
|
||||
//
|
||||
// This module enforces rules that cannot be expressed through Rust's type system
|
||||
// alone. It runs server-side only because it uses `ipnetwork` for CIDR arithmetic.
|
||||
|
||||
use ipnetwork::IpNetwork;
|
||||
use std::net::IpAddr;
|
||||
use thiserror::Error;
|
||||
|
||||
// ─── Validation errors ────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ValidationError {
|
||||
/// The CIDR string is malformed (e.g. "192.168.1/24" instead of "192.168.1.0/24")
|
||||
#[error("Invalid CIDR '{0}': {1}")]
|
||||
InvalidCidr(String, ipnetwork::IpNetworkError),
|
||||
|
||||
/// The IP address string is malformed
|
||||
#[error("Invalid IP address '{0}': {1}")]
|
||||
InvalidIp(String, std::net::AddrParseError),
|
||||
|
||||
/// The host IP does not fall within the network's CIDR range
|
||||
#[error("IP address {ip} does not belong to network {cidr}")]
|
||||
IpOutsideNetwork { ip: String, cidr: String },
|
||||
}
|
||||
|
||||
// ─── CIDR validation ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Validates that a string is a well-formed CIDR block.
|
||||
/// Returns the parsed network on success.
|
||||
///
|
||||
/// `&str`: borrowed string reference — no copy, just a borrow.
|
||||
/// `Result<T, E>`: either a value T (success) or an error E (failure).
|
||||
pub fn validate_cidr(cidr: &str) -> Result<IpNetwork, ValidationError> {
|
||||
cidr.parse::<IpNetwork>()
|
||||
// `.map_err` transforms the error if `parse` fails.
|
||||
// `|e|` is a closure: an anonymous function that takes `e` as a parameter.
|
||||
.map_err(|e| ValidationError::InvalidCidr(cidr.to_string(), e))
|
||||
}
|
||||
|
||||
// ─── IP-in-network validation ─────────────────────────────────────────────────
|
||||
|
||||
/// Verifies that an IP address belongs to a given CIDR network range.
|
||||
///
|
||||
/// Key business rule: a host must always reside within its assigned network.
|
||||
/// Example: 192.168.1.10 ✓ in 192.168.1.0/24
|
||||
/// 10.0.0.1 ✗ in 192.168.1.0/24
|
||||
pub fn validate_ip_in_network(ip: &str, cidr: &str) -> Result<(), ValidationError> {
|
||||
let network = validate_cidr(cidr)?;
|
||||
|
||||
let address: IpAddr = ip
|
||||
.parse()
|
||||
.map_err(|e| ValidationError::InvalidIp(ip.to_string(), e))?;
|
||||
|
||||
// `IpNetwork::contains` returns true if the address falls within the range.
|
||||
if network.contains(address) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ValidationError::IpOutsideNetwork {
|
||||
ip: ip.to_string(),
|
||||
cidr: cidr.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// `#[cfg(test)]`: this block is only compiled when running `cargo test`.
|
||||
// Writing tests in the same file as the code being tested is idiomatic Rust.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
// `super::*` imports everything from the parent module (this file).
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ip_within_valid_network() {
|
||||
// `.unwrap()` is acceptable in tests — a failure here fails the test.
|
||||
assert!(validate_ip_in_network("192.168.1.10", "192.168.1.0/24").is_ok());
|
||||
assert!(validate_ip_in_network("10.0.0.1", "10.0.0.0/8").is_ok());
|
||||
assert!(validate_ip_in_network("172.16.5.100", "172.16.0.0/12").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ip_outside_network() {
|
||||
assert!(validate_ip_in_network("10.0.0.1", "192.168.1.0/24").is_err());
|
||||
assert!(validate_ip_in_network("192.168.2.1", "192.168.1.0/24").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_cidr() {
|
||||
assert!(validate_cidr("not-a-cidr").is_err());
|
||||
assert!(validate_cidr("192.168.1/24").is_err()); // truncated address
|
||||
assert!(validate_cidr("192.168.1.0/33").is_err()); // prefix > 32
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_ip() {
|
||||
assert!(validate_ip_in_network("999.0.0.1", "192.168.1.0/24").is_err());
|
||||
assert!(validate_ip_in_network("not-an-ip", "192.168.1.0/24").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_protocol() {
|
||||
assert_eq!(crate::models::Port::known_protocol(22), Some("SSH"));
|
||||
assert_eq!(crate::models::Port::known_protocol(443), Some("HTTPS"));
|
||||
assert_eq!(crate::models::Port::known_protocol(9999), None);
|
||||
}
|
||||
}
|
||||
1347
style/rust-ipam.css
Normal file
1347
style/rust-ipam.css
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user