Compare commits

..

53 Commits

Author SHA1 Message Date
e68845a2ce fix(navigation): back button on host detail returns to application when coming from there
- application_detail: host links now include ?back=/applications/:id
- host_detail: back_label handles /applications/ prefix → "← Application"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 23:01:05 +02:00
5b3170c6d1 feat(seed): add homelab applications with associated ports
Add 16 common homelab applications (Nginx, Pi-hole, WireGuard, OpenVPN,
PostgreSQL, MariaDB, Redis, Grafana, Prometheus, Elasticsearch, Kibana,
Portainer, Jellyfin, Home Assistant, Syncthing, Vaultwarden) with their
standard port associations.

Also extends the ports catalog with 10 new entries (WireGuard, Syncthing,
Jellyfin, Home Assistant, Portainer, MariaDB, Redis, etc.) and logs the
application count in the seed binary output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 22:51:46 +02:00
624839849f fix(host-detail): restore flex-grow on app link to right-align remove button
The <span class="app-row__name"> (flex: 1) was replaced by an <a> without
that class, so the Remove button lost its right-alignment. Adding
app-row__name to the <a> restores the layout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 22:43:55 +02:00
255f20cda4 feat(navigation): link app names on host detail to app detail with back button
- Host detail: application names are now clickable links to
  /applications/:id?back=/hosts/:host_id
- Application detail: back button reads the ?back query param and
  returns to the originating host page (label "← Host") or falls
  back to /applications ("← Applications")

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 22:38:39 +02:00
a8d98aeee2 style(applications): use btn-danger class on table delete button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 22:22:35 +02:00
bef28f44a1 feat(applications): add application detail page
- New page /applications/:id with identity (editable name), associated
  ports (add/remove), linked hosts (read-only via shared ports), and
  delete with confirmation modal
- Add get_application_detail and update_application server functions
- Add ApplicationDetail and HostRef types in api/applications
- Add update_application to the repository layer
- Application names in the list are now clickable links

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 22:17:49 +02:00
cf0a095ada feat(applications): add ports field to create application form
The add-application modal now accepts a comma-separated list of port
numbers (same UX as the add-host form). Ports are associated with the
new application atomically in create_application on the server side.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 22:06:24 +02:00
353fe09a99 fix(hosts,applications): fix wasm-bindgen panic when closing modal
Closing a modal by clicking the backdrop, Cancel, or × called
show_modal.set(false) synchronously inside a wasm-bindgen closure.
Leptos immediately unmounts the modal, freeing all its closures
while the click handler is still on the call stack, which causes
wasm-bindgen to panic with "closure invoked after being dropped".

Fix: introduce a close() helper that defers set(false) to the next
microtask via spawn_local, so the closure returns to wasm-bindgen
before the modal is unmounted.

Also switch autofocus from Effect+get() to spawn_local+get_untracked()
to avoid subscribing NodeRef as a reactive dependency, which would
re-trigger during unmount and risk the same panic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:49:33 +02:00
5228a76468 fix(hosts,applications): fix modal re-open bug and autofocus first field
Move the add-modal auto-close Effect from each modal component to its
parent page component. This prevents the stale-value re-trigger bug
where the Effect would immediately close the modal on second open
because action.value() still held the previous Ok result.

Also add autofocus on the first input field of each add modal using
NodeRef<Input> so the user can start typing immediately on open.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:33:33 +02:00
60e02ca453 fix(host-detail): move modals outside Suspense and auto-close Effect to parent
Modals rendered inside <Suspense> were unmounted each time the host
resource re-fetched, killing their reactive subscriptions and preventing
them from reopening. Moving them to the <div> level above <Suspense>
keeps them alive across re-fetches.

The auto-close Effect for the add-app modal is also moved from
AddAppModal to HostDetailPage so it is never recreated across
open/close cycles, avoiding the stale-value re-trigger bug.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:33:28 +02:00
052711b720 fix(host-detail): fix add-app modal not reopening after successful addition
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:19:04 +02:00
f5058bd54a fix(host-detail): switch host resource to LocalResource to fix hydration mismatch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:12:30 +02:00
54a5c2525f feat(host-detail): replace checkboxes with pick list + selected tags in add-app modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:07:58 +02:00
359d67fabc fix(hosts): switch hosts resource to LocalResource to fix hydration warning
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 03:12:14 +02:00
5789aba86b feat(host-detail): add direct host-application association with modal multi-select
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 03:08:58 +02:00
a6ce382eb5 chore(build): exclude seed binary from WASM build via required-features
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:58:35 +02:00
1b55b13541 feat(applications): replace inline add form with modal and add name filter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:53:40 +02:00
4d0be98160 feat(applications): add applications list page with host count and delete modal
- API: ApplicationWithCounts struct + get_applications_with_counts() — counts
  distinct hosts linked via matching ports (application_ports ↔ host_ports)
- ApplicationsPage at /applications: inline add form, table with Name and
  Hosts columns, delete confirmation modal showing affected host count
- Nav: add Applications link

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:48:53 +02:00
62e9609fe8 feat(hosts): add delete confirmation modal on hosts list page
Replace the direct dispatch on the Delete button with a pending_delete
signal (id + name). A DeleteHostModal identical to the one in host detail
opens for confirmation before the action is dispatched. The modal closes
automatically after a successful deletion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:45:33 +02:00
7274157a80 fix(network-detail): wrap disabled attr in braces to fix pagination next button
The view! macro misparses `disabled=move || expr >= other` because >= without
braces is ambiguous — the rest of the expression renders as text content.
Fix: `disabled={move || ...}`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:42:29 +02:00
c3e2d5dcf6 feat(networks): add network detail page with paginated host list and contextual back button
- API: add get_network(id) server function
- NetworkDetailPage at /networks/:id — network name + CIDR header, paginated
  host table (Name, IP, Ports, Apps) linking to /hosts/:id?back=/networks/:id
- Networks list: make network name a link to its detail page
- HostDetailPage: read ?back= query param to show "← Network" or "← Hosts"
  and navigate to the correct destination

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:37:06 +02:00
ba4d2a60c6 style(host-detail): centre host title, move delete button to bottom-right
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:30:19 +02:00
577a655aee style(host-detail): polish buttons, back link and port list
- Save changes / Add port: add btn-primary class for consistent blue accent
- Back button: stacked above page title, styled as a small bordered button
- Port list: remove row background, replace full border with bottom separator only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:26:40 +02:00
2a6d925e59 feat(hosts): add host detail page with identity edit, port management and delete
- Repository: add update_host (name, IP, network reassignment with CIDR validation)
- API: get_host_detail (host + resolved network + ports), update_host,
  add_host_port, remove_host_port server functions
- Client: HostDetailPage at /hosts/:id — identity form, ports list with
  per-port Remove button, Add port input, delete confirmation modal with
  navigation back to /hosts on success
- CSS: detail-section cards, port-row list, btn-danger-solid, back-link

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:21:00 +02:00
0221ce26f9 feat(seeds): add port catalog and host port assignments to dev seed
Adds 25 common ports (SSH, HTTP/S, SMTP, PostgreSQL, etc.) to the ports
catalog and assigns realistic open ports to each seeded host based on its
role (web server, database, NAS, VPN gateway, etc.).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:11:52 +02:00
d2284727a2 fix(hosts): use LocalResource for network/app dropdowns to fix hydration blank
Resource::new() with SSR returns None during hydration outside <Suspense>,
causing dropdowns to stay empty on direct page load. LocalResource fetches
client-side only, bypassing the hydration mismatch entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:08:53 +02:00
eef0ae0b54 fix(hosts): remove port filter hint to fix filter bar alignment
The field-hint span made the port field taller than others; with
align-items: end on the grid, the input was offset upward.
The placeholder now carries the same information.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:58:39 +02:00
19dda00c17 fix(config): add bin-target to avoid cargo-leptos/seed binary conflict
Without bin-target, cargo-leptos fails when multiple binaries exist
(rust-ipam + seed). Specifying the main server binary fixes the issue.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:57:26 +02:00
df6aecef51 fix(repository): add missing name column in find_network query
The sed replacement during the network name feature didn't update
find_network's SELECT, causing a ColumnNotFound panic on host creation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:55:35 +02:00
6018874aa4 feat(hosts): multi-port filter and port list on host creation
- Network dropdowns now show "Name - CIDR" in both filter bar and add modal
- Port filter accepts comma-separated ports (e.g. "80, 443"); a host must
  have ALL listed ports open to match (AND semantics)
- Add host modal has a new "Open ports" field (comma-separated); ports are
  registered in the catalog and linked to the host on creation
- Port conditions are inlined as validated integers in SQL (no injection risk)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:50:26 +02:00
e0ddf58a17 docs(claude): fix network architecture description to include name field
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:40:49 +02:00
d9ee121fbb feat(networks): add name field to networks
- Migration 0007: ALTER TABLE networks ADD COLUMN name TEXT NOT NULL DEFAULT ''
- Network model, repository, and API updated to include name
- Networks page: name input in the add form, Name column as first column in table
- Delete modal now shows "Name (CIDR)" for clarity
- Hosts page: network dropdowns now show network name instead of CIDR
- Seeds updated with names (LAN, DMZ, Corporate, VPN)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:38:40 +02:00
e17b8ee722 feat(seed): add dev seed binary with networks and hosts
Creates a self-contained `seed` binary (cargo run --features ssr --bin seed)
that loads realistic test data into the database. Idempotent: safe to run
multiple times without creating duplicates.

Data: 4 networks (LAN, DMZ, corporate, VPN) and 17 hosts spread across them.
Both SQLite and PostgreSQL seed files are provided.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:33:21 +02:00
55d8ed9f72 feat(networks): add delete confirmation modal with host count warning
Show a modal before deleting a network. If the network has hosts,
display a warning with the exact count since they will be cascade-deleted.
Host count comes from the existing NetworkWithCounts data (no extra query).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:28:12 +02:00
30dd1ad0b0 fix(config): wire cargo-leptos features and CSS source file
- Add bin-features/lib-features so cargo-leptos enables ssr/hydrate
  correctly (server was exiting immediately with empty main otherwise)
- Add style-file so the CSS bundle is no longer empty
- Replace #[cfg(target_arch = "wasm32")] with #[cfg(feature = "hydrate")]
  in theme.rs to match when web-sys is actually available

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:25:10 +02:00
a4fc5b176f feat(hosts): replace inline add form with modal dialog
The add-host form is now opened via a "+ Add host" button in the page
header. The modal closes on Cancel, backdrop click, × button, or
automatically after a successful creation.

Adds modal CSS with backdrop blur and entry animation, .btn-primary /
.btn-secondary shared button styles, and a .page-header flex layout
reusable across list pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 23:57:08 +02:00
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
56 changed files with 10579 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 est définit par son nom et son CIDR (ex: 192.168.1.0/24)
- L'application peut gérer plusieurs réseaux distinct.
- Chaques hôtes doit appartenir au réseaux dans lequel il est définit.

3646
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,116 @@
[package]
name = "rust-ipam"
version = "0.1.0"
edition = "2024"
edition = "2021"
# Leptos nécessite deux formats de compilation :
# - rlib : bibliothèque normale, utilisée par le serveur Axum
# - cdylib : bibliothèque dynamique compilée en WebAssembly pour le navigateur
[[bin]]
name = "rust-ipam"
path = "src/main.rs"
[[bin]]
name = "seed"
path = "src/bin/seed.rs"
required-features = ["ssr"]
[lib]
crate-type = ["cdylib", "rlib"]
# Les "features" permettent d'activer du code conditionnellement selon le contexte.
# On compile deux fois le même code : une fois en mode "ssr" (serveur), une fois "hydrate" (WASM).
[features]
# Mode serveur : active Axum, Tokio, et le rendu HTML côté serveur
ssr = [
"dep:axum",
"dep:tokio",
"dep:tower-http",
"dep:leptos_axum",
"dep:tracing-subscriber",
"dep:dotenvy",
"dep:ipnetwork",
"dep:sqlx",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
]
# Mode client : compile l'application en WebAssembly pour le navigateur
# Note : seul `leptos` expose une feature "hydrate" ; leptos_meta et leptos_router
# n'en ont pas besoin — ils s'adaptent automatiquement au mode de compilation.
hydrate = [
"dep:console_error_panic_hook",
"dep:wasm-bindgen",
"dep:web-sys",
"leptos/hydrate",
]
[dependencies]
# --- Dépendances partagées (compilées côté serveur ET client) ---
# Framework UI réactif full-stack : le cœur du projet
leptos = { version = "0.7", features = [] }
# Gestion des balises HTML <head> (title, meta tags, liens CSS...)
leptos_meta = { version = "0.7", features = [] }
# Routeur : associe des URLs à des composants, côté serveur et client
leptos_router = { version = "0.7", features = [] }
# Dérive automatiquement des types d'erreurs idiomatiques Rust
thiserror = "1"
# Macros pour les logs : tracing::info!(), tracing::error!()...
tracing = "0.1"
# Sérialisation/désérialisation — nécessaire pour les server functions Leptos
# (les types de retour doivent traverser la frontière server ↔ client)
serde = { version = "1", features = ["derive"] }
# --- Dépendances serveur uniquement (activées par la feature "ssr") ---
# Serveur HTTP asynchrone rapide et ergonomique
axum = { version = "0.7", optional = true }
# Runtime asynchrone Rust (nécessaire pour `async fn main()` et les Futures)
tokio = { version = "1", features = ["full"], optional = true }
# Pont entre Leptos et Axum : SSR, server functions, streaming...
leptos_axum = { version = "0.7", optional = true }
# Middleware HTTP : sert les fichiers statiques (CSS, WASM compilé, images...)
tower-http = { version = "0.5", features = ["fs"], optional = true }
# Formateur de logs pour le terminal (affiche les messages tracing::info!...)
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
# Charge automatiquement le fichier .env au démarrage du serveur
dotenvy = { version = "0.15", optional = true }
# Parsing et calcul de plages d'adresses IP (CIDR) — ex: 192.168.1.0/24
ipnetwork = { version = "0.20", optional = true }
# Database access: connection pools, queries, migrations — SQLite + PostgreSQL
# "any" = runtime-dispatched driver (same code works with both backends)
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "sqlite", "postgres", "migrate", "any"], optional = true }
# --- Dépendances client uniquement (activées par la feature "hydrate") ---
# Affiche les panics Rust dans la console du navigateur (indispensable pour déboguer)
console_error_panic_hook = { version = "0.1", optional = true }
# Pont entre Rust/WASM et JavaScript : permet d'appeler du JS depuis Rust
wasm-bindgen = { version = "0.2", optional = true }
# Bindings aux APIs du navigateur : window, document, localStorage, Element...
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Storage"], optional = true }
# Configuration Leptos lue par get_configuration(Some("Cargo.toml"))
# Définit les chemins des fichiers compilés et l'adresse du serveur.
[package.metadata.leptos]
output-name = "rust-ipam" # Nom de base des fichiers .wasm et .js générés
site-root = "target/site" # Dossier racine des fichiers compilés par trunk
site-pkg-dir = "pkg" # Sous-dossier des assets WASM/JS dans site-root
site-addr = "127.0.0.1:3000" # Adresse d'écoute du serveur Axum
reload-port = 3001 # Port WebSocket pour le hot-reload en développement
style-file = "style/rust-ipam.css" # Source CSS compilé dans pkg/rust-ipam.css
# Features activées par cargo-leptos lors du build
bin-target = "rust-ipam" # Main server binary (excludes src/bin/seed.rs)
bin-features = ["ssr"] # SSR binary (Axum server)
lib-features = ["hydrate"] # WASM bundle (browser)
# Profil de compilation WASM optimisé pour réduire la taille du fichier .wasm
# Un fichier WASM plus petit = page qui charge plus vite
[profile.wasm-release]
inherits = "release"
opt-level = "z" # Optimise pour la taille (z) plutôt que la vitesse (3)
lto = true # Link-Time Optimization : élimine le code mort entre crates
codegen-units = 1 # Un seul thread de codegen = meilleure optimisation globale
panic = "abort" # Pas de stack unwinding = binaire plus petit

29
Trunk.toml Normal file
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,3 @@
-- Add a human-readable name to networks.
-- DEFAULT '' allows the migration to run on databases that already have rows.
ALTER TABLE networks ADD COLUMN name TEXT NOT NULL DEFAULT '';

View File

@@ -0,0 +1,8 @@
-- host_applications: direct association between a host and an application.
-- Allows explicitly tagging a host with an application regardless of ports.
-- One application can only be linked once to a given host.
CREATE TABLE IF NOT EXISTS host_applications (
host_id BIGINT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
application_id BIGINT NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
PRIMARY KEY (host_id, application_id)
);

View File

@@ -0,0 +1,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)
);

View File

@@ -0,0 +1,3 @@
-- Add a human-readable name to networks.
-- DEFAULT '' allows the migration to run on databases that already have rows.
ALTER TABLE networks ADD COLUMN name TEXT NOT NULL DEFAULT '';

View File

@@ -0,0 +1,8 @@
-- host_applications: direct association between a host and an application.
-- Allows explicitly tagging a host with an application regardless of ports.
-- One application can only be linked once to a given host.
CREATE TABLE IF NOT EXISTS host_applications (
host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
application_id INTEGER NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
PRIMARY KEY (host_id, application_id)
);

286
seeds/postgres/dev_seed.sql Normal file
View File

@@ -0,0 +1,286 @@
-- dev_seed.sql (PostgreSQL) — development test data
--
-- Running this script is idempotent: existing rows are left untouched
-- and missing rows are inserted. Safe to run multiple times.
--
-- Load with: cargo run --features ssr --bin seed
-- ── Networks ──────────────────────────────────────────────────────────────────
INSERT INTO networks (name, cidr) VALUES
('LAN', '192.168.1.0/24'),
('DMZ', '192.168.10.0/24'),
('Corporate', '10.0.0.0/8'),
('VPN', '172.16.0.0/16')
ON CONFLICT (cidr) DO NOTHING;
-- ── Hosts ─────────────────────────────────────────────────────────────────────
-- LAN — 192.168.1.0/24
INSERT INTO hosts (name, ip, network_id)
SELECT name, ip, (SELECT id FROM networks WHERE cidr = '192.168.1.0/24')
FROM (VALUES
('gateway', '192.168.1.1'),
('workstation-01', '192.168.1.10'),
('workstation-02', '192.168.1.11'),
('workstation-03', '192.168.1.12'),
('nas-01', '192.168.1.20'),
('printer-01', '192.168.1.50')
) AS t(name, ip)
WHERE NOT EXISTS (SELECT 1 FROM hosts WHERE hosts.name = t.name AND hosts.ip = t.ip);
-- DMZ — 192.168.10.0/24
INSERT INTO hosts (name, ip, network_id)
SELECT name, ip, (SELECT id FROM networks WHERE cidr = '192.168.10.0/24')
FROM (VALUES
('web-server-01', '192.168.10.10'),
('web-server-02', '192.168.10.11'),
('db-server-01', '192.168.10.20'),
('mail-server-01', '192.168.10.30')
) AS t(name, ip)
WHERE NOT EXISTS (SELECT 1 FROM hosts WHERE hosts.name = t.name AND hosts.ip = t.ip);
-- Corporate backbone — 10.0.0.0/8
INSERT INTO hosts (name, ip, network_id)
SELECT name, ip, (SELECT id FROM networks WHERE cidr = '10.0.0.0/8')
FROM (VALUES
('core-switch-01', '10.0.0.1'),
('monitoring-01', '10.0.1.10'),
('backup-server-01', '10.0.1.20'),
('log-server-01', '10.0.1.30')
) AS t(name, ip)
WHERE NOT EXISTS (SELECT 1 FROM hosts WHERE hosts.name = t.name AND hosts.ip = t.ip);
-- VPN — 172.16.0.0/16
INSERT INTO hosts (name, ip, network_id)
SELECT name, ip, (SELECT id FROM networks WHERE cidr = '172.16.0.0/16')
FROM (VALUES
('vpn-gateway-01', '172.16.0.1'),
('vpn-client-01', '172.16.1.10'),
('vpn-client-02', '172.16.1.11')
) AS t(name, ip)
WHERE NOT EXISTS (SELECT 1 FROM hosts WHERE hosts.name = t.name AND hosts.ip = t.ip);
-- ── Ports catalog ─────────────────────────────────────────────────────────────
INSERT INTO ports (number, description) VALUES
(22, 'SSH'),
(25, 'SMTP'),
(53, 'DNS'),
(80, 'HTTP'),
(143, 'IMAP'),
(161, 'SNMP'),
(443, 'HTTPS'),
(445, 'SMB'),
(465, 'SMTPS'),
(500, 'IKE / IPSec'),
(514, 'Syslog'),
(587, 'SMTP Submission'),
(873, 'rsync'),
(993, 'IMAPS'),
(1194, 'OpenVPN'),
(2049, 'NFS'),
(3000, 'Grafana / Gitea'),
(3306, 'MariaDB / MySQL'),
(3389, 'RDP'),
(4500, 'IPSec NAT-T'),
(5044, 'Logstash Beats'),
(5432, 'PostgreSQL'),
(5601, 'Kibana'),
(6379, 'Redis'),
(8096, 'Jellyfin'),
(8123, 'Home Assistant'),
(8384, 'Syncthing UI'),
(8920, 'Jellyfin HTTPS'),
(9000, 'Portainer'),
(9090, 'Prometheus'),
(9100, 'node_exporter / JetDirect'),
(9200, 'Elasticsearch'),
(9443, 'Portainer HTTPS'),
(22000, 'Syncthing'),
(51820, 'WireGuard')
ON CONFLICT (number) DO NOTHING;
-- ── Host ports ────────────────────────────────────────────────────────────────
-- gateway: SSH, DNS, HTTP, HTTPS
INSERT INTO host_ports (host_id, port_number)
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 53, 80, 443]) p
WHERE h.name = 'gateway' AND h.ip = '192.168.1.1'
ON CONFLICT DO NOTHING;
-- workstation-01: SSH, RDP
INSERT INTO host_ports (host_id, port_number)
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 3389]) p
WHERE h.name = 'workstation-01' AND h.ip = '192.168.1.10'
ON CONFLICT DO NOTHING;
-- workstation-02: SSH, RDP
INSERT INTO host_ports (host_id, port_number)
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 3389]) p
WHERE h.name = 'workstation-02' AND h.ip = '192.168.1.11'
ON CONFLICT DO NOTHING;
-- workstation-03: SSH, RDP
INSERT INTO host_ports (host_id, port_number)
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 3389]) p
WHERE h.name = 'workstation-03' AND h.ip = '192.168.1.12'
ON CONFLICT DO NOTHING;
-- nas-01: SSH, HTTP, HTTPS, SMB, NFS
INSERT INTO host_ports (host_id, port_number)
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 80, 443, 445, 2049]) p
WHERE h.name = 'nas-01' AND h.ip = '192.168.1.20'
ON CONFLICT DO NOTHING;
-- printer-01: HTTP, HTTPS, JetDirect
INSERT INTO host_ports (host_id, port_number)
SELECT h.id, p FROM hosts h, unnest(ARRAY[80, 443, 9100]) p
WHERE h.name = 'printer-01' AND h.ip = '192.168.1.50'
ON CONFLICT DO NOTHING;
-- web-server-01: SSH, HTTP, HTTPS
INSERT INTO host_ports (host_id, port_number)
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 80, 443]) p
WHERE h.name = 'web-server-01' AND h.ip = '192.168.10.10'
ON CONFLICT DO NOTHING;
-- web-server-02: SSH, HTTP, HTTPS
INSERT INTO host_ports (host_id, port_number)
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 80, 443]) p
WHERE h.name = 'web-server-02' AND h.ip = '192.168.10.11'
ON CONFLICT DO NOTHING;
-- db-server-01: SSH, PostgreSQL
INSERT INTO host_ports (host_id, port_number)
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 5432]) p
WHERE h.name = 'db-server-01' AND h.ip = '192.168.10.20'
ON CONFLICT DO NOTHING;
-- mail-server-01: SSH, SMTP, IMAP, SMTPS, Submission, IMAPS
INSERT INTO host_ports (host_id, port_number)
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 25, 143, 465, 587, 993]) p
WHERE h.name = 'mail-server-01' AND h.ip = '192.168.10.30'
ON CONFLICT DO NOTHING;
-- core-switch-01: SSH, SNMP
INSERT INTO host_ports (host_id, port_number)
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 161]) p
WHERE h.name = 'core-switch-01' AND h.ip = '10.0.0.1'
ON CONFLICT DO NOTHING;
-- monitoring-01: SSH, HTTP, HTTPS, Grafana, Prometheus
INSERT INTO host_ports (host_id, port_number)
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 80, 443, 3000, 9090]) p
WHERE h.name = 'monitoring-01' AND h.ip = '10.0.1.10'
ON CONFLICT DO NOTHING;
-- backup-server-01: SSH, SMB, rsync
INSERT INTO host_ports (host_id, port_number)
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 445, 873]) p
WHERE h.name = 'backup-server-01' AND h.ip = '10.0.1.20'
ON CONFLICT DO NOTHING;
-- log-server-01: SSH, Syslog, Logstash Beats, Elasticsearch, Kibana
INSERT INTO host_ports (host_id, port_number)
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 514, 5044, 9200, 5601]) p
WHERE h.name = 'log-server-01' AND h.ip = '10.0.1.30'
ON CONFLICT DO NOTHING;
-- vpn-gateway-01: SSH, IKE, OpenVPN, IPSec NAT-T
INSERT INTO host_ports (host_id, port_number)
SELECT h.id, p FROM hosts h, unnest(ARRAY[22, 500, 1194, 4500]) p
WHERE h.name = 'vpn-gateway-01' AND h.ip = '172.16.0.1'
ON CONFLICT DO NOTHING;
-- vpn clients: SSH only
INSERT INTO host_ports (host_id, port_number)
SELECT h.id, 22 FROM hosts h
WHERE h.name = 'vpn-client-01' AND h.ip = '172.16.1.10'
ON CONFLICT DO NOTHING;
INSERT INTO host_ports (host_id, port_number)
SELECT h.id, 22 FROM hosts h
WHERE h.name = 'vpn-client-02' AND h.ip = '172.16.1.11'
ON CONFLICT DO NOTHING;
-- ── Applications ──────────────────────────────────────────────────────────────
-- applications has no UNIQUE constraint on name, so we use WHERE NOT EXISTS.
INSERT INTO applications (name)
SELECT v.name FROM (VALUES
('Nginx'),
('Pi-hole'),
('WireGuard'),
('OpenVPN'),
('PostgreSQL'),
('MariaDB'),
('Redis'),
('Grafana'),
('Prometheus'),
('Elasticsearch'),
('Kibana'),
('Portainer'),
('Jellyfin'),
('Home Assistant'),
('Syncthing'),
('Vaultwarden')
) AS v(name)
WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = v.name);
-- ── Application ports ─────────────────────────────────────────────────────────
-- Nginx: HTTP, HTTPS
INSERT INTO application_ports (application_id, port_number) SELECT id, 80 FROM applications WHERE name = 'Nginx' ON CONFLICT DO NOTHING;
INSERT INTO application_ports (application_id, port_number) SELECT id, 443 FROM applications WHERE name = 'Nginx' ON CONFLICT DO NOTHING;
-- Pi-hole: DNS, HTTP (admin UI), HTTPS
INSERT INTO application_ports (application_id, port_number) SELECT id, 53 FROM applications WHERE name = 'Pi-hole' ON CONFLICT DO NOTHING;
INSERT INTO application_ports (application_id, port_number) SELECT id, 80 FROM applications WHERE name = 'Pi-hole' ON CONFLICT DO NOTHING;
INSERT INTO application_ports (application_id, port_number) SELECT id, 443 FROM applications WHERE name = 'Pi-hole' ON CONFLICT DO NOTHING;
-- WireGuard
INSERT INTO application_ports (application_id, port_number) SELECT id, 51820 FROM applications WHERE name = 'WireGuard' ON CONFLICT DO NOTHING;
-- OpenVPN
INSERT INTO application_ports (application_id, port_number) SELECT id, 1194 FROM applications WHERE name = 'OpenVPN' ON CONFLICT DO NOTHING;
-- PostgreSQL
INSERT INTO application_ports (application_id, port_number) SELECT id, 5432 FROM applications WHERE name = 'PostgreSQL' ON CONFLICT DO NOTHING;
-- MariaDB
INSERT INTO application_ports (application_id, port_number) SELECT id, 3306 FROM applications WHERE name = 'MariaDB' ON CONFLICT DO NOTHING;
-- Redis
INSERT INTO application_ports (application_id, port_number) SELECT id, 6379 FROM applications WHERE name = 'Redis' ON CONFLICT DO NOTHING;
-- Grafana
INSERT INTO application_ports (application_id, port_number) SELECT id, 3000 FROM applications WHERE name = 'Grafana' ON CONFLICT DO NOTHING;
-- Prometheus
INSERT INTO application_ports (application_id, port_number) SELECT id, 9090 FROM applications WHERE name = 'Prometheus' ON CONFLICT DO NOTHING;
-- Elasticsearch
INSERT INTO application_ports (application_id, port_number) SELECT id, 9200 FROM applications WHERE name = 'Elasticsearch' ON CONFLICT DO NOTHING;
-- Kibana
INSERT INTO application_ports (application_id, port_number) SELECT id, 5601 FROM applications WHERE name = 'Kibana' ON CONFLICT DO NOTHING;
-- Portainer: HTTP, HTTPS
INSERT INTO application_ports (application_id, port_number) SELECT id, 9000 FROM applications WHERE name = 'Portainer' ON CONFLICT DO NOTHING;
INSERT INTO application_ports (application_id, port_number) SELECT id, 9443 FROM applications WHERE name = 'Portainer' ON CONFLICT DO NOTHING;
-- Jellyfin: HTTP, HTTPS
INSERT INTO application_ports (application_id, port_number) SELECT id, 8096 FROM applications WHERE name = 'Jellyfin' ON CONFLICT DO NOTHING;
INSERT INTO application_ports (application_id, port_number) SELECT id, 8920 FROM applications WHERE name = 'Jellyfin' ON CONFLICT DO NOTHING;
-- Home Assistant
INSERT INTO application_ports (application_id, port_number) SELECT id, 8123 FROM applications WHERE name = 'Home Assistant' ON CONFLICT DO NOTHING;
-- Syncthing: UI, data sync
INSERT INTO application_ports (application_id, port_number) SELECT id, 8384 FROM applications WHERE name = 'Syncthing' ON CONFLICT DO NOTHING;
INSERT INTO application_ports (application_id, port_number) SELECT id, 22000 FROM applications WHERE name = 'Syncthing' ON CONFLICT DO NOTHING;
-- Vaultwarden: HTTP, HTTPS
INSERT INTO application_ports (application_id, port_number) SELECT id, 80 FROM applications WHERE name = 'Vaultwarden' ON CONFLICT DO NOTHING;
INSERT INTO application_ports (application_id, port_number) SELECT id, 443 FROM applications WHERE name = 'Vaultwarden' ON CONFLICT DO NOTHING;

242
seeds/sqlite/dev_seed.sql Normal file
View File

@@ -0,0 +1,242 @@
-- dev_seed.sql (SQLite) — development test data
--
-- Running this script is idempotent: existing rows are left untouched
-- and missing rows are inserted. Safe to run multiple times.
--
-- Load with: cargo run --features ssr --bin seed
-- ── Networks ──────────────────────────────────────────────────────────────────
INSERT OR IGNORE INTO networks (name, cidr) VALUES ('LAN', '192.168.1.0/24');
INSERT OR IGNORE INTO networks (name, cidr) VALUES ('DMZ', '192.168.10.0/24');
INSERT OR IGNORE INTO networks (name, cidr) VALUES ('Corporate', '10.0.0.0/8');
INSERT OR IGNORE INTO networks (name, cidr) VALUES ('VPN', '172.16.0.0/16');
-- ── Hosts ─────────────────────────────────────────────────────────────────────
-- Hosts have no UNIQUE constraint, so we guard each insert with WHERE NOT EXISTS.
-- Network IDs are resolved by subquery on cidr for portability.
-- LAN — 192.168.1.0/24
INSERT INTO hosts (name, ip, network_id) SELECT 'gateway', '192.168.1.1', id FROM networks WHERE cidr = '192.168.1.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'gateway' AND ip = '192.168.1.1');
INSERT INTO hosts (name, ip, network_id) SELECT 'workstation-01', '192.168.1.10', id FROM networks WHERE cidr = '192.168.1.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'workstation-01' AND ip = '192.168.1.10');
INSERT INTO hosts (name, ip, network_id) SELECT 'workstation-02', '192.168.1.11', id FROM networks WHERE cidr = '192.168.1.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'workstation-02' AND ip = '192.168.1.11');
INSERT INTO hosts (name, ip, network_id) SELECT 'workstation-03', '192.168.1.12', id FROM networks WHERE cidr = '192.168.1.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'workstation-03' AND ip = '192.168.1.12');
INSERT INTO hosts (name, ip, network_id) SELECT 'nas-01', '192.168.1.20', id FROM networks WHERE cidr = '192.168.1.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'nas-01' AND ip = '192.168.1.20');
INSERT INTO hosts (name, ip, network_id) SELECT 'printer-01', '192.168.1.50', id FROM networks WHERE cidr = '192.168.1.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'printer-01' AND ip = '192.168.1.50');
-- DMZ — 192.168.10.0/24
INSERT INTO hosts (name, ip, network_id) SELECT 'web-server-01', '192.168.10.10', id FROM networks WHERE cidr = '192.168.10.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'web-server-01' AND ip = '192.168.10.10');
INSERT INTO hosts (name, ip, network_id) SELECT 'web-server-02', '192.168.10.11', id FROM networks WHERE cidr = '192.168.10.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'web-server-02' AND ip = '192.168.10.11');
INSERT INTO hosts (name, ip, network_id) SELECT 'db-server-01', '192.168.10.20', id FROM networks WHERE cidr = '192.168.10.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'db-server-01' AND ip = '192.168.10.20');
INSERT INTO hosts (name, ip, network_id) SELECT 'mail-server-01', '192.168.10.30', id FROM networks WHERE cidr = '192.168.10.0/24' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'mail-server-01' AND ip = '192.168.10.30');
-- Corporate backbone — 10.0.0.0/8
INSERT INTO hosts (name, ip, network_id) SELECT 'core-switch-01', '10.0.0.1', id FROM networks WHERE cidr = '10.0.0.0/8' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'core-switch-01' AND ip = '10.0.0.1');
INSERT INTO hosts (name, ip, network_id) SELECT 'monitoring-01', '10.0.1.10', id FROM networks WHERE cidr = '10.0.0.0/8' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'monitoring-01' AND ip = '10.0.1.10');
INSERT INTO hosts (name, ip, network_id) SELECT 'backup-server-01', '10.0.1.20', id FROM networks WHERE cidr = '10.0.0.0/8' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'backup-server-01' AND ip = '10.0.1.20');
INSERT INTO hosts (name, ip, network_id) SELECT 'log-server-01', '10.0.1.30', id FROM networks WHERE cidr = '10.0.0.0/8' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'log-server-01' AND ip = '10.0.1.30');
-- VPN — 172.16.0.0/16
INSERT INTO hosts (name, ip, network_id) SELECT 'vpn-gateway-01', '172.16.0.1', id FROM networks WHERE cidr = '172.16.0.0/16' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'vpn-gateway-01' AND ip = '172.16.0.1');
INSERT INTO hosts (name, ip, network_id) SELECT 'vpn-client-01', '172.16.1.10', id FROM networks WHERE cidr = '172.16.0.0/16' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'vpn-client-01' AND ip = '172.16.1.10');
INSERT INTO hosts (name, ip, network_id) SELECT 'vpn-client-02', '172.16.1.11', id FROM networks WHERE cidr = '172.16.0.0/16' AND NOT EXISTS (SELECT 1 FROM hosts WHERE name = 'vpn-client-02' AND ip = '172.16.1.11');
-- ── Ports catalog ─────────────────────────────────────────────────────────────
INSERT OR IGNORE INTO ports (number, description) VALUES
(22, 'SSH'),
(25, 'SMTP'),
(53, 'DNS'),
(80, 'HTTP'),
(143, 'IMAP'),
(161, 'SNMP'),
(443, 'HTTPS'),
(445, 'SMB'),
(465, 'SMTPS'),
(500, 'IKE / IPSec'),
(514, 'Syslog'),
(587, 'SMTP Submission'),
(873, 'rsync'),
(993, 'IMAPS'),
(1194, 'OpenVPN'),
(2049, 'NFS'),
(3000, 'Grafana / Gitea'),
(3306, 'MariaDB / MySQL'),
(3389, 'RDP'),
(4500, 'IPSec NAT-T'),
(5044, 'Logstash Beats'),
(5432, 'PostgreSQL'),
(5601, 'Kibana'),
(6379, 'Redis'),
(8096, 'Jellyfin'),
(8123, 'Home Assistant'),
(8384, 'Syncthing UI'),
(8920, 'Jellyfin HTTPS'),
(9000, 'Portainer'),
(9090, 'Prometheus'),
(9100, 'node_exporter / JetDirect'),
(9200, 'Elasticsearch'),
(9443, 'Portainer HTTPS'),
(22000, 'Syncthing'),
(51820, 'WireGuard');
-- ── Host ports ────────────────────────────────────────────────────────────────
-- INSERT OR IGNORE is safe: host_ports has a composite PRIMARY KEY (host_id, port_number).
-- Host IDs are resolved by subquery on (name, ip) to stay independent of auto-increment values.
-- gateway: SSH, DNS, HTTP (admin UI), HTTPS (admin UI)
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'gateway' AND ip = '192.168.1.1';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 53 FROM hosts WHERE name = 'gateway' AND ip = '192.168.1.1';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 80 FROM hosts WHERE name = 'gateway' AND ip = '192.168.1.1';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 443 FROM hosts WHERE name = 'gateway' AND ip = '192.168.1.1';
-- workstations: SSH, RDP
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'workstation-01' AND ip = '192.168.1.10';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 3389 FROM hosts WHERE name = 'workstation-01' AND ip = '192.168.1.10';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'workstation-02' AND ip = '192.168.1.11';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 3389 FROM hosts WHERE name = 'workstation-02' AND ip = '192.168.1.11';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'workstation-03' AND ip = '192.168.1.12';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 3389 FROM hosts WHERE name = 'workstation-03' AND ip = '192.168.1.12';
-- nas-01: SSH, HTTP (web UI), HTTPS, SMB, NFS
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'nas-01' AND ip = '192.168.1.20';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 80 FROM hosts WHERE name = 'nas-01' AND ip = '192.168.1.20';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 443 FROM hosts WHERE name = 'nas-01' AND ip = '192.168.1.20';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 445 FROM hosts WHERE name = 'nas-01' AND ip = '192.168.1.20';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 2049 FROM hosts WHERE name = 'nas-01' AND ip = '192.168.1.20';
-- printer-01: HTTP (web UI), HTTPS, JetDirect
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 80 FROM hosts WHERE name = 'printer-01' AND ip = '192.168.1.50';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 443 FROM hosts WHERE name = 'printer-01' AND ip = '192.168.1.50';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 9100 FROM hosts WHERE name = 'printer-01' AND ip = '192.168.1.50';
-- web servers: SSH, HTTP, HTTPS
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'web-server-01' AND ip = '192.168.10.10';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 80 FROM hosts WHERE name = 'web-server-01' AND ip = '192.168.10.10';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 443 FROM hosts WHERE name = 'web-server-01' AND ip = '192.168.10.10';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'web-server-02' AND ip = '192.168.10.11';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 80 FROM hosts WHERE name = 'web-server-02' AND ip = '192.168.10.11';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 443 FROM hosts WHERE name = 'web-server-02' AND ip = '192.168.10.11';
-- db-server-01: SSH, PostgreSQL
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'db-server-01' AND ip = '192.168.10.20';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 5432 FROM hosts WHERE name = 'db-server-01' AND ip = '192.168.10.20';
-- mail-server-01: SSH, SMTP, IMAP, SMTPS, SMTP Submission, IMAPS
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'mail-server-01' AND ip = '192.168.10.30';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 25 FROM hosts WHERE name = 'mail-server-01' AND ip = '192.168.10.30';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 143 FROM hosts WHERE name = 'mail-server-01' AND ip = '192.168.10.30';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 465 FROM hosts WHERE name = 'mail-server-01' AND ip = '192.168.10.30';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 587 FROM hosts WHERE name = 'mail-server-01' AND ip = '192.168.10.30';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 993 FROM hosts WHERE name = 'mail-server-01' AND ip = '192.168.10.30';
-- core-switch-01: SSH, SNMP
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'core-switch-01' AND ip = '10.0.0.1';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 161 FROM hosts WHERE name = 'core-switch-01' AND ip = '10.0.0.1';
-- monitoring-01: SSH, HTTP, HTTPS, Grafana, Prometheus
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'monitoring-01' AND ip = '10.0.1.10';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 80 FROM hosts WHERE name = 'monitoring-01' AND ip = '10.0.1.10';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 443 FROM hosts WHERE name = 'monitoring-01' AND ip = '10.0.1.10';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 3000 FROM hosts WHERE name = 'monitoring-01' AND ip = '10.0.1.10';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 9090 FROM hosts WHERE name = 'monitoring-01' AND ip = '10.0.1.10';
-- backup-server-01: SSH, SMB, rsync
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'backup-server-01' AND ip = '10.0.1.20';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 445 FROM hosts WHERE name = 'backup-server-01' AND ip = '10.0.1.20';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 873 FROM hosts WHERE name = 'backup-server-01' AND ip = '10.0.1.20';
-- log-server-01: SSH, Syslog, Logstash Beats, Elasticsearch, Kibana
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'log-server-01' AND ip = '10.0.1.30';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 514 FROM hosts WHERE name = 'log-server-01' AND ip = '10.0.1.30';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 5044 FROM hosts WHERE name = 'log-server-01' AND ip = '10.0.1.30';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 9200 FROM hosts WHERE name = 'log-server-01' AND ip = '10.0.1.30';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 5601 FROM hosts WHERE name = 'log-server-01' AND ip = '10.0.1.30';
-- vpn-gateway-01: SSH, IKE, IPSec NAT-T, OpenVPN
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'vpn-gateway-01' AND ip = '172.16.0.1';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 500 FROM hosts WHERE name = 'vpn-gateway-01' AND ip = '172.16.0.1';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 1194 FROM hosts WHERE name = 'vpn-gateway-01' AND ip = '172.16.0.1';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 4500 FROM hosts WHERE name = 'vpn-gateway-01' AND ip = '172.16.0.1';
-- vpn clients: SSH only
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'vpn-client-01' AND ip = '172.16.1.10';
INSERT OR IGNORE INTO host_ports (host_id, port_number) SELECT id, 22 FROM hosts WHERE name = 'vpn-client-02' AND ip = '172.16.1.11';
-- ── Applications ──────────────────────────────────────────────────────────────
-- applications has no UNIQUE constraint on name, so we use WHERE NOT EXISTS.
INSERT INTO applications (name) SELECT 'Nginx' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Nginx');
INSERT INTO applications (name) SELECT 'Pi-hole' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Pi-hole');
INSERT INTO applications (name) SELECT 'WireGuard' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'WireGuard');
INSERT INTO applications (name) SELECT 'OpenVPN' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'OpenVPN');
INSERT INTO applications (name) SELECT 'PostgreSQL' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'PostgreSQL');
INSERT INTO applications (name) SELECT 'MariaDB' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'MariaDB');
INSERT INTO applications (name) SELECT 'Redis' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Redis');
INSERT INTO applications (name) SELECT 'Grafana' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Grafana');
INSERT INTO applications (name) SELECT 'Prometheus' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Prometheus');
INSERT INTO applications (name) SELECT 'Elasticsearch' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Elasticsearch');
INSERT INTO applications (name) SELECT 'Kibana' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Kibana');
INSERT INTO applications (name) SELECT 'Portainer' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Portainer');
INSERT INTO applications (name) SELECT 'Jellyfin' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Jellyfin');
INSERT INTO applications (name) SELECT 'Home Assistant' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Home Assistant');
INSERT INTO applications (name) SELECT 'Syncthing' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Syncthing');
INSERT INTO applications (name) SELECT 'Vaultwarden' WHERE NOT EXISTS (SELECT 1 FROM applications WHERE name = 'Vaultwarden');
-- ── Application ports ─────────────────────────────────────────────────────────
-- application_ports has a composite PRIMARY KEY, so INSERT OR IGNORE is safe.
-- Nginx: HTTP, HTTPS
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 80 FROM applications WHERE name = 'Nginx';
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 443 FROM applications WHERE name = 'Nginx';
-- Pi-hole: DNS, HTTP (admin UI), HTTPS
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 53 FROM applications WHERE name = 'Pi-hole';
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 80 FROM applications WHERE name = 'Pi-hole';
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 443 FROM applications WHERE name = 'Pi-hole';
-- WireGuard
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 51820 FROM applications WHERE name = 'WireGuard';
-- OpenVPN
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 1194 FROM applications WHERE name = 'OpenVPN';
-- PostgreSQL
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 5432 FROM applications WHERE name = 'PostgreSQL';
-- MariaDB
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 3306 FROM applications WHERE name = 'MariaDB';
-- Redis
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 6379 FROM applications WHERE name = 'Redis';
-- Grafana
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 3000 FROM applications WHERE name = 'Grafana';
-- Prometheus
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 9090 FROM applications WHERE name = 'Prometheus';
-- Elasticsearch
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 9200 FROM applications WHERE name = 'Elasticsearch';
-- Kibana
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 5601 FROM applications WHERE name = 'Kibana';
-- Portainer: HTTP, HTTPS
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 9000 FROM applications WHERE name = 'Portainer';
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 9443 FROM applications WHERE name = 'Portainer';
-- Jellyfin: HTTP, HTTPS
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 8096 FROM applications WHERE name = 'Jellyfin';
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 8920 FROM applications WHERE name = 'Jellyfin';
-- Home Assistant
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 8123 FROM applications WHERE name = 'Home Assistant';
-- Syncthing: UI, data sync
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 8384 FROM applications WHERE name = 'Syncthing';
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 22000 FROM applications WHERE name = 'Syncthing';
-- Vaultwarden: HTTP, HTTPS
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 80 FROM applications WHERE name = 'Vaultwarden';
INSERT OR IGNORE INTO application_ports (application_id, port_number) SELECT id, 443 FROM applications WHERE name = 'Vaultwarden';

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

@@ -0,0 +1,251 @@
// api/applications.rs — Server functions for applications and their port associations
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use crate::models::Application;
// Minimal host reference used by ApplicationDetail.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HostRef {
pub id: i64,
pub name: String,
pub ip: String,
}
// Full detail for a single application: identity, associated ports, and linked hosts.
// Linked hosts are those that have at least one port matching an application_ports entry.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ApplicationDetail {
pub id: i64,
pub name: String,
pub ports: Vec<u16>,
pub hosts: Vec<HostRef>,
}
// Application row enriched with the number of hosts that use at least one of
// its registered ports. Host count is computed via the join:
// application_ports → host_ports (matched on port_number) → hosts
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ApplicationWithCounts {
pub id: i64,
pub name: String,
/// Distinct hosts that have at least one port matching this application.
pub host_count: i64,
}
// ─── Queries ──────────────────────────────────────────────────────────────────
/// Returns all applications enriched with their associated host count.
#[server]
pub async fn get_applications_with_counts() -> Result<Vec<ApplicationWithCounts>, ServerFnError> {
use sqlx::{AnyPool, Row};
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
let rows = sqlx::query(
"SELECT
a.id,
a.name,
COUNT(DISTINCT hp.host_id) AS host_count
FROM applications a
LEFT JOIN application_ports ap ON ap.application_id = a.id
LEFT JOIN host_ports hp ON hp.port_number = ap.port_number
GROUP BY a.id, a.name
ORDER BY a.name",
)
.fetch_all(&pool)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(rows
.into_iter()
.map(|row| ApplicationWithCounts {
id: row.get("id"),
name: row.get("name"),
host_count: row.get("host_count"),
})
.collect())
}
/// Returns all applications ordered by name.
#[server]
pub async fn get_applications() -> Result<Vec<Application>, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::applications as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
repo::list_applications(&pool)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Returns full detail for a single application: identity, ports, and linked hosts.
///
/// Linked hosts are hosts that have at least one open port matching one of
/// the application's registered port numbers (via application_ports ↔ host_ports).
#[server]
pub async fn get_application_detail(id: i64) -> Result<ApplicationDetail, ServerFnError> {
use sqlx::{AnyPool, Row};
use crate::server::repository::applications as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
let app = repo::find_application(&pool, id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
.ok_or_else(|| ServerFnError::new(format!("Application {id} not found")))?;
let ports = repo::list_ports_for_application(&pool, id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let rows = sqlx::query(
"SELECT DISTINCT h.id, h.name, h.ip
FROM hosts h
JOIN host_ports hp ON hp.host_id = h.id
JOIN application_ports ap ON ap.port_number = hp.port_number
WHERE ap.application_id = $1
ORDER BY h.name",
)
.bind(id)
.fetch_all(&pool)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let hosts = rows
.iter()
.map(|row| HostRef {
id: row.get("id"),
name: row.get("name"),
ip: row.get("ip"),
})
.collect();
Ok(ApplicationDetail { id: app.id, name: app.name, ports, hosts })
}
/// Returns the port numbers associated with an application.
#[server]
pub async fn get_ports_for_application(
application_id: i64,
) -> Result<Vec<u16>, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::applications as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
repo::list_ports_for_application(&pool, application_id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}
// ─── Mutations ────────────────────────────────────────────────────────────────
/// Updates the name of an application and returns the updated record.
#[server]
pub async fn update_application(id: i64, name: String) -> Result<Application, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::applications as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
if name.trim().is_empty() {
return Err(ServerFnError::new("Application name cannot be empty"));
}
repo::update_application(&pool, id, name.trim())
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Creates a new application, then associates the given port numbers.
///
/// `ports` is a comma-separated list of port numbers (e.g. "80,443").
/// An empty string means no ports are associated.
#[server]
pub async fn create_application(name: String, ports: String) -> Result<Application, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::applications as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
let app = repo::create_application(&pool, &name)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let port_numbers: Vec<u16> = ports
.split(',')
.filter_map(|s| s.trim().parse::<u16>().ok())
.filter(|&p| p >= 1)
.collect();
for port_number in port_numbers {
repo::add_port_to_application(&pool, app.id, port_number)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
}
Ok(app)
}
/// Deletes an application and all its port associations.
///
/// Returns `true` if the application existed and was deleted.
#[server]
pub async fn delete_application(id: i64) -> Result<bool, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::applications as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
repo::delete_application(&pool, id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Associates a port number with an application.
///
/// If the association already exists, this is a no-op.
#[server]
pub async fn add_port_to_application(
application_id: i64,
port_number: u16,
) -> Result<(), ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::applications as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
repo::add_port_to_application(&pool, application_id, port_number)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Removes a port association from an application.
///
/// If the association does not exist, this is a no-op.
#[server]
pub async fn remove_port_from_application(
application_id: i64,
port_number: u16,
) -> Result<(), ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::applications as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
repo::remove_port_from_application(&pool, application_id, port_number)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}

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

@@ -0,0 +1,436 @@
// api/hosts.rs — Server functions for hosts
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use crate::models::{Application, Host, Port};
// ─── Presentation types ───────────────────────────────────────────────────────
// A host row enriched with its network CIDR and pre-computed counts.
// Used by the paginated hosts list (get_hosts_page).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HostRow {
pub id: i64,
pub name: String,
pub ip: String,
pub network_id: i64,
pub network_cidr: String,
pub port_count: i64,
pub application_count: i64,
}
// Result of a paginated, filtered host query.
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct HostsPage {
pub rows: Vec<HostRow>,
pub total: i64, // total rows matching the filters (ignoring pagination)
pub page: i64, // current page (1-indexed)
pub per_page: i64, // items per page; 0 = all
pub total_pages: i64, // ceil(total / per_page); always ≥ 1
}
// Full host detail: identity fields + resolved network + open ports + linked applications.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HostDetail {
pub id: i64,
pub name: String,
pub ip: String,
pub network_id: i64,
pub network_name: String,
pub network_cidr: String,
pub ports: Vec<Port>,
pub applications: Vec<Application>,
}
// ─── Queries ──────────────────────────────────────────────────────────────────
/// Returns full detail for a single host: identity, network, and open ports.
#[server]
pub async fn get_host_detail(id: i64) -> Result<HostDetail, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::{
applications as app_repo,
hosts as host_repo,
networks,
ports as port_repo,
};
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
let host = host_repo::find_host(&pool, id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
.ok_or_else(|| ServerFnError::new(format!("Host {id} not found")))?;
let network = networks::find_network(&pool, host.network_id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
.ok_or_else(|| ServerFnError::new(format!("Network {} not found", host.network_id)))?;
let ports = port_repo::list_ports_for_host(&pool, id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let applications = app_repo::list_applications_for_host(&pool, id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(HostDetail {
id: host.id,
name: host.name,
ip: host.ip,
network_id: host.network_id,
network_name: network.name,
network_cidr: network.cidr,
ports,
applications,
})
}
/// Returns all hosts belonging to a given network.
#[server]
pub async fn get_hosts_by_network(network_id: i64) -> Result<Vec<Host>, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::hosts as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
repo::list_hosts_by_network(&pool, network_id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Returns a filtered and paginated list of hosts across all networks.
///
/// `port_filter` is a comma-separated list of port numbers (e.g. "80,443").
/// A host matches only if it has ALL the specified ports open.
/// An empty string means no port filter.
///
/// Port conditions are inlined in the SQL as integer literals (safe: values
/// are parsed and range-checked before use — no raw user strings are injected).
#[server]
pub async fn get_hosts_page(
name_filter: String,
network_id_filter: i64,
port_filter: String,
application_id_filter: i64,
page: i64,
per_page: i64,
) -> Result<HostsPage, ServerFnError> {
use sqlx::{AnyPool, Row};
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
let name_like: Option<String> = if name_filter.is_empty() {
None
} else {
Some(format!("%{}%", name_filter))
};
let network_id: Option<i64> = if network_id_filter == 0 { None } else { Some(network_id_filter) };
let app_id: Option<i64> = if application_id_filter == 0 { None } else { Some(application_id_filter) };
// Parse and validate port numbers from the CSV string.
// Inlined as integer literals in SQL — safe because they are range-checked i64s.
let ports: Vec<i64> = port_filter
.split(',')
.filter_map(|s| s.trim().parse::<i64>().ok())
.filter(|&p| p >= 1 && p <= 65535)
.collect();
// One EXISTS clause per required port (AND semantics: host must have ALL ports).
let port_conditions: String = ports
.iter()
.map(|p| format!(
" AND EXISTS (SELECT 1 FROM host_ports WHERE host_id = h.id AND port_number = {p})"
))
.collect();
// $1 = name_like, $2 = network_id, $3 = app_id
// Pagination: $4 = limit, $5 = offset
let where_clause = format!(
"JOIN networks n ON n.id = h.network_id
LEFT JOIN host_ports hp ON hp.host_id = h.id
LEFT JOIN application_ports ap ON ap.port_number = hp.port_number
WHERE ($1 IS NULL OR LOWER(h.name) LIKE LOWER($1))
AND ($2 IS NULL OR h.network_id = $2)
AND ($3 IS NULL OR EXISTS (
SELECT 1 FROM host_ports hp2
JOIN application_ports ap2 ON ap2.port_number = hp2.port_number
WHERE hp2.host_id = h.id AND ap2.application_id = $3
))
{port_conditions}"
);
let count_sql = format!("SELECT COUNT(DISTINCT h.id) FROM hosts h {where_clause}");
let total: i64 = sqlx::query_scalar(&count_sql)
.bind(name_like.as_deref())
.bind(network_id)
.bind(app_id)
.fetch_one(&pool)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let safe_page = page.max(1);
let (limit, offset, total_pages) = if per_page <= 0 {
(1_000_000_000i64, 0i64, 1i64)
} else {
let tp = ((total + per_page - 1) / per_page).max(1);
(per_page, (safe_page - 1) * per_page, tp)
};
let data_sql = format!(
"SELECT h.id, h.name, h.ip, h.network_id,
n.cidr AS network_cidr,
COUNT(DISTINCT hp.port_number) AS port_count,
COUNT(DISTINCT ap.application_id) AS application_count
FROM hosts h
{where_clause}
GROUP BY h.id, h.name, h.ip, h.network_id, n.cidr
ORDER BY h.name, h.id
LIMIT $4 OFFSET $5"
);
let rows = sqlx::query(&data_sql)
.bind(name_like.as_deref())
.bind(network_id)
.bind(app_id)
.bind(limit)
.bind(offset)
.fetch_all(&pool)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let host_rows = rows
.into_iter()
.map(|row| HostRow {
id: row.get("id"),
name: row.get("name"),
ip: row.get("ip"),
network_id: row.get("network_id"),
network_cidr: row.get("network_cidr"),
port_count: row.get("port_count"),
application_count: row.get("application_count"),
})
.collect();
Ok(HostsPage {
rows: host_rows,
total,
page: safe_page,
per_page,
total_pages,
})
}
// ─── Mutations ────────────────────────────────────────────────────────────────
/// Creates a new host inside the specified network, then opens the given ports.
///
/// `ports` is a comma-separated list of port numbers (e.g. "22,80,443").
/// Ports are auto-registered in the global catalog if not already present.
/// An empty string means no ports are opened.
#[server]
pub async fn create_host(
name: String,
ip: String,
network_id: i64,
ports: String,
) -> Result<Host, ServerFnError> {
use sqlx::AnyPool;
use crate::server::{
repository::{hosts, networks, ports as port_repo},
validation::validate_ip_in_network,
};
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
let network = networks::find_network(&pool, network_id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
.ok_or_else(|| ServerFnError::new(format!("Network {network_id} not found")))?;
validate_ip_in_network(&ip, &network.cidr)
.map_err(|e| ServerFnError::new(e.to_string()))?;
let host = hosts::create_host(&pool, &name, &ip, network_id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
// Parse, validate, and open each port on the new host.
let port_numbers: Vec<u16> = ports
.split(',')
.filter_map(|s| s.trim().parse::<u16>().ok())
.filter(|&p| p >= 1)
.collect();
for port_number in port_numbers {
port_repo::add_port_to_host(&pool, host.id, port_number)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
}
Ok(host)
}
/// Updates a host's name, IP address, and network assignment.
///
/// Validates that the new IP falls within the CIDR of the new network.
#[server]
pub async fn update_host(
id: i64,
name: String,
ip: String,
network_id: i64,
) -> Result<Host, ServerFnError> {
use sqlx::AnyPool;
use crate::server::{
repository::{hosts as host_repo, networks},
validation::validate_ip_in_network,
};
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
if name.trim().is_empty() {
return Err(ServerFnError::new("Name must not be empty"));
}
let network = networks::find_network(&pool, network_id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
.ok_or_else(|| ServerFnError::new(format!("Network {network_id} not found")))?;
validate_ip_in_network(&ip, &network.cidr)
.map_err(|e| ServerFnError::new(e.to_string()))?;
host_repo::update_host(&pool, id, name.trim(), ip.trim(), network_id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
.ok_or_else(|| ServerFnError::new(format!("Host {id} not found")))
}
/// Opens a single port on a host.
///
/// Auto-registers the port in the global catalog if not already present.
/// If the port is already open on this host, the call is a no-op.
#[server]
pub async fn add_host_port(host_id: i64, port_number: i64) -> Result<(), ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::ports as port_repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
if !(1..=65535).contains(&port_number) {
return Err(ServerFnError::new("Port number must be between 1 and 65535"));
}
port_repo::add_port_to_host(&pool, host_id, port_number as u16)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Closes a port on a host (removes the host-port association).
///
/// The port entry in the global catalog is not deleted.
/// If the port was not open on this host, the call is a no-op.
#[server]
pub async fn remove_host_port(host_id: i64, port_number: i64) -> Result<(), ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::ports as port_repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
port_repo::remove_port_from_host(&pool, host_id, port_number as u16)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Returns all applications not yet directly linked to a host.
///
/// Used to populate the "add applications" modal on the host detail page.
#[server]
pub async fn get_applications_not_on_host(
host_id: i64,
) -> Result<Vec<Application>, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::applications as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
repo::list_applications_not_on_host(&pool, host_id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Links one or more applications to a host.
///
/// `application_ids` is a comma-separated string of application IDs (e.g. "1,3,7").
/// Already-linked applications are silently skipped (no-op).
#[server]
pub async fn add_host_applications(
host_id: i64,
application_ids: String,
) -> Result<(), ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::applications as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
let ids: Vec<i64> = application_ids
.split(',')
.filter_map(|s| s.trim().parse::<i64>().ok())
.collect();
for application_id in ids {
repo::add_application_to_host(&pool, host_id, application_id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
}
Ok(())
}
/// Removes the direct link between a host and an application.
///
/// Returns `true` if the association existed and was removed.
#[server]
pub async fn remove_host_application(
host_id: i64,
application_id: i64,
) -> Result<bool, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::applications as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
repo::remove_application_from_host(&pool, host_id, application_id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Deletes a host by id.
///
/// Also removes all its port associations (via `ON DELETE CASCADE`).
/// Returns `true` if the host existed and was deleted.
#[server]
pub async fn delete_host(id: i64) -> Result<bool, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::hosts as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
repo::delete_host(&pool, id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}

20
src/api/mod.rs Normal file
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;

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

@@ -0,0 +1,134 @@
// api/networks.rs — Server functions for networks
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use crate::models::Network;
// Network row augmented with pre-computed counts.
// Defined here (not in models.rs) because it is a presentation model
// specific to the Networks page, not a pure domain entity.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct NetworkWithCounts {
pub id: i64,
pub name: String,
pub cidr: String,
/// Number of hosts whose IP falls within this network's CIDR range.
pub host_count: i64,
/// Number of distinct applications linked via ports open on hosts in this network.
pub application_count: i64,
}
// ─── Queries ──────────────────────────────────────────────────────────────────
/// Returns all networks from the database.
///
/// Called by the Networks page to populate the list.
#[server]
pub async fn get_networks() -> Result<Vec<Network>, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::networks as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
repo::list_networks(&pool)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Returns all networks enriched with host and application counts.
#[server]
pub async fn get_networks_with_counts() -> Result<Vec<NetworkWithCounts>, ServerFnError> {
use sqlx::{AnyPool, Row};
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
let rows = sqlx::query(
"SELECT
n.id,
n.name,
n.cidr,
(SELECT COUNT(*) FROM hosts WHERE network_id = n.id) AS host_count,
(SELECT COUNT(DISTINCT ap.application_id)
FROM hosts h
JOIN host_ports hp ON hp.host_id = h.id
JOIN application_ports ap ON ap.port_number = hp.port_number
WHERE h.network_id = n.id) AS application_count
FROM networks n
ORDER BY n.id",
)
.fetch_all(&pool)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let networks = rows
.into_iter()
.map(|row| NetworkWithCounts {
id: row.get("id"),
name: row.get("name"),
cidr: row.get("cidr"),
host_count: row.get("host_count"),
application_count: row.get("application_count"),
})
.collect();
Ok(networks)
}
/// Returns a single network by id, or an error if it does not exist.
#[server]
pub async fn get_network(id: i64) -> Result<Network, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::networks as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
repo::find_network(&pool, id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?
.ok_or_else(|| ServerFnError::new(format!("Network {id} not found")))
}
// ─── Mutations ────────────────────────────────────────────────────────────────
/// Creates a new network with the given name and CIDR block.
///
/// Returns an error if the CIDR is malformed or already exists.
#[server]
pub async fn create_network(name: String, cidr: String) -> Result<Network, ServerFnError> {
use sqlx::AnyPool;
use crate::server::{repository::networks as repo, validation::validate_cidr};
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
if name.trim().is_empty() {
return Err(ServerFnError::new("Network name cannot be empty"));
}
validate_cidr(&cidr).map_err(|e| ServerFnError::new(e.to_string()))?;
repo::create_network(&pool, name.trim(), &cidr)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Deletes a network by id.
///
/// Also deletes all hosts in that network (via `ON DELETE CASCADE`).
/// Returns `true` if the network existed and was deleted.
#[server]
pub async fn delete_network(id: i64) -> Result<bool, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::networks as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
repo::delete_network(&pool, id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}

45
src/api/summary.rs Normal file
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 })
}

124
src/app.rs Normal file
View File

@@ -0,0 +1,124 @@
// app.rs — Root Leptos components
//
// This file contains two components:
// - `Shell` : full HTML document (head + body) — SSR only
// - `App` : page content and router — shared between SSR and WASM
use leptos::prelude::*;
use leptos_meta::*;
use leptos_router::{
components::{Route, Router, Routes},
path,
};
use crate::client::{
application_detail::ApplicationDetailPage,
applications::ApplicationsPage,
home::HomePage,
host_detail::HostDetailPage,
hosts::HostsPage,
network_detail::NetworkDetailPage,
networks::NetworksPage,
theme::ThemeToggle,
};
// Shell — full HTML document rendered by the Axum server.
//
// This component only exists in SSR mode (`#[cfg(feature = "ssr")]`).
// It provides the HTML skeleton that leptos_meta requires:
// a valid <head> and <body>. Without it, <Title> and <Stylesheet>
// components have nowhere to inject their output.
//
// SSR rendering flow:
// 1. Axum calls Shell() for each incoming request
// 2. Shell renders <head> with MetaTags (a placeholder filled by App)
// 3. Shell renders <body> containing App()
// 4. App() calls provide_meta_context() and registers metadata
// 5. Leptos retroactively injects that metadata into MetaTags
// 6. HydrationScripts generates <script> tags to load the WASM bundle
#[cfg(feature = "ssr")]
#[component]
pub fn Shell(
// LeptosOptions holds project configuration (paths, file names, ports...).
// Used by HydrationScripts to build the WASM bundle URLs.
options: leptos::config::LeptosOptions,
) -> impl IntoView {
view! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
// MetaTags: placeholder where leptos_meta injects tags collected
// from <Title>, <Stylesheet>, <Meta>... defined inside App().
<MetaTags/>
// HydrationScripts: generates <link> and <script> tags that load
// the trunk-compiled WASM bundle and call hydrate() from lib.rs.
<HydrationScripts options=options.clone()/>
// AutoReload: hot-reload during development (no-op in production).
// Only activates when the LEPTOS_WATCH environment variable is set.
<AutoReload options/>
</head>
<body>
// App() runs here, provides the meta context, and renders page content.
<App/>
</body>
</html>
}
}
// App — root component shared between the server (SSR) and the browser (WASM).
//
// This component is rendered:
// - server-side : inside the Shell <body>, to generate HTML
// - client-side : via hydrate() in lib.rs, to attach reactivity
//
// `-> impl IntoView` : returns "something displayable". The concrete type is
// opaque because Leptos's `view!` macro generates a complex internal type.
#[component]
pub fn App() -> impl IntoView {
// Initialize the Leptos metadata context.
// Without this call, <Title>, <Stylesheet>, and <Meta> in child components
// would have no context to store their metadata in.
provide_meta_context();
view! {
<Title text="Rust IPAM — IP Address Manager"/>
// Load the global CSS from /pkg/rust-ipam.css.
// This file is generated by trunk from style.css (to be added later).
<Stylesheet id="main" href="/pkg/rust-ipam.css"/>
// Router handles client-side navigation without full page reloads.
// On the server, it determines which component to render for the requested URL.
<Router>
<nav>
<a href="/">"Rust IPAM"</a>
<a href="/networks">"Networks"</a>
<a href="/hosts">"Hosts"</a>
<a href="/applications">"Applications"</a>
<span class="nav-spacer"/>
<ThemeToggle/>
</nav>
<main>
// <Routes> is the container for all route definitions.
// `fallback` is displayed when no route matches the current URL.
<Routes fallback=|| view! {
<div class="not-found">
<h1>"404 — Page not found"</h1>
<a href="/">"← Back to home"</a>
</div>
}>
<Route path=path!("/") view=HomePage/>
<Route path=path!("/networks") view=NetworksPage/>
<Route path=path!("/networks/:id") view=NetworkDetailPage/>
<Route path=path!("/hosts") view=HostsPage/>
<Route path=path!("/hosts/:id") view=HostDetailPage/>
<Route path=path!("/applications") view=ApplicationsPage/>
<Route path=path!("/applications/:id") view=ApplicationDetailPage/>
</Routes>
</main>
</Router>
}
}

97
src/bin/seed.rs Normal file
View File

@@ -0,0 +1,97 @@
// bin/seed.rs — Development seed loader
//
// Inserts a realistic set of networks and hosts into the database so the UI
// can be tested without manual data entry.
//
// The seed is idempotent: running it multiple times never duplicates rows.
//
// Usage:
// cargo run --features ssr --bin seed
//
// The DATABASE_URL is read from the .env file (or environment variable),
// exactly like the main server.
#[tokio::main]
async fn main() {
use rust_ipam::server::{
config::{AppConfig, DatabaseBackend},
db::{create_pool, run_migrations},
};
// Load .env so DATABASE_URL is available without exporting it manually.
// Errors are ignored: the variable may already be set in the environment.
let _ = dotenvy::dotenv();
tracing_subscriber::fmt()
.with_env_filter(
std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()),
)
.init();
let config = AppConfig::from_env()
.expect("Configuration error — check DATABASE_URL in your .env file");
tracing::info!("Connecting to {} ({})", config.backend, config.database_url);
let pool = create_pool(&config)
.await
.expect("Failed to connect to database");
run_migrations(&pool, &config.backend)
.await
.expect("Migration failed");
// Pick the seed file that matches the active backend.
// `include_str!` embeds the SQL at compile time so the binary is self-contained.
let sql = match config.backend {
DatabaseBackend::Sqlite => include_str!("../../seeds/sqlite/dev_seed.sql"),
DatabaseBackend::Postgres => include_str!("../../seeds/postgres/dev_seed.sql"),
};
// Strip comment lines first, then split on ';'.
// sqlx does not support multiple statements in a single `query()` call.
// Without pre-stripping comments, a block like "-- section\nINSERT …"
// would start with "--" and get incorrectly discarded.
let sql_stripped: String = sql
.lines()
.filter(|line| !line.trim().starts_with("--"))
.collect::<Vec<_>>()
.join("\n");
let statements: Vec<String> = sql_stripped
.split(';')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let total = statements.len();
for (i, stmt) in statements.iter().enumerate() {
sqlx::query(stmt)
.execute(&pool)
.await
.unwrap_or_else(|e| panic!("Statement {}/{} failed: {}\nSQL: {}", i + 1, total, e, stmt));
}
tracing::info!("Seed complete — {} statement(s) executed.", total);
// Count what was inserted so the operator can confirm at a glance.
let network_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM networks")
.fetch_one(&pool)
.await
.unwrap_or(0);
let host_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM hosts")
.fetch_one(&pool)
.await
.unwrap_or(0);
let application_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM applications")
.fetch_one(&pool)
.await
.unwrap_or(0);
tracing::info!(
"Database now contains {} network(s), {} host(s) and {} application(s).",
network_count, host_count, application_count
);
}

View File

@@ -0,0 +1,311 @@
// client/application_detail.rs — Application detail page
//
// Shows all information for a single application:
// - Identity form : name — editable, saved with "Save changes"
// - Ports section : ports associated with this application + Add/Remove per port
// - Hosts section : hosts sharing at least one port with this application (read-only)
// - Delete button : confirmation modal, then navigates back to /applications
use leptos::prelude::*;
use leptos_router::hooks::{use_navigate, use_params_map, use_query_map};
use crate::api::applications::{
AddPortToApplication, DeleteApplication, RemovePortFromApplication,
UpdateApplication, get_application_detail,
};
// ─── Delete confirmation modal ────────────────────────────────────────────────
#[component]
fn DeleteModal(
app_name: String,
delete_action: ServerAction<DeleteApplication>,
app_id: i64,
show_modal: RwSignal<bool>,
) -> impl IntoView {
view! {
<div class="modal-backdrop" on:click=move |_| show_modal.set(false)>
<div class="modal" on:click=move |e| e.stop_propagation()>
<div class="modal__header">
<h2>"Delete application"</h2>
<button class="modal__close" type="button" aria-label="Close"
on:click=move |_| show_modal.set(false)>
"×"
</button>
</div>
<div class="modal__body">
<p class="warning">
"Are you sure you want to delete "
<strong>{app_name}</strong>
"? All port associations will also be removed."
</p>
</div>
<div class="modal__actions">
<button class="btn-secondary" type="button"
on:click=move |_| show_modal.set(false)>
"Cancel"
</button>
<button class="btn-danger" type="button"
on:click=move |_| { delete_action.dispatch(DeleteApplication { id: app_id }); }>
"Delete"
</button>
</div>
</div>
</div>
}.into_any()
}
// ─── Main page component ──────────────────────────────────────────────────────
#[component]
pub fn ApplicationDetailPage() -> impl IntoView {
let params = use_params_map();
let app_id = move || {
params.read().get("id")
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0)
};
let query = use_query_map();
let back_url = move || {
query.read().get("back")
.map(|s| s.to_string())
.unwrap_or_else(|| "/applications".to_string())
};
let back_label = move || {
if back_url().starts_with("/hosts/") { "← Host" } else { "← Applications" }
};
let update_action = ServerAction::<UpdateApplication>::new();
let add_port_action = ServerAction::<AddPortToApplication>::new();
let remove_port_action = ServerAction::<RemovePortFromApplication>::new();
let delete_action = ServerAction::<DeleteApplication>::new();
let show_delete_modal = RwSignal::new(false);
let app = LocalResource::new(move || {
let _ = update_action.version().get();
let _ = add_port_action.version().get();
let _ = remove_port_action.version().get();
get_application_detail(app_id())
});
let name_sig = RwSignal::new(String::new());
let new_port = RwSignal::new(String::new());
// Sync the editable name whenever fresh data arrives.
Effect::new(move |_| {
if let Some(r) = app.get() {
if let Ok(ref detail) = *r {
name_sig.set(detail.name.clone());
}
}
});
let navigate = use_navigate();
Effect::new(move |_| {
if let Some(Ok(true)) = delete_action.value().get() {
navigate("/applications", Default::default());
}
});
view! {
<div class="application-detail-page">
// Delete modal lives OUTSIDE <Suspense> so it is not unmounted when
// the application resource re-fetches.
{move || show_delete_modal.get().then(|| view! {
<DeleteModal
app_name=name_sig.get()
delete_action=delete_action
app_id=app_id()
show_modal=show_delete_modal
/>
})}
<Suspense fallback=|| view! { <p class="empty">"Loading application…"</p> }>
{move || app.get().map(|r| match (*r).clone() {
Err(e) => view! {
<p class="error">"Could not load application: " {e.to_string()}</p>
}.into_any(),
Ok(detail) => {
let id = detail.id;
let port_count = detail.ports.len();
let host_count = detail.hosts.len();
let ports = detail.ports;
let hosts = detail.hosts;
let ports_list = if ports.is_empty() {
view! {
<p class="empty">"No ports associated with this application."</p>
}.into_any()
} else {
view! {
<div class="port-list">
{ports.into_iter().map(|num| {
view! {
<div class="port-row">
<span class="port-row__number">{num}</span>
<button
class="btn-danger"
type="button"
on:click=move |_| {
remove_port_action.dispatch(
RemovePortFromApplication {
application_id: id,
port_number: num,
}
);
}
>
"Remove"
</button>
</div>
}
}).collect_view()}
</div>
}.into_any()
};
let hosts_list = if hosts.is_empty() {
view! {
<p class="empty">"No hosts share a port with this application."</p>
}.into_any()
} else {
view! {
<div class="app-list">
{hosts.into_iter().map(|host| {
view! {
<div class="app-row">
<a class="table-link"
href=format!("/hosts/{}?back=/applications/{}", host.id, id)>
{host.name}
</a>
<span class="cell-mono">{host.ip}</span>
</div>
}
}).collect_view()}
</div>
}.into_any()
};
view! {
// ── Page header ───────────────────────────────────
<div class="page-header detail-page-header">
<a class="back-btn" href=move || back_url()>
{move || back_label()}
</a>
<h1 class="detail-page-title">{move || name_sig.get()}</h1>
</div>
// ── Identity form ─────────────────────────────────
<section class="detail-section">
<h2 class="detail-section__title">"Identity"</h2>
<div class="detail-form">
<label class="detail-field">
"Name"
<input
type="text"
prop:value=move || name_sig.get()
on:input=move |e| name_sig.set(event_target_value(&e))
/>
</label>
{move || update_action.value().get()
.and_then(|r| r.err())
.map(|e| view! { <p class="error">{e.to_string()}</p> })
}
<div class="form-actions">
<button
class="btn-primary"
type="button"
on:click=move |_| {
update_action.dispatch(UpdateApplication {
id,
name: name_sig.get_untracked(),
});
}
>
"Save changes"
</button>
</div>
</div>
</section>
// ── Ports section ─────────────────────────────────
<section class="detail-section">
<h2 class="detail-section__title">
{format!("Associated ports ({})", port_count)}
</h2>
{ports_list}
{move || remove_port_action.value().get()
.and_then(|r| r.err())
.map(|e| view! {
<p class="error">"Remove failed: " {e.to_string()}</p>
})
}
<div class="port-add-row">
<input
type="number"
min="1"
max="65535"
placeholder="Port number (165535)"
prop:value=move || new_port.get()
on:input=move |e| new_port.set(event_target_value(&e))
/>
<button
class="btn-primary"
type="button"
on:click=move |_| {
let raw = new_port.get_untracked();
if let Ok(n) = raw.trim().parse::<i64>() {
if (1..=65535).contains(&n) {
add_port_action.dispatch(AddPortToApplication {
application_id: id,
port_number: n as u16,
});
new_port.set(String::new());
}
}
}
>
"Add port"
</button>
{move || add_port_action.value().get()
.and_then(|r| r.err())
.map(|e| view! {
<p class="error">"Add failed: " {e.to_string()}</p>
})
}
</div>
</section>
// ── Hosts section (read-only — linked via shared ports) ──
<section class="detail-section">
<h2 class="detail-section__title">
{format!("Linked hosts ({})", host_count)}
</h2>
{hosts_list}
</section>
// ── Danger zone ───────────────────────────────────
<div class="danger-zone">
<button
class="btn-danger-solid"
type="button"
on:click=move |_| show_delete_modal.set(true)
>
"Delete application"
</button>
</div>
}.into_any()
}
})}
</Suspense>
</div>
}.into_any()
}

294
src/client/applications.rs Normal file
View File

@@ -0,0 +1,294 @@
// client/applications.rs — Applications list page
//
// Displays all applications with:
// - Add button : opens a modal to create an application by name
// - Filter bar : name substring filter (client-side)
// - Table : application name + number of associated hosts
// - Delete : confirmation modal before deletion
use leptos::prelude::*;
use leptos::form::ActionForm;
use leptos::html::Input;
use crate::api::applications::{
ApplicationWithCounts, CreateApplication, DeleteApplication,
get_applications_with_counts,
};
// ─── Add application modal ────────────────────────────────────────────────────
#[component]
fn AddApplicationModal(
create_action: ServerAction<CreateApplication>,
show_modal: RwSignal<bool>,
) -> impl IntoView {
use leptos::task::spawn_local;
let name_ref = NodeRef::<Input>::new();
// Defer focus to the next microtask so the element is in the DOM.
// Using get_untracked() avoids subscribing to NodeRef's reactive signal,
// which would otherwise re-trigger during modal unmount and cause
// "closure invoked after being dropped" in wasm-bindgen.
spawn_local(async move {
if let Some(el) = name_ref.get_untracked() {
let _ = el.focus();
}
});
// close() defers show_modal.set(false) to the next microtask.
// Without this, setting the signal synchronously inside a click handler
// unmounts the modal (and frees its closures) while the handler is still
// on the call stack, causing wasm-bindgen to panic.
let close = move || spawn_local(async move { show_modal.set(false) });
view! {
<div class="modal-backdrop" on:click=move |_| close()>
<div class="modal" on:click=move |e| e.stop_propagation()>
<div class="modal__header">
<h2>"Add an application"</h2>
<button class="modal__close" type="button" aria-label="Close"
on:click=move |_| close()>
"×"
</button>
</div>
<ActionForm action=create_action>
<div class="add-form__fields">
<label>
"Name"
<input
node_ref=name_ref
type="text"
name="name"
placeholder="e.g. Nginx, PostgreSQL, Prometheus"
required
/>
</label>
<label>
"Associated ports"
<input
type="text"
name="ports"
placeholder="e.g. 22, 80, 443"
/>
<span class="field-hint">"Comma-separated port numbers"</span>
</label>
</div>
<div class="modal__actions">
<button class="btn-secondary" type="button"
on:click=move |_| close()>
"Cancel"
</button>
<button type="submit">"Add application"</button>
</div>
</ActionForm>
{move || create_action.value().get()
.and_then(|r| r.err())
.map(|e| view! { <p class="error">{e.to_string()}</p> })
}
</div>
</div>
}.into_any()
}
// ─── Delete confirmation modal ────────────────────────────────────────────────
#[component]
fn DeleteAppModal(
app: ApplicationWithCounts,
delete_action: ServerAction<DeleteApplication>,
pending_delete: RwSignal<Option<ApplicationWithCounts>>,
) -> impl IntoView {
let id = app.id;
let label = app.name.clone();
let host_count = app.host_count;
view! {
<div class="modal-backdrop" on:click=move |_| pending_delete.set(None)>
<div class="modal" on:click=move |e| e.stop_propagation()>
<div class="modal__header">
<h2>"Delete application"</h2>
<button class="modal__close" type="button" aria-label="Close"
on:click=move |_| pending_delete.set(None)>
"×"
</button>
</div>
<div class="modal__body">
<p>"Delete application " <strong>{label}</strong> "?"</p>
{(host_count > 0).then(|| view! {
<p class="warning">
"This application is linked to "
{host_count}
{if host_count == 1 { " host" } else { " hosts" }}
" via shared ports. The port associations will be removed."
</p>
})}
</div>
<div class="modal__actions">
<button class="btn-secondary" type="button"
on:click=move |_| pending_delete.set(None)>
"Cancel"
</button>
<button class="btn-danger" type="button"
on:click=move |_| { delete_action.dispatch(DeleteApplication { id }); }>
"Delete"
</button>
</div>
</div>
</div>
}.into_any()
}
// ─── Page ─────────────────────────────────────────────────────────────────────
#[component]
pub fn ApplicationsPage() -> impl IntoView {
let create_action = ServerAction::<CreateApplication>::new();
let delete_action = ServerAction::<DeleteApplication>::new();
let show_modal = RwSignal::new(false);
// Some(app) = delete modal open for that app; None = closed.
let pending_delete: RwSignal<Option<ApplicationWithCounts>> = RwSignal::new(None);
// Name filter (client-side — list is typically small)
let name_filter = RwSignal::new(String::new());
// Close the add modal when the action transitions pending→done with Ok.
// Lives in the parent so it is never recreated across modal open/close cycles,
// which avoids the stale-value re-trigger bug.
Effect::new(move |was_pending: Option<bool>| {
let is_pending = create_action.pending().get();
if was_pending == Some(true) && !is_pending {
if let Some(Ok(_)) = create_action.value().get() {
show_modal.set(false);
}
}
is_pending
});
// Close the delete modal automatically after a successful deletion.
Effect::new(move |_| {
if let Some(Ok(_)) = delete_action.value().get() {
pending_delete.set(None);
}
});
let applications = Resource::new(
move || (create_action.version().get(), delete_action.version().get()),
|_| get_applications_with_counts(),
);
view! {
<div class="applications-page">
// ── Page header ───────────────────────────────────────────────────
<div class="page-header">
<h1>"Applications"</h1>
<button class="btn-primary" on:click=move |_| show_modal.set(true)>
"+ Add application"
</button>
</div>
// ── Add modal ─────────────────────────────────────────────────────
{move || show_modal.get().then(|| view! {
<AddApplicationModal
create_action=create_action
show_modal=show_modal
/>
})}
// ── Delete modal ──────────────────────────────────────────────────
{move || pending_delete.get().map(|app| view! {
<DeleteAppModal
app=app
delete_action=delete_action
pending_delete=pending_delete
/>
})}
// ── Filter bar ────────────────────────────────────────────────────
<section class="filter-bar">
<div class="filter-bar__fields">
<label class="filter-field">
"Name"
<input
type="text"
placeholder="Search…"
on:input=move |e| name_filter.set(event_target_value(&e))
/>
</label>
</div>
</section>
// ── Application list ──────────────────────────────────────────────
<section class="list">
{move || delete_action.value().get()
.and_then(|r| r.err())
.map(|e| view! { <p class="error">"Delete failed: " {e.to_string()}</p> })
}
<Suspense fallback=|| view! { <p>"Loading applications…"</p> }>
{move || applications.get().map(|result| match result {
Err(e) => view! {
<p class="error">"Could not load applications: " {e.to_string()}</p>
}.into_any(),
Ok(list) => {
let filter = name_filter.get().to_lowercase();
let filtered: Vec<_> = list.into_iter()
.filter(|app| filter.is_empty() || app.name.to_lowercase().contains(&filter))
.collect();
if filtered.is_empty() {
view! {
<p class="empty">"No applications match the current filter."</p>
}.into_any()
} else {
view! {
<div class="table-container">
<table>
<thead>
<tr>
<th>"Name"</th>
<th class="col-count">"Hosts"</th>
<th class="col-actions">"Actions"</th>
</tr>
</thead>
<tbody>
{filtered.into_iter().map(|app| {
let app_clone = app.clone();
view! {
<tr>
<td>
<a class="table-link"
href=format!("/applications/{}", app.id)>
{app.name}
</a>
</td>
<td class="col-count">{app.host_count}</td>
<td class="col-actions">
<button class="btn-danger" on:click=move |_| {
pending_delete.set(Some(app_clone.clone()));
}>
"Delete"
</button>
</td>
</tr>
}
}).collect_view()}
</tbody>
</table>
</div>
}.into_any()
}
}
})}
</Suspense>
</section>
</div>
}
}

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

585
src/client/host_detail.rs Normal file
View File

@@ -0,0 +1,585 @@
// client/host_detail.rs — Host detail page
//
// Shows all information for a single host:
// - Identity form : name, IP, network dropdown — editable, saved with "Save changes"
// - Ports section : full list with Remove per port + Add port input
// - Applications : directly linked apps with Remove + modal multi-select to add
// - Delete button : opens a confirmation modal, then navigates back to /hosts
use leptos::prelude::*;
use leptos_router::hooks::{use_navigate, use_params_map, use_query_map};
use crate::api::{
hosts::{
AddHostApplications, AddHostPort, DeleteHost, RemoveHostApplication,
RemoveHostPort, UpdateHost, get_applications_not_on_host, get_host_detail,
},
networks::get_networks,
};
use crate::models::Application;
// ─── Add applications modal ───────────────────────────────────────────────────
// Scrollable pick list + selected tags:
// - Top: scrollable list of available apps; clicking one moves it to the
// selected section and removes it from the list.
// - Bottom: selected apps shown as removable tags; clicking × puts the app
// back in the list.
//
// The auto-close Effect lives in the PARENT to avoid the re-trigger bug
// (an Effect inside a conditionally-rendered component fires on mount and
// would immediately close the modal if the action already held a past Ok value).
#[component]
fn AddAppModal(
host_id: i64,
available_apps_res: LocalResource<Result<Vec<Application>, ServerFnError>>,
add_action: ServerAction<AddHostApplications>,
show_modal: RwSignal<bool>,
) -> impl IntoView {
// Full Application structs so names are available in the selected tag list.
let selected: RwSignal<Vec<Application>> = RwSignal::new(vec![]);
view! {
<div class="modal-backdrop" on:click=move |_| show_modal.set(false)>
<div class="modal" on:click=move |e| e.stop_propagation()>
<div class="modal__header">
<h2>"Add applications"</h2>
<button class="modal__close" type="button" aria-label="Close"
on:click=move |_| show_modal.set(false)>
"×"
</button>
</div>
<div class="modal__body">
// ── Scrollable pick list ──────────────────────────────────
{move || match available_apps_res.get() {
None => view! { <p class="empty">"Loading…"</p> }.into_any(),
Some(r) => match (*r).clone() {
Err(e) => view! {
<p class="error">
"Could not load applications: " {e.to_string()}
</p>
}.into_any(),
Ok(apps) => {
// Exclude already-selected apps from the displayed list.
let sel_ids: Vec<i64> = selected.get()
.iter().map(|a| a.id).collect();
let displayed: Vec<Application> = apps.into_iter()
.filter(|a| !sel_ids.contains(&a.id))
.collect();
if displayed.is_empty() && sel_ids.is_empty() {
view! {
<p class="empty">
"All applications are already linked to this host."
</p>
}.into_any()
} else if displayed.is_empty() {
view! {
<p class="empty">
"All available applications have been selected."
</p>
}.into_any()
} else {
view! {
<ul class="app-pick-list">
{displayed.into_iter().map(|app| {
let app_clone = app.clone();
view! {
<li class="app-pick-item"
on:click=move |_| {
selected.update(|v| {
v.push(app_clone.clone());
});
}
>
<span>{app.name}</span>
<span class="app-pick-item__add">"+"</span>
</li>
}
}).collect_view()}
</ul>
}.into_any()
}
}
}
}}
// ── Selected tags (shown once at least one app is chosen) ─
{move || (!selected.get().is_empty()).then(|| {
let sel = selected.get();
view! {
<div class="app-selected-section">
<span class="app-selected-label">"Selected:"</span>
<div class="app-selected-list">
{sel.into_iter().map(|app| {
let app_id = app.id;
view! {
<span class="app-selected-tag">
{app.name}
<button
class="app-selected-tag__remove"
type="button"
aria-label="Remove"
on:click=move |_| {
selected.update(|v| {
v.retain(|x| x.id != app_id);
});
}
>"×"</button>
</span>
}
}).collect_view()}
</div>
</div>
}
})}
{move || add_action.value().get()
.and_then(|r| r.err())
.map(|e| view! { <p class="error">{e.to_string()}</p> })
}
</div>
<div class="modal__actions">
<button class="btn-secondary" type="button"
on:click=move |_| show_modal.set(false)>
"Cancel"
</button>
<button
class="btn-primary"
type="button"
disabled={move || selected.get().is_empty()}
on:click=move |_| {
let ids_str = selected.get_untracked()
.iter()
.map(|a| a.id.to_string())
.collect::<Vec<_>>()
.join(",");
if !ids_str.is_empty() {
add_action.dispatch(AddHostApplications {
host_id,
application_ids: ids_str,
});
}
}
>
"Add selected"
</button>
</div>
</div>
</div>
}.into_any()
}
// ─── Delete confirmation modal ────────────────────────────────────────────────
#[component]
fn DeleteModal(
host_name: String,
delete_action: ServerAction<DeleteHost>,
host_id: i64,
show_modal: RwSignal<bool>,
) -> impl IntoView {
view! {
<div class="modal-backdrop" on:click=move |_| show_modal.set(false)>
<div class="modal" on:click=move |e| e.stop_propagation()>
<div class="modal__header">
<h2>"Delete host"</h2>
<button class="modal__close" type="button" aria-label="Close"
on:click=move |_| show_modal.set(false)>
"×"
</button>
</div>
<div class="modal__body">
<p class="warning">
"Are you sure you want to delete "
<strong>{host_name}</strong>
"? All port and application associations will also be removed."
</p>
</div>
<div class="modal__actions">
<button class="btn-secondary" type="button"
on:click=move |_| show_modal.set(false)>
"Cancel"
</button>
<button class="btn-danger" type="button"
on:click=move |_| { delete_action.dispatch(DeleteHost { id: host_id }); }>
"Delete"
</button>
</div>
</div>
</div>
}.into_any()
}
// ─── Main page component ──────────────────────────────────────────────────────
#[component]
pub fn HostDetailPage() -> impl IntoView {
let params = use_params_map();
let host_id = move || {
params.read().get("id")
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0)
};
let query = use_query_map();
let back_url = move || {
query.read().get("back")
.map(|s| s.to_string())
.unwrap_or_else(|| "/hosts".to_string())
};
let back_label = move || {
if back_url().starts_with("/networks/") { "← Network" }
else if back_url().starts_with("/applications/") { "← Application" }
else { "← Hosts" }
};
let update_action = ServerAction::<UpdateHost>::new();
let add_port_action = ServerAction::<AddHostPort>::new();
let remove_port_action = ServerAction::<RemoveHostPort>::new();
let add_app_action = ServerAction::<AddHostApplications>::new();
let remove_app_action = ServerAction::<RemoveHostApplication>::new();
let delete_action = ServerAction::<DeleteHost>::new();
let show_delete_modal = RwSignal::new(false);
let show_add_app_modal = RwSignal::new(false);
// Auto-close the add-app modal when the action completes successfully.
// Lives here (not inside AddAppModal) so it is never recreated across modal open/close cycles.
Effect::new(move |was_pending: Option<bool>| {
let is_pending = add_app_action.pending().get();
if was_pending == Some(true) && !is_pending {
if let Some(Ok(_)) = add_app_action.value().get() {
show_add_app_modal.set(false);
}
}
is_pending
});
// LocalResource avoids reading the resource outside <Suspense> during hydration,
// which would cause a mismatch between the SSR-rendered fallback and the content
// the WASM expects to find after the resource resolves.
let host = LocalResource::new(move || {
let _ = update_action.version().get();
let _ = add_port_action.version().get();
let _ = remove_port_action.version().get();
let _ = add_app_action.version().get();
let _ = remove_app_action.version().get();
get_host_detail(host_id())
});
// Networks dropdown — LocalResource avoids SSR/hydration mismatch.
let networks_res = LocalResource::new(|| get_networks());
// Available apps for the modal: re-fetched whenever add/remove completes.
let add_app_ver = add_app_action.version();
let remove_app_ver = remove_app_action.version();
let available_apps_res = LocalResource::new(move || {
let _ = add_app_ver.get();
let _ = remove_app_ver.get();
get_applications_not_on_host(host_id())
});
let name_sig = RwSignal::new(String::new());
let ip_sig = RwSignal::new(String::new());
let net_id_sig = RwSignal::new(0i64);
let new_port = RwSignal::new(String::new());
// Sync edit signals whenever fresh host data arrives.
// LocalResource wraps its value in SendWrapper, so we dereference with `*r`.
Effect::new(move |_| {
if let Some(r) = host.get() {
if let Ok(ref detail) = *r {
name_sig.set(detail.name.clone());
ip_sig.set(detail.ip.clone());
net_id_sig.set(detail.network_id);
}
}
});
let navigate = use_navigate();
Effect::new(move |_| {
if let Some(Ok(true)) = delete_action.value().get() {
navigate("/hosts", Default::default());
}
});
view! {
<div class="host-detail-page">
// Modals live OUTSIDE <Suspense> so they are not unmounted when the
// host resource re-fetches (which would kill their reactive subscriptions).
{move || show_add_app_modal.get().then(|| view! {
<AddAppModal
host_id=host_id()
available_apps_res=available_apps_res
add_action=add_app_action
show_modal=show_add_app_modal
/>
})}
{move || show_delete_modal.get().then(|| view! {
<DeleteModal
host_name=name_sig.get()
delete_action=delete_action
host_id=host_id()
show_modal=show_delete_modal
/>
})}
<Suspense fallback=|| view! { <p class="empty">"Loading host…"</p> }>
{move || host.get().map(|r| match (*r).clone() {
Err(e) => view! {
<p class="error">"Could not load host: " {e.to_string()}</p>
}.into_any(),
Ok(detail) => {
let id = detail.id;
let port_count = detail.ports.len();
let app_count = detail.applications.len();
let ports = detail.ports;
let applications = detail.applications;
// Pre-built ports view — consumes `ports` once, not reactively.
let ports_list = if ports.is_empty() {
view! {
<p class="empty">"No ports open on this host."</p>
}.into_any()
} else {
view! {
<div class="port-list">
{ports.into_iter().map(|port| {
let num = port.number;
view! {
<div class="port-row">
<span class="port-row__number">{num}</span>
<span class="port-row__desc">
{port.description.unwrap_or_default()}
</span>
<button
class="btn-danger"
type="button"
on:click=move |_| {
remove_port_action.dispatch(
RemoveHostPort { host_id: id, port_number: num as i64 }
);
}
>
"Remove"
</button>
</div>
}
}).collect_view()}
</div>
}.into_any()
};
// Pre-built applications view.
let apps_list = if applications.is_empty() {
view! {
<p class="empty">"No applications linked to this host."</p>
}.into_any()
} else {
view! {
<div class="app-list">
{applications.into_iter().map(|app| {
let app_id = app.id;
view! {
<div class="app-row">
<a class="table-link app-row__name"
href=format!("/applications/{}?back=/hosts/{}", app_id, id)>
{app.name}
</a>
<button
class="btn-danger"
type="button"
on:click=move |_| {
remove_app_action.dispatch(
RemoveHostApplication { host_id: id, application_id: app_id }
);
}
>
"Remove"
</button>
</div>
}
}).collect_view()}
</div>
}.into_any()
};
view! {
// ── Page header ──────────────────────────────────
<div class="page-header detail-page-header">
<a class="back-btn" href=move || back_url()>
{move || back_label()}
</a>
<h1 class="detail-page-title">{move || name_sig.get()}</h1>
</div>
// ── Identity form ─────────────────────────────────
<section class="detail-section">
<h2 class="detail-section__title">"Identity"</h2>
<div class="detail-form">
<label class="detail-field">
"Name"
<input
type="text"
prop:value=move || name_sig.get()
on:input=move |e| name_sig.set(event_target_value(&e))
/>
</label>
<label class="detail-field">
"IP address"
<input
type="text"
prop:value=move || ip_sig.get()
on:input=move |e| ip_sig.set(event_target_value(&e))
/>
</label>
<label class="detail-field">
"Network"
<select on:change=move |e| {
net_id_sig.set(
event_target_value(&e).parse().unwrap_or(0)
);
}>
{move || networks_res.get()
.and_then(|r| (*r).clone().ok())
.map(|nets| {
let current = net_id_sig.get();
nets.into_iter().map(|n| {
let label = format!("{} - {}", n.name, n.cidr);
view! {
<option
value=n.id.to_string()
selected=n.id == current
>
{label}
</option>
}
}).collect_view()
})
}
</select>
</label>
{move || update_action.value().get()
.and_then(|r| r.err())
.map(|e| view! { <p class="error">{e.to_string()}</p> })
}
<div class="form-actions">
<button
class="btn-primary"
type="button"
on:click=move |_| {
update_action.dispatch(UpdateHost {
id,
name: name_sig.get_untracked(),
ip: ip_sig.get_untracked(),
network_id: net_id_sig.get_untracked(),
});
}
>
"Save changes"
</button>
</div>
</div>
</section>
// ── Ports section ─────────────────────────────────
<section class="detail-section">
<h2 class="detail-section__title">
{format!("Open ports ({})", port_count)}
</h2>
{ports_list}
{move || remove_port_action.value().get()
.and_then(|r| r.err())
.map(|e| view! {
<p class="error">"Remove failed: " {e.to_string()}</p>
})
}
<div class="port-add-row">
<input
type="number"
min="1"
max="65535"
placeholder="Port number (165535)"
prop:value=move || new_port.get()
on:input=move |e| new_port.set(event_target_value(&e))
/>
<button
class="btn-primary"
type="button"
on:click=move |_| {
let raw = new_port.get_untracked();
if let Ok(n) = raw.trim().parse::<i64>() {
if (1..=65535).contains(&n) {
add_port_action.dispatch(AddHostPort {
host_id: id,
port_number: n,
});
new_port.set(String::new());
}
}
}
>
"Add port"
</button>
{move || add_port_action.value().get()
.and_then(|r| r.err())
.map(|e| view! {
<p class="error">"Add failed: " {e.to_string()}</p>
})
}
</div>
</section>
// ── Applications section ──────────────────────────
<section class="detail-section">
<h2 class="detail-section__title">
{format!("Applications ({})", app_count)}
</h2>
{apps_list}
{move || remove_app_action.value().get()
.and_then(|r| r.err())
.map(|e| view! {
<p class="error">"Remove failed: " {e.to_string()}</p>
})
}
<div class="port-add-row">
<button
class="btn-primary"
type="button"
on:click=move |_| show_add_app_modal.set(true)
>
"+ Add applications"
</button>
</div>
</section>
// ── Danger zone ───────────────────────────────────
<div class="danger-zone">
<button
class="btn-danger-solid"
type="button"
on:click=move |_| show_delete_modal.set(true)
>
"Delete host"
</button>
</div>
}.into_any()
}
})}
</Suspense>
</div>
}.into_any()
}

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

@@ -0,0 +1,474 @@
// client/hosts.rs — Hosts list page
//
// Displays all hosts across every network with:
// - Add button : opens a modal form to create a host inside a chosen network
// - Filter bar : name (substring), network, open ports (CSV), application
// - Table : name, IP, network, port count, application count, delete
// - Pagination : configurable page size (15 / 25 / 50 / 100 / All)
//
// Sub-components call `.into_any()` on their views to erase the concrete
// Leptos type, preventing the parent from accumulating a deeply-nested
// generic type that overflows the compiler's query depth limit.
use leptos::prelude::*;
use leptos::form::ActionForm;
use leptos::html::Input;
use crate::api::{
applications::get_applications,
hosts::{CreateHost, DeleteHost, get_hosts_page, HostsPage as HostsPageData},
networks::get_networks,
};
const PER_PAGE_OPTIONS: &[(i64, &str)] = &[
(15, "15"),
(25, "25"),
(50, "50"),
(100, "100"),
(0, "All"),
];
// ─── Delete host modal ────────────────────────────────────────────────────────
#[component]
fn DeleteHostModal(
host_name: String,
host_id: i64,
delete_action: ServerAction<DeleteHost>,
pending_delete: RwSignal<Option<(i64, String)>>,
) -> impl IntoView {
view! {
<div class="modal-backdrop" on:click=move |_| pending_delete.set(None)>
<div class="modal" on:click=move |e| e.stop_propagation()>
<div class="modal__header">
<h2>"Delete host"</h2>
<button class="modal__close" type="button" aria-label="Close"
on:click=move |_| pending_delete.set(None)>
"×"
</button>
</div>
<div class="modal__body">
<p class="warning">
"Are you sure you want to delete "
<strong>{host_name}</strong>
"? All port associations will also be removed."
</p>
</div>
<div class="modal__actions">
<button class="btn-secondary" type="button"
on:click=move |_| pending_delete.set(None)>
"Cancel"
</button>
<button class="btn-danger" type="button"
on:click=move |_| { delete_action.dispatch(DeleteHost { id: host_id }); }>
"Delete"
</button>
</div>
</div>
</div>
}.into_any()
}
// ─── Add host modal ───────────────────────────────────────────────────────────
#[component]
fn AddHostModal(
create_action: ServerAction<CreateHost>,
networks_res: LocalResource<Result<Vec<crate::models::Network>, ServerFnError>>,
show_modal: RwSignal<bool>,
) -> impl IntoView {
use leptos::task::spawn_local;
let name_ref = NodeRef::<Input>::new();
spawn_local(async move {
if let Some(el) = name_ref.get_untracked() {
let _ = el.focus();
}
});
let close = move || spawn_local(async move { show_modal.set(false) });
view! {
<div class="modal-backdrop" on:click=move |_| close()>
<div class="modal" on:click=move |e| e.stop_propagation()>
<div class="modal__header">
<h2>"Add a host"</h2>
<button
class="modal__close"
type="button"
aria-label="Close"
on:click=move |_| close()
>"×"</button>
</div>
<ActionForm action=create_action>
<div class="add-form__fields">
<label>
"Name"
<input node_ref=name_ref type="text" name="name" placeholder="e.g. web-server-01" required/>
</label>
<label>
"IP address"
<input type="text" name="ip" placeholder="e.g. 192.168.1.10" required/>
</label>
<label>
"Network"
<select name="network_id" required>
<option value="">"— choose —"</option>
{move || networks_res.get()
.and_then(|r| (*r).clone().ok())
.map(|nets| nets.into_iter().map(|n| {
let label = format!("{} - {}", n.name, n.cidr);
view! { <option value=n.id.to_string()>{label}</option> }
}).collect_view())
}
</select>
</label>
<label>
"Open ports"
<input
type="text"
name="ports"
placeholder="e.g. 22, 80, 443"
/>
<span class="field-hint">"Comma-separated port numbers"</span>
</label>
</div>
<div class="modal__actions">
<button
class="btn-secondary"
type="button"
on:click=move |_| close()
>"Cancel"</button>
<button type="submit">"Add host"</button>
</div>
</ActionForm>
{move || create_action.value().get()
.and_then(|r| r.err())
.map(|e| view! { <p class="error">{e.to_string()}</p> })
}
</div>
</div>
}.into_any()
}
// ─── Filter bar ───────────────────────────────────────────────────────────────
#[component]
fn FilterBar(
networks_res: LocalResource<Result<Vec<crate::models::Network>, ServerFnError>>,
applications_res: LocalResource<Result<Vec<crate::models::Application>, ServerFnError>>,
name_filter: RwSignal<String>,
network_id_filter: RwSignal<i64>,
port_filter: RwSignal<String>,
app_id_filter: RwSignal<i64>,
page: RwSignal<i64>,
) -> impl IntoView {
view! {
<section class="filter-bar">
<div class="filter-bar__fields">
<label class="filter-field">
"Name"
<input
type="text"
placeholder="Search…"
on:change=move |e| {
name_filter.set(event_target_value(&e));
page.set(1);
}
/>
</label>
<label class="filter-field">
"Network"
<select on:change=move |e| {
network_id_filter.set(event_target_value(&e).parse().unwrap_or(0));
page.set(1);
}>
<option value="0">"All networks"</option>
{move || networks_res.get()
.and_then(|r| (*r).clone().ok())
.map(|nets| nets.into_iter().map(|n| {
let label = format!("{} - {}", n.name, n.cidr);
view! { <option value=n.id.to_string()>{label}</option> }
}).collect_view())
}
</select>
</label>
<label class="filter-field">
"Open ports"
<input
type="text"
placeholder="e.g. 80, 443 (all required)"
on:change=move |e| {
port_filter.set(event_target_value(&e));
page.set(1);
}
/>
</label>
<label class="filter-field">
"Application"
<select on:change=move |e| {
app_id_filter.set(event_target_value(&e).parse().unwrap_or(0));
page.set(1);
}>
<option value="0">"All applications"</option>
{move || applications_res.get()
.and_then(|r| (*r).clone().ok())
.map(|apps| apps.into_iter().map(|a| {
view! { <option value=a.id.to_string()>{a.name}</option> }
}).collect_view())
}
</select>
</label>
</div>
</section>
}.into_any()
}
// ─── Pagination bar ───────────────────────────────────────────────────────────
#[component]
fn PaginationBar(
total: Signal<i64>,
page: RwSignal<i64>,
per_page: RwSignal<i64>,
total_pages: Signal<i64>,
) -> impl IntoView {
view! {
<div class="pagination-bar">
<div class="pagination-bar__info">
{move || {
let t = total.get();
if t == 0 { "No hosts found".to_string() }
else { format!("{} host{}", t, if t == 1 { "" } else { "s" }) }
}}
</div>
<div class="pagination-bar__controls">
<label class="pagination-per-page">
"Per page "
<select on:change=move |e| {
per_page.set(event_target_value(&e).parse().unwrap_or(15));
page.set(1);
}>
{PER_PAGE_OPTIONS.iter().map(|(value, label)| {
view! {
<option value=value.to_string() selected=*value == 15>
{*label}
</option>
}
}).collect_view()}
</select>
</label>
{move || (per_page.get() > 0).then(|| view! {
<div class="pagination-nav">
<button
disabled=move || page.get() <= 1
on:click=move |_| page.update(|p| *p = (*p - 1).max(1))
>""</button>
<span class="pagination-nav__label">
{move || format!("Page {} of {}", page.get(), total_pages.get().max(1))}
</span>
<button
disabled={move || page.get() >= total_pages.get()}
on:click=move |_| {
let max = total_pages.get_untracked();
page.update(|p| *p = (*p + 1).min(max));
}
>""</button>
</div>
})}
</div>
</div>
}.into_any()
}
// ─── Host table ───────────────────────────────────────────────────────────────
#[component]
fn HostTable(
hosts: LocalResource<Result<HostsPageData, ServerFnError>>,
pending_delete: RwSignal<Option<(i64, String)>>,
) -> impl IntoView {
view! {
<Suspense fallback=|| view! { <p class="empty">"Loading hosts…"</p> }>
{move || hosts.get().map(|r| match (*r).clone() {
Err(e) => view! {
<p class="error">"Could not load hosts: " {e.to_string()}</p>
}.into_any(),
Ok(HostsPageData { rows, .. }) if rows.is_empty() => view! {
<p class="empty">"No hosts match the current filters."</p>
}.into_any(),
Ok(HostsPageData { rows, .. }) => view! {
<div class="table-container">
<table>
<thead>
<tr>
<th>"Name"</th>
<th>"IP"</th>
<th>"Network"</th>
<th class="col-count">"Ports"</th>
<th class="col-count">"Applications"</th>
<th class="col-actions">"Actions"</th>
</tr>
</thead>
<tbody>
{rows.into_iter().map(|host| {
let id = host.id;
let delete_name = host.name.clone();
view! {
<tr>
<td>
<a class="table-link" href=format!("/hosts/{id}")>
{host.name}
</a>
</td>
<td class="cell-mono">{host.ip}</td>
<td>
<a class="table-link" href=format!("/networks/{}", host.network_id)>
{host.network_cidr}
</a>
</td>
<td class="col-count">{host.port_count}</td>
<td class="col-count">{host.application_count}</td>
<td class="col-actions">
<button on:click=move |_| {
pending_delete.set(Some((id, delete_name.clone())));
}>
"Delete"
</button>
</td>
</tr>
}
}).collect_view()}
</tbody>
</table>
</div>
}.into_any(),
})}
</Suspense>
}.into_any()
}
// ─── Main page component ──────────────────────────────────────────────────────
#[component]
pub fn HostsPage() -> impl IntoView {
let create_action = ServerAction::<CreateHost>::new();
let delete_action = ServerAction::<DeleteHost>::new();
let show_modal = RwSignal::new(false);
// None = no modal, Some((id, name)) = delete confirmation open.
let pending_delete: RwSignal<Option<(i64, String)>> = RwSignal::new(None);
// Close the add modal on pending→done with Ok (lives in parent to avoid stale-value re-trigger).
Effect::new(move |was_pending: Option<bool>| {
let is_pending = create_action.pending().get();
if was_pending == Some(true) && !is_pending {
if let Some(Ok(_)) = create_action.value().get() {
show_modal.set(false);
}
}
is_pending
});
// Close the delete modal automatically after a successful deletion.
Effect::new(move |_| {
if let Some(Ok(_)) = delete_action.value().get() {
pending_delete.set(None);
}
});
// Filter signals ("" / 0 = no filter)
let name_filter = RwSignal::new(String::new());
let network_id_filter = RwSignal::new(0i64);
let port_filter = RwSignal::new(String::new()); // CSV of port numbers
let app_id_filter = RwSignal::new(0i64);
let page = RwSignal::new(1i64);
let per_page = RwSignal::new(15i64);
// LocalResource avoids reading a resource outside <Suspense> during hydration.
// All dependencies (filters, pagination, action versions) are client-side only,
// so there is no benefit to SSR for this resource.
let hosts = LocalResource::new(move || {
let _ = create_action.version().get();
let _ = delete_action.version().get();
get_hosts_page(
name_filter.get(),
network_id_filter.get(),
port_filter.get(),
app_id_filter.get(),
page.get(),
per_page.get(),
)
});
let networks_res = LocalResource::new(|| get_networks());
let applications_res = LocalResource::new(|| get_applications());
let total_pages = Signal::derive(move || {
hosts.get().and_then(|r| (*r).clone().ok()).map(|p| p.total_pages).unwrap_or(1)
});
let total = Signal::derive(move || {
hosts.get().and_then(|r| (*r).clone().ok()).map(|p| p.total).unwrap_or(0)
});
view! {
<div class="hosts-page">
<div class="page-header">
<h1>"Hosts"</h1>
<button class="btn-primary" on:click=move |_| show_modal.set(true)>
"+ Add host"
</button>
</div>
{move || show_modal.get().then(|| view! {
<AddHostModal
create_action=create_action
networks_res=networks_res
show_modal=show_modal
/>
})}
{move || pending_delete.get().map(|(host_id, host_name)| view! {
<DeleteHostModal
host_name=host_name
host_id=host_id
delete_action=delete_action
pending_delete=pending_delete
/>
})}
<FilterBar
networks_res=networks_res
applications_res=applications_res
name_filter=name_filter
network_id_filter=network_id_filter
port_filter=port_filter
app_id_filter=app_id_filter
page=page
/>
<section class="list">
{move || delete_action.value().get()
.and_then(|r| r.err())
.map(|e| view! { <p class="error">"Delete failed: " {e.to_string()}</p> })
}
<PaginationBar total=total page=page per_page=per_page total_pages=total_pages/>
<HostTable hosts=hosts pending_delete=pending_delete/>
</section>
</div>
}.into_any()
}

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

@@ -0,0 +1,19 @@
// client/mod.rs — Client UI module
//
// Contains Leptos pages and components.
//
// Despite the name "client", this code runs on BOTH sides:
// - Server-side : to generate the initial HTML (SSR)
// - Browser : compiled to WASM to make the interface interactive
//
// Do not place code here that requires browser-only APIs (window, document...)
// without guarding it with `#[cfg(target_arch = "wasm32")]`.
pub mod application_detail; // Application detail: identity, ports, linked hosts, delete
pub mod applications; // Applications list and creation
pub mod home; // Home page
pub mod host_detail; // Host detail: identity, ports, edit, delete
pub mod hosts; // Hosts list with filters and pagination
pub mod network_detail; // Network detail: info + paginated host list
pub mod networks; // Networks list and creation
pub mod theme; // Theme toggle component (light / dark / system)

View File

@@ -0,0 +1,200 @@
// client/network_detail.rs — Network detail page
//
// Displays a single network (name + CIDR) with a paginated list of its hosts.
// Each host name links to /hosts/:id?back=/networks/:network_id so that the
// host detail page can offer a contextual "back to network" button.
use leptos::prelude::*;
use leptos_router::hooks::use_params_map;
use crate::api::{
hosts::{get_hosts_page, HostsPage as HostsPageData},
networks::get_network,
};
const PER_PAGE_OPTIONS: &[(i64, &str)] = &[
(15, "15"),
(25, "25"),
(50, "50"),
(100, "100"),
(0, "All"),
];
// ─── Main page component ──────────────────────────────────────────────────────
#[component]
pub fn NetworkDetailPage() -> impl IntoView {
let params = use_params_map();
let network_id = move || {
params.read().get("id")
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0)
};
let page = RwSignal::new(1i64);
let per_page = RwSignal::new(15i64);
// Network metadata — reloads only when the ID changes.
let network = Resource::new(
move || network_id(),
|id| get_network(id),
);
// Paginated host list for this network.
// Guards against network_id = 0 to avoid fetching all hosts.
let hosts = Resource::new(
move || (network_id(), page.get(), per_page.get()),
|(net_id, p, pp)| async move {
if net_id == 0 {
return Err(ServerFnError::new("Invalid network ID"));
}
get_hosts_page(String::new(), net_id, String::new(), 0, p, pp).await
},
);
let total_pages = Signal::derive(move || {
hosts.get().and_then(|r| r.ok()).map(|d| d.total_pages).unwrap_or(1)
});
let total = Signal::derive(move || {
hosts.get().and_then(|r| r.ok()).map(|d| d.total).unwrap_or(0)
});
view! {
<div class="network-detail-page">
<Suspense fallback=|| view! { <p class="empty">"Loading network…"</p> }>
{move || network.get().map(|result| match result {
Err(e) => view! {
<p class="error">"Could not load network: " {e.to_string()}</p>
}.into_any(),
Ok(net) => {
let net_id = net.id;
view! {
// ── Header ────────────────────────────────────────
<div class="page-header detail-page-header">
<a class="back-btn" href="/networks">"← Networks"</a>
<h1 class="detail-page-title">{net.name}</h1>
<p class="network-detail-cidr">{net.cidr}</p>
</div>
// ── Hosts section ────────────────────────────────
<section class="detail-section">
<h2 class="detail-section__title">"Hosts"</h2>
// Pagination bar
<div class="pagination-bar">
<div class="pagination-bar__info">
{move || {
let t = total.get();
if t == 0 { "No hosts".to_string() }
else { format!("{} host{}", t, if t == 1 { "" } else { "s" }) }
}}
</div>
<div class="pagination-bar__controls">
<label class="pagination-per-page">
"Per page "
<select on:change=move |e| {
per_page.set(
event_target_value(&e).parse().unwrap_or(15)
);
page.set(1);
}>
{PER_PAGE_OPTIONS.iter().map(|(v, label)| {
view! {
<option value=v.to_string() selected=*v == 15>
{*label}
</option>
}
}).collect_view()}
</select>
</label>
{move || (per_page.get() > 0).then(|| view! {
<div class="pagination-nav">
<button
disabled=move || page.get() <= 1
on:click=move |_| page.update(|p| *p = (*p - 1).max(1))
>""</button>
<span class="pagination-nav__label">
{move || format!(
"Page {} of {}",
page.get(),
total_pages.get().max(1)
)}
</span>
<button
disabled={move || page.get() >= total_pages.get()}
on:click=move |_| {
let max = total_pages.get_untracked();
page.update(|p| *p = (*p + 1).min(max));
}
>""</button>
</div>
})}
</div>
</div>
// Host table
<Suspense fallback=|| view! {
<p class="empty">"Loading hosts…"</p>
}>
{move || hosts.get().map(|result| match result {
Err(e) => view! {
<p class="error">
"Could not load hosts: " {e.to_string()}
</p>
}.into_any(),
Ok(HostsPageData { rows, .. }) if rows.is_empty() => view! {
<p class="empty">"No hosts in this network."</p>
}.into_any(),
Ok(HostsPageData { rows, .. }) => view! {
<div class="table-container">
<table>
<thead>
<tr>
<th>"Name"</th>
<th>"IP"</th>
<th class="col-count">"Ports"</th>
<th class="col-count">"Apps"</th>
</tr>
</thead>
<tbody>
{rows.into_iter().map(|host| {
// Pass the current network as the back destination
// so the host detail page can link back here.
let href = format!(
"/hosts/{}?back=/networks/{}",
host.id, net_id
);
view! {
<tr>
<td>
<a class="table-link" href=href>
{host.name}
</a>
</td>
<td class="cell-mono">{host.ip}</td>
<td class="col-count">
{host.port_count}
</td>
<td class="col-count">
{host.application_count}
</td>
</tr>
}
}).collect_view()}
</tbody>
</table>
</div>
}.into_any(),
})}
</Suspense>
</section>
}.into_any()
}
})}
</Suspense>
</div>
}.into_any()
}

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

@@ -0,0 +1,203 @@
// client/networks.rs — Networks page
use leptos::prelude::*;
use leptos::form::ActionForm;
use crate::api::networks::{CreateNetwork, DeleteNetwork, NetworkWithCounts, get_networks_with_counts};
// ─── Delete confirmation modal ────────────────────────────────────────────────
#[component]
fn DeleteConfirmModal(
network: NetworkWithCounts,
delete_action: ServerAction<DeleteNetwork>,
pending_delete: RwSignal<Option<NetworkWithCounts>>,
) -> impl IntoView {
let id = network.id;
let label = format!("{} ({})", network.name, network.cidr);
let host_count = network.host_count;
view! {
<div class="modal-backdrop" on:click=move |_| pending_delete.set(None)>
<div class="modal" on:click=move |e| e.stop_propagation()>
<div class="modal__header">
<h2>"Delete network"</h2>
</div>
<div class="modal__body">
<p>"Delete network " <strong>{label}</strong> "?"</p>
{(host_count > 0).then(|| view! {
<p class="warning">
"Warning: "
{host_count}
{if host_count == 1 { " host" } else { " hosts" }}
" belonging to this network will also be deleted."
</p>
})}
</div>
<div class="modal__actions">
<button
class="btn-secondary"
type="button"
on:click=move |_| pending_delete.set(None)
>"Cancel"</button>
<button
class="btn-danger"
type="button"
on:click=move |_| {
delete_action.dispatch(DeleteNetwork { id });
}
>"Delete"</button>
</div>
</div>
</div>
}.into_any()
}
// ─── Page ─────────────────────────────────────────────────────────────────────
#[component]
pub fn NetworksPage() -> impl IntoView {
let create_action = ServerAction::<CreateNetwork>::new();
let delete_action = ServerAction::<DeleteNetwork>::new();
// Stores the network pending deletion; Some = modal open, None = closed.
let pending_delete: RwSignal<Option<NetworkWithCounts>> = RwSignal::new(None);
// Close the modal automatically after a successful deletion.
Effect::new(move |_| {
if let Some(Ok(_)) = delete_action.value().get() {
pending_delete.set(None);
}
});
let networks = Resource::new(
move || (create_action.version().get(), delete_action.version().get()),
|_| get_networks_with_counts(),
);
view! {
<div class="networks-page">
<h1>"Networks"</h1>
// ── Delete confirmation modal ──────────────────────────────────────
{move || pending_delete.get().map(|network| view! {
<DeleteConfirmModal
network=network
delete_action=delete_action
pending_delete=pending_delete
/>
})}
// ── Add form ──────────────────────────────────────────────────────
<section class="add-form">
<h2>"Add a network"</h2>
<ActionForm action=create_action>
<label>
"Name"
<input
type="text"
name="name"
placeholder="e.g. LAN, DMZ, VPN"
required
/>
</label>
<label>
"CIDR block"
<input
type="text"
name="cidr"
placeholder="e.g. 192.168.1.0/24"
required
/>
</label>
<button type="submit">"Add"</button>
</ActionForm>
{move || {
create_action
.value()
.get()
.and_then(|r| r.err())
.map(|e| view! { <p class="error">{e.to_string()}</p> })
}}
</section>
// ── Network list ──────────────────────────────────────────────────
<section class="list">
<h2>"All networks"</h2>
{move || {
delete_action
.value()
.get()
.and_then(|r| r.err())
.map(|e| view! { <p class="error">"Delete failed: " {e.to_string()}</p> })
}}
<Suspense fallback=|| view! { <p>"Loading networks…"</p> }>
{move || {
networks.get().map(|result| match result {
Err(e) => view! {
<p class="error">"Could not load networks: " {e.to_string()}</p>
}
.into_any(),
Ok(list) if list.is_empty() => view! {
<p class="empty">"No networks yet. Add one above."</p>
}
.into_any(),
Ok(list) => view! {
<div class="table-container">
<table>
<thead>
<tr>
<th>"Name"</th>
<th>"CIDR"</th>
<th class="col-count">"Hosts"</th>
<th class="col-count">"Applications"</th>
<th class="col-actions">"Actions"</th>
</tr>
</thead>
<tbody>
{list
.into_iter()
.map(|network| {
let network_clone = network.clone();
let net_id = network.id;
view! {
<tr>
<td>
<a class="table-link"
href=format!("/networks/{}", net_id)>
{network.name}
</a>
</td>
<td class="cell-mono">{network.cidr}</td>
<td class="col-count">{network.host_count}</td>
<td class="col-count">{network.application_count}</td>
<td class="col-actions">
<button on:click=move |_| {
pending_delete.set(Some(network_clone.clone()));
}>
"Delete"
</button>
</td>
</tr>
}
})
.collect_view()}
</tbody>
</table>
</div>
}
.into_any(),
})
}}
</Suspense>
</section>
</div>
}
}

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

@@ -0,0 +1,138 @@
// client/theme.rs — Theme toggle component
//
// Architecture for multi-theme support:
// - ThemeChoice enum: each variant maps to a `data-theme` attribute value
// (None for "System" = follow OS preference via CSS media query)
// - Adding a future theme: add a variant here + a CSS [data-theme="..."] block
// - The active theme is persisted in localStorage under "ipam-theme"
// - When no value is stored, the CSS media query `prefers-color-scheme` decides
//
// SSR vs WASM:
// - SSR renders the button with no data-theme on <html> (system default applies)
// - On hydration, the WASM Effect reads localStorage and applies the stored choice
// - The `#[cfg(target_arch = "wasm32")]` guards prevent DOM/localStorage calls
// from being compiled into the server binary
use leptos::prelude::*;
// Used only in WASM builds — suppress false-positive dead_code warnings from SSR
#[allow(dead_code)]
const STORAGE_KEY: &str = "ipam-theme";
// Each variant corresponds to a CSS `[data-theme]` value (None = follow OS).
// To add a theme: add a variant, implement the methods, add CSS variables.
#[allow(dead_code)]
#[derive(Clone, PartialEq, Debug, Default)]
pub enum ThemeChoice {
#[default]
System, // No data-theme attribute; CSS prefers-color-scheme decides
Light,
Dark,
}
#[allow(dead_code)]
impl ThemeChoice {
// The attribute value written to <html data-theme="...">, or None to remove it.
fn attr_value(&self) -> Option<&'static str> {
match self {
Self::System => None,
Self::Light => Some("light"),
Self::Dark => Some("dark"),
}
}
// Label shown in the toggle button.
fn label(&self) -> &'static str {
match self {
Self::System => "Auto",
Self::Light => "Light",
Self::Dark => "Dark",
}
}
// Cycles to the next theme. Extend this as new variants are added.
fn next(&self) -> Self {
match self {
Self::System => Self::Light,
Self::Light => Self::Dark,
Self::Dark => Self::System,
}
}
fn from_stored(s: &str) -> Self {
match s {
"light" => Self::Light,
"dark" => Self::Dark,
_ => Self::System,
}
}
}
// ─── DOM helpers (WASM only) ─────────────────────────────────────────────────
// Reads the stored theme name from localStorage.
// Guard on `hydrate` feature rather than `target_arch` because web-sys is
// only activated by that feature in Cargo.toml.
#[cfg(feature = "hydrate")]
fn load_stored_theme() -> Option<ThemeChoice> {
let storage = web_sys::window()?.local_storage().ok()??;
let value = storage.get_item(STORAGE_KEY).ok()??;
Some(ThemeChoice::from_stored(&value))
}
// Applies `data-theme` attribute to <html> and persists to localStorage.
#[cfg(feature = "hydrate")]
fn apply_and_persist(choice: &ThemeChoice) {
let Some(window) = web_sys::window() else { return };
let Some(document) = window.document() else { return };
let Some(root) = document.document_element() else { return };
match choice.attr_value() {
Some(v) => { let _ = root.set_attribute("data-theme", v); }
None => { let _ = root.remove_attribute("data-theme"); }
}
if let Ok(Some(storage)) = window.local_storage() {
match choice.attr_value() {
Some(v) => { let _ = storage.set_item(STORAGE_KEY, v); }
None => { let _ = storage.remove_item(STORAGE_KEY); }
}
}
}
// ─── Component ───────────────────────────────────────────────────────────────
#[component]
pub fn ThemeToggle() -> impl IntoView {
let theme = RwSignal::new(ThemeChoice::System);
// Effect 1: runs once on mount — reads localStorage and initializes the signal.
// Does NOT track `theme`, so it never re-runs after the initial mount.
// Setting the signal here triggers Effect 2 below.
Effect::new(move |_| {
#[cfg(feature = "hydrate")]
if let Some(stored) = load_stored_theme() {
theme.set(stored);
}
});
// Effect 2: tracks `theme` — applies the choice to the DOM and localStorage
// whenever the signal changes (both on init and after user clicks).
Effect::new(move |_| {
let current = theme.get(); // tracked — re-runs when theme changes
#[cfg(feature = "hydrate")]
apply_and_persist(&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() {}

144
src/models.rs Normal file
View File

@@ -0,0 +1,144 @@
// models.rs — Shared data models (server + client)
//
// This module defines the structs that represent the IPAM domain entities.
// They are compiled for both the server and WASM, because Leptos needs them
// on both sides:
// - Server : to read/write the database and render HTML
// - Client : to display data inside Leptos components
//
// Each struct derives `Serialize` and `Deserialize` from serde.
// This is required for Leptos to transfer data between the server and the
// browser through server functions (#[server]).
use serde::{Deserialize, Serialize};
// ─── Network ──────────────────────────────────────────────────────────────────
/// An IP network defined by its CIDR range.
///
/// Example: { id: 1, cidr: "192.168.1.0/24" }
/// → covers 192.168.1.0 to 192.168.1.255 (254 usable hosts)
///
/// CIDR (Classless Inter-Domain Routing) combines the network address and
/// the subnet mask into a single field: <address>/<prefix length>.
/// /24 = 24-bit mask = 255.255.255.0
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Network {
/// Unique identifier, auto-incremented by the database.
/// `i64` is a signed 64-bit integer — maps to `BIGINT` in SQL.
pub id: i64,
/// Human-readable name. Examples: "LAN", "DMZ", "VPN"
pub name: String,
/// Address range in CIDR notation.
/// Examples: "10.0.0.0/8", "172.16.0.0/12", "192.168.1.0/24"
pub cidr: String,
}
// ─── Host ─────────────────────────────────────────────────────────────────────
/// A host (server, workstation, network device) belonging to a network.
///
/// Constraint: the IP address must fall within the CIDR range of the network
/// referenced by `network_id`. This is enforced on creation and update.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Host {
pub id: i64,
/// Human-readable name. Examples: "web-server-01", "main-router"
pub name: String,
/// IPv4 address stored as text. Example: "192.168.1.10"
/// We use String instead of IpAddr to simplify serialization
/// and database storage.
pub ip: String,
/// Foreign key referencing the network this host belongs to.
pub network_id: i64,
}
// ─── Port ─────────────────────────────────────────────────────────────────────
/// A network port entry in the global port catalog.
///
/// Ports are defined once here; host_ports and application_ports link them
/// to hosts and applications through separate join tables.
/// Well-known ports (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,224 @@
// repository/applications.rs — CRUD for applications and their port associations
use sqlx::{AnyPool, Row};
use crate::models::Application;
use crate::server::db::DbError;
// ─── Read ─────────────────────────────────────────────────────────────────────
/// Returns every application ordered by name.
pub async fn list_applications(pool: &AnyPool) -> Result<Vec<Application>, DbError> {
let rows = sqlx::query("SELECT id, name FROM applications ORDER BY name")
.fetch_all(pool)
.await?;
Ok(rows.iter().map(row_to_application).collect())
}
/// Returns a single application by id, or `None` if it does not exist.
pub async fn find_application(
pool: &AnyPool,
id: i64,
) -> Result<Option<Application>, DbError> {
let row = sqlx::query("SELECT id, name FROM applications WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await?;
Ok(row.as_ref().map(row_to_application))
}
// ─── Write ────────────────────────────────────────────────────────────────────
/// Inserts a new application and returns the created record.
pub async fn create_application(pool: &AnyPool, name: &str) -> Result<Application, DbError> {
let row =
sqlx::query("INSERT INTO applications (name) VALUES ($1) RETURNING id, name")
.bind(name)
.fetch_one(pool)
.await?;
Ok(row_to_application(&row))
}
/// Updates the name of an application. Returns the updated record.
pub async fn update_application(pool: &AnyPool, id: i64, name: &str) -> Result<Application, DbError> {
let row = sqlx::query(
"UPDATE applications SET name = $1 WHERE id = $2 RETURNING id, name",
)
.bind(name)
.bind(id)
.fetch_one(pool)
.await?;
Ok(row_to_application(&row))
}
/// Deletes an application and its port associations (via `ON DELETE CASCADE`).
///
/// Returns `true` if a row was deleted, `false` if the id did not exist.
pub async fn delete_application(pool: &AnyPool, id: i64) -> Result<bool, DbError> {
let result = sqlx::query("DELETE FROM applications WHERE id = $1")
.bind(id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
// ─── Host-application associations ───────────────────────────────────────────
/// Returns all applications linked directly to a host, ordered by name.
pub async fn list_applications_for_host(
pool: &AnyPool,
host_id: i64,
) -> Result<Vec<Application>, DbError> {
let rows = sqlx::query(
"SELECT a.id, a.name
FROM applications a
JOIN host_applications ha ON ha.application_id = a.id
WHERE ha.host_id = $1
ORDER BY a.name",
)
.bind(host_id)
.fetch_all(pool)
.await?;
Ok(rows.iter().map(row_to_application).collect())
}
/// Returns all applications NOT yet linked to a host, ordered by name.
pub async fn list_applications_not_on_host(
pool: &AnyPool,
host_id: i64,
) -> Result<Vec<Application>, DbError> {
let rows = sqlx::query(
"SELECT id, name FROM applications
WHERE id NOT IN (
SELECT application_id FROM host_applications WHERE host_id = $1
)
ORDER BY name",
)
.bind(host_id)
.fetch_all(pool)
.await?;
Ok(rows.iter().map(row_to_application).collect())
}
/// Links an application directly to a host.
///
/// If the link already exists, this is a no-op (not an error).
pub async fn add_application_to_host(
pool: &AnyPool,
host_id: i64,
application_id: i64,
) -> Result<(), DbError> {
let result = sqlx::query(
"INSERT INTO host_applications (host_id, application_id) VALUES ($1, $2)",
)
.bind(host_id)
.bind(application_id)
.execute(pool)
.await;
match result {
Ok(_) => Ok(()),
Err(sqlx::Error::Database(ref e)) if e.is_unique_violation() => Ok(()),
Err(e) => Err(DbError::Connection(e)),
}
}
/// Removes the direct link between a host and an application.
///
/// Returns `true` if the association existed and was removed.
pub async fn remove_application_from_host(
pool: &AnyPool,
host_id: i64,
application_id: i64,
) -> Result<bool, DbError> {
let result = sqlx::query(
"DELETE FROM host_applications WHERE host_id = $1 AND application_id = $2",
)
.bind(host_id)
.bind(application_id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
// ─── Application-port associations ───────────────────────────────────────────
/// Returns all port numbers associated with an application, sorted numerically.
///
/// Note: the `application_ports` table does NOT have a FK to `ports`,
/// so an application can reference a port number that is not in the catalog.
/// Returns plain `u16` numbers, not full `Port` structs.
pub async fn list_ports_for_application(
pool: &AnyPool,
application_id: i64,
) -> Result<Vec<u16>, DbError> {
let rows = sqlx::query(
"SELECT port_number FROM application_ports
WHERE application_id = $1
ORDER BY port_number",
)
.bind(application_id)
.fetch_all(pool)
.await?;
Ok(rows
.iter()
.map(|r| r.get::<i64, _>("port_number") as u16)
.collect())
}
/// Associates a port number with an application.
///
/// If the association already exists, the call is a no-op (not an error).
/// The port does not need to be in the `ports` catalog.
pub async fn add_port_to_application(
pool: &AnyPool,
application_id: i64,
port_number: u16,
) -> Result<(), DbError> {
let result = sqlx::query(
"INSERT INTO application_ports (application_id, port_number) VALUES ($1, $2)",
)
.bind(application_id)
.bind(port_number as i64)
.execute(pool)
.await;
match result {
Ok(_) => Ok(()),
Err(sqlx::Error::Database(ref e)) if e.is_unique_violation() => Ok(()),
Err(e) => Err(DbError::Connection(e)),
}
}
/// Removes a port association from an application.
///
/// If the association did not exist, this is a no-op (not an error).
pub async fn remove_port_from_application(
pool: &AnyPool,
application_id: i64,
port_number: u16,
) -> Result<(), DbError> {
sqlx::query(
"DELETE FROM application_ports
WHERE application_id = $1 AND port_number = $2",
)
.bind(application_id)
.bind(port_number as i64)
.execute(pool)
.await?;
Ok(())
}
// ─── Row mapping ──────────────────────────────────────────────────────────────
fn row_to_application(row: &sqlx::any::AnyRow) -> Application {
Application {
id: row.get("id"),
name: row.get("name"),
}
}

View File

@@ -0,0 +1,113 @@
// repository/hosts.rs — CRUD for the `hosts` table
use sqlx::{AnyPool, Row};
use crate::models::Host;
use crate::server::db::DbError;
// ─── Read ─────────────────────────────────────────────────────────────────────
/// Returns every host belonging to a network, sorted by name.
pub async fn list_hosts_by_network(
pool: &AnyPool,
network_id: i64,
) -> Result<Vec<Host>, DbError> {
let rows = sqlx::query(
"SELECT id, name, ip, network_id FROM hosts
WHERE network_id = $1
ORDER BY name",
)
.bind(network_id)
.fetch_all(pool)
.await?;
Ok(rows.iter().map(row_to_host).collect())
}
/// Returns a single host by id, or `None` if it does not exist.
pub async fn find_host(pool: &AnyPool, id: i64) -> Result<Option<Host>, DbError> {
let row = sqlx::query(
"SELECT id, name, ip, network_id FROM hosts WHERE id = $1",
)
.bind(id)
.fetch_optional(pool)
.await?;
Ok(row.as_ref().map(row_to_host))
}
// ─── Write ────────────────────────────────────────────────────────────────────
/// Inserts a new host into the given network and returns the created record.
///
/// The caller must validate that `ip` falls within the CIDR range of `network_id`
/// before calling this function — use `server::validation::validate_ip_in_network`.
pub async fn create_host(
pool: &AnyPool,
name: &str,
ip: &str,
network_id: i64,
) -> Result<Host, DbError> {
let row = sqlx::query(
"INSERT INTO hosts (name, ip, network_id)
VALUES ($1, $2, $3)
RETURNING id, name, ip, network_id",
)
.bind(name)
.bind(ip)
.bind(network_id)
.fetch_one(pool)
.await?;
Ok(row_to_host(&row))
}
/// Updates a host's name, IP address, and network assignment.
///
/// Returns the updated host, or `None` if the id does not exist.
/// The caller must validate that `ip` falls within the CIDR range of the
/// new `network_id` before calling this function.
pub async fn update_host(
pool: &AnyPool,
id: i64,
name: &str,
ip: &str,
network_id: i64,
) -> Result<Option<Host>, DbError> {
let row = sqlx::query(
"UPDATE hosts SET name = $1, ip = $2, network_id = $3
WHERE id = $4
RETURNING id, name, ip, network_id",
)
.bind(name)
.bind(ip)
.bind(network_id)
.bind(id)
.fetch_optional(pool)
.await?;
Ok(row.as_ref().map(row_to_host))
}
/// Deletes a host and all its port associations (via `ON DELETE CASCADE`).
///
/// Returns `true` if a row was deleted, `false` if the id did not exist.
pub async fn delete_host(pool: &AnyPool, id: i64) -> Result<bool, DbError> {
let result = sqlx::query("DELETE FROM hosts WHERE id = $1")
.bind(id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
// ─── Row mapping ──────────────────────────────────────────────────────────────
fn row_to_host(row: &sqlx::any::AnyRow) -> Host {
Host {
id: row.get("id"),
name: row.get("name"),
ip: row.get("ip"),
network_id: row.get("network_id"),
}
}

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,83 @@
// repository/networks.rs — CRUD for the `networks` table
use sqlx::{AnyPool, Row};
use crate::models::Network;
use crate::server::db::DbError;
// ─── Read ─────────────────────────────────────────────────────────────────────
/// Returns every network ordered by id.
pub async fn list_networks(pool: &AnyPool) -> Result<Vec<Network>, DbError> {
// `fetch_all` runs the query and collects every row into a Vec.
// It returns an error if the query fails; an empty table returns Ok(vec![]).
let rows = sqlx::query("SELECT id, name, cidr FROM networks ORDER BY id")
.fetch_all(pool)
.await?;
// `.iter().map(...).collect()` transforms each raw DB row into a Network struct.
Ok(rows.iter().map(row_to_network).collect())
}
/// Returns a single network by id, or `None` if it does not exist.
pub async fn find_network(pool: &AnyPool, id: i64) -> Result<Option<Network>, DbError> {
// `fetch_optional` returns `Ok(None)` when no row matches — unlike
// `fetch_one`, which returns an error when nothing is found.
let row = sqlx::query("SELECT id, name, cidr FROM networks WHERE id = $1")
.bind(id) // `$1` is replaced with the value of `id` at runtime
.fetch_optional(pool)
.await?;
// `Option::map` applies the conversion only if the row is Some.
Ok(row.as_ref().map(row_to_network))
}
// ─── Write ────────────────────────────────────────────────────────────────────
/// Inserts a new network and returns the created record (with its auto-generated id).
///
/// Fails with `DbError::Connection` if the CIDR is already registered
/// (the `cidr` column has a UNIQUE constraint).
///
/// `RETURNING id, cidr` reads back the inserted row in a single round-trip,
/// avoiding a separate SELECT after the INSERT.
/// Requires SQLite ≥ 3.35 (2021) and any PostgreSQL version.
pub async fn create_network(pool: &AnyPool, name: &str, cidr: &str) -> Result<Network, DbError> {
let row = sqlx::query(
"INSERT INTO networks (name, cidr) VALUES ($1, $2) RETURNING id, name, cidr",
)
.bind(name)
.bind(cidr)
.fetch_one(pool)
.await?;
Ok(row_to_network(&row))
}
/// Deletes a network by id and all its hosts (via `ON DELETE CASCADE`).
///
/// Returns `true` if a row was deleted, `false` if the id did not exist.
pub async fn delete_network(pool: &AnyPool, id: i64) -> Result<bool, DbError> {
// `execute` runs the query without fetching rows.
// `rows_affected()` tells us how many rows were actually deleted.
let result = sqlx::query("DELETE FROM networks WHERE id = $1")
.bind(id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
// ─── Row mapping ──────────────────────────────────────────────────────────────
/// Converts a raw database row into a `Network` struct.
///
/// `row.get("col")` extracts a typed value by column name.
/// The type must implement `sqlx::Decode` for the `Any` backend.
fn row_to_network(row: &sqlx::any::AnyRow) -> Network {
Network {
id: row.get("id"),
name: row.get("name"),
cidr: row.get("cidr"),
}
}

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

1347
style/rust-ipam.css Normal file

File diff suppressed because it is too large Load Diff