feat: rename to SUB-ALIGATOR, dark theme, pool deletion, header nav

- Rename app title to SUB-ALIGATOR (header, page titles, exports, README)
- Add dark amber-terminal theme with a header toggle, persisted in localStorage
- Add pool deletion (soft delete): allocations are kept in history and
  block the address space again when the same or an overlapping pool is
  re-added (re-enable on exact CIDR match, re-attach contained allocations)
- Reject new pools that overlap an enabled pool
- Move Home link into the page header, keep other links in the footer
- Load app.js globally via base template, add theme.js (CSP-safe, no inline JS)
This commit is contained in:
SW 2026-10-01 18:00:32 +02:00
parent 9ed12fa1d8
commit 6e24ce541a
14 changed files with 493 additions and 57 deletions

View file

@ -1,4 +1,4 @@
# IP Subnet Allocator # SUB-ALIGATOR
A simple internal web application for tracking and automatically allocating IPv4 subnets. A simple internal web application for tracking and automatically allocating IPv4 subnets.
@ -19,6 +19,8 @@ It is designed to run only inside a LAN/VPN. **The application does not include
- Allocation history with filtering, search, and pagination. - Allocation history with filtering, search, and pagination.
- Release subnets (status `released`) with a mandatory note, directly from the history table or detail page. - Release subnets (status `released`) with a mandatory note, directly from the history table or detail page.
- Released subnets free up address space and can be reused by the first-fit algorithm. - Released subnets free up address space and can be reused by the first-fit algorithm.
- Delete pools without losing their allocations: the subnets stay in history and keep blocking the address range if the same or an overlapping pool is added again.
- Light and dark (amber terminal) theme, switchable in the page header and remembered per browser.
- Audit log of all operations. - Audit log of all operations.
- Markdown, CSV, and JSON exports. - Markdown, CSV, and JSON exports.
- SQLite migrations run automatically on startup. - SQLite migrations run automatically on startup.

View file

@ -42,7 +42,7 @@ async fn generate_markdown(
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let path = dir.join("allocations.md"); let path = dir.join("allocations.md");
let mut lines = vec![ let mut lines = vec![
"# IP Subnet Allocator – Export".to_string(), "# SUB-ALIGATOR – Export".to_string(),
"".to_string(), "".to_string(),
format!("Generated: {}", chrono::Utc::now().to_rfc3339()), format!("Generated: {}", chrono::Utc::now().to_rfc3339()),
"".to_string(), "".to_string(),

View file

@ -273,8 +273,8 @@ pub async fn release_in_tx(
Ok(()) Ok(())
} }
async fn regenerate_after_change(state: &AppState) -> anyhow::Result<()> { pub(crate) async fn regenerate_after_change(state: &AppState) -> anyhow::Result<()> {
let pools: Vec<Pool> = sqlx::query_as("SELECT * FROM pools WHERE enabled = true") let pools: Vec<Pool> = sqlx::query_as("SELECT * FROM pools ORDER BY id")
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await?; .await?;
let allocations: Vec<Allocation> = let allocations: Vec<Allocation> =

View file

@ -2,9 +2,9 @@ use crate::audit::{get_actor_from_request, log_event};
use crate::csrf::{ensure_csrf_cookie, validate_csrf}; use crate::csrf::{ensure_csrf_cookie, validate_csrf};
use crate::models::{now_utc_rfc3339, Pool}; use crate::models::{now_utc_rfc3339, Pool};
use crate::state::AppState; use crate::state::AppState;
use crate::templates::{PoolListTemplate, SetupTemplate}; use crate::templates::{PoolListTemplate, PoolRow, SetupTemplate};
use askama::Template; use askama::Template;
use axum::extract::{Form, State}; use axum::extract::{Form, Path, State};
use axum::http::HeaderMap; use axum::http::HeaderMap;
use axum::response::{Html, IntoResponse, Redirect, Response}; use axum::response::{Html, IntoResponse, Redirect, Response};
use axum_extra::extract::cookie::CookieJar; use axum_extra::extract::cookie::CookieJar;
@ -24,6 +24,11 @@ pub struct CreatePoolForm {
pub csrf_token: String, pub csrf_token: String,
} }
#[derive(Deserialize, Debug)]
pub struct DeletePoolForm {
pub csrf_token: String,
}
pub async fn setup_page(State(state): State<AppState>, jar: CookieJar) -> Response { pub async fn setup_page(State(state): State<AppState>, jar: CookieJar) -> Response {
let (jar, csrf_token) = ensure_csrf_cookie(jar); let (jar, csrf_token) = ensure_csrf_cookie(jar);
@ -60,31 +65,124 @@ pub async fn create_pool(
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_setup_with_error(&jar, csrf_token, "Invalid CSRF token.").into_response(); return render_setup_with_error(csrf_token, "Invalid CSRF token.").into_response();
} }
match validate_and_insert_pool(&state.pool, &form, &actor).await { match validate_and_insert_pool(&state.pool, &form, &actor).await {
Ok(_) => { Ok(outcome) => {
info!("Pool {} created by {}", form.slug, actor); info!("Pool {} saved by {} ({:?})", form.slug, actor, outcome);
crate::handlers::allocations::regenerate_after_change(&state)
.await
.ok();
Redirect::to("/").into_response() Redirect::to("/").into_response()
} }
Err(e) => render_setup_with_error(&jar, csrf_token, &e.to_string()).into_response(), Err(e) => render_setup_with_error(csrf_token, &e.to_string()).into_response(),
} }
} }
pub async fn delete_pool(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
Path(id): Path<i64>,
Form(form): Form<DeletePoolForm>,
) -> 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_pool_list_with_error(&state, csrf_token, "Invalid CSRF token.")
.await
.into_response();
}
match soft_delete_pool(&state, id, &actor).await {
Ok(()) => {
info!("Pool {} deleted (disabled) by {}", id, actor);
crate::handlers::allocations::regenerate_after_change(&state)
.await
.ok();
Redirect::to("/pools").into_response()
}
Err(e) => {
error!("Failed to delete pool {}: {}", id, e);
render_pool_list_with_error(&state, csrf_token, &e.to_string())
.await
.into_response()
}
}
}
async fn soft_delete_pool(state: &AppState, id: i64, actor: &str) -> anyhow::Result<()> {
let mut conn = state.pool.acquire().await?;
sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await?;
let pool: Option<Pool> = sqlx::query_as("SELECT * FROM pools WHERE id = ?")
.bind(id)
.fetch_optional(&mut *conn)
.await?;
let pool = match pool {
Some(p) => p,
None => {
sqlx::query("ROLLBACK").execute(&mut *conn).await?;
anyhow::bail!("Pool not found.");
}
};
if !pool.is_enabled() {
sqlx::query("ROLLBACK").execute(&mut *conn).await?;
anyhow::bail!("Pool is already deleted.");
}
let (active_count, reserved_count, released_count): (i64, i64, i64) = sqlx::query_as(
"SELECT \
COALESCE(SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END), 0), \
COALESCE(SUM(CASE WHEN status = 'reserved' THEN 1 ELSE 0 END), 0), \
COALESCE(SUM(CASE WHEN status = 'released' THEN 1 ELSE 0 END), 0) \
FROM allocations WHERE pool_id = ?",
)
.bind(id)
.fetch_one(&mut *conn)
.await?;
// Soft delete only: the pool row and all of its allocations stay in the
// database, so the address range keeps its history and can be re-attached
// if the same or an overlapping pool is added again later.
sqlx::query("UPDATE pools SET enabled = false WHERE id = ?")
.bind(id)
.execute(&mut *conn)
.await?;
log_event(
&mut conn,
"pool_deleted",
None,
Some(id),
actor,
json!({
"slug": pool.slug,
"cidr": pool.cidr,
"allocations_kept": {
"active": active_count,
"reserved": reserved_count,
"released": released_count,
},
}),
)
.await
.ok();
sqlx::query("COMMIT").execute(&mut *conn).await?;
Ok(())
}
pub async fn pool_list(State(state): State<AppState>, jar: CookieJar) -> Response { pub async fn pool_list(State(state): State<AppState>, jar: CookieJar) -> Response {
let (jar, csrf_token) = ensure_csrf_cookie(jar); let (jar, csrf_token) = ensure_csrf_cookie(jar);
let pools: Vec<Pool> = sqlx::query_as::<_, Pool>("SELECT * FROM pools ORDER BY id") let template = build_pool_list_template(&state, csrf_token, String::new()).await;
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let template = PoolListTemplate {
pools,
csrf_token,
error: String::new(),
};
match template.render() { match template.render() {
Ok(html) => (jar, Html(html)).into_response(), Ok(html) => (jar, Html(html)).into_response(),
@ -95,11 +193,64 @@ pub async fn pool_list(State(state): State<AppState>, jar: CookieJar) -> Respons
} }
} }
fn render_setup_with_error( async fn render_pool_list_with_error(
_jar: &CookieJar, state: &AppState,
csrf_token: String, csrf_token: String,
error: &str, error: &str,
) -> Html<String> { ) -> Html<String> {
let template = build_pool_list_template(state, csrf_token, error.to_string()).await;
match template.render() {
Ok(html) => Html(html),
Err(e) => {
error!("Pool list template render error: {}", e);
Html("Error rendering pool list.".to_string())
}
}
}
async fn build_pool_list_template(
state: &AppState,
csrf_token: String,
error: String,
) -> PoolListTemplate {
let pools: Vec<Pool> =
sqlx::query_as::<_, Pool>("SELECT * FROM pools WHERE enabled = true ORDER BY id")
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let mut rows = Vec::with_capacity(pools.len());
for pool in pools {
let (active_count, reserved_count, released_count): (i64, i64, i64) = sqlx::query_as(
"SELECT \
COALESCE(SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END), 0), \
COALESCE(SUM(CASE WHEN status = 'reserved' THEN 1 ELSE 0 END), 0), \
COALESCE(SUM(CASE WHEN status = 'released' THEN 1 ELSE 0 END), 0) \
FROM allocations WHERE pool_id = ?",
)
.bind(pool.id)
.fetch_one(&state.pool)
.await
.unwrap_or((0, 0, 0));
rows.push(PoolRow {
total_count: active_count + reserved_count + released_count,
active_count,
reserved_count,
released_count,
pool,
});
}
PoolListTemplate {
pools: rows,
csrf_token,
error,
}
}
fn render_setup_with_error(csrf_token: String, error: &str) -> Html<String> {
let template = SetupTemplate { let template = SetupTemplate {
csrf_token, csrf_token,
error: error.to_string(), error: error.to_string(),
@ -114,11 +265,25 @@ fn render_setup_with_error(
} }
} }
#[derive(Debug)]
pub enum CreatePoolOutcome {
Created { id: i64, reattached: i64 },
Reenabled { id: i64 },
}
fn nets_overlap(a: &Ipv4Net, b: &Ipv4Net) -> bool {
a.network() <= b.broadcast() && b.network() <= a.broadcast()
}
fn net_contains(outer: &Ipv4Net, inner: &Ipv4Net) -> bool {
outer.network() <= inner.network() && inner.broadcast() <= outer.broadcast()
}
async fn validate_and_insert_pool( async fn validate_and_insert_pool(
pool: &SqlitePool, pool: &SqlitePool,
form: &CreatePoolForm, form: &CreatePoolForm,
actor: &str, actor: &str,
) -> anyhow::Result<()> { ) -> anyhow::Result<CreatePoolOutcome> {
let slug = form.slug.trim().to_lowercase(); let slug = form.slug.trim().to_lowercase();
if slug.is_empty() || !slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { if slug.is_empty() || !slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
anyhow::bail!("Slug must contain only lowercase letters, numbers and hyphens."); anyhow::bail!("Slug must contain only lowercase letters, numbers and hyphens.");
@ -176,15 +341,90 @@ async fn validate_and_insert_pool(
.execute(&mut *conn) .execute(&mut *conn)
.await?; .await?;
let existing: Option<i64> = sqlx::query_scalar("SELECT id FROM pools WHERE slug = ? OR cidr = ?") let existing_by_cidr: Option<Pool> = sqlx::query_as("SELECT * FROM pools WHERE cidr = ?")
.bind(&slug)
.bind(&cidr) .bind(&cidr)
.fetch_optional(&mut *conn) .fetch_optional(&mut *conn)
.await?; .await?;
if existing.is_some() { if let Some(existing) = existing_by_cidr {
if existing.is_enabled() {
sqlx::query("ROLLBACK").execute(&mut *conn).await?; sqlx::query("ROLLBACK").execute(&mut *conn).await?;
anyhow::bail!("A pool with the same slug or CIDR already exists."); anyhow::bail!("A pool with the same CIDR already exists.");
}
// The same range was deleted before: re-enable it so all of its
// historical allocations immediately block the address space again.
let slug_taken: Option<i64> =
sqlx::query_scalar("SELECT id FROM pools WHERE slug = ? AND id != ?")
.bind(&slug)
.bind(existing.id)
.fetch_optional(&mut *conn)
.await?;
if slug_taken.is_some() {
sqlx::query("ROLLBACK").execute(&mut *conn).await?;
anyhow::bail!("Slug is already used by another pool.");
}
sqlx::query(
"UPDATE pools SET slug = ?, name = ?, kind = ?, allowed_prefixes_json = ?, enabled = true WHERE id = ?",
)
.bind(&slug)
.bind(name)
.bind(kind)
.bind(&allowed_json)
.bind(existing.id)
.execute(&mut *conn)
.await?;
log_event(
&mut conn,
"pool_reenabled",
None,
Some(existing.id),
actor,
json!({
"slug": slug,
"cidr": cidr,
"kind": kind,
"allowed_prefixes": prefixes,
}),
)
.await
.ok();
sqlx::query("COMMIT").execute(&mut *conn).await?;
return Ok(CreatePoolOutcome::Reenabled { id: existing.id });
}
let slug_taken: Option<i64> = sqlx::query_scalar("SELECT id FROM pools WHERE slug = ?")
.bind(&slug)
.fetch_optional(&mut *conn)
.await?;
if slug_taken.is_some() {
sqlx::query("ROLLBACK").execute(&mut *conn).await?;
anyhow::bail!("A pool with the same slug already exists.");
}
// Two enabled pools must never cover the same address space.
let enabled_pools: Vec<Pool> =
sqlx::query_as("SELECT * FROM pools WHERE enabled = true")
.fetch_all(&mut *conn)
.await?;
for p in &enabled_pools {
if let Ok(other) = p.cidr.parse::<Ipv4Net>()
&& nets_overlap(&net, &other)
{
sqlx::query("ROLLBACK").execute(&mut *conn).await?;
anyhow::bail!(
"Pool range overlaps with existing pool '{}' ({}).",
p.name,
p.cidr
);
}
} }
let id = sqlx::query( let id = sqlx::query(
@ -201,6 +441,45 @@ async fn validate_and_insert_pool(
.await? .await?
.last_insert_rowid(); .last_insert_rowid();
// Re-attach allocations that belonged to deleted pools and fall inside
// the new range, so the first-fit algorithm keeps respecting them and
// no overlapping subnet can be handed out.
let disabled_pools: Vec<Pool> =
sqlx::query_as("SELECT * FROM pools WHERE enabled = false AND id != ?")
.bind(id)
.fetch_all(&mut *conn)
.await?;
let mut reattached: i64 = 0;
for dp in &disabled_pools {
let Ok(dp_net) = dp.cidr.parse::<Ipv4Net>() else {
continue;
};
if !nets_overlap(&net, &dp_net) {
continue;
}
let orphans: Vec<(i64, String)> =
sqlx::query_as("SELECT id, cidr FROM allocations WHERE pool_id = ?")
.bind(dp.id)
.fetch_all(&mut *conn)
.await?;
for (alloc_id, alloc_cidr) in orphans {
let Ok(alloc_net) = alloc_cidr.parse::<Ipv4Net>() else {
continue;
};
if net_contains(&net, &alloc_net) {
sqlx::query("UPDATE allocations SET pool_id = ? WHERE id = ?")
.bind(id)
.bind(alloc_id)
.execute(&mut *conn)
.await?;
reattached += 1;
}
}
}
log_event( log_event(
&mut conn, &mut conn,
"pool_created", "pool_created",
@ -212,6 +491,7 @@ async fn validate_and_insert_pool(
"cidr": cidr, "cidr": cidr,
"kind": kind, "kind": kind,
"allowed_prefixes": prefixes, "allowed_prefixes": prefixes,
"reattached_allocations": reattached,
}), }),
) )
.await .await
@ -219,5 +499,5 @@ async fn validate_and_insert_pool(
sqlx::query("COMMIT").execute(&mut *conn).await?; sqlx::query("COMMIT").execute(&mut *conn).await?;
Ok(()) Ok(CreatePoolOutcome::Created { id, reattached })
} }

View file

@ -9,6 +9,7 @@ pub fn router() -> Router<crate::state::AppState> {
.route("/setup", get(pools::setup_page).post(pools::create_pool)) .route("/setup", get(pools::setup_page).post(pools::create_pool))
.route("/pools", get(pools::pool_list)) .route("/pools", get(pools::pool_list))
.route("/pools/new", post(pools::create_pool)) .route("/pools/new", post(pools::create_pool))
.route("/pools/{id}/delete", post(pools::delete_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))

View file

@ -150,10 +150,19 @@ pub struct SetupTemplate {
pub error: String, pub error: String,
} }
#[derive(Clone, Debug)]
pub struct PoolRow {
pub pool: Pool,
pub active_count: i64,
pub reserved_count: i64,
pub released_count: i64,
pub total_count: i64,
}
#[derive(Template)] #[derive(Template)]
#[template(path = "pools.html")] #[template(path = "pools.html")]
pub struct PoolListTemplate { pub struct PoolListTemplate {
pub pools: Vec<Pool>, pub pools: Vec<PoolRow>,
pub csrf_token: String, pub csrf_token: String,
pub error: String, pub error: String,
} }

View file

@ -25,6 +25,17 @@ function initApp() {
}); });
}); });
// Open pool delete modal from the pools page
document.querySelectorAll('.pool-delete-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
const id = btn.dataset.id;
const modal = document.getElementById('delete-pool-modal-' + id);
if (modal) {
modal.style.display = 'flex';
}
});
});
// Close modal via Cancel button // Close modal via Cancel button
document.querySelectorAll('.cancel-btn').forEach(function (btn) { document.querySelectorAll('.cancel-btn').forEach(function (btn) {
btn.addEventListener('click', function () { btn.addEventListener('click', function () {

View file

@ -14,11 +14,38 @@
--card-bg: #faf8f3; --card-bg: #faf8f3;
--hover: #ebe7de; --hover: #ebe7de;
--active: #d4edda; --active: #d4edda;
--active-fg: #155724;
--reserved: #fff3cd; --reserved: #fff3cd;
--reserved-fg: #856404;
--released: #e2e3e5; --released: #e2e3e5;
--released-fg: #383d41;
--link: #0044cc; --link: #0044cc;
} }
[data-theme="dark"] {
--bg: #0c0c0c;
--fg: #ffa63d;
--muted: #b97c35;
--border: #7a4d16;
--accent: #2b1c08;
--accent-fg: #ffb966;
--success-bg: #0e2214;
--success-fg: #7ed491;
--error-bg: #260f0f;
--error-fg: #ff7b72;
--warning-bg: #26200a;
--warning-fg: #e3c05c;
--card-bg: #121212;
--hover: #241a0d;
--active: #0e2214;
--active-fg: #7ed491;
--reserved: #26200a;
--reserved-fg: #e3c05c;
--released: #1a1a1a;
--released-fg: #9a9a9a;
--link: #ffc06e;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@ -57,6 +84,27 @@ header {
border: 2px solid var(--border); border: 2px solid var(--border);
padding: 1rem; padding: 1rem;
background: var(--card-bg); background: var(--card-bg);
position: relative;
}
.header-nav {
position: absolute;
top: 0.75rem;
display: flex;
gap: 0.5rem;
}
.header-nav-left {
left: 0.75rem;
}
.header-nav-right {
right: 0.75rem;
}
.header-nav .btn {
padding: 0.25rem 0.6rem;
font-size: 0.875rem;
} }
header h1 { header h1 {
@ -133,6 +181,7 @@ textarea:focus {
.btn:hover { .btn:hover {
background: var(--fg); background: var(--fg);
color: var(--bg);
} }
.btn-secondary { .btn-secondary {
@ -251,17 +300,17 @@ tr:hover {
.status.active { .status.active {
background: var(--active); background: var(--active);
color: #155724; color: var(--active-fg);
} }
.status.reserved { .status.reserved {
background: var(--reserved); background: var(--reserved);
color: #856404; color: var(--reserved-fg);
} }
.status.released { .status.released {
background: var(--released); background: var(--released);
color: #383d41; color: var(--released-fg);
} }
.pagination { .pagination {
@ -345,6 +394,10 @@ tr:hover {
padding: 0.5rem; padding: 0.5rem;
} }
header {
padding-top: 3rem;
}
.grid { .grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

49
static/theme.js Normal file
View file

@ -0,0 +1,49 @@
(function () {
var STORAGE_KEY = 'sub-aligator-theme';
function storedTheme() {
try {
return localStorage.getItem(STORAGE_KEY);
} catch (e) {
return null;
}
}
function currentTheme() {
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
}
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
}
// Apply the saved theme before the body renders to avoid a flash of the wrong theme.
applyTheme(storedTheme() === 'dark' ? 'dark' : 'light');
function updateToggle(btn) {
btn.textContent = currentTheme() === 'dark' ? '[ Light ]' : '[ Dark ]';
btn.setAttribute('aria-pressed', currentTheme() === 'dark' ? 'true' : 'false');
}
function wireToggle() {
var btn = document.getElementById('theme-toggle');
if (!btn) {
return;
}
updateToggle(btn);
btn.addEventListener('click', function () {
var next = currentTheme() === 'dark' ? 'light' : 'dark';
applyTheme(next);
try {
localStorage.setItem(STORAGE_KEY, next);
} catch (e) {}
updateToggle(btn);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', wireToggle);
} else {
wireToggle();
}
})();

View file

@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Detail {{ allocation.cidr }} | IP Subnet Allocator{% endblock %} {% block title %}Detail {{ allocation.cidr }} | SUB-ALIGATOR{% endblock %}
{% block content %} {% block content %}
<div id="flash-container"> <div id="flash-container">
@ -123,6 +123,4 @@
</form> </form>
</div> </div>
</div> </div>
<script src="/static/app.js"></script>
{% endblock %} {% endblock %}

View file

@ -4,21 +4,28 @@
<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">
<meta name="htmx-config" content='{"includeIndicatorStyles":false}'> <meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
<title>{% block title %}IP Subnet Allocator{% endblock %}</title> <title>{% block title %}SUB-ALIGATOR{% endblock %}</title>
<script src="/static/theme.js"></script>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js" defer></script> <script src="/static/htmx.min.js" defer></script>
<script src="/static/app.js" defer></script>
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<header> <header>
<h1>IP Subnet Allocator</h1> <div class="header-nav header-nav-left">
<a href="/" class="btn btn-secondary">Home</a>
</div>
<div class="header-nav header-nav-right">
<button type="button" id="theme-toggle" class="btn btn-secondary">[ Dark ]</button>
</div>
<h1>SUB-ALIGATOR</h1>
<p>Internal IPv4 subnet allocation and tracking</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="/">Home</a> |
<a href="/pools">Manage pools</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> |

View file

@ -9,8 +9,7 @@
<section class="box"> <section class="box">
<div class="box-title">No pools configured</div> <div class="box-title">No pools configured</div>
<p>Create at least one pool before allocating subnets.</p> <p>Create at least one pool before allocating subnets.</p>
<a href="/setup" class="btn">Create first pool</a> <a href="/pools" class="btn">Add a pool</a>
<a href="/pools" class="btn btn-secondary">Manage pools</a>
</section> </section>
{% else %} {% else %}
<section class="grid"> <section class="grid">
@ -132,6 +131,4 @@
</form> </form>
</div> </div>
</div> </div>
<script src="/static/app.js"></script>
{% endblock %} {% endblock %}

View file

@ -1,11 +1,17 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Manage pools | IP Subnet Allocator{% endblock %} {% block title %}Manage pools | SUB-ALIGATOR{% endblock %}
{% block content %} {% block content %}
<section class="box"> <section class="box">
<div class="box-title">Existing pools</div> <div class="box-title">Existing pools</div>
{% if error != "" %}
<div class="message error">
<p>{{ error }}</p>
</div>
{% endif %}
{% if pools.len() == 0 %} {% if pools.len() == 0 %}
<p>No pools configured yet.</p> <p>No pools configured yet.</p>
{% else %} {% else %}
@ -17,16 +23,22 @@
<th>Parent prefix</th> <th>Parent prefix</th>
<th>Type</th> <th>Type</th>
<th>Allowed prefixes</th> <th>Allowed prefixes</th>
<th>Allocations (active / reserved / released)</th>
<th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for p in pools %} {% for p in pools %}
<tr> <tr>
<td data-label="Name">{{ p.name }}</td> <td data-label="Name">{{ p.pool.name }}</td>
<td data-label="Slug">{{ p.slug }}</td> <td data-label="Slug">{{ p.pool.slug }}</td>
<td data-label="Parent prefix">{{ p.cidr }}</td> <td data-label="Parent prefix">{{ p.pool.cidr }}</td>
<td data-label="Type">{{ p.kind }}</td> <td data-label="Type">{{ p.pool.kind }}</td>
<td data-label="Allowed prefixes">{{ p.allowed_prefixes_json }}</td> <td data-label="Allowed prefixes">{{ p.pool.allowed_prefixes_json }}</td>
<td data-label="Allocations">{{ p.active_count }} / {{ p.reserved_count }} / {{ p.released_count }}</td>
<td data-label="Actions">
<button type="button" class="btn btn-danger pool-delete-btn" data-id="{{ p.pool.id }}">Delete</button>
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@ -37,24 +49,19 @@
<section class="box"> <section class="box">
<div class="box-title">Add new pool</div> <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"> <form method="post" action="/pools/new">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="slug">Pool slug *</label> <label for="slug">Pool slug *</label>
<input type="text" id="slug" name="slug" required maxlength="50" pattern="[a-z0-9-]+" placeholder="e.g. vpn"> <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> <p class="small">Short machine identifier used in URLs and API paths (e.g. <code>vpn</code>). Lowercase letters, numbers and hyphens only.</p>
<label for="name">Pool name *</label> <label for="name">Pool name *</label>
<input type="text" id="name" name="name" required minlength="2" maxlength="100" placeholder="e.g. VPN subnets"> <input type="text" id="name" name="name" required minlength="2" maxlength="100" placeholder="e.g. VPN subnets">
<label for="cidr">Parent prefix (CIDR) *</label> <label for="cidr">Parent prefix (CIDR) *</label>
<input type="text" id="cidr" name="cidr" required placeholder="e.g. 10.200.0.0/16"> <input type="text" id="cidr" name="cidr" required placeholder="e.g. 10.200.0.0/16">
<p class="small">If you re-add a range that was deleted before, its previous allocations are kept and will block the address space again.</p>
<label for="kind">Type *</label> <label for="kind">Type *</label>
<input type="text" id="kind" name="kind" required maxlength="50" placeholder="e.g. VPN"> <input type="text" id="kind" name="kind" required maxlength="50" placeholder="e.g. VPN">
@ -69,4 +76,26 @@
</div> </div>
</form> </form>
</section> </section>
{% for p in pools %}
<div id="delete-pool-modal-{{ p.pool.id }}" class="modal-overlay" style="display:none;">
<div class="modal">
<h2 class="modal-title">Delete pool</h2>
<form method="post" action="/pools/{{ p.pool.id }}/delete" class="modal-body">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<p>Pool: <strong>{{ p.pool.name }}</strong> ({{ p.pool.cidr }})</p>
{% if p.total_count > 0 %}
<p>This pool has {{ p.active_count }} active, {{ p.reserved_count }} reserved and {{ p.released_count }} released allocations.</p>
<p><strong>Allocations will NOT be deleted.</strong> They stay in history and will keep blocking this address range if you later add the same or an overlapping pool again.</p>
{% else %}
<p>This pool has no allocations.</p>
{% endif %}
<div class="modal-actions">
<button type="button" class="btn btn-secondary cancel-btn" data-modal="delete-pool-modal-{{ p.pool.id }}">Cancel</button>
<button type="submit" class="btn btn-danger">Delete pool</button>
</div>
</form>
</div>
</div>
{% endfor %}
{% endblock %} {% endblock %}

View file

@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Setup | IP Subnet Allocator{% endblock %} {% block title %}Setup | SUB-ALIGATOR{% endblock %}
{% block content %} {% block content %}
<section class="box"> <section class="box">
@ -19,7 +19,7 @@
<label for="slug">Pool slug *</label> <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"> <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> <p class="small">Short machine identifier used in URLs and API paths (e.g. <code>vlan-customers</code>). Lowercase letters, numbers and hyphens only.</p>
<label for="name">Pool name *</label> <label for="name">Pool name *</label>
<input type="text" id="name" name="name" required minlength="2" maxlength="100" placeholder="e.g. VLAN / customer subnets"> <input type="text" id="name" name="name" required minlength="2" maxlength="100" placeholder="e.g. VLAN / customer subnets">