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:
parent
9ed12fa1d8
commit
6e24ce541a
14 changed files with 493 additions and 57 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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> =
|
||||||
|
|
|
||||||
|
|
@ -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?;
|
||||||
|
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?;
|
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 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 })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 () {
|
||||||
|
|
|
||||||
|
|
@ -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
49
static/theme.js
Normal 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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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> |
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue