Compare commits

...

17 Commits

Author SHA1 Message Date
042793f385 feat(hosts): add hosts list page with filters, pagination and delete
Implements task #7. The Hosts page provides:
- Name/network/port/application filters (sentinel values instead of
  Option<T> to avoid server function serialization issues)
- Configurable page size (15 default, 25/50/100/All)
- Prev/next navigation with total host count
- Add host form with network selector
- Delete action per row

Sub-components (AddHostForm, FilterBar, PaginationBar, HostTable) each
call .into_any() to erase their concrete view types. This breaks the
deeply-nested generic type that caused "queries overflow the depth
limit!" without requiring an unbounded recursion_limit increase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 23:23:24 +02:00
5b1f30fe24 feat(networks): add host/application counts columns and fix Actions header
Adds NetworkWithCounts presentation model and get_networks_with_counts()
server function using a single SQL query with correlated subqueries.
Networks table now shows host count, application count, and has the
Actions column header properly right-aligned to match the Delete button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 23:23:12 +02:00
3ee39b96bb feat(home): replace demo with entity count dashboard
Home page now shows one clickable summary card per entity type
(Networks, Hosts, Applications). Each card displays the total count
fetched from the database via a single get_summary() server function,
then navigates to the corresponding page on click.

Counts are pre-rendered server-side via <Suspense> so the page is
useful even before the WASM bundle loads.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 22:49:25 +02:00
589aab7e3d feat(style): add CSS theme system with light/dark mode toggle
Introduces a global design system using CSS custom properties as
design tokens. Light and dark themes are defined via [data-theme]
attribute on <html>; the system preference (prefers-color-scheme)
is the default when no explicit choice is stored.

ThemeToggle component (Auto → Light → Dark cycle) persists the
choice to localStorage and applies it on hydration without flash.
New themes can be added by defining a [data-theme="name"] CSS block
and adding a variant to ThemeChoice.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 22:49:17 +02:00
3aeb74e5bc Update .gitignore 2026-05-15 22:26:19 +02:00
e902efc04f feat(networks): add Networks page with create/delete and SQLite auto-setup
- Add client/networks.rs: Leptos page with ServerAction + Resource pattern
  * ActionForm for CIDR creation (auto-clears after submit)
  * delete button dispatches DeleteNetwork action per row
  * Resource re-fetches after each create/delete via action.version()
  * Suspense shows "Loading…" while Resource is pending
- Register /networks route in app.rs with temporary nav bar
- Fix db.rs: create_pool now creates the SQLite file if missing
  (AnyPool has no create_if_missing option unlike SqlitePool)
- Remove redundant directory creation from main.rs (handled in db.rs)
- Fix ActionForm import: use leptos::form::ActionForm (not leptos_router)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 22:17:47 +02:00
75c13b261b feat(api): add Leptos server functions bridging client and server
- Add src/api/ module with server functions for networks, hosts, applications
- Each server function retrieves the pool via use_context::<AnyPool>()
- Pool is injected via provide_context in two places in main.rs:
  * leptos_routes_with_context: for SSR renders and inline server fn calls
  * handle_server_fns_with_context on /api/*fn_name: for WASM client calls
- create_host validates IP against network CIDR before inserting
- create_network validates CIDR format before inserting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 22:01:31 +02:00
a352a8edfd feat(repository): add CRUD layer for all domain entities
- Add server/repository/ module with networks, hosts, ports, applications
- Use sqlx::query() + manual row mapping (no compile-time DB required)
- Handle unique-constraint conflicts with is_unique_violation() for
  cross-database compatibility (SQLite + PostgreSQL via AnyPool)
- add_port_to_host auto-registers the port in the catalog (prevents FK errors)
- application_ports has no FK to ports (intentional: loose association)
- Add DbError::NotFound variant for missing-record cases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 21:52:32 +02:00
f13097591c feat(db): add SQLx migrations and AppState with connection pool
- Add sqlx 0.8 (AnyPool, runtime-tokio, sqlite, postgres, migrate)
- Create 6 migration files for both SQLite and PostgreSQL backends
- Add server/db.rs: create_pool and run_migrations helpers
- Add server/state.rs: AppState with LeptosOptions + AnyPool
- Run migrations at server startup before accepting requests
- Fix Port model: remove host_id (ports are now a global catalog)
- Add HostPort join struct for the host_ports many-to-many table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 21:46:16 +02:00
18804e740c style(i18n): translate all code and comments to English
Rename French identifiers to English across all source files:
  - validate_cidr / validate_ip_in_network (was valider_*)
  - known_protocol (was protocole_connu)
  - counter / doubled (was compteur / double)
  - InvalidCidr / InvalidIp / IpOutsideNetwork (was *Invalide / *HorsReseau)
  - test names and error messages

All comments, doc strings, .expect() messages, and tracing logs converted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 19:56:47 +02:00
4c11a8608b docs(claude): enforce English for code and comments
Add convention rule: all generated code and comments must follow
standard conventions and be written in English.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 19:51:41 +02:00
4e9eab0450 feat(models): add domain structs and CIDR validation
Add shared models (Network, Host, Port, Application, ApplicationPort)
with serde derives for Leptos server function serialization.
Add server/validation.rs with valider_ip_dans_reseau() and 5 unit tests.
Gate SSR-only modules (config, validation) with #[cfg(feature = "ssr")].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 19:48:52 +02:00
b6d1e22d25 feat(config): add database configuration layer with backend detection
Add AppConfig loaded from .env via dotenvy. DATABASE_URL prefix
determines the backend (sqlite:// → SQLite, postgresql:// → PostgreSQL).
ConfigError via thiserror gives clear messages on missing or unknown URLs.
Server logs the chosen backend at startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 19:44:55 +02:00
18134d6f4b fix(build): fix trunk output path and disable file hashing
Set dist=target/site/pkg so trunk outputs WASM alongside where Axum
serves /pkg/. Disable filehash so HydrationScripts can resolve
rust-ipam.js and rust-ipam_bg.wasm without content hash suffixes.
Add data-target-name to index.html to disambiguate lib from bin target.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 19:35:16 +02:00
acaa121658 Added architecture section 2026-05-15 19:33:37 +02:00
1746d9d922 fix(ssr): add Shell component and fix Leptos SSR configuration
Add Shell component wrapping the full HTML document (DOCTYPE, head, body)
required by leptos_meta. Add [package.metadata.leptos] to Cargo.toml and
switch get_configuration to Some("Cargo.toml"). Server now returns valid
HTML with title injection and WASM hydration scripts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 19:24:10 +02:00
efad573c3b feat(scaffold): add Axum + Leptos SSR base structure
Sets up the full project skeleton: Cargo.toml with ssr/hydrate features,
Axum server entry point, shared Leptos lib, root App component with router,
server/client module split, and Trunk config for WASM build.

Both `cargo check --features ssr` and `cargo check --features hydrate --target wasm32-unknown-unknown` pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 19:16:35 +02:00
45 changed files with 7408 additions and 4 deletions

18
.env.example Normal file
View 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
View File

@@ -1 +1,4 @@
/target
.env
data/
.DS_Store

View File

@@ -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 et définit par 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

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,102 @@
[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
[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
# 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
View 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
View 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>

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

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

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

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

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

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

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

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

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

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

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

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

107
src/api/applications.rs Normal file
View File

@@ -0,0 +1,107 @@
// api/applications.rs — Server functions for applications and their port associations
use leptos::prelude::*;
use crate::models::Application;
// ─── Queries ──────────────────────────────────────────────────────────────────
/// 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 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 ────────────────────────────────────────────────────────────────
/// Creates a new application and returns the created record.
#[server]
pub async fn create_application(name: String) -> Result<Application, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::applications as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
repo::create_application(&pool, &name)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// 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()))
}

222
src/api/hosts.rs Normal file
View File

@@ -0,0 +1,222 @@
// api/hosts.rs — Server functions for hosts
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use crate::models::Host;
// ─── 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
}
// ─── Queries ──────────────────────────────────────────────────────────────────
/// 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.
///
/// Filter parameters use sentinel values (0 / empty string) to mean "no filter":
/// - `name_filter` : substring match on host name (case-insensitive); "" = all
/// - `network_id_filter` : exact network id; 0 = all
/// - `port_filter` : hosts with this port open; 0 = all
/// - `application_id_filter` : hosts linked to this application; 0 = all
/// - `per_page` : items per page; 0 = return everything
/// - `page` : 1-indexed page number
///
/// The SQL uses each bind parameter twice in the WHERE clause
/// (once for the IS NULL guard, once for the actual comparison).
/// Each $N placeholder refers to the N-th bound argument by index.
#[server]
pub async fn get_hosts_page(
name_filter: String,
network_id_filter: i64,
port_filter: i64,
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"))?;
// Convert sentinel values to Option for SQL NULL binding.
// None → binds as SQL NULL → "$N IS NULL" evaluates to TRUE → filter skipped.
let name_like: Option<String> = if name_filter.is_empty() {
None
} else {
Some(format!("%{}%", name_filter))
};
let network_id: Option<i64> = if network_id_filter == 0 { None } else { Some(network_id_filter) };
let port: Option<i64> = if port_filter == 0 { None } else { Some(port_filter) };
let app_id: Option<i64> = if application_id_filter == 0 { None } else { Some(application_id_filter) };
// Each filter param is bound twice so the same $N can appear in both
// the IS NULL guard and the comparison without re-declaring parameters.
const WHERE: &str = "
JOIN networks n ON n.id = h.network_id
LEFT JOIN host_ports hp ON hp.host_id = h.id
LEFT JOIN application_ports ap ON ap.port_number = hp.port_number
WHERE ($1 IS NULL OR LOWER(h.name) LIKE LOWER($1))
AND ($2 IS NULL OR h.network_id = $2)
AND ($3 IS NULL OR EXISTS (
SELECT 1 FROM host_ports
WHERE host_id = h.id AND port_number = $3
))
AND ($4 IS NULL OR EXISTS (
SELECT 1 FROM host_ports hp2
JOIN application_ports ap2 ON ap2.port_number = hp2.port_number
WHERE hp2.host_id = h.id AND ap2.application_id = $4
))";
// Count matching hosts (ignoring pagination).
let count_sql = format!("SELECT COUNT(DISTINCT h.id) FROM hosts h {WHERE}");
let total: i64 = sqlx::query_scalar(&count_sql)
.bind(name_like.as_deref())
.bind(network_id)
.bind(port)
.bind(app_id)
.fetch_one(&pool)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
// Compute pagination bounds.
let safe_page = page.max(1);
let (limit, offset, total_pages) = if per_page <= 0 {
(1_000_000_000i64, 0i64, 1i64)
} else {
let tp = ((total + per_page - 1) / per_page).max(1);
(per_page, (safe_page - 1) * per_page, tp)
};
// Fetch the page of hosts with enriched columns.
let data_sql = format!(
"SELECT h.id, h.name, h.ip, h.network_id,
n.cidr AS network_cidr,
COUNT(DISTINCT hp.port_number) AS port_count,
COUNT(DISTINCT ap.application_id) AS application_count
FROM hosts h
{WHERE}
GROUP BY h.id, h.name, h.ip, h.network_id, n.cidr
ORDER BY h.name, h.id
LIMIT $5 OFFSET $6"
);
let rows = sqlx::query(&data_sql)
.bind(name_like.as_deref())
.bind(network_id)
.bind(port)
.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.
///
/// Validates that `ip` falls within the CIDR of `network_id`.
/// Returns an error if the network does not exist or the IP is out of range.
#[server]
pub async fn create_host(
name: String,
ip: String,
network_id: i64,
) -> Result<Host, ServerFnError> {
use sqlx::AnyPool;
use crate::server::{
repository::{hosts, networks},
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()))?;
hosts::create_host(&pool, &name, &ip, network_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
View 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;

125
src/api/networks.rs Normal file
View File

@@ -0,0 +1,125 @@
// 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)]
pub struct NetworkWithCounts {
pub id: i64,
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;
// `use_context` retrieves a value previously registered with `provide_context`.
// The pool was injected in main.rs before every request.
// `ok_or_else` converts `None` into an error (defensive: should never happen).
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
// Propagate any DB error as a ServerFnError so the client sees a clean message.
repo::list_networks(&pool)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Returns all networks enriched with host and application counts.
///
/// A single SQL query fetches everything at once using correlated subqueries,
/// avoiding N+1 round-trips regardless of the number of networks.
///
/// `application_count` = distinct applications whose registered ports appear
/// among the ports open on hosts in each network (via host_ports → application_ports).
#[server]
pub async fn get_networks_with_counts() -> Result<Vec<NetworkWithCounts>, ServerFnError> {
use sqlx::{AnyPool, Row};
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
let rows = sqlx::query(
"SELECT
n.id,
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"),
cidr: row.get("cidr"),
host_count: row.get("host_count"),
application_count: row.get("application_count"),
})
.collect();
Ok(networks)
}
// ─── Mutations ────────────────────────────────────────────────────────────────
/// Creates a new network with the given CIDR block.
///
/// Returns the created record (with its auto-generated id).
/// Returns an error if the CIDR is malformed or already exists.
#[server]
pub async fn create_network(cidr: String) -> Result<Network, ServerFnError> {
use sqlx::AnyPool;
use crate::server::{repository::networks as repo, validation::validate_cidr};
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
// Validate the CIDR before touching the database.
// Example of a valid CIDR: "192.168.1.0/24"
validate_cidr(&cidr).map_err(|e| ServerFnError::new(e.to_string()))?;
repo::create_network(&pool, &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
View 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 })
}

110
src/app.rs Normal file
View File

@@ -0,0 +1,110 @@
// 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::{home::HomePage, hosts::HostsPage, 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>
<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!("/hosts") view=HostsPage/>
</Routes>
</main>
</Router>
}
}

72
src/client/home.rs Normal file
View 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>
}
}

352
src/client/hosts.rs Normal file
View File

@@ -0,0 +1,352 @@
// client/hosts.rs — Hosts list page
//
// Displays all hosts across every network with:
// - Add form : create a host inside a chosen network
// - Filter bar : name (substring), network, open port, application
// - Table : name, IP, network, port count, application count, delete
// - Pagination : configurable page size (15 / 25 / 50 / 100 / All)
//
// Each sub-component calls `.into_any()` on its view to return `AnyView`
// (a type-erased wrapper). This prevents Rust from composing all the nested
// generic types into a single enormous type in the parent's monomorphization,
// which would otherwise overflow the compiler's query depth limit.
use leptos::prelude::*;
use leptos::form::ActionForm;
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"),
];
// ─── Add host form ────────────────────────────────────────────────────────────
#[component]
fn AddHostForm(
create_action: ServerAction<CreateHost>,
networks_res: Resource<Result<Vec<crate::models::Network>, ServerFnError>>,
) -> impl IntoView {
view! {
<section class="add-form">
<h2>"Add a host"</h2>
<ActionForm action=create_action>
<div class="add-form__fields">
<label>
"Name"
<input type="text" name="name" placeholder="e.g. web-server-01" required/>
</label>
<label>
"IP address"
<input type="text" name="ip" placeholder="e.g. 192.168.1.10" required/>
</label>
<label>
"Network"
<select name="network_id" required>
<option value="">"— choose —"</option>
{move || networks_res.get()
.and_then(|r| r.ok())
.map(|nets| nets.into_iter().map(|n| {
view! { <option value=n.id.to_string()>{n.cidr}</option> }
}).collect_view())
}
</select>
</label>
<button type="submit">"Add"</button>
</div>
</ActionForm>
{move || create_action.value().get()
.and_then(|r| r.err())
.map(|e| view! { <p class="error">{e.to_string()}</p> })
}
</section>
}.into_any()
}
// ─── Filter bar ───────────────────────────────────────────────────────────────
#[component]
fn FilterBar(
networks_res: Resource<Result<Vec<crate::models::Network>, ServerFnError>>,
applications_res: Resource<Result<Vec<crate::models::Application>, ServerFnError>>,
name_filter: RwSignal<String>,
network_id_filter: RwSignal<i64>,
port_filter: RwSignal<i64>,
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.ok())
.map(|nets| nets.into_iter().map(|n| {
view! { <option value=n.id.to_string()>{n.cidr}</option> }
}).collect_view())
}
</select>
</label>
<label class="filter-field">
"Open port"
<input
type="number"
min="1"
max="65535"
placeholder="e.g. 443"
on:change=move |e| {
port_filter.set(event_target_value(&e).parse().unwrap_or(0));
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.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>
// Page navigation — hidden when showing all results (per_page == 0)
{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 ───────────────────────────────────────────────────────────────
// Separate component for the table body to further reduce type depth in HostsPage.
#[component]
fn HostTable(
hosts: Resource<Result<HostsPageData, ServerFnError>>,
delete_action: ServerAction<DeleteHost>,
) -> impl IntoView {
view! {
<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 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;
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 |_| {
delete_action.dispatch(DeleteHost { id });
}>
"Delete"
</button>
</td>
</tr>
}
}).collect_view()}
</tbody>
</table>
</div>
}.into_any(),
})}
</Suspense>
}.into_any()
}
// ─── Main page component ──────────────────────────────────────────────────────
#[component]
pub fn HostsPage() -> impl IntoView {
// Actions
let create_action = ServerAction::<CreateHost>::new();
let delete_action = ServerAction::<DeleteHost>::new();
// Filter signals (0 / "" = no filter)
let name_filter = RwSignal::new(String::new());
let network_id_filter = RwSignal::new(0i64);
let port_filter = RwSignal::new(0i64);
let app_id_filter = RwSignal::new(0i64);
// Pagination signals
let page = RwSignal::new(1i64);
let per_page = RwSignal::new(15i64);
// Hosts resource — refetches whenever any filter/pagination/action changes
let hosts = Resource::new(
move || (
name_filter.get(),
network_id_filter.get(),
port_filter.get(),
app_id_filter.get(),
page.get(),
per_page.get(),
create_action.version().get(),
delete_action.version().get(),
),
|(name, net, port, app, p, pp, _, _)| get_hosts_page(name, net, port, app, p, pp),
);
// Dropdown resources (fetched once on mount)
let networks_res = Resource::new(|| (), |_| get_networks());
let applications_res = Resource::new(|| (), |_| get_applications());
// Derived pagination signals
let total_pages = Signal::derive(move || {
hosts.get().and_then(|r| r.ok()).map(|p| p.total_pages).unwrap_or(1)
});
let total = Signal::derive(move || {
hosts.get().and_then(|r| r.ok()).map(|p| p.total).unwrap_or(0)
});
view! {
<div class="hosts-page">
<h1>"Hosts"</h1>
<AddHostForm create_action=create_action networks_res=networks_res/>
<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 delete_action=delete_action/>
</section>
</div>
}.into_any()
}

15
src/client/mod.rs Normal file
View File

@@ -0,0 +1,15 @@
// 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 home; // Home page
pub mod hosts; // Hosts list with filters and pagination
pub mod networks; // Networks list and creation
pub mod theme; // Theme toggle component (light / dark / system)

155
src/client/networks.rs Normal file
View File

@@ -0,0 +1,155 @@
// client/networks.rs — Networks page
//
// Displays all CIDR networks managed by the IPAM and lets the user add or
// delete them. All data operations go through Leptos server functions
// (api/networks.rs), which run on the server and are called via HTTP
// from the browser after hydration.
//
// Key Leptos 0.7 concepts used here:
// - `ServerAction<F>` : wraps a `#[server]` function for use with forms / buttons
// - `Resource::new` : async data that re-fetches when its source signal changes
// - `action.version() : a Signal<usize> that increments after each dispatch,
// used here as a dependency to trigger list re-fetches
// - `<ActionForm>` : a form that submits to a ServerAction (no JS needed)
// - `<Suspense>` : shows a fallback while the Resource is loading
use leptos::prelude::*;
use leptos::form::ActionForm;
use crate::api::networks::{CreateNetwork, DeleteNetwork, get_networks_with_counts};
#[component]
pub fn NetworksPage() -> impl IntoView {
// ── Actions ───────────────────────────────────────────────────────────────
//
// `ServerAction<F>` binds a `#[server]` function to a reactive action.
// Under the hood it posts to `/api/<fn-name>` and updates its signals
// (.pending(), .value(), .version()) when the call completes.
let create_action = ServerAction::<CreateNetwork>::new();
let delete_action = ServerAction::<DeleteNetwork>::new();
// ── Data resource ─────────────────────────────────────────────────────────
//
// `Resource::new(source, fetcher)`:
// - source : a closure whose return value Leptos tracks reactively
// - fetcher : an async closure called whenever the source changes
//
// By reading `.version()` from both actions, the list automatically
// re-fetches after any create or delete, keeping the view in sync.
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>
// ── Add form ──────────────────────────────────────────────────────
//
// `<ActionForm action=create_action>` submits the form to the server
// function registered in `create_action`. The `name` attribute on
// each input must match the parameter name in `create_network(cidr: String)`.
// After submission the form clears itself automatically.
<section class="add-form">
<h2>"Add a network"</h2>
<ActionForm action=create_action>
<label>
"CIDR block"
<input
type="text"
name="cidr"
placeholder="e.g. 192.168.1.0/24"
required
/>
</label>
<button type="submit">"Add"</button>
</ActionForm>
// Show the error from the last create attempt, if any.
// `action.value().get()` → Option<Result<Network, ServerFnError>>
// `.and_then(|r| r.err())` extracts the error when present.
{move || {
create_action
.value()
.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>
// Show delete errors above the list.
{move || {
delete_action
.value()
.get()
.and_then(|r| r.err())
.map(|e| view! { <p class="error">"Delete failed: " {e.to_string()}</p> })
}}
// `<Suspense>` shows `fallback` while the Resource is loading,
// then switches to the children once data is available.
<Suspense fallback=|| view! { <p>"Loading networks…"</p> }>
{move || {
// `networks.get()` → None while loading, Some(result) once done.
// Returning None here keeps <Suspense> in its fallback state.
networks.get().map(|result| match result {
Err(e) => view! {
<p class="error">"Could not load networks: " {e.to_string()}</p>
}
.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>"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 id = network.id;
view! {
<tr>
<td>{network.cidr}</td>
<td class="col-count">{network.host_count}</td>
<td class="col-count">{network.application_count}</td>
<td class="col-actions">
<button on:click=move |_| {
delete_action
.dispatch(DeleteNetwork { id });
}>
"Delete"
</button>
</td>
</tr>
}
})
.collect_view()}
</tbody>
</table>
</div>
}
.into_any(),
})
}}
</Suspense>
</section>
</div>
}
}

136
src/client/theme.rs Normal file
View File

@@ -0,0 +1,136 @@
// 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.
#[cfg(target_arch = "wasm32")]
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(target_arch = "wasm32")]
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(target_arch = "wasm32")]
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(target_arch = "wasm32")]
apply_and_persist(&current);
// 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
View 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);
}

View File

@@ -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() {}

141
src/models.rs Normal file
View File

@@ -0,0 +1,141 @@
// 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,
/// 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 (01023) have standardized protocol assignments.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Port {
/// TCP/UDP port number (065535).
/// `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
View 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
View 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
View 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;

View File

@@ -0,0 +1,134 @@
// 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))
}
/// 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)
}
// ─── 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"),
}
}

View File

@@ -0,0 +1,86 @@
// 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))
}
/// 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"),
}
}

View 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 065535 fit in u16).
pub mod applications;
pub mod hosts;
pub mod networks;
pub mod ports;

View File

@@ -0,0 +1,79 @@
// 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, 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, 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, cidr: &str) -> Result<Network, DbError> {
let row = sqlx::query("INSERT INTO networks (cidr) VALUES ($1) RETURNING id, cidr")
.bind(cidr)
.fetch_one(pool) // exactly one row is returned by RETURNING
.await?;
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"),
cidr: row.get("cidr"),
}
}

View 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` (065535).
// 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
View 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
View 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
View 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);
}
}

908
style/rust-ipam.css Normal file
View File

@@ -0,0 +1,908 @@
/* ============================================================
DESIGN TOKENS
All visual properties live as CSS custom properties.
To add a new theme, define a [data-theme="my-theme"] block
with the same variable set below.
============================================================ */
:root {
/* --- Shared tokens (identical across all themes) --- */
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI",
Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: "SF Mono", "Fira Code", "Cascadia Code", "Consolas", monospace;
--size-xs: 4px;
--size-sm: 8px;
--size-md: 16px;
--size-lg: 24px;
--size-xl: 40px;
--size-2xl: 64px;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-pill: 999px;
--font-xs: 12px;
--font-sm: 13px;
--font-base: 15px;
--font-lg: 17px;
--font-xl: 22px;
--font-2xl: 28px;
--font-3xl: 34px;
--transition-fast: 100ms ease;
--transition-base: 200ms ease;
--nav-height: 52px;
--content-width: 1000px;
/* --- Light theme (default) --- */
--bg: #f5f5f7;
--bg-surface: #ffffff;
--bg-surface2: #f9f9fb;
--bg-hover: rgba(0, 0, 0, 0.04);
--bg-overlay: rgba(255, 255, 255, 0.8);
--border: rgba(0, 0, 0, 0.12);
--border-focus: #0071e3;
--text: #1d1d1f;
--text-secondary: #6e6e73;
--text-tertiary: #aeaeb2;
--text-on-accent: #ffffff;
--accent: #0071e3;
--accent-hover: #0077ed;
--accent-light: rgba(0, 113, 227, 0.10);
--danger: #ff3b30;
--danger-hover: #ff2d20;
--danger-light: rgba(255, 59, 48, 0.10);
--success: #34c759;
--shadow-sm: 0 1px 2px rgba(0,0,0,.06), 0 1px 4px rgba(0,0,0,.04);
--shadow-md: 0 4px 12px rgba(0,0,0,.08), 0 2px 4px rgba(0,0,0,.04);
--shadow-lg: 0 8px 24px rgba(0,0,0,.12), 0 4px 8px rgba(0,0,0,.06);
}
/* System dark mode — only applies when no explicit data-theme is set */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--bg: #000000;
--bg-surface: #1c1c1e;
--bg-surface2: #2c2c2e;
--bg-hover: rgba(255, 255, 255, 0.06);
--bg-overlay: rgba(28, 28, 30, 0.85);
--border: rgba(255, 255, 255, 0.12);
--border-focus: #0a84ff;
--text: #f5f5f7;
--text-secondary: #aeaeb2;
--text-tertiary: #636366;
--text-on-accent: #ffffff;
--accent: #0a84ff;
--accent-hover: #409cff;
--accent-light: rgba(10, 132, 255, 0.15);
--danger: #ff453a;
--danger-hover: #ff6961;
--danger-light: rgba(255, 69, 58, 0.15);
--success: #32d74b;
--shadow-sm: 0 1px 2px rgba(0,0,0,.3), 0 1px 4px rgba(0,0,0,.2);
--shadow-md: 0 4px 12px rgba(0,0,0,.4), 0 2px 4px rgba(0,0,0,.2);
--shadow-lg: 0 8px 24px rgba(0,0,0,.5), 0 4px 8px rgba(0,0,0,.3);
}
}
/* Explicit light theme */
[data-theme="light"] {
--bg: #f5f5f7;
--bg-surface: #ffffff;
--bg-surface2: #f9f9fb;
--bg-hover: rgba(0, 0, 0, 0.04);
--bg-overlay: rgba(255, 255, 255, 0.8);
--border: rgba(0, 0, 0, 0.12);
--border-focus: #0071e3;
--text: #1d1d1f;
--text-secondary: #6e6e73;
--text-tertiary: #aeaeb2;
--text-on-accent: #ffffff;
--accent: #0071e3;
--accent-hover: #0077ed;
--accent-light: rgba(0, 113, 227, 0.10);
--danger: #ff3b30;
--danger-hover: #ff2d20;
--danger-light: rgba(255, 59, 48, 0.10);
--success: #34c759;
--shadow-sm: 0 1px 2px rgba(0,0,0,.06), 0 1px 4px rgba(0,0,0,.04);
--shadow-md: 0 4px 12px rgba(0,0,0,.08), 0 2px 4px rgba(0,0,0,.04);
--shadow-lg: 0 8px 24px rgba(0,0,0,.12), 0 4px 8px rgba(0,0,0,.06);
}
/* Explicit dark theme */
[data-theme="dark"] {
--bg: #000000;
--bg-surface: #1c1c1e;
--bg-surface2: #2c2c2e;
--bg-hover: rgba(255, 255, 255, 0.06);
--bg-overlay: rgba(28, 28, 30, 0.85);
--border: rgba(255, 255, 255, 0.12);
--border-focus: #0a84ff;
--text: #f5f5f7;
--text-secondary: #aeaeb2;
--text-tertiary: #636366;
--text-on-accent: #ffffff;
--accent: #0a84ff;
--accent-hover: #409cff;
--accent-light: rgba(10, 132, 255, 0.15);
--danger: #ff453a;
--danger-hover: #ff6961;
--danger-light: rgba(255, 69, 58, 0.15);
--success: #32d74b;
--shadow-sm: 0 1px 2px rgba(0,0,0,.3), 0 1px 4px rgba(0,0,0,.2);
--shadow-md: 0 4px 12px rgba(0,0,0,.4), 0 2px 4px rgba(0,0,0,.2);
--shadow-lg: 0 8px 24px rgba(0,0,0,.5), 0 4px 8px rgba(0,0,0,.3);
}
/* ============================================================
RESET & BASE
============================================================ */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: var(--font-base);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
font-family: var(--font-sans);
font-size: var(--font-base);
line-height: 1.6;
color: var(--text);
background: var(--bg);
min-height: 100vh;
transition: background var(--transition-base), color var(--transition-base);
}
/* ============================================================
NAVIGATION
============================================================ */
nav {
position: sticky;
top: 0;
z-index: 100;
height: var(--nav-height);
display: flex;
align-items: center;
gap: var(--size-sm);
padding: 0 var(--size-lg);
background: var(--bg-overlay);
border-bottom: 1px solid var(--border);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
}
nav a {
font-size: var(--font-sm);
font-weight: 500;
color: var(--text-secondary);
text-decoration: none;
padding: var(--size-xs) var(--size-sm);
border-radius: var(--radius-sm);
transition: color var(--transition-fast), background var(--transition-fast);
}
nav a:hover {
color: var(--text);
background: var(--bg-hover);
}
nav a.active {
color: var(--accent);
}
/* Spacer pushes theme toggle to the right */
nav .nav-spacer {
flex: 1;
}
/* ============================================================
LAYOUT
============================================================ */
main {
max-width: var(--content-width);
margin: 0 auto;
padding: var(--size-xl) var(--size-lg);
}
/* ============================================================
TYPOGRAPHY
============================================================ */
h1 {
font-size: var(--font-3xl);
font-weight: 700;
letter-spacing: -0.5px;
color: var(--text);
margin-bottom: var(--size-sm);
}
h2 {
font-size: var(--font-xl);
font-weight: 600;
letter-spacing: -0.3px;
color: var(--text);
margin-bottom: var(--size-md);
}
h3 {
font-size: var(--font-lg);
font-weight: 600;
color: var(--text);
margin-bottom: var(--size-sm);
}
p {
color: var(--text-secondary);
line-height: 1.7;
}
code {
font-family: var(--font-mono);
font-size: 0.9em;
background: var(--bg-surface2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 1px 6px;
}
/* ============================================================
BUTTONS
============================================================ */
button,
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--size-xs);
font-family: var(--font-sans);
font-size: var(--font-sm);
font-weight: 500;
line-height: 1;
padding: 8px 16px;
border-radius: var(--radius-sm);
border: 1px solid transparent;
cursor: pointer;
transition:
background var(--transition-fast),
color var(--transition-fast),
box-shadow var(--transition-fast),
transform var(--transition-fast);
text-decoration: none;
white-space: nowrap;
user-select: none;
}
button:active,
.btn:active {
transform: scale(0.98);
}
/* Primary — filled accent */
button[type="submit"],
.btn-primary {
background: var(--accent);
color: var(--text-on-accent);
border-color: var(--accent);
}
button[type="submit"]:hover,
.btn-primary:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
box-shadow: var(--shadow-sm);
}
/* Secondary — outlined */
.btn-secondary {
background: transparent;
color: var(--text);
border-color: var(--border);
}
.btn-secondary:hover {
background: var(--bg-hover);
}
/* Danger — for delete actions */
.btn-danger {
background: transparent;
color: var(--danger);
border-color: transparent;
font-size: var(--font-xs);
padding: 4px 10px;
}
.btn-danger:hover {
background: var(--danger-light);
border-color: var(--danger);
}
/* Theme toggle */
.theme-toggle {
background: var(--bg-hover);
color: var(--text-secondary);
border: 1px solid var(--border);
font-size: var(--font-xs);
font-weight: 600;
padding: 4px 10px;
border-radius: var(--radius-pill);
letter-spacing: 0.3px;
}
.theme-toggle:hover {
color: var(--text);
background: var(--bg-surface2);
border-color: var(--border-focus);
}
/* ============================================================
FORMS & INPUTS
============================================================ */
.form-group {
display: flex;
flex-direction: column;
gap: var(--size-xs);
margin-bottom: var(--size-md);
}
label {
display: flex;
flex-direction: column;
gap: var(--size-xs);
font-size: var(--font-sm);
font-weight: 500;
color: var(--text-secondary);
}
input[type="text"],
input[type="email"],
input[type="number"],
input[type="password"],
input[type="search"],
select,
textarea {
font-family: var(--font-sans);
font-size: var(--font-base);
color: var(--text);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 9px 12px;
width: 100%;
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast);
outline: none;
-webkit-appearance: none;
}
input::placeholder,
textarea::placeholder {
color: var(--text-tertiary);
}
input:focus,
select:focus,
textarea:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--accent-light);
}
/* Inline form layout (label + input + button on one line) */
.form-inline {
display: flex;
align-items: flex-end;
gap: var(--size-sm);
flex-wrap: wrap;
}
.form-inline label {
flex: 1;
min-width: 200px;
}
/* ============================================================
CARDS & SECTIONS
============================================================ */
.card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: var(--size-lg);
transition: box-shadow var(--transition-base);
}
.card:hover {
box-shadow: var(--shadow-md);
}
/* Section spacing between cards */
section + section {
margin-top: var(--size-lg);
}
/* ============================================================
TABLES
============================================================ */
.table-container {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-sm);
}
thead {
background: var(--bg-surface2);
border-bottom: 1px solid var(--border);
}
thead th {
padding: 10px var(--size-md);
text-align: left;
font-size: var(--font-xs);
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
color: var(--text-tertiary);
}
tbody tr {
border-bottom: 1px solid var(--border);
transition: background var(--transition-fast);
}
tbody tr:last-child {
border-bottom: none;
}
tbody tr:hover {
background: var(--bg-hover);
}
tbody td {
padding: 12px var(--size-md);
color: var(--text);
vertical-align: middle;
}
/* Count columns (Hosts, Applications...) — right-aligned, muted color */
th.col-count,
td.col-count {
text-align: right;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
width: 120px;
}
th.col-count {
color: var(--text-tertiary);
}
/* Actions column — right-aligned to match the button position */
th.col-actions,
td.col-actions {
text-align: right;
width: 100px;
}
/* ============================================================
STATUS MESSAGES
============================================================ */
.error {
display: flex;
align-items: center;
gap: var(--size-sm);
font-size: var(--font-sm);
color: var(--danger);
background: var(--danger-light);
border: 1px solid var(--danger);
border-radius: var(--radius-sm);
padding: var(--size-sm) var(--size-md);
margin-bottom: var(--size-md);
}
.empty {
font-size: var(--font-sm);
color: var(--text-tertiary);
text-align: center;
padding: var(--size-xl);
}
/* ============================================================
HOME PAGE — Dashboard
============================================================ */
.home-page {
max-width: 860px;
margin: 0 auto;
padding-top: var(--size-2xl);
}
.home-header {
text-align: center;
margin-bottom: var(--size-2xl);
}
.home-header h1 {
font-size: var(--font-3xl);
margin-bottom: var(--size-xs);
}
.home-header p {
font-size: var(--font-lg);
color: var(--text-secondary);
margin-bottom: 0;
}
/* Summary grid — one card per entity */
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--size-md);
}
/* Clickable summary card */
.summary-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--size-sm);
padding: var(--size-xl) var(--size-lg);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
text-decoration: none;
cursor: pointer;
transition:
box-shadow var(--transition-base),
border-color var(--transition-base),
transform var(--transition-base),
background var(--transition-base);
}
.summary-card:hover {
box-shadow: var(--shadow-md);
border-color: var(--accent);
background: var(--accent-light);
transform: translateY(-2px);
}
.summary-card:active {
transform: translateY(0);
box-shadow: var(--shadow-sm);
}
.summary-card__count {
font-size: var(--font-3xl);
font-weight: 700;
letter-spacing: -1px;
color: var(--text);
line-height: 1;
/* Animate count changes gracefully */
transition: color var(--transition-base);
}
.summary-card:hover .summary-card__count {
color: var(--accent);
}
.summary-card__label {
font-size: var(--font-sm);
font-weight: 500;
color: var(--text-secondary);
letter-spacing: 0.3px;
text-transform: uppercase;
}
/* ============================================================
NETWORKS PAGE
============================================================ */
.networks-page h1 {
margin-bottom: var(--size-lg);
}
.networks-page .add-form {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: var(--size-lg);
margin-bottom: var(--size-lg);
}
.networks-page .add-form h2 {
margin-bottom: var(--size-md);
}
.networks-page .add-form form {
display: flex;
align-items: flex-end;
gap: var(--size-sm);
flex-wrap: wrap;
}
.networks-page .add-form label {
flex: 1;
min-width: 220px;
}
.networks-page .add-form input {
margin-top: var(--size-xs);
}
.networks-page .list h2 {
margin-bottom: var(--size-md);
}
.networks-page .list .table-container {
margin-top: 0;
}
/* Delete button inside table */
.networks-page td button {
background: transparent;
color: var(--danger);
border: 1px solid transparent;
font-size: var(--font-xs);
padding: 3px 10px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.networks-page td button:hover {
background: var(--danger-light);
border-color: var(--danger);
}
/* ============================================================
404 PAGE
============================================================ */
.not-found {
text-align: center;
padding-top: var(--size-2xl);
}
.not-found h1 {
font-size: var(--font-2xl);
margin-bottom: var(--size-md);
}
.not-found a {
color: var(--accent);
text-decoration: none;
font-weight: 500;
}
.not-found a:hover {
text-decoration: underline;
}
/* ============================================================
TABLE UTILITIES — shared across pages
============================================================ */
/* Clickable link inside a table cell */
.table-link {
color: var(--accent);
text-decoration: none;
font-weight: 500;
}
.table-link:hover {
text-decoration: underline;
}
/* Monospace cell (IPs, CIDRs…) */
.cell-mono {
font-family: var(--font-mono);
font-size: var(--font-sm);
color: var(--text-secondary);
}
/* ============================================================
FILTER BAR
============================================================ */
.filter-bar {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: var(--size-md) var(--size-lg);
margin-bottom: var(--size-md);
}
.filter-bar__fields {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--size-md);
align-items: end;
}
.filter-field {
display: flex;
flex-direction: column;
gap: var(--size-xs);
font-size: var(--font-sm);
font-weight: 500;
color: var(--text-secondary);
}
/* ============================================================
PAGINATION BAR
============================================================ */
.pagination-bar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--size-sm);
padding: var(--size-sm) 0;
margin-bottom: var(--size-sm);
}
.pagination-bar__info {
font-size: var(--font-sm);
color: var(--text-secondary);
}
.pagination-bar__controls {
display: flex;
align-items: center;
gap: var(--size-md);
}
.pagination-per-page {
display: flex;
align-items: center;
gap: var(--size-xs);
font-size: var(--font-sm);
color: var(--text-secondary);
font-weight: 500;
}
.pagination-per-page select {
width: auto;
padding: 4px 8px;
font-size: var(--font-sm);
}
.pagination-nav {
display: flex;
align-items: center;
gap: var(--size-xs);
}
.pagination-nav button {
background: var(--bg-surface);
color: var(--text);
border: 1px solid var(--border);
padding: 4px 10px;
font-size: var(--font-base);
border-radius: var(--radius-sm);
min-width: 32px;
}
.pagination-nav button:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--accent);
color: var(--accent);
}
.pagination-nav button:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.pagination-nav__label {
font-size: var(--font-sm);
color: var(--text-secondary);
white-space: nowrap;
padding: 0 var(--size-xs);
}
/* ============================================================
HOSTS PAGE
============================================================ */
.hosts-page h1 {
margin-bottom: var(--size-lg);
}
.hosts-page .add-form {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: var(--size-lg);
margin-bottom: var(--size-md);
}
.hosts-page .add-form h2 {
margin-bottom: var(--size-md);
}
.add-form__fields {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--size-md);
align-items: end;
}
.add-form__fields button[type="submit"] {
align-self: end;
}
/* Delete button inside hosts table */
.hosts-page td button {
background: transparent;
color: var(--danger);
border: 1px solid transparent;
font-size: var(--font-xs);
padding: 3px 10px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.hosts-page td button:hover {
background: var(--danger-light);
border-color: var(--danger);
}