feat: pool management, setup wizard, English UI and README, remove brew formula
This commit is contained in:
parent
bc4eeef048
commit
147c6d32f1
20 changed files with 609 additions and 230 deletions
|
|
@ -1,17 +0,0 @@
|
||||||
class SubAligator < Formula
|
|
||||||
desc "Internal IP subnet allocator"
|
|
||||||
homepage "https://git.janvete.com/seth/sub-aligator"
|
|
||||||
url "https://git.janvete.com/seth/sub-aligator/archive/v0.1.0.tar.gz"
|
|
||||||
sha256 "44b0c1282a59584d00e8b534fad3570aa8c1f90362012d4fe445039b88f61fe4"
|
|
||||||
license "MIT"
|
|
||||||
|
|
||||||
depends_on "rust" => :build
|
|
||||||
|
|
||||||
def install
|
|
||||||
system "cargo", "install", *std_cargo_args
|
|
||||||
end
|
|
||||||
|
|
||||||
test do
|
|
||||||
system "#{bin}/sub-aligator", "--version"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
150
README.md
150
README.md
|
|
@ -1,40 +1,40 @@
|
||||||
# IP Subnet Allocator
|
# IP Subnet Allocator
|
||||||
|
|
||||||
Jednoduchá interní webová aplikace pro evidenci a automatické přidělování IPv4 subnetů.
|
A simple internal web application for tracking and automatically allocating IPv4 subnets.
|
||||||
|
|
||||||
## Účel
|
## Purpose
|
||||||
|
|
||||||
Aplikace slouží výhradně pro interní evidenci a automatické přidělování IPv4 subnetů. Není to obecný IPAM, monitoring, inventář zařízení, správa VLAN ani síťová topologie.
|
This application is intended solely for internal tracking and automatic allocation of IPv4 subnets. It is not a general IPAM, monitoring tool, device inventory, VLAN manager, or network topology viewer.
|
||||||
|
|
||||||
Je navržena pro provoz pouze uvnitř LAN/VPN. **Aplikace neobsahuje autentizaci** – musí být zařazena za firewall/reverse proxy v důvěryhodné síti.
|
It is designed to run only inside a LAN/VPN. **The application does not include authentication** and must be placed behind a firewall/reverse proxy in a trusted network.
|
||||||
|
|
||||||
## Přehled funkcí
|
## Features
|
||||||
|
|
||||||
- Dva přednastavené pooly:
|
- Manage one or more IP pools manually through the web UI.
|
||||||
- VLAN / zákaznické subnety: `10.91.0.0/16`
|
- First-run setup wizard: create the first pool before allocating subnets.
|
||||||
- VPN subnety: `10.200.0.0/16`
|
- Automatic subnet allocation using a **first-fit** algorithm for prefixes /20 through /30.
|
||||||
- Automatické přidělování subnetů pomocí **first-fit** algoritmu pro prefixy /20 až /30.
|
- Atomic allocation writes using SQLite `BEGIN IMMEDIATE` transactions.
|
||||||
- Atomický zápis alokací přes SQLite transakci `BEGIN IMMEDIATE`.
|
- Retro TUI/terminal-style web UI, server-side rendered with Askama.
|
||||||
- Webové UI s retro TUI/terminál vzhledem, server-side renderované pomocí Askama.
|
- Dynamic preview of the next free subnet via HTMX.
|
||||||
- Dynamické preview dalšího volného subnetu přes HTMX.
|
- Allocation history with filtering, search, and pagination.
|
||||||
- Historie alokací s filtrem, vyhledáváním a stránkováním.
|
- Release subnets (status `released`) with a mandatory note, directly from the history table or detail page.
|
||||||
- Uvolňování subnetů (status `released`) s povinnou poznámkou.
|
- Released subnets free up address space and can be reused by the first-fit algorithm.
|
||||||
- Auditní log všech operací.
|
- Audit log of all operations.
|
||||||
- Exporty do Markdown, CSV a JSON.
|
- Markdown, CSV, and JSON exports.
|
||||||
- SQLite migrace probíhají automaticky při startu.
|
- SQLite migrations run automatically on startup.
|
||||||
- Zálohovací a obnovovací skripty.
|
- Backup and restore scripts.
|
||||||
|
|
||||||
## Architektura
|
## Architecture
|
||||||
|
|
||||||
- **Rust** edition 2024
|
- **Rust** edition 2024
|
||||||
- **Axum** + **Tokio**
|
- **Axum** + **Tokio**
|
||||||
- **SQLx** s **SQLite**
|
- **SQLx** with **SQLite**
|
||||||
- **Askama** pro HTML šablony
|
- **Askama** for HTML templates
|
||||||
- **HTMX** z lokálního statického souboru
|
- **HTMX** from a local static file
|
||||||
- Vlastní CSS bez externích frameworků
|
- Custom CSS without external frameworks
|
||||||
- CIDR/IP výpočty pomocí `ipnet`
|
- CIDR/IP calculations using `ipnet`
|
||||||
|
|
||||||
## Rychlý start
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd sub-aligator
|
cd sub-aligator
|
||||||
|
|
@ -42,79 +42,79 @@ cp .env.example .env
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Otevřete prohlížeč na:
|
Open a browser at:
|
||||||
|
|
||||||
```
|
```
|
||||||
http://IP_SERVERU:8080
|
http://SERVER_IP:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
Výchozí port lze změnit v `.env` pomocí proměnné `APP_PORT`.
|
On first launch you will be asked to create the first pool. The default port can be changed in `.env` via the `APP_PORT` variable.
|
||||||
|
|
||||||
## Persistentní data
|
## Persistent data
|
||||||
|
|
||||||
Docker Compose vytváří lokální adresáře:
|
Docker Compose creates local directories:
|
||||||
|
|
||||||
- `./data` – SQLite databáze
|
- `./data` – SQLite database
|
||||||
- `./exports` – vygenerované exporty (Markdown, CSV, JSON)
|
- `./exports` – generated exports (Markdown, CSV, JSON)
|
||||||
- `./backups` – zálohy databáze
|
- `./backups` – database backups
|
||||||
|
|
||||||
Tyto adresáře jsou bind mountovány do kontejneru, takže data přetrvávají i po odstranění kontejneru.
|
These directories are bind-mounted into the container, so data persists even if the container is removed.
|
||||||
|
|
||||||
## Záloha a obnova
|
## Backup and restore
|
||||||
|
|
||||||
### Záloha
|
### Backup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/backup.sh
|
./scripts/backup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Skript vytvoří konzistentní zálohu databáze pomocí `sqlite3 .backup` s UTC timestampem v názvu souboru. Zálohy se ukládají do `./backups`.
|
The script creates a consistent database backup using `sqlite3 .backup` with a UTC timestamp in the filename. Backups are stored in `./backups`.
|
||||||
|
|
||||||
### Obnova
|
### Restore
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/restore.sh ./backups/subnet-allocator.db.20240930T120000Z.backup
|
./scripts/restore.sh ./backups/subnet-allocator.db.20240930T120000Z.backup
|
||||||
```
|
```
|
||||||
|
|
||||||
**Upozornění:** obnova přepíše aktuální databázi. Před obnovou doporučujeme zastavit kontejner a vytvořit zálohu aktuálního stavu.
|
**Warning:** restore overwrites the current database. Stop the container and back up the current state before restoring.
|
||||||
|
|
||||||
### Automatizace zálohy
|
### Automating backups
|
||||||
|
|
||||||
Na hostovi můžete použít systemd timer nebo cron. Příklad cron záznamu:
|
On the host you can use a systemd timer or cron. Example cron entry:
|
||||||
|
|
||||||
```cron
|
```cron
|
||||||
0 2 * * * /cesta/k/sub-aligator/scripts/backup.sh >> /var/log/subnet-allocator-backup.log 2>&1
|
0 2 * * * /path/to/sub-aligator/scripts/backup.sh >> /var/log/subnet-allocator-backup.log 2>&1
|
||||||
```
|
```
|
||||||
|
|
||||||
## Exporty
|
## Exports
|
||||||
|
|
||||||
Po každém úspěšném vytvoření nebo uvolnění subnetu se vygenerují read-only exporty:
|
After every successful allocation or release, read-only exports are regenerated:
|
||||||
|
|
||||||
- `/app/exports/allocations.md`
|
- `/app/exports/allocations.md`
|
||||||
- `/app/exports/allocations.csv`
|
- `/app/exports/allocations.csv`
|
||||||
- `/app/exports/allocations.json`
|
- `/app/exports/allocations.json`
|
||||||
|
|
||||||
Ke stažení jsou dostupné přes endpointy:
|
They are also available for download via endpoints:
|
||||||
|
|
||||||
- `GET /exports/allocations.md`
|
- `GET /exports/allocations.md`
|
||||||
- `GET /exports/allocations.csv`
|
- `GET /exports/allocations.csv`
|
||||||
- `GET /exports/allocations.json`
|
- `GET /exports/allocations.json`
|
||||||
|
|
||||||
Markdown export obsahuje samostatnou sekci pro každý pool s tabulkou aktivních a rezervovaných alokací. CSV a JSON obsahují všechny alokace včetně released.
|
The Markdown export contains a separate section for each pool with a table of active and reserved allocations. CSV and JSON include all allocations, including released ones.
|
||||||
|
|
||||||
## Bezpečnostní omezení
|
## Security notes
|
||||||
|
|
||||||
- Aplikace **nemá autentizaci**. Používejte ji pouze za VPN/LAN/firewallem.
|
- The application **has no authentication**. Use it only behind a VPN/LAN/firewall.
|
||||||
- CSRF tokeny jsou povinné pro POST formuláře.
|
- CSRF tokens are required for POST forms.
|
||||||
- Nastaveny bezpečnostní hlavičky: `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Content-Security-Policy`.
|
- Security headers are set: `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Content-Security-Policy`.
|
||||||
- Aplikace nemá žádný defaultní přístup z Internetu; bind portu se řeší přes Docker Compose.
|
- The application has no default Internet exposure; port binding is handled by Docker Compose.
|
||||||
- `/healthz` vrací pouze `{"status":"ok"}`.
|
- `/healthz` returns only `{"status":"ok"}`.
|
||||||
|
|
||||||
## Pozdější přidání autentizace přes Caddy + Authentik
|
## Adding authentication later via Caddy + Authentik
|
||||||
|
|
||||||
Aplikace je připravena pro autentizaci přes důvěryhodnou reverse proxy. Funkce `get_actor_from_request()` v `src/audit.rs` nyní vrací `local`, ale může být upravena pro čtení hlavičky, například `X-Authentik-Username`.
|
The application is prepared for authentication through a trusted reverse proxy. The function `get_actor_from_request()` in `src/audit.rs` currently returns `local`, but can be updated to read a header such as `X-Authentik-Username`.
|
||||||
|
|
||||||
Příklad konfigurace Caddy s Authentik:
|
Example Caddy configuration with Authentik:
|
||||||
|
|
||||||
```caddy
|
```caddy
|
||||||
subnet.example.com {
|
subnet.example.com {
|
||||||
|
|
@ -127,43 +127,45 @@ subnet.example.com {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Po úpravě `get_actor_from_request()` bude auditní log obsahovat skutečného uživatele.
|
After updating `get_actor_from_request()`, the audit log will record the real user.
|
||||||
|
|
||||||
## Příklad first-fit alokace
|
## First-fit allocation example
|
||||||
|
|
||||||
1. První požadavek `/30` získá `10.91.0.0/30`.
|
1. First request `/30` receives `10.91.0.0/30`.
|
||||||
2. Následný požadavek `/24` získá `10.91.1.0/24` (vyhne se obsazenému `/30`).
|
2. Next request `/24` receives `10.91.1.0/24` (avoids the occupied `/30`).
|
||||||
3. Další požadavek `/30` zaplní mezeru a získá `10.91.0.4/30`.
|
3. Another request `/30` fills the gap and receives `10.91.0.4/30`.
|
||||||
|
|
||||||
Algoritmus vždy respektuje CIDR zarovnání a najde první volný prostor dostatečné velikosti.
|
The algorithm always respects CIDR alignment and finds the first free space large enough.
|
||||||
|
|
||||||
## Endpointy
|
## Endpoints
|
||||||
|
|
||||||
- `GET /` – hlavní stránka
|
- `GET /` – main page
|
||||||
- `GET /healthz` – healthcheck
|
- `GET /healthz` – healthcheck
|
||||||
|
- `GET /setup` – first-run pool setup
|
||||||
|
- `GET /pools` – pool management
|
||||||
|
- `POST /pools/new` – create a new pool
|
||||||
- `GET /partials/pools/{slug}/preview?prefix_length=24` – HTMX preview
|
- `GET /partials/pools/{slug}/preview?prefix_length=24` – HTMX preview
|
||||||
- `POST /allocations` – vytvoření alokace
|
- `POST /allocations` – create an allocation
|
||||||
- `GET /allocations` – seznam alokací (součást `/`)
|
- `GET /allocations/{id}` – allocation detail
|
||||||
- `GET /allocations/{id}` – detail alokace
|
- `POST /allocations/{id}/release` – release an allocation
|
||||||
- `POST /allocations/{id}/release` – uvolnění alokace
|
- `GET /exports/allocations.{md,csv,json}` – exports
|
||||||
- `GET /exports/allocations.{md,csv,json}` – exporty
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Kontejner nenastartuje
|
### Container does not start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose logs -f subnet-allocator
|
docker compose logs -f subnet-allocator
|
||||||
```
|
```
|
||||||
|
|
||||||
### Databáze je uzamčena
|
### Database is locked
|
||||||
|
|
||||||
Pokud dojde k neočekávanému ukončení, může zůstat `-wal` nebo `-shm` soubor. Zastavte kontejner a zkontrolujte `./data`.
|
If the process terminates unexpectedly, a `-wal` or `-shm` file may remain. Stop the container and check `./data`.
|
||||||
|
|
||||||
### Exporty se negenerují
|
### Exports are not generated
|
||||||
|
|
||||||
Zkontrolujte práva adresáře `./exports` a proměnnou `APP_EXPORTS_DIR`.
|
Check the permissions of the `./exports` directory and the `APP_EXPORTS_DIR` variable.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
MIT. Viz soubor `LICENSE`.
|
MIT. See the `LICENSE` file.
|
||||||
|
|
|
||||||
37
src/db.rs
37
src/db.rs
|
|
@ -1,5 +1,4 @@
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::models::now_utc_rfc3339;
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use sqlx::migrate::{MigrateDatabase, Migrator};
|
use sqlx::migrate::{MigrateDatabase, Migrator};
|
||||||
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqliteSynchronous};
|
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqliteSynchronous};
|
||||||
|
|
@ -38,45 +37,9 @@ pub async fn init_db(config: &Config) -> anyhow::Result<SqlitePool> {
|
||||||
.await
|
.await
|
||||||
.context("run migrations")?;
|
.context("run migrations")?;
|
||||||
|
|
||||||
seed_default_pools(&pool).await.context("seed default pools")?;
|
|
||||||
|
|
||||||
info!("Database initialized: {}", database_url);
|
info!("Database initialized: {}", database_url);
|
||||||
Ok(pool)
|
Ok(pool)
|
||||||
} else {
|
} else {
|
||||||
anyhow::bail!("Unsupported DATABASE_URL: {}", database_url)
|
anyhow::bail!("Unsupported DATABASE_URL: {}", database_url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn seed_default_pools(pool: &SqlitePool) -> anyhow::Result<()> {
|
|
||||||
let allowed = serde_json::to_string(&[20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30])?;
|
|
||||||
|
|
||||||
let now = now_utc_rfc3339();
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT OR IGNORE INTO pools (slug, name, cidr, kind, allowed_prefixes_json, enabled, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
||||||
)
|
|
||||||
.bind("vlan")
|
|
||||||
.bind("VLAN / zákaznické subnety")
|
|
||||||
.bind("10.91.0.0/16")
|
|
||||||
.bind("VLAN")
|
|
||||||
.bind(&allowed)
|
|
||||||
.bind(true)
|
|
||||||
.bind(&now)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT OR IGNORE INTO pools (slug, name, cidr, kind, allowed_prefixes_json, enabled, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
||||||
)
|
|
||||||
.bind("vpn")
|
|
||||||
.bind("VPN subnety")
|
|
||||||
.bind("10.200.0.0/16")
|
|
||||||
.bind("VPN")
|
|
||||||
.bind(&allowed)
|
|
||||||
.bind(true)
|
|
||||||
.bind(&now)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ async fn generate_markdown(
|
||||||
let mut lines = vec![
|
let mut lines = vec![
|
||||||
"# IP Subnet Allocator – Export".to_string(),
|
"# IP Subnet Allocator – Export".to_string(),
|
||||||
"".to_string(),
|
"".to_string(),
|
||||||
format!("Vygenerováno: {}", chrono::Utc::now().to_rfc3339()),
|
format!("Generated: {}", chrono::Utc::now().to_rfc3339()),
|
||||||
"".to_string(),
|
"".to_string(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -58,13 +58,13 @@ async fn generate_markdown(
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if pool_allocs.is_empty() {
|
if pool_allocs.is_empty() {
|
||||||
lines.push("*Žádné aktivní ani rezervované alokace.*".to_string());
|
lines.push("*No active or reserved allocations.*".to_string());
|
||||||
lines.push("".to_string());
|
lines.push("".to_string());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push("| Subnet | Stav | Poznámka | Vytvořeno |".to_string());
|
lines.push("| Subnet | Status | Note | Created |".to_string());
|
||||||
lines.push("|--------|------|----------|-----------|".to_string());
|
lines.push("|--------|--------|------|---------|".to_string());
|
||||||
for a in pool_allocs {
|
for a in pool_allocs {
|
||||||
lines.push(format!(
|
lines.push(format!(
|
||||||
"| {} | {} | {} | {} |",
|
"| {} | {} | {} | {} |",
|
||||||
|
|
|
||||||
|
|
@ -42,17 +42,17 @@ pub async fn create_allocation(
|
||||||
let actor = get_actor_from_request(&headers);
|
let actor = get_actor_from_request(&headers);
|
||||||
|
|
||||||
if !validate_csrf(&jar, &form.csrf_token) {
|
if !validate_csrf(&jar, &form.csrf_token) {
|
||||||
return render_index_with_error(&state, &jar, &csrf_token, "Neplatný CSRF token.").await.into_response();
|
return render_index_with_error(&state, &jar, &csrf_token, "Invalid CSRF token.").await.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let note = form.note.trim();
|
let note = form.note.trim();
|
||||||
if note.len() < 3 {
|
if note.len() < 3 {
|
||||||
return render_index_with_error(&state, &jar, &csrf_token, "Poznámka musí mít alespoň 3 znaky.").await.into_response();
|
return render_index_with_error(&state, &jar, &csrf_token, "Note must be at least 3 characters long.").await.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let pool = match load_pool_by_slug(&state.pool, &form.pool_slug).await {
|
let pool = match load_pool_by_slug(&state.pool, &form.pool_slug).await {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => return render_index_with_error(&state, &jar, &csrf_token, "Pool neexistuje.").await.into_response(),
|
None => return render_index_with_error(&state, &jar, &csrf_token, "Pool does not exist.").await.into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = allocate_in_tx(
|
let result = allocate_in_tx(
|
||||||
|
|
@ -87,7 +87,7 @@ pub async fn create_allocation(
|
||||||
let mut message = success.clone();
|
let mut message = success.clone();
|
||||||
if actual_cidr_different {
|
if actual_cidr_different {
|
||||||
message.note = format!(
|
message.note = format!(
|
||||||
"{} (preview se mezitím změnil, přiděleno: {})",
|
"{} (preview changed in the meantime, allocated: {})",
|
||||||
allocation.note, allocation.cidr
|
allocation.note, allocation.cidr
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -195,7 +195,7 @@ pub async fn release_allocation(
|
||||||
let actor = get_actor_from_request(&headers);
|
let actor = get_actor_from_request(&headers);
|
||||||
|
|
||||||
if !validate_csrf(&jar, &form.csrf_token) {
|
if !validate_csrf(&jar, &form.csrf_token) {
|
||||||
return render_detail_with_error(&state, id, &jar, &csrf_token, "Neplatný CSRF token.").await.into_response();
|
return render_detail_with_error(&state, id, &jar, &csrf_token, "Invalid CSRF token.").await.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let release_note = form.release_note.trim();
|
let release_note = form.release_note.trim();
|
||||||
|
|
@ -205,9 +205,10 @@ pub async fn release_allocation(
|
||||||
id,
|
id,
|
||||||
&jar,
|
&jar,
|
||||||
&csrf_token,
|
&csrf_token,
|
||||||
"Poznámka k uvolnění musí mít alespoň 3 znaky.",
|
"Release note must be at least 3 characters long.",
|
||||||
)
|
)
|
||||||
.await.into_response();
|
.await
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = release_in_tx(&state.pool, id, release_note, &actor).await;
|
let result = release_in_tx(&state.pool, id, release_note, &actor).await;
|
||||||
|
|
@ -216,7 +217,7 @@ pub async fn release_allocation(
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
let _ = regenerate_after_change(&state).await;
|
let _ = regenerate_after_change(&state).await;
|
||||||
info!("Released allocation {} by {}", id, actor);
|
info!("Released allocation {} by {}", id, actor);
|
||||||
render_detail_with_error(&state, id, &jar, &csrf_token, "Subnet byl úspěšně uvolněn.").await.into_response()
|
render_detail_with_error(&state, id, &jar, &csrf_token, "Subnet released successfully.").await.into_response()
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Release failed for allocation {}: {}", id, e);
|
warn!("Release failed for allocation {}: {}", id, e);
|
||||||
|
|
@ -242,7 +243,7 @@ pub async fn release_in_tx(
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_one(&mut *conn)
|
.fetch_one(&mut *conn)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| anyhow::anyhow!("Alokace neexistuje nebo již není aktivní."))?;
|
.map_err(|_| anyhow::anyhow!("Allocation does not exist or is no longer active."))?;
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE allocations SET status = 'released', released_at = ?, released_by = ?, release_note = ? WHERE id = ?",
|
"UPDATE allocations SET status = 'released', released_at = ?, released_by = ?, release_note = ? WHERE id = ?",
|
||||||
|
|
@ -299,7 +300,7 @@ async fn render_index_with_error(state: &AppState, _jar: &CookieJar, csrf_token:
|
||||||
Ok(html) => Html(html),
|
Ok(html) => Html(html),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Template render error: {}", e);
|
error!("Template render error: {}", e);
|
||||||
Html("Chyba při vykreslování stránky.".to_string())
|
Html("Error rendering page.".to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -310,7 +311,7 @@ async fn render_index_with_success(state: &AppState, _jar: &CookieJar, csrf_toke
|
||||||
Ok(html) => Html(html),
|
Ok(html) => Html(html),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Template render error: {}", e);
|
error!("Template render error: {}", e);
|
||||||
Html("Chyba při vykreslování stránky.".to_string())
|
Html("Error rendering page.".to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -320,8 +321,15 @@ pub async fn build_index_template(
|
||||||
csrf_token: &str,
|
csrf_token: &str,
|
||||||
flash: FlashData,
|
flash: FlashData,
|
||||||
) -> IndexTemplate {
|
) -> IndexTemplate {
|
||||||
|
let pools = load_pool_templates(state).await;
|
||||||
|
let all_pools = sqlx::query_as::<_, Pool>("SELECT * FROM pools ORDER BY id")
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
IndexTemplate {
|
IndexTemplate {
|
||||||
pools: load_pool_templates(state).await,
|
pools,
|
||||||
|
all_pools,
|
||||||
allocations: vec![],
|
allocations: vec![],
|
||||||
page: 1,
|
page: 1,
|
||||||
total_pages: 0,
|
total_pages: 0,
|
||||||
|
|
@ -350,7 +358,7 @@ async fn render_detail_with_error(state: &AppState, id: i64, _jar: &CookieJar, c
|
||||||
Ok(html) => Html(html),
|
Ok(html) => Html(html),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Template render error: {}", e);
|
error!("Template render error: {}", e);
|
||||||
Html("Chyba při vykreslování stránky.".to_string())
|
Html("Error rendering page.".to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -379,7 +387,7 @@ async fn build_detail_template(
|
||||||
pool_name: "N/A".to_string(),
|
pool_name: "N/A".to_string(),
|
||||||
address_count: 0,
|
address_count: 0,
|
||||||
usable_host_count: 0,
|
usable_host_count: 0,
|
||||||
note: "Alokace nenalezena.".to_string(),
|
note: "Allocation not found.".to_string(),
|
||||||
ticket: "–".to_string(),
|
ticket: "–".to_string(),
|
||||||
status: "released".to_string(),
|
status: "released".to_string(),
|
||||||
network: "N/A".to_string(),
|
network: "N/A".to_string(),
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,4 @@ pub mod allocations;
|
||||||
pub mod exports;
|
pub mod exports;
|
||||||
pub mod pages;
|
pub mod pages;
|
||||||
pub mod partials;
|
pub mod partials;
|
||||||
|
pub mod pools;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use crate::state::AppState;
|
||||||
use crate::templates::{AllocationDetailTemplate, FlashData, IndexTemplate, PoolTemplate};
|
use crate::templates::{AllocationDetailTemplate, FlashData, IndexTemplate, PoolTemplate};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
use axum::response::{Html, IntoResponse, Response};
|
use axum::response::{Html, IntoResponse, Redirect, Response};
|
||||||
use axum_extra::extract::cookie::CookieJar;
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
|
|
@ -27,10 +27,19 @@ pub async fn index(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
Query(q): Query<IndexQuery>,
|
Query(q): Query<IndexQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> Response {
|
||||||
let (jar, csrf_token) = ensure_csrf_cookie(jar);
|
let (jar, csrf_token) = ensure_csrf_cookie(jar);
|
||||||
let page = q.page.max(1);
|
let page = q.page.max(1);
|
||||||
|
|
||||||
|
let pool_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pools")
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
if pool_count == 0 {
|
||||||
|
return Redirect::to("/setup").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
let pools: Vec<PoolTemplate> = sqlx::query_as::<_, Pool>(
|
let pools: Vec<PoolTemplate> = sqlx::query_as::<_, Pool>(
|
||||||
"SELECT * FROM pools WHERE enabled = true ORDER BY id",
|
"SELECT * FROM pools WHERE enabled = true ORDER BY id",
|
||||||
)
|
)
|
||||||
|
|
@ -41,6 +50,11 @@ pub async fn index(
|
||||||
.map(PoolTemplate::from)
|
.map(PoolTemplate::from)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let all_pools: Vec<Pool> = sqlx::query_as::<_, Pool>("SELECT * FROM pools ORDER BY id")
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
let (allocations, total_count) =
|
let (allocations, total_count) =
|
||||||
load_allocations(&state, &q.q, &q.pool, &q.status, page).await;
|
load_allocations(&state, &q.q, &q.pool, &q.status, page).await;
|
||||||
|
|
||||||
|
|
@ -48,6 +62,7 @@ pub async fn index(
|
||||||
|
|
||||||
let template = IndexTemplate {
|
let template = IndexTemplate {
|
||||||
pools,
|
pools,
|
||||||
|
all_pools,
|
||||||
allocations,
|
allocations,
|
||||||
page,
|
page,
|
||||||
total_pages,
|
total_pages,
|
||||||
|
|
@ -63,7 +78,7 @@ pub async fn index(
|
||||||
Ok(html) => (jar, Html(html)).into_response(),
|
Ok(html) => (jar, Html(html)).into_response(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Index template render error: {}", e);
|
error!("Index template render error: {}", e);
|
||||||
(jar, Html("Chyba při vykreslování stránky.".to_string())).into_response()
|
(jar, Html("Error rendering page.".to_string())).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -85,7 +100,7 @@ pub async fn allocation_detail(
|
||||||
let allocation = match allocation {
|
let allocation = match allocation {
|
||||||
Some(a) => a,
|
Some(a) => a,
|
||||||
None => {
|
None => {
|
||||||
return (jar, Html("Alokace nenalezena.".to_string())).into_response();
|
return (jar, Html("Allocation not found.".to_string())).into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -154,7 +169,7 @@ pub async fn allocation_detail(
|
||||||
Ok(html) => (jar, Html(html)).into_response(),
|
Ok(html) => (jar, Html(html)).into_response(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Detail template render error: {}", e);
|
error!("Detail template render error: {}", e);
|
||||||
(jar, Html("Chyba při vykreslování stránky.".to_string())).into_response()
|
(jar, Html("Error rendering page.".to_string())).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ pub async fn pool_preview(
|
||||||
address_count: 0,
|
address_count: 0,
|
||||||
usable_host_count: 0,
|
usable_host_count: 0,
|
||||||
has_error: true,
|
has_error: true,
|
||||||
error: "Pool neexistuje.".to_string(),
|
error: "Pool does not exist.".to_string(),
|
||||||
};
|
};
|
||||||
return Html(template.render().unwrap_or_default());
|
return Html(template.render().unwrap_or_default());
|
||||||
}
|
}
|
||||||
|
|
@ -72,7 +72,7 @@ pub async fn pool_preview(
|
||||||
Ok(html) => Html(html),
|
Ok(html) => Html(html),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Preview template render error: {}", e);
|
error!("Preview template render error: {}", e);
|
||||||
Html("Chyba preview.".to_string())
|
Html("Preview error.".to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
223
src/handlers/pools.rs
Normal file
223
src/handlers/pools.rs
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
use crate::audit::{get_actor_from_request, log_event};
|
||||||
|
use crate::csrf::{ensure_csrf_cookie, validate_csrf};
|
||||||
|
use crate::models::{now_utc_rfc3339, Pool};
|
||||||
|
use crate::state::AppState;
|
||||||
|
use crate::templates::{PoolListTemplate, SetupTemplate};
|
||||||
|
use askama::Template;
|
||||||
|
use axum::extract::{Form, State};
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
use axum::response::{Html, IntoResponse, Redirect, Response};
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
use ipnet::Ipv4Net;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct CreatePoolForm {
|
||||||
|
pub slug: String,
|
||||||
|
pub name: String,
|
||||||
|
pub cidr: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub allowed_prefixes: String,
|
||||||
|
pub csrf_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn setup_page(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||||
|
let (jar, csrf_token) = ensure_csrf_cookie(jar);
|
||||||
|
|
||||||
|
let pool_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pools")
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
if pool_count > 0 {
|
||||||
|
return Redirect::to("/").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let template = SetupTemplate {
|
||||||
|
csrf_token,
|
||||||
|
error: String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
match template.render() {
|
||||||
|
Ok(html) => (jar, Html(html)).into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Setup template render error: {}", e);
|
||||||
|
(jar, Html("Error rendering setup page.".to_string())).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_pool(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Form(form): Form<CreatePoolForm>,
|
||||||
|
) -> Response {
|
||||||
|
let (jar, csrf_token) = ensure_csrf_cookie(jar);
|
||||||
|
let actor = get_actor_from_request(&headers);
|
||||||
|
|
||||||
|
if !validate_csrf(&jar, &form.csrf_token) {
|
||||||
|
return render_setup_with_error(&jar, csrf_token, "Invalid CSRF token.").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
match validate_and_insert_pool(&state.pool, &form, &actor).await {
|
||||||
|
Ok(_) => {
|
||||||
|
info!("Pool {} created by {}", form.slug, actor);
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => render_setup_with_error(&jar, csrf_token, &e.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pool_list(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||||
|
let (jar, csrf_token) = ensure_csrf_cookie(jar);
|
||||||
|
|
||||||
|
let pools: Vec<Pool> = sqlx::query_as::<_, Pool>("SELECT * FROM pools ORDER BY id")
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let template = PoolListTemplate {
|
||||||
|
pools,
|
||||||
|
csrf_token,
|
||||||
|
error: String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
match template.render() {
|
||||||
|
Ok(html) => (jar, Html(html)).into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Pool list template render error: {}", e);
|
||||||
|
(jar, Html("Error rendering pool list.".to_string())).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_setup_with_error(
|
||||||
|
_jar: &CookieJar,
|
||||||
|
csrf_token: String,
|
||||||
|
error: &str,
|
||||||
|
) -> Html<String> {
|
||||||
|
let template = SetupTemplate {
|
||||||
|
csrf_token,
|
||||||
|
error: error.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
match template.render() {
|
||||||
|
Ok(html) => Html(html),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Setup template render error: {}", e);
|
||||||
|
Html("Error rendering setup page.".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_and_insert_pool(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
form: &CreatePoolForm,
|
||||||
|
actor: &str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let slug = form.slug.trim().to_lowercase();
|
||||||
|
if slug.is_empty() || !slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
|
||||||
|
anyhow::bail!("Slug must contain only lowercase letters, numbers and hyphens.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = form.name.trim();
|
||||||
|
if name.len() < 2 {
|
||||||
|
anyhow::bail!("Pool name must have at least 2 characters.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let kind = form.kind.trim();
|
||||||
|
if kind.is_empty() {
|
||||||
|
anyhow::bail!("Pool type is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let net: Ipv4Net = form
|
||||||
|
.cidr
|
||||||
|
.trim()
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| anyhow::anyhow!("Invalid IPv4 CIDR: {}", form.cidr))?;
|
||||||
|
let cidr = net.to_string();
|
||||||
|
let parent_prefix = net.prefix_len() as i32;
|
||||||
|
|
||||||
|
let prefixes: Vec<i32> = form
|
||||||
|
.allowed_prefixes
|
||||||
|
.split(',')
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
.map(|s| s.trim().parse::<i32>())
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|_| anyhow::anyhow!("Allowed prefixes must be comma-separated integers."))?;
|
||||||
|
|
||||||
|
if prefixes.is_empty() {
|
||||||
|
anyhow::bail!("At least one allowed prefix length is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
for p in &prefixes {
|
||||||
|
if *p < 1 || *p > 32 {
|
||||||
|
anyhow::bail!("Prefix length {} is out of range 1-32.", p);
|
||||||
|
}
|
||||||
|
if *p < parent_prefix {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Allowed prefix /{} is larger than the pool prefix /{}.",
|
||||||
|
p,
|
||||||
|
parent_prefix
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let allowed_json = serde_json::to_string(&prefixes)?;
|
||||||
|
let now = now_utc_rfc3339();
|
||||||
|
|
||||||
|
let mut conn = pool.acquire().await?;
|
||||||
|
|
||||||
|
sqlx::query("BEGIN IMMEDIATE")
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let existing: Option<i64> = sqlx::query_scalar("SELECT id FROM pools WHERE slug = ? OR cidr = ?")
|
||||||
|
.bind(&slug)
|
||||||
|
.bind(&cidr)
|
||||||
|
.fetch_optional(&mut *conn)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if existing.is_some() {
|
||||||
|
sqlx::query("ROLLBACK").execute(&mut *conn).await?;
|
||||||
|
anyhow::bail!("A pool with the same slug or CIDR already exists.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = sqlx::query(
|
||||||
|
"INSERT INTO pools (slug, name, cidr, kind, allowed_prefixes_json, enabled, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||||
|
)
|
||||||
|
.bind(&slug)
|
||||||
|
.bind(name)
|
||||||
|
.bind(&cidr)
|
||||||
|
.bind(kind)
|
||||||
|
.bind(&allowed_json)
|
||||||
|
.bind(true)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut *conn)
|
||||||
|
.await?
|
||||||
|
.last_insert_rowid();
|
||||||
|
|
||||||
|
log_event(
|
||||||
|
&mut conn,
|
||||||
|
"pool_created",
|
||||||
|
None,
|
||||||
|
Some(id),
|
||||||
|
actor,
|
||||||
|
json!({
|
||||||
|
"slug": slug,
|
||||||
|
"cidr": cidr,
|
||||||
|
"kind": kind,
|
||||||
|
"allowed_prefixes": prefixes,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
sqlx::query("COMMIT").execute(&mut *conn).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::handlers::{allocations, exports, pages, partials};
|
use crate::handlers::{allocations, exports, pages, partials, pools};
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
|
||||||
|
|
@ -6,6 +6,9 @@ pub fn router() -> Router<crate::state::AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(pages::index))
|
.route("/", get(pages::index))
|
||||||
.route("/healthz", get(pages::healthz))
|
.route("/healthz", get(pages::healthz))
|
||||||
|
.route("/setup", get(pools::setup_page).post(pools::create_pool))
|
||||||
|
.route("/pools", get(pools::pool_list))
|
||||||
|
.route("/pools/new", post(pools::create_pool))
|
||||||
.route("/partials/pools/{slug}/preview", get(partials::pool_preview))
|
.route("/partials/pools/{slug}/preview", get(partials::pool_preview))
|
||||||
.route("/allocations", post(allocations::create_allocation))
|
.route("/allocations", post(allocations::create_allocation))
|
||||||
.route("/allocations/{id}", get(pages::allocation_detail))
|
.route("/allocations/{id}", get(pages::allocation_detail))
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ pub struct BaseTemplate {
|
||||||
#[template(path = "index.html")]
|
#[template(path = "index.html")]
|
||||||
pub struct IndexTemplate {
|
pub struct IndexTemplate {
|
||||||
pub pools: Vec<PoolTemplate>,
|
pub pools: Vec<PoolTemplate>,
|
||||||
|
pub all_pools: Vec<Pool>,
|
||||||
pub allocations: Vec<AllocationListItem>,
|
pub allocations: Vec<AllocationListItem>,
|
||||||
pub page: i64,
|
pub page: i64,
|
||||||
pub total_pages: i64,
|
pub total_pages: i64,
|
||||||
|
|
@ -130,6 +131,7 @@ pub struct AllocationsTableTemplate {
|
||||||
pub query: String,
|
pub query: String,
|
||||||
pub pool_filter: String,
|
pub pool_filter: String,
|
||||||
pub status_filter: String,
|
pub status_filter: String,
|
||||||
|
pub csrf_token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
|
|
@ -140,3 +142,18 @@ pub struct AllocationDetailTemplate {
|
||||||
pub csrf_token: String,
|
pub csrf_token: String,
|
||||||
pub flash: FlashData,
|
pub flash: FlashData,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "setup.html")]
|
||||||
|
pub struct SetupTemplate {
|
||||||
|
pub csrf_token: String,
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "pools.html")]
|
||||||
|
pub struct PoolListTemplate {
|
||||||
|
pub pools: Vec<Pool>,
|
||||||
|
pub csrf_token: String,
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,22 @@ textarea:focus {
|
||||||
color: var(--error-bg);
|
color: var(--error-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
display: inline;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--link);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link:hover {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
.preview-box {
|
.preview-box {
|
||||||
border: 2px solid var(--border);
|
border: 2px solid var(--border);
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="box">
|
<section class="box">
|
||||||
<div class="box-title">Detail alokace {{ allocation.cidr }}</div>
|
<div class="box-title">Allocation detail {{ allocation.cidr }}</div>
|
||||||
|
|
||||||
<div class="detail-grid">
|
<div class="detail-grid">
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
|
|
@ -20,19 +20,19 @@
|
||||||
<div class="value">{{ allocation.pool_name }}</div>
|
<div class="value">{{ allocation.pool_name }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<div class="label">Typ</div>
|
<div class="label">Type</div>
|
||||||
<div class="value">{{ allocation.kind }}</div>
|
<div class="value">{{ allocation.kind }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<div class="label">Stav</div>
|
<div class="label">Status</div>
|
||||||
<div class="value"><span class="status {{ allocation.status }}">{{ allocation.status }}</span></div>
|
<div class="value"><span class="status {{ allocation.status }}">{{ allocation.status }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<div class="label">Celkem adres</div>
|
<div class="label">Total addresses</div>
|
||||||
<div class="value">{{ allocation.address_count }}</div>
|
<div class="value">{{ allocation.address_count }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<div class="label">Použitelných hostů</div>
|
<div class="label">Usable hosts</div>
|
||||||
<div class="value">{{ allocation.usable_host_count }}</div>
|
<div class="value">{{ allocation.usable_host_count }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
|
|
@ -44,15 +44,15 @@
|
||||||
<div class="value">{{ allocation.broadcast }}</div>
|
<div class="value">{{ allocation.broadcast }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<div class="label">První host</div>
|
<div class="label">First host</div>
|
||||||
<div class="value">{{ allocation.first_host }}</div>
|
<div class="value">{{ allocation.first_host }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<div class="label">Poslední host</div>
|
<div class="label">Last host</div>
|
||||||
<div class="value">{{ allocation.last_host }}</div>
|
<div class="value">{{ allocation.last_host }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<div class="label">Poznámka</div>
|
<div class="label">Note</div>
|
||||||
<div class="value">{{ allocation.note }}</div>
|
<div class="value">{{ allocation.note }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
|
|
@ -60,47 +60,47 @@
|
||||||
<div class="value">{{ allocation.ticket }}</div>
|
<div class="value">{{ allocation.ticket }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<div class="label">Vytvořeno</div>
|
<div class="label">Created</div>
|
||||||
<div class="value">{{ allocation.created_at_local }} ({{ allocation.created_by }})</div>
|
<div class="value">{{ allocation.created_at_local }} ({{ allocation.created_by }})</div>
|
||||||
</div>
|
</div>
|
||||||
{% if allocation.released_at_local != "–" %}
|
{% if allocation.released_at_local != "–" %}
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<div class="label">Uvolněno</div>
|
<div class="label">Released</div>
|
||||||
<div class="value">{{ allocation.released_at_local }} ({{ allocation.released_by }})</div>
|
<div class="value">{{ allocation.released_at_local }} ({{ allocation.released_by }})</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<div class="label">Poznámka k uvolnění</div>
|
<div class="label">Release note</div>
|
||||||
<div class="value">{{ allocation.release_note }}</div>
|
<div class="value">{{ allocation.release_note }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if allocation.status == "active" || allocation.status == "reserved" %}
|
{% if allocation.status == "active" || allocation.status == "reserved" %}
|
||||||
<button class="btn btn-danger" onclick="document.getElementById('release-modal').style.display='flex'">Uvolnit subnet</button>
|
<button class="btn btn-danger" onclick="document.getElementById('release-modal').style.display='flex'">Release subnet</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<a href="/" class="btn btn-secondary">Zpět</a>
|
<a href="/" class="btn btn-secondary">Back</a>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if audit_events.len() > 0 %}
|
{% if audit_events.len() > 0 %}
|
||||||
<section class="box">
|
<section class="box">
|
||||||
<div class="box-title">Auditní historie</div>
|
<div class="box-title">Audit history</div>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Čas</th>
|
<th>Time</th>
|
||||||
<th>Událost</th>
|
<th>Event</th>
|
||||||
<th>Aktor</th>
|
<th>Actor</th>
|
||||||
<th>Detaily</th>
|
<th>Details</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for e in audit_events %}
|
{% for e in audit_events %}
|
||||||
<tr>
|
<tr>
|
||||||
<td data-label="Čas">{{ e.created_at_local }}</td>
|
<td data-label="Time">{{ e.created_at_local }}</td>
|
||||||
<td data-label="Událost">{{ e.event_type }}</td>
|
<td data-label="Event">{{ e.event_type }}</td>
|
||||||
<td data-label="Aktor">{{ e.actor }}</td>
|
<td data-label="Actor">{{ e.actor }}</td>
|
||||||
<td data-label="Detaily"><pre class="small" style="margin:0;">{{ e.details_json }}</pre></td>
|
<td data-label="Details"><pre class="small" style="margin:0;">{{ e.details_json }}</pre></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -110,16 +110,16 @@
|
||||||
|
|
||||||
<dialog id="release-modal" class="modal-overlay" style="display:none;">
|
<dialog id="release-modal" class="modal-overlay" style="display:none;">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h2 class="modal-title">Uvolnit subnet {{ allocation.cidr }}</h2>
|
<h2 class="modal-title">Release subnet {{ allocation.cidr }}</h2>
|
||||||
<form method="post" action="/allocations/{{ allocation.id }}/release" class="modal-body" id="release-form">
|
<form method="post" action="/allocations/{{ allocation.id }}/release" class="modal-body" id="release-form">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
<p class="small">Subnet přejde do stavu <span class="status released">released</span> a adresní prostor bude znovu použitelný first-fit algoritmem. Záznam zůstane v historii.</p>
|
<p class="small">The subnet will be marked as <span class="status released">released</span> and its address space will become available again via the first-fit algorithm. The record remains in history.</p>
|
||||||
<label for="release-note">Poznámka k uvolnění *</label>
|
<label for="release-note">Release note *</label>
|
||||||
<textarea id="release-note" name="release_note" rows="3" required minlength="3" maxlength="500"></textarea>
|
<textarea id="release-note" name="release_note" rows="3" required minlength="3" maxlength="500"></textarea>
|
||||||
</form>
|
</form>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" class="btn btn-secondary" onclick="document.getElementById('release-modal').style.display='none'">Zrušit</button>
|
<button type="button" class="btn btn-secondary" onclick="document.getElementById('release-modal').style.display='none'">Cancel</button>
|
||||||
<button type="submit" class="btn btn-danger" form="release-form">Uvolnit</button>
|
<button type="submit" class="btn btn-danger" form="release-form">Release</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="cs">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|
@ -13,12 +13,13 @@
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header>
|
<header>
|
||||||
<h1>IP Subnet Allocator</h1>
|
<h1>IP Subnet Allocator</h1>
|
||||||
<p>Interní evidence a přidělování IPv4 subnetů</p>
|
<p>Internal IPv4 subnet allocation and tracking</p>
|
||||||
</header>
|
</header>
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
<footer class="box">
|
<footer class="box">
|
||||||
<p class="small">
|
<p class="small">
|
||||||
<a href="/">Hlavní stránka</a> |
|
<a href="/">Home</a> |
|
||||||
|
<a href="/pools">Manage pools</a> |
|
||||||
<a href="/exports/allocations.md">Markdown export</a> |
|
<a href="/exports/allocations.md">Markdown export</a> |
|
||||||
<a href="/exports/allocations.csv">CSV export</a> |
|
<a href="/exports/allocations.csv">CSV export</a> |
|
||||||
<a href="/exports/allocations.json">JSON export</a>
|
<a href="/exports/allocations.json">JSON export</a>
|
||||||
|
|
|
||||||
|
|
@ -2,46 +2,71 @@
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Subnet</th>
|
<th>Subnet</th>
|
||||||
<th>Typ</th>
|
<th>Type</th>
|
||||||
<th>Pool</th>
|
<th>Pool</th>
|
||||||
<th>Počet adres</th>
|
<th>Addresses</th>
|
||||||
<th>Poznámka</th>
|
<th>Note</th>
|
||||||
<th>Stav</th>
|
<th>Status</th>
|
||||||
<th>Vytvořeno</th>
|
<th>Created</th>
|
||||||
<th>Akce</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for a in allocations %}
|
{% for a in allocations %}
|
||||||
<tr>
|
<tr>
|
||||||
<td data-label="Subnet"><a href="/allocations/{{ a.id }}">{{ a.cidr }}</a></td>
|
<td data-label="Subnet"><a href="/allocations/{{ a.id }}">{{ a.cidr }}</a></td>
|
||||||
<td data-label="Typ">{{ a.kind }}</td>
|
<td data-label="Type">{{ a.kind }}</td>
|
||||||
<td data-label="Pool">{{ a.pool_name }}</td>
|
<td data-label="Pool">{{ a.pool_name }}</td>
|
||||||
<td data-label="Počet adres">{{ a.address_count }}</td>
|
<td data-label="Addresses">{{ a.address_count }}</td>
|
||||||
<td data-label="Poznámka">{{ a.note }}</td>
|
<td data-label="Note">{{ a.note }}</td>
|
||||||
<td data-label="Stav"><span class="status {{ a.status }}">{{ a.status }}</span></td>
|
<td data-label="Status"><span class="status {{ a.status }}">{{ a.status }}</span></td>
|
||||||
<td data-label="Vytvořeno">{{ a.created_at_local }}</td>
|
<td data-label="Created">{{ a.created_at_local }}</td>
|
||||||
<td data-label="Akce"><a href="/allocations/{{ a.id }}">Detail</a></td>
|
<td data-label="Actions">
|
||||||
|
<a href="/allocations/{{ a.id }}">Detail</a>
|
||||||
|
{% if a.status == "active" || a.status == "reserved" %}
|
||||||
|
| <button type="button" class="btn-link" onclick="document.getElementById('release-modal-{{ a.id }}').style.display='flex'">Release</button>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="8" style="text-align:center;">Žádné alokace.</td>
|
<td colspan="8" style="text-align:center;">No allocations.</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{% for a in allocations %}
|
||||||
|
{% if a.status == "active" || a.status == "reserved" %}
|
||||||
|
<dialog id="release-modal-{{ a.id }}" class="modal-overlay" style="display:none;">
|
||||||
|
<div class="modal">
|
||||||
|
<h2 class="modal-title">Release subnet {{ a.cidr }}</h2>
|
||||||
|
<form method="post" action="/allocations/{{ a.id }}/release" class="modal-body" id="release-form-{{ a.id }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<p class="small">The subnet will be marked as <span class="status released">released</span> and its address space will become available again. The record remains in history.</p>
|
||||||
|
<label for="release-note-{{ a.id }}">Release note *</label>
|
||||||
|
<textarea id="release-note-{{ a.id }}" name="release_note" rows="3" required minlength="3" maxlength="500"></textarea>
|
||||||
|
</form>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="document.getElementById('release-modal-{{ a.id }}').style.display='none'">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-danger" form="release-form-{{ a.id }}">Release</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
{% if total_pages > 1 %}
|
{% if total_pages > 1 %}
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
<div>
|
<div>
|
||||||
{% if page > 1 %}
|
{% if page > 1 %}
|
||||||
<a class="btn btn-secondary" href="?page={{ page - 1 }}{% if query != "" %}&q={{ query }}{% endif %}{% if pool_filter != "" %}&pool={{ pool_filter }}{% endif %}{% if status_filter != "" %}&status={{ status_filter }}{% endif %}">← Předchozí</a>
|
<a class="btn btn-secondary" href="?page={{ page - 1 }}{% if query != "" %}&q={{ query }}{% endif %}{% if pool_filter != "" %}&pool={{ pool_filter }}{% endif %}{% if status_filter != "" %}&status={{ status_filter }}{% endif %}">← Previous</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div>Strana {{ page }} / {{ total_pages }} (celkem {{ total_count }})</div>
|
<div>Page {{ page }} / {{ total_pages }} (total {{ total_count }})</div>
|
||||||
<div>
|
<div>
|
||||||
{% if page < total_pages %}
|
{% if page < total_pages %}
|
||||||
<a class="btn btn-secondary" href="?page={{ page + 1 }}{% if query != "" %}&q={{ query }}{% endif %}{% if pool_filter != "" %}&pool={{ pool_filter }}{% endif %}{% if status_filter != "" %}&status={{ status_filter }}{% endif %}">Další →</a>
|
<a class="btn btn-secondary" href="?page={{ page + 1 }}{% if query != "" %}&q={{ query }}{% endif %}{% if pool_filter != "" %}&pool={{ pool_filter }}{% endif %}{% if status_filter != "" %}&status={{ status_filter }}{% endif %}">Next →</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
{% if flash.has_success %}
|
{% if flash.has_success %}
|
||||||
<div class="message success" id="flash-success">
|
<div class="message success" id="flash-success">
|
||||||
<p><strong>Přiděleno:</strong> {{ flash.success_cidr }}</p>
|
<p><strong>Allocated:</strong> {{ flash.success_cidr }}</p>
|
||||||
<p>{{ flash.success_note }}</p>
|
<p>{{ flash.success_note }}</p>
|
||||||
<p class="small">
|
<p class="small">
|
||||||
Network: {{ flash.success_network }} |
|
Network: {{ flash.success_network }} |
|
||||||
Broadcast: {{ flash.success_broadcast }} |
|
Broadcast: {{ flash.success_broadcast }} |
|
||||||
První host: {{ flash.success_first_host }} |
|
First host: {{ flash.success_first_host }} |
|
||||||
Poslední host: {{ flash.success_last_host }} |
|
Last host: {{ flash.success_last_host }} |
|
||||||
Použitelných hostů: {{ flash.success_usable_host_count }}
|
Usable hosts: {{ flash.success_usable_host_count }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,20 @@
|
||||||
{% include "fragments/flash.html" %}
|
{% include "fragments/flash.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if pools.len() == 0 %}
|
||||||
|
<section class="box">
|
||||||
|
<div class="box-title">No pools configured</div>
|
||||||
|
<p>Create at least one pool before allocating subnets.</p>
|
||||||
|
<a href="/setup" class="btn">Create first pool</a>
|
||||||
|
<a href="/pools" class="btn btn-secondary">Manage pools</a>
|
||||||
|
</section>
|
||||||
|
{% else %}
|
||||||
<section class="grid">
|
<section class="grid">
|
||||||
{% for pool in pools %}
|
{% for pool in pools %}
|
||||||
<article class="box" id="pool-card-{{ pool.slug }}">
|
<article class="box" id="pool-card-{{ pool.slug }}">
|
||||||
<div class="box-title">{{ pool.name }}</div>
|
<div class="box-title">{{ pool.name }}</div>
|
||||||
<p><span class="label">Parent prefix:</span> <span class="value">{{ pool.cidr }}</span></p>
|
<p><span class="label">Parent prefix:</span> <span class="value">{{ pool.cidr }}</span></p>
|
||||||
<p><span class="label">Typ:</span> <span class="value">{{ pool.kind }}</span></p>
|
<p><span class="label">Type:</span> <span class="value">{{ pool.kind }}</span></p>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
hx-get="/partials/pools/{{ pool.slug }}/preview"
|
hx-get="/partials/pools/{{ pool.slug }}/preview"
|
||||||
|
|
@ -18,13 +26,13 @@
|
||||||
hx-trigger="change from:select, load"
|
hx-trigger="change from:select, load"
|
||||||
hx-indicator="#indicator-{{ pool.slug }}"
|
hx-indicator="#indicator-{{ pool.slug }}"
|
||||||
>
|
>
|
||||||
<label for="prefix-{{ pool.slug }}">Velikost subnetu</label>
|
<label for="prefix-{{ pool.slug }}">Subnet size</label>
|
||||||
<select name="prefix_length" id="prefix-{{ pool.slug }}" onchange="updateModalPrefix('{{ pool.slug }}', this.value)">
|
<select name="prefix_length" id="prefix-{{ pool.slug }}" onchange="updateModalPrefix('{{ pool.slug }}', this.value)">
|
||||||
{% for pl in pool.allowed_prefixes %}
|
{% for pl in pool.allowed_prefixes %}
|
||||||
<option value="{{ pl }}" {% if pl == "24" %}selected{% endif %}>/{{ pl }}</option>
|
<option value="{{ pl }}" {% if pl == "24" %}selected{% endif %}>/{{ pl }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<div id="indicator-{{ pool.slug }}" class="small" style="display:none;">Načítám...</div>
|
<div id="indicator-{{ pool.slug }}" class="small" style="display:none;">Loading...</div>
|
||||||
<div id="preview-{{ pool.slug }}"></div>
|
<div id="preview-{{ pool.slug }}"></div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
@ -33,40 +41,41 @@
|
||||||
style="margin-top: 1rem;"
|
style="margin-top: 1rem;"
|
||||||
onclick="openAllocateModal('{{ pool.slug }}')"
|
onclick="openAllocateModal('{{ pool.slug }}')"
|
||||||
>
|
>
|
||||||
[ Přidělit další volný subnet ]
|
[ Allocate next free subnet ]
|
||||||
</button>
|
</button>
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</section>
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<section class="box">
|
<section class="box">
|
||||||
<div class="box-title">Přidělené subnety</div>
|
<div class="box-title">Allocated subnets</div>
|
||||||
|
|
||||||
<form method="get" action="/" class="filters">
|
<form method="get" action="/" class="filters">
|
||||||
<div>
|
<div>
|
||||||
<label for="q">Vyhledat</label>
|
<label for="q">Search</label>
|
||||||
<input type="text" id="q" name="q" value="{{ query }}" placeholder="CIDR nebo poznámka">
|
<input type="text" id="q" name="q" value="{{ query }}" placeholder="CIDR or note">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="pool">Pool</label>
|
<label for="pool">Pool</label>
|
||||||
<select id="pool" name="pool">
|
<select id="pool" name="pool">
|
||||||
<option value="" {% if pool_filter == "" %}selected{% endif %}>Všechny</option>
|
<option value="" {% if pool_filter == "" %}selected{% endif %}>All</option>
|
||||||
{% for p in pools %}
|
{% for p in all_pools %}
|
||||||
<option value="{{ p.slug }}" {% if pool_filter == p.slug %}selected{% endif %}>{{ p.name }}</option>
|
<option value="{{ p.slug }}" {% if pool_filter == p.slug %}selected{% endif %}>{{ p.name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="status">Stav</label>
|
<label for="status">Status</label>
|
||||||
<select id="status" name="status">
|
<select id="status" name="status">
|
||||||
<option value="" {% if status_filter == "" %}selected{% endif %}>Všechny</option>
|
<option value="" {% if status_filter == "" %}selected{% endif %}>All</option>
|
||||||
<option value="active" {% if status_filter == "active" %}selected{% endif %}>active</option>
|
<option value="active" {% if status_filter == "active" %}selected{% endif %}>active</option>
|
||||||
<option value="reserved" {% if status_filter == "reserved" %}selected{% endif %}>reserved</option>
|
<option value="reserved" {% if status_filter == "reserved" %}selected{% endif %}>reserved</option>
|
||||||
<option value="released" {% if status_filter == "released" %}selected{% endif %}>released</option>
|
<option value="released" {% if status_filter == "released" %}selected{% endif %}>released</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<button type="submit" class="btn">Filtrovat</button>
|
<button type="submit" class="btn">Filter</button>
|
||||||
<a href="/" class="btn btn-secondary">Reset</a>
|
<a href="/" class="btn btn-secondary">Reset</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -78,7 +87,7 @@
|
||||||
|
|
||||||
<dialog id="allocate-modal" class="modal-overlay" style="display:none;">
|
<dialog id="allocate-modal" class="modal-overlay" style="display:none;">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h2 class="modal-title">Potvrdit přidělení subnetu</h2>
|
<h2 class="modal-title">Confirm subnet allocation</h2>
|
||||||
<form method="post" action="/allocations" class="modal-body" id="allocate-form">
|
<form method="post" action="/allocations" class="modal-body" id="allocate-form">
|
||||||
<input type="hidden" name="pool_slug" id="modal-pool-slug">
|
<input type="hidden" name="pool_slug" id="modal-pool-slug">
|
||||||
<input type="hidden" name="prefix_length" id="modal-prefix-length">
|
<input type="hidden" name="prefix_length" id="modal-prefix-length">
|
||||||
|
|
@ -94,30 +103,30 @@
|
||||||
<div class="value" id="modal-prefix-display"></div>
|
<div class="value" id="modal-prefix-display"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<div class="label">Navrhovaný subnet</div>
|
<div class="label">Proposed subnet</div>
|
||||||
<div class="value" id="modal-cidr"></div>
|
<div class="value" id="modal-cidr"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<div class="label">Celkem adres</div>
|
<div class="label">Total addresses</div>
|
||||||
<div class="value" id="modal-address-count"></div>
|
<div class="value" id="modal-address-count"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<div class="label">Použitelných hostů</div>
|
<div class="label">Usable hosts</div>
|
||||||
<div class="value" id="modal-usable-hosts"></div>
|
<div class="value" id="modal-usable-hosts"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="small" id="modal-warning"></p>
|
<p class="small" id="modal-warning"></p>
|
||||||
|
|
||||||
<label for="note">Poznámka *</label>
|
<label for="note">Note *</label>
|
||||||
<textarea id="note" name="note" rows="3" required minlength="3" maxlength="500"></textarea>
|
<textarea id="note" name="note" rows="3" required minlength="3" maxlength="500"></textarea>
|
||||||
|
|
||||||
<label for="ticket">Ticket / reference</label>
|
<label for="ticket">Ticket / reference</label>
|
||||||
<input type="text" id="ticket" name="ticket" maxlength="100">
|
<input type="text" id="ticket" name="ticket" maxlength="100">
|
||||||
</form>
|
</form>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" class="btn btn-secondary" onclick="closeAllocateModal()">Zrušit</button>
|
<button type="button" class="btn btn-secondary" onclick="closeAllocateModal()">Cancel</button>
|
||||||
<button type="submit" class="btn" form="allocate-form">Potvrdit přidělení</button>
|
<button type="submit" class="btn" form="allocate-form">Confirm allocation</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
@ -133,7 +142,7 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
function updateModalPrefix(slug, prefixLength) {
|
function updateModalPrefix(slug, prefixLength) {
|
||||||
// preview se aktualizuje přes HTMX automaticky
|
// preview is updated via HTMX automatically
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openAllocateModal(slug) {
|
async function openAllocateModal(slug) {
|
||||||
|
|
@ -146,7 +155,6 @@
|
||||||
document.getElementById('modal-pool-name').textContent = poolData[slug].name;
|
document.getElementById('modal-pool-name').textContent = poolData[slug].name;
|
||||||
document.getElementById('modal-prefix-display').textContent = '/' + prefixLength;
|
document.getElementById('modal-prefix-display').textContent = '/' + prefixLength;
|
||||||
|
|
||||||
// Načíst preview z endpointu
|
|
||||||
const resp = await fetch('/partials/pools/' + slug + '/preview?prefix_length=' + prefixLength);
|
const resp = await fetch('/partials/pools/' + slug + '/preview?prefix_length=' + prefixLength);
|
||||||
const html = await resp.text();
|
const html = await resp.text();
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,23 @@
|
||||||
<div class="preview-box">
|
<div class="preview-box">
|
||||||
{% if has_error %}
|
{% if has_error %}
|
||||||
<p class="error" id="preview-warning">{{ error }}</p>
|
<p class="error" id="preview-warning">{{ error }}</p>
|
||||||
<p class="label">Další volný subnet: <span class="value">–</span></p>
|
<p class="label">Next free subnet: <span class="value">–</span></p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>
|
<p>
|
||||||
<span class="label">Další volný subnet:</span>
|
<span class="label">Next free subnet:</span>
|
||||||
<span class="value" id="preview-cidr">{{ cidr }}</span>
|
<span class="value" id="preview-cidr">{{ cidr }}</span>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span class="label">Celkem adres:</span>
|
<span class="label">Total addresses:</span>
|
||||||
<span class="value" id="preview-address-count">{{ address_count }}</span>
|
<span class="value" id="preview-address-count">{{ address_count }}</span>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span class="label">Použitelných hostů:</span>
|
<span class="label">Usable hosts:</span>
|
||||||
<span class="value" id="preview-usable-hosts">{{ usable_host_count }}</span>
|
<span class="value" id="preview-usable-hosts">{{ usable_host_count }}</span>
|
||||||
</p>
|
</p>
|
||||||
{% if prefix_length >= 31 %}
|
{% if prefix_length >= 31 %}
|
||||||
<p class="small" id="preview-warning">
|
<p class="small" id="preview-warning">
|
||||||
Pro /31 a /32 se network/broadcast adresy dle RFC 3021 mohou shodovat s host adresami.
|
For /31 and /32, network/broadcast addresses may coincide with host addresses per RFC 3021.
|
||||||
</p>
|
</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="small" id="preview-warning"></p>
|
<p class="small" id="preview-warning"></p>
|
||||||
|
|
|
||||||
72
templates/pools.html
Normal file
72
templates/pools.html
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Manage pools | IP Subnet Allocator{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="box">
|
||||||
|
<div class="box-title">Existing pools</div>
|
||||||
|
|
||||||
|
{% if pools.len() == 0 %}
|
||||||
|
<p>No pools configured yet.</p>
|
||||||
|
{% else %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Slug</th>
|
||||||
|
<th>Parent prefix</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Allowed prefixes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in pools %}
|
||||||
|
<tr>
|
||||||
|
<td data-label="Name">{{ p.name }}</td>
|
||||||
|
<td data-label="Slug">{{ p.slug }}</td>
|
||||||
|
<td data-label="Parent prefix">{{ p.cidr }}</td>
|
||||||
|
<td data-label="Type">{{ p.kind }}</td>
|
||||||
|
<td data-label="Allowed prefixes">{{ p.allowed_prefixes_json }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="box">
|
||||||
|
<div class="box-title">Add new pool</div>
|
||||||
|
|
||||||
|
{% if error != "" %}
|
||||||
|
<div class="message error">
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/pools/new">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|
||||||
|
<label for="slug">Pool slug *</label>
|
||||||
|
<input type="text" id="slug" name="slug" required maxlength="50" pattern="[a-z0-9-]+" placeholder="e.g. vpn">
|
||||||
|
<p class="small">Lowercase letters, numbers and hyphens only.</p>
|
||||||
|
|
||||||
|
<label for="name">Pool name *</label>
|
||||||
|
<input type="text" id="name" name="name" required minlength="2" maxlength="100" placeholder="e.g. VPN subnets">
|
||||||
|
|
||||||
|
<label for="cidr">Parent prefix (CIDR) *</label>
|
||||||
|
<input type="text" id="cidr" name="cidr" required placeholder="e.g. 10.200.0.0/16">
|
||||||
|
|
||||||
|
<label for="kind">Type *</label>
|
||||||
|
<input type="text" id="kind" name="kind" required maxlength="50" placeholder="e.g. VPN">
|
||||||
|
|
||||||
|
<label for="allowed_prefixes">Allowed prefix lengths *</label>
|
||||||
|
<input type="text" id="allowed_prefixes" name="allowed_prefixes" required value="20,21,22,23,24,25,26,27,28,29,30" placeholder="e.g. 24,25,26">
|
||||||
|
<p class="small">Comma-separated list of prefix lengths. Each must be equal to or larger than the pool prefix.</p>
|
||||||
|
|
||||||
|
<div style="margin-top: 1rem;">
|
||||||
|
<button type="submit" class="btn">Add pool</button>
|
||||||
|
<a href="/" class="btn btn-secondary">Back</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
42
templates/setup.html
Normal file
42
templates/setup.html
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Setup | IP Subnet Allocator{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="box">
|
||||||
|
<div class="box-title">Initial setup</div>
|
||||||
|
|
||||||
|
<p>Create the first IP pool. You can add more pools later from the <a href="/pools">Manage pools</a> page.</p>
|
||||||
|
|
||||||
|
{% if error != "" %}
|
||||||
|
<div class="message error">
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/setup">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
|
||||||
|
<label for="slug">Pool slug *</label>
|
||||||
|
<input type="text" id="slug" name="slug" required maxlength="50" pattern="[a-z0-9-]+" placeholder="e.g. vlan-customers">
|
||||||
|
<p class="small">Lowercase letters, numbers and hyphens only. Used in URLs.</p>
|
||||||
|
|
||||||
|
<label for="name">Pool name *</label>
|
||||||
|
<input type="text" id="name" name="name" required minlength="2" maxlength="100" placeholder="e.g. VLAN / customer subnets">
|
||||||
|
|
||||||
|
<label for="cidr">Parent prefix (CIDR) *</label>
|
||||||
|
<input type="text" id="cidr" name="cidr" required placeholder="e.g. 10.91.0.0/16">
|
||||||
|
|
||||||
|
<label for="kind">Type *</label>
|
||||||
|
<input type="text" id="kind" name="kind" required maxlength="50" placeholder="e.g. VLAN">
|
||||||
|
|
||||||
|
<label for="allowed_prefixes">Allowed prefix lengths *</label>
|
||||||
|
<input type="text" id="allowed_prefixes" name="allowed_prefixes" required value="20,21,22,23,24,25,26,27,28,29,30" placeholder="e.g. 24,25,26">
|
||||||
|
<p class="small">Comma-separated list of prefix lengths. Each must be equal to or larger than the pool prefix.</p>
|
||||||
|
|
||||||
|
<div style="margin-top: 1rem;">
|
||||||
|
<button type="submit" class="btn">Create pool</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
Loading…
Add table
Reference in a new issue