feat: vlan management

This commit is contained in:
2025-12-27 23:03:06 +00:00
parent 7e1c4b126e
commit c7350aeb1f
6 changed files with 459 additions and 155 deletions
+13
View File
@@ -337,6 +337,19 @@ def init_db(app=None):
if not cursor.fetchone():
cursor.execute('ALTER TABLE IPAddress ADD COLUMN notes TEXT DEFAULT NULL')
# Add VLAN columns to Subnet table if they don't exist
cursor.execute("SHOW COLUMNS FROM Subnet LIKE 'vlan_id'")
if not cursor.fetchone():
cursor.execute('ALTER TABLE Subnet ADD COLUMN vlan_id INTEGER DEFAULT NULL')
cursor.execute("SHOW COLUMNS FROM Subnet LIKE 'vlan_description'")
if not cursor.fetchone():
cursor.execute('ALTER TABLE Subnet ADD COLUMN vlan_description VARCHAR(255) DEFAULT NULL')
cursor.execute("SHOW COLUMNS FROM Subnet LIKE 'vlan_notes'")
if not cursor.fetchone():
cursor.execute('ALTER TABLE Subnet ADD COLUMN vlan_notes TEXT DEFAULT NULL')
# Define all permissions with categories
permissions = [
# View permissions
+155 -17
View File
@@ -421,6 +421,22 @@ def parse_custom_field_value(field_type, raw_value):
# text, textarea, ip_address, email, url, select
return str(raw_value)
def validate_vlan_id(vlan_id_str):
"""
Validate VLAN ID. Must be integer between 1-4094 (standard VLAN range).
Returns (is_valid, error_message, vlan_id_int)
"""
if vlan_id_str is None or vlan_id_str == '':
return True, None, None
try:
vlan_id = int(vlan_id_str)
if vlan_id < 1 or vlan_id > 4094:
return False, "VLAN ID must be between 1 and 4094", None
return True, None, vlan_id
except ValueError:
return False, "VLAN ID must be a valid integer", None
def get_custom_fields_for_entity(entity_type, entity_id, conn=None):
"""
Retrieve custom field definitions with their values for an entity.
@@ -1422,7 +1438,17 @@ def register_routes(app, limiter=None):
from flask import current_app
with get_db_connection(current_app) as conn:
custom_fields = get_custom_fields_for_entity('subnet', subnet_id, conn=conn)
return render_with_user('subnet.html', subnet=cached_result['subnet'],
# Ensure VLAN fields are in cached subnet dict
subnet_dict = cached_result['subnet']
if 'vlan_id' not in subnet_dict:
cursor = conn.cursor()
cursor.execute('SELECT vlan_id, vlan_description, vlan_notes FROM Subnet WHERE id = %s', (subnet_id,))
vlan_row = cursor.fetchone()
if vlan_row:
subnet_dict['vlan_id'] = vlan_row[0]
subnet_dict['vlan_description'] = vlan_row[1]
subnet_dict['vlan_notes'] = vlan_row[2]
return render_with_user('subnet.html', subnet=subnet_dict,
ip_addresses=cached_result['ip_addresses'],
utilization=cached_result['utilization'],
custom_fields=custom_fields,
@@ -1431,7 +1457,7 @@ def register_routes(app, limiter=None):
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT id, name, cidr FROM Subnet WHERE id = %s', (subnet_id,))
cursor.execute('SELECT id, name, cidr, vlan_id, vlan_description, vlan_notes FROM Subnet WHERE id = %s', (subnet_id,))
subnet = cursor.fetchone()
cursor.execute('SELECT id, ip, hostname, notes FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
ip_addresses = cursor.fetchall()
@@ -1484,7 +1510,14 @@ def register_routes(app, limiter=None):
device_id, device_description = match
ip_addresses_with_device.append((ip_id, ip_address, hostname, device_id, device_description, ip_notes))
subnet_dict = {'id': subnet[0], 'name': subnet[1], 'cidr': subnet[2]}
subnet_dict = {
'id': subnet[0],
'name': subnet[1],
'cidr': subnet[2],
'vlan_id': subnet[3] if len(subnet) > 3 else None,
'vlan_description': subnet[4] if len(subnet) > 4 else None,
'vlan_notes': subnet[5] if len(subnet) > 5 else None
}
result = {
'subnet': subnet_dict,
'ip_addresses': ip_addresses_with_device,
@@ -1504,7 +1537,19 @@ def register_routes(app, limiter=None):
name = request.form['name']
cidr = request.form['cidr']
site = request.form['site']
vlan_id_str = request.form.get('vlan_id', '').strip()
vlan_description = request.form.get('vlan_description', '').strip()
vlan_notes = request.form.get('vlan_notes', '').strip()
user_name = get_current_user_name()
# Validate VLAN ID if provided
if vlan_id_str:
is_valid, error_msg, vlan_id = validate_vlan_id(vlan_id_str)
if not is_valid:
return render_with_user('admin.html', subnets=[], error=error_msg)
else:
vlan_id = None
try:
network = ip_network(cidr, strict=False)
if network.prefixlen < 24:
@@ -1514,11 +1559,13 @@ def register_routes(app, limiter=None):
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('INSERT INTO Subnet (name, cidr, site) VALUES (%s, %s, %s)', (name, cidr, site))
cursor.execute('INSERT INTO Subnet (name, cidr, site, vlan_id, vlan_description, vlan_notes) VALUES (%s, %s, %s, %s, %s, %s)',
(name, cidr, site, vlan_id, vlan_description if vlan_description else None, vlan_notes if vlan_notes else None))
subnet_id = cursor.lastrowid
ip_rows = [(str(ip), subnet_id) for ip in network.hosts()]
cursor.executemany('INSERT INTO IPAddress (ip, subnet_id) VALUES (%s, %s)', ip_rows)
add_audit_log(session['user_id'], 'add_subnet', f"Added subnet {name} ({cidr})", subnet_id, conn=conn)
vlan_info = f" (VLAN {vlan_id})" if vlan_id else ""
add_audit_log(session['user_id'], 'add_subnet', f"Added subnet {name} ({cidr}){vlan_info}", subnet_id, conn=conn)
conn.commit()
# Invalidate cache
cache.clear('index')
@@ -1535,7 +1582,19 @@ def register_routes(app, limiter=None):
name = request.form['name']
cidr = request.form['cidr']
site = request.form['site']
vlan_id_str = request.form.get('vlan_id', '').strip()
vlan_description = request.form.get('vlan_description', '').strip()
vlan_notes = request.form.get('vlan_notes', '').strip()
user_name = get_current_user_name()
# Validate VLAN ID if provided
if vlan_id_str:
is_valid, error_msg, vlan_id = validate_vlan_id(vlan_id_str)
if not is_valid:
return render_with_user('admin.html', subnets=[], error=error_msg)
else:
vlan_id = None
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
@@ -1543,8 +1602,10 @@ def register_routes(app, limiter=None):
old_subnet = cursor.fetchone()
if old_subnet:
old_name, old_cidr = old_subnet
cursor.execute('UPDATE Subnet SET name = %s, cidr = %s, site = %s WHERE id = %s', (name, cidr, site, subnet_id))
add_audit_log(session['user_id'], 'edit_subnet', f"Edited subnet from {old_name} ({old_cidr}) to {name} ({cidr}) at site {site}", subnet_id, conn=conn)
cursor.execute('UPDATE Subnet SET name = %s, cidr = %s, site = %s, vlan_id = %s, vlan_description = %s, vlan_notes = %s WHERE id = %s',
(name, cidr, site, vlan_id, vlan_description if vlan_description else None, vlan_notes if vlan_notes else None, subnet_id))
vlan_info = f" (VLAN {vlan_id})" if vlan_id else ""
add_audit_log(session['user_id'], 'edit_subnet', f"Edited subnet from {old_name} ({old_cidr}) to {name} ({cidr}) at site {site}{vlan_info}", subnet_id, conn=conn)
conn.commit()
# Invalidate cache
invalidate_cache_for_subnet(subnet_id)
@@ -1581,13 +1642,24 @@ def register_routes(app, limiter=None):
def admin():
cache_key = 'admin'
cached_result = cache.get(cache_key)
# Check if cached data has VLAN fields (for backward compatibility)
if cached_result is not None:
# Verify cached subnets have VLAN fields, if not, refresh cache
if cached_result.get('subnets') and len(cached_result['subnets']) > 0:
sample_subnet = cached_result['subnets'][0]
if 'vlan_id' not in sample_subnet:
# Cache is stale, clear it and regenerate
cache.clear(cache_key)
cached_result = None
if cached_result is not None:
return render_with_user('admin.html', **cached_result)
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT id, name, cidr, site FROM Subnet ORDER BY site, name')
cursor.execute('SELECT id, name, cidr, site, vlan_id, vlan_description, vlan_notes FROM Subnet ORDER BY site, name')
subnet_rows = cursor.fetchall()
subnets = []
for row in subnet_rows:
@@ -1619,6 +1691,9 @@ def register_routes(app, limiter=None):
'name': row[1],
'cidr': row[2],
'site': row[3] or 'Unassigned',
'vlan_id': row[4] if len(row) > 4 and row[4] is not None else None,
'vlan_description': row[5] if len(row) > 5 and row[5] is not None else None,
'vlan_notes': row[6] if len(row) > 6 and row[6] is not None else None,
'utilization': {
'percent': round(utilization_percent, 1),
'assigned': assigned_ips,
@@ -3942,7 +4017,7 @@ def register_routes(app, limiter=None):
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute('SELECT id, name, cidr, site FROM Subnet ORDER BY site, name')
cursor.execute('SELECT id, name, cidr, site, vlan_id, vlan_description, vlan_notes FROM Subnet ORDER BY site, name')
subnets = cursor.fetchall()
for subnet in subnets:
cursor.execute('SELECT COUNT(*) as total, COUNT(CASE WHEN hostname IS NOT NULL THEN 1 END) as used FROM IPAddress WHERE subnet_id = %s', (subnet['id'],))
@@ -3970,7 +4045,7 @@ def register_routes(app, limiter=None):
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute('SELECT id, name, cidr, site FROM Subnet WHERE id = %s', (subnet_id,))
cursor.execute('SELECT id, name, cidr, site, vlan_id, vlan_description, vlan_notes FROM Subnet WHERE id = %s', (subnet_id,))
subnet = cursor.fetchone()
if not subnet:
return jsonify({'error': 'Subnet not found'}), 404
@@ -4035,6 +4110,17 @@ def register_routes(app, limiter=None):
name = data['name']
cidr = data['cidr']
site = data.get('site', '')
vlan_id_str = str(data.get('vlan_id', '')).strip() if data.get('vlan_id') else ''
vlan_description = data.get('vlan_description', '').strip() if data.get('vlan_description') else ''
vlan_notes = data.get('vlan_notes', '').strip() if data.get('vlan_notes') else ''
# Validate VLAN ID if provided
if vlan_id_str:
is_valid, error_msg, vlan_id = validate_vlan_id(vlan_id_str)
if not is_valid:
return jsonify({'error': error_msg}), 400
else:
vlan_id = None
try:
network = ip_network(cidr, strict=False)
@@ -4046,13 +4132,23 @@ def register_routes(app, limiter=None):
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('INSERT INTO Subnet (name, cidr, site) VALUES (%s, %s, %s)', (name, cidr, site))
cursor.execute('INSERT INTO Subnet (name, cidr, site, vlan_id, vlan_description, vlan_notes) VALUES (%s, %s, %s, %s, %s, %s)',
(name, cidr, site, vlan_id, vlan_description if vlan_description else None, vlan_notes if vlan_notes else None))
subnet_id = cursor.lastrowid
ip_rows = [(str(ip), subnet_id) for ip in network.hosts()]
cursor.executemany('INSERT INTO IPAddress (ip, subnet_id) VALUES (%s, %s)', ip_rows)
add_audit_log(request.api_user['id'], 'add_subnet', f"Added subnet {name} ({cidr})", subnet_id, conn=conn)
vlan_info = f" (VLAN {vlan_id})" if vlan_id else ""
add_audit_log(request.api_user['id'], 'add_subnet', f"Added subnet {name} ({cidr}){vlan_info}", subnet_id, conn=conn)
conn.commit()
return jsonify({'id': subnet_id, 'name': name, 'cidr': cidr, 'site': site}), 201
return jsonify({
'id': subnet_id,
'name': name,
'cidr': cidr,
'site': site,
'vlan_id': vlan_id,
'vlan_description': vlan_description if vlan_description else None,
'vlan_notes': vlan_notes if vlan_notes else None
}), 201
@app.route('/api/v1/subnets/<int:subnet_id>', methods=['PUT'])
@rate_limit("50 per minute")
@@ -4066,16 +4162,37 @@ def register_routes(app, limiter=None):
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT name, cidr, site FROM Subnet WHERE id = %s', (subnet_id,))
cursor.execute('SELECT name, cidr, site, vlan_id, vlan_description, vlan_notes FROM Subnet WHERE id = %s', (subnet_id,))
old_subnet = cursor.fetchone()
if not old_subnet:
return jsonify({'error': 'Subnet not found'}), 404
old_name, old_cidr, old_site = old_subnet
old_name, old_cidr, old_site, old_vlan_id, old_vlan_desc, old_vlan_notes = old_subnet
new_name = data.get('name', old_name)
new_cidr = data.get('cidr', old_cidr)
new_site = data.get('site', old_site)
# Handle VLAN fields
vlan_id_str = str(data.get('vlan_id', '')).strip() if data.get('vlan_id') is not None else ''
new_vlan_description = data.get('vlan_description', '').strip() if data.get('vlan_description') else ''
new_vlan_notes = data.get('vlan_notes', '').strip() if data.get('vlan_notes') else ''
# Validate VLAN ID if provided
if vlan_id_str:
is_valid, error_msg, new_vlan_id = validate_vlan_id(vlan_id_str)
if not is_valid:
return jsonify({'error': error_msg}), 400
elif 'vlan_id' in data and data['vlan_id'] is None:
new_vlan_id = None
else:
new_vlan_id = old_vlan_id
# Use old values if not provided in request
if 'vlan_description' not in data:
new_vlan_description = old_vlan_desc if old_vlan_desc else ''
if 'vlan_notes' not in data:
new_vlan_notes = old_vlan_notes if old_vlan_notes else ''
updates = []
values = []
if new_name != old_name:
@@ -4087,21 +4204,42 @@ def register_routes(app, limiter=None):
if new_site != old_site:
updates.append('site = %s')
values.append(new_site)
if new_vlan_id != old_vlan_id:
updates.append('vlan_id = %s')
values.append(new_vlan_id)
if new_vlan_description != (old_vlan_desc or ''):
updates.append('vlan_description = %s')
values.append(new_vlan_description if new_vlan_description else None)
if new_vlan_notes != (old_vlan_notes or ''):
updates.append('vlan_notes = %s')
values.append(new_vlan_notes if new_vlan_notes else None)
if not updates:
return jsonify({'error': 'No changes to apply'}), 400
values.append(subnet_id)
cursor.execute(f'UPDATE Subnet SET {", ".join(updates)} WHERE id = %s', values)
vlan_info = f" (VLAN {new_vlan_id})" if new_vlan_id else ""
add_audit_log(
request.api_user['id'],
'edit_subnet',
f"Edited subnet from {old_name} ({old_cidr}) to {new_name} ({new_cidr}) at site {new_site or 'Unassigned'}",
f"Edited subnet from {old_name} ({old_cidr}) to {new_name} ({new_cidr}) at site {new_site or 'Unassigned'}{vlan_info}",
subnet_id,
conn=conn
)
conn.commit()
return jsonify({'message': 'Subnet updated successfully', 'subnet': {'id': subnet_id, 'name': new_name, 'cidr': new_cidr, 'site': new_site}})
return jsonify({
'message': 'Subnet updated successfully',
'subnet': {
'id': subnet_id,
'name': new_name,
'cidr': new_cidr,
'site': new_site,
'vlan_id': new_vlan_id,
'vlan_description': new_vlan_description if new_vlan_description else None,
'vlan_notes': new_vlan_notes if new_vlan_notes else None
}
})
@app.route('/api/v1/subnets/<int:subnet_id>', methods=['DELETE'])
@rate_limit("50 per minute")
+83 -1
View File
@@ -3,24 +3,99 @@ function showAddSubnetModal() {
document.getElementById('add-subnet-name').value = '';
document.getElementById('add-subnet-cidr').value = '';
document.getElementById('add-subnet-site').value = '';
document.getElementById('add-subnet-vlan-id').value = '';
document.getElementById('add-subnet-vlan-description').value = '';
document.getElementById('add-subnet-vlan-notes').value = '';
document.getElementById('vlan-id-error').classList.add('hidden');
}
function closeAddSubnetModal() {
document.getElementById('add-subnet-modal').classList.add('hidden');
document.getElementById('cidr-error').classList.add('hidden');
document.getElementById('vlan-id-error').classList.add('hidden');
}
function editSubnet(subnetId, name, cidr, site) {
function editSubnet(subnetId, name, cidr, site, vlanId, vlanDescription, vlanNotes) {
document.getElementById('edit-subnet-id').value = subnetId;
document.getElementById('edit-subnet-name').value = name;
document.getElementById('edit-subnet-cidr').value = cidr;
document.getElementById('edit-subnet-site').value = site;
document.getElementById('edit-subnet-vlan-id').value = vlanId || '';
document.getElementById('edit-subnet-vlan-description').value = vlanDescription || '';
document.getElementById('edit-subnet-vlan-notes').value = vlanNotes || '';
document.getElementById('edit-subnet-modal').classList.remove('hidden');
}
function closeEditSubnetModal() {
document.getElementById('edit-subnet-modal').classList.add('hidden');
document.getElementById('edit-cidr-error').classList.add('hidden');
document.getElementById('edit-vlan-id-error').classList.add('hidden');
}
function validateVlanId(vlanIdValue, errorElementId) {
if (!vlanIdValue || vlanIdValue.trim() === '') {
return true; // VLAN ID is optional
}
const vlanId = parseInt(vlanIdValue.trim());
if (isNaN(vlanId)) {
const errorElement = document.getElementById(errorElementId);
if (errorElement) {
errorElement.textContent = 'VLAN ID must be a valid integer';
errorElement.classList.remove('hidden');
}
return false;
}
if (vlanId < 1 || vlanId > 4094) {
const errorElement = document.getElementById(errorElementId);
if (errorElement) {
errorElement.textContent = 'VLAN ID must be between 1 and 4094';
errorElement.classList.remove('hidden');
}
return false;
}
const errorElement = document.getElementById(errorElementId);
if (errorElement) {
errorElement.classList.add('hidden');
}
return true;
}
function validateSubnetForm() {
const cidrInput = document.getElementById('add-subnet-cidr');
const cidrError = document.getElementById('cidr-error');
const cidr = cidrInput.value.trim();
// Basic CIDR validation
const cidrPattern = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/;
if (!cidrPattern.test(cidr)) {
cidrError.textContent = 'Invalid CIDR format. Use format like 192.168.1.0/24';
cidrError.classList.remove('hidden');
return false;
}
// Check prefix length
const parts = cidr.split('/');
if (parts.length === 2) {
const prefixLen = parseInt(parts[1]);
if (prefixLen < 24 || prefixLen > 32) {
cidrError.textContent = 'Subnet must be /24 or smaller (e.g., /24, /25, ... /32)';
cidrError.classList.remove('hidden');
return false;
}
}
cidrError.classList.add('hidden');
// Validate VLAN ID
const vlanIdInput = document.getElementById('add-subnet-vlan-id');
if (!validateVlanId(vlanIdInput.value, 'vlan-id-error')) {
return false;
}
return true;
}
function validateEditSubnetForm() {
@@ -48,6 +123,13 @@ function validateEditSubnetForm() {
}
cidrError.classList.add('hidden');
// Validate VLAN ID
const vlanIdInput = document.getElementById('edit-subnet-vlan-id');
if (!validateVlanId(vlanIdInput.value, 'edit-vlan-id-error')) {
return false;
}
return true;
}
+1 -1
View File
@@ -1 +1 @@
function showAddSubnetModal(){document.getElementById("add-subnet-modal").classList.remove("hidden"),document.getElementById("add-subnet-name").value="",document.getElementById("add-subnet-cidr").value="",document.getElementById("add-subnet-site").value=""}function closeAddSubnetModal(){document.getElementById("add-subnet-modal").classList.add("hidden"),document.getElementById("cidr-error").classList.add("hidden")}function editSubnet(e,t,d,n){document.getElementById("edit-subnet-id").value=e,document.getElementById("edit-subnet-name").value=t,document.getElementById("edit-subnet-cidr").value=d,document.getElementById("edit-subnet-site").value=n,document.getElementById("edit-subnet-modal").classList.remove("hidden")}function closeEditSubnetModal(){document.getElementById("edit-subnet-modal").classList.add("hidden"),document.getElementById("edit-cidr-error").classList.add("hidden")}function validateEditSubnetForm(){let e=document.getElementById("edit-subnet-cidr"),t=document.getElementById("edit-cidr-error"),d=e.value.trim();if(!/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(d))return t.textContent="Invalid CIDR format. Use format like 192.168.1.0/24",t.classList.remove("hidden"),!1;let n=d.split("/");if(2===n.length){let l=parseInt(n[1]);if(l<24||l>32)return t.textContent="Subnet must be /24 or smaller (e.g., /24, /25, ... /32)",t.classList.remove("hidden"),!1}return t.classList.add("hidden"),!0}window.onclick=function(e){let t=document.getElementById("add-subnet-modal"),d=document.getElementById("edit-subnet-modal");e.target===t&&closeAddSubnetModal(),e.target===d&&closeEditSubnetModal()};
function showAddSubnetModal(){document.getElementById("add-subnet-modal").classList.remove("hidden"),document.getElementById("add-subnet-name").value="",document.getElementById("add-subnet-cidr").value="",document.getElementById("add-subnet-site").value="",document.getElementById("add-subnet-vlan-id").value="",document.getElementById("add-subnet-vlan-description").value="",document.getElementById("add-subnet-vlan-notes").value="",document.getElementById("vlan-id-error").classList.add("hidden")}function closeAddSubnetModal(){document.getElementById("add-subnet-modal").classList.add("hidden"),document.getElementById("cidr-error").classList.add("hidden"),document.getElementById("vlan-id-error").classList.add("hidden")}function editSubnet(e,t,d,n,l,i,a){document.getElementById("edit-subnet-id").value=e,document.getElementById("edit-subnet-name").value=t,document.getElementById("edit-subnet-cidr").value=d,document.getElementById("edit-subnet-site").value=n,document.getElementById("edit-subnet-vlan-id").value=l||"",document.getElementById("edit-subnet-vlan-description").value=i||"",document.getElementById("edit-subnet-vlan-notes").value=a||"",document.getElementById("edit-subnet-modal").classList.remove("hidden")}function closeEditSubnetModal(){document.getElementById("edit-subnet-modal").classList.add("hidden"),document.getElementById("edit-cidr-error").classList.add("hidden"),document.getElementById("edit-vlan-id-error").classList.add("hidden")}function validateVlanId(e,t){if(!e||""===e.trim())return!0;let d=parseInt(e.trim());if(isNaN(d)){let n=document.getElementById(t);return n&&(n.textContent="VLAN ID must be a valid integer",n.classList.remove("hidden")),!1}if(d<1||d>4094){let l=document.getElementById(t);return l&&(l.textContent="VLAN ID must be between 1 and 4094",l.classList.remove("hidden")),!1}let i=document.getElementById(t);return i&&i.classList.add("hidden"),!0}function validateSubnetForm(){let e=document.getElementById("add-subnet-cidr"),t=document.getElementById("cidr-error"),d=e.value.trim();if(!/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(d))return t.textContent="Invalid CIDR format. Use format like 192.168.1.0/24",t.classList.remove("hidden"),!1;let n=d.split("/");if(2===n.length){let l=parseInt(n[1]);if(l<24||l>32)return t.textContent="Subnet must be /24 or smaller (e.g., /24, /25, ... /32)",t.classList.remove("hidden"),!1}t.classList.add("hidden");let i=document.getElementById("add-subnet-vlan-id");return!!validateVlanId(i.value,"vlan-id-error")}function validateEditSubnetForm(){let e=document.getElementById("edit-subnet-cidr"),t=document.getElementById("edit-cidr-error"),d=e.value.trim();if(!/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(d))return t.textContent="Invalid CIDR format. Use format like 192.168.1.0/24",t.classList.remove("hidden"),!1;let n=d.split("/");if(2===n.length){let l=parseInt(n[1]);if(l<24||l>32)return t.textContent="Subnet must be /24 or smaller (e.g., /24, /25, ... /32)",t.classList.remove("hidden"),!1}t.classList.add("hidden");let i=document.getElementById("edit-subnet-vlan-id");return!!validateVlanId(i.value,"edit-vlan-id-error")}window.onclick=function(e){let t=document.getElementById("add-subnet-modal"),d=document.getElementById("edit-subnet-modal");e.target===t&&closeAddSubnetModal(),e.target===d&&closeEditSubnetModal()};
+17 -1
View File
@@ -107,6 +107,7 @@
<th class="text-center p-3">Name</th>
<th class="text-center p-3">CIDR</th>
<th class="text-center p-3">Site</th>
<th class="text-center p-3">VLAN ID</th>
<th class="text-center p-3">Utilisation</th>
<th class="text-center p-3">Actions</th>
</tr>
@@ -119,6 +120,13 @@
<td class="p-3 text-center">
<span class="px-2 py-1 bg-gray-300 dark:bg-zinc-700 rounded text-sm">{{ subnet.site }}</span>
</td>
<td class="p-3 text-center">
{% if subnet.vlan_id %}
<span class="px-2 py-1 bg-gray-300 dark:bg-zinc-700 rounded text-sm font-mono">{{ subnet.vlan_id }}</span>
{% else %}
<span class="text-sm text-gray-500"></span>
{% endif %}
</td>
<td class="p-3 text-center">
{% if subnet.utilization %}
<span class="text-sm text-gray-600 dark:text-gray-400">{{ subnet.utilization.percent }}%</span>
@@ -133,7 +141,7 @@
<i class="fas fa-eye"></i>
</a>
{% if can_edit_subnet %}
<button onclick="editSubnet({{ subnet.id }}, '{{ subnet.name|replace("'", "\\'") }}', '{{ subnet.cidr|replace("'", "\\'") }}', '{{ subnet.site|replace("'", "\\'") }}')" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Edit Subnet">
<button onclick="editSubnet({{ subnet.id }}, '{{ subnet.name|replace("'", "\\'") }}', '{{ subnet.cidr|replace("'", "\\'") }}', '{{ subnet.site|replace("'", "\\'") }}', {{ subnet.vlan_id if subnet.vlan_id else 'null' }}, '{{ subnet.vlan_description|replace("'", "\\'") if subnet.vlan_description else "" }}', '{{ subnet.vlan_notes|replace("'", "\\'")|replace("\n", "\\n") if subnet.vlan_notes else "" }}')" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Edit Subnet">
<i class="fas fa-edit"></i>
</button>
{% endif %}
@@ -176,7 +184,11 @@
<input type="text" name="name" id="add-subnet-name" placeholder="Subnet Name" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
<input type="text" name="cidr" id="add-subnet-cidr" placeholder="CIDR (e.g., 192.168.1.0/24)" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
<input type="text" name="site" id="add-subnet-site" placeholder="Site/Location" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
<input type="number" name="vlan_id" id="add-subnet-vlan-id" placeholder="VLAN ID (1-4094, optional)" min="1" max="4094" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full">
<input type="text" name="vlan_description" id="add-subnet-vlan-description" placeholder="VLAN Description (optional)" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full">
<textarea name="vlan_notes" id="add-subnet-vlan-notes" placeholder="VLAN Notes (optional)" rows="3" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full resize-y"></textarea>
<span id="cidr-error" class="text-red-500 text-sm hidden"></span>
<span id="vlan-id-error" class="text-red-500 text-sm hidden"></span>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button type="button" onclick="closeAddSubnetModal()" class="px-4 py-2 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
@@ -201,7 +213,11 @@
<input type="text" name="name" id="edit-subnet-name" placeholder="Subnet Name" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
<input type="text" name="cidr" id="edit-subnet-cidr" placeholder="CIDR (e.g., 192.168.1.0/24)" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
<input type="text" name="site" id="edit-subnet-site" placeholder="Site/Location" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
<input type="number" name="vlan_id" id="edit-subnet-vlan-id" placeholder="VLAN ID (1-4094, optional)" min="1" max="4094" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full">
<input type="text" name="vlan_description" id="edit-subnet-vlan-description" placeholder="VLAN Description (optional)" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full">
<textarea name="vlan_notes" id="edit-subnet-vlan-notes" placeholder="VLAN Notes (optional)" rows="3" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full resize-y"></textarea>
<span id="edit-cidr-error" class="text-red-500 text-sm hidden"></span>
<span id="edit-vlan-id-error" class="text-red-500 text-sm hidden"></span>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button type="button" onclick="closeEditSubnetModal()" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
+190 -135
View File
@@ -19,152 +19,207 @@
<i class="fas fa-file-csv fa-lg"></i>
</button>
</div>
{% if utilization %}
<div class="hidden sm:flex justify-center mb-4">
<div class="bg-gray-200 dark:bg-zinc-800 px-4 py-2 rounded-lg text-sm">
<span class="font-medium">{{ utilization.percent }}% used</span>
<span class="text-gray-600 dark:text-gray-400 mx-2"></span>
<span class="text-gray-600 dark:text-gray-400">{{ utilization.assigned }} assigned</span>
<span class="text-gray-600 dark:text-gray-400 mx-2"></span>
<span class="text-gray-600 dark:text-gray-400">{{ utilization.dhcp }} DHCP</span>
<span class="text-gray-600 dark:text-gray-400 mx-2"></span>
<span class="text-gray-600 dark:text-gray-400">{{ utilization.available }} available</span>
</div>
</div>
{% endif %}
<div class="flex justify-center mb-4">
<a href="/subnet/{{ subnet.id }}/dhcp" class="hidden sm:flex bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg shadow items-center gap-2">
<i class="fas fa-network-wired"></i> Define DHCP Pool
</a>
</div>
<button id="toggle-desc" class="sm:hidden bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg mb-4 w-full">Show Descriptions</button>
<!-- Custom Fields Section -->
{% if custom_fields %}
<div class="custom-fields-section mb-6 max-w-md mx-auto">
{% if can_edit_subnet %}
<form action="/subnet/{{ subnet.id }}/update_custom_fields" method="POST" id="custom-fields-form">
<div class="space-y-4">
<!-- Info Grid: 3 columns on desktop, 1 on mobile -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<!-- Utilisation Stats Column -->
{% if utilization %}
<div class="bg-gray-200 dark:bg-zinc-800 p-4 rounded-lg">
<h3 class="text-lg font-bold mb-3 flex items-center gap-2">
<i class="fas fa-chart-pie"></i>
Utilisation
</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Used:</span>
<span class="font-medium">{{ utilization.percent }}%</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Assigned:</span>
<span>{{ utilization.assigned }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">DHCP:</span>
<span>{{ utilization.dhcp }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Available:</span>
<span>{{ utilization.available }}</span>
</div>
<div class="flex justify-between border-t border-gray-600 pt-2 mt-2">
<span class="text-gray-600 dark:text-gray-400">Total:</span>
<span class="font-medium">{{ utilization.total }}</span>
</div>
</div>
</div>
{% endif %}
<!-- VLAN Information Column -->
{% if subnet.vlan_id or subnet.vlan_description or subnet.vlan_notes %}
<div class="bg-gray-200 dark:bg-zinc-800 p-4 rounded-lg">
<h3 class="text-lg font-bold mb-3 flex items-center gap-2">
<i class="fas fa-network-wired"></i>
VLAN
</h3>
<div class="space-y-2 text-sm">
{% if subnet.vlan_id %}
<div>
<span class="text-gray-600 dark:text-gray-400">ID:</span>
<span class="ml-2 px-2 py-1 bg-gray-300 dark:bg-zinc-700 rounded text-sm font-mono">{{ subnet.vlan_id }}</span>
</div>
{% endif %}
{% if subnet.vlan_description %}
<div>
<span class="text-gray-600 dark:text-gray-400 block mb-1">Description:</span>
<span>{{ subnet.vlan_description }}</span>
</div>
{% endif %}
{% if subnet.vlan_notes %}
<div>
<span class="text-gray-600 dark:text-gray-400 block mb-1">Notes:</span>
<div class="whitespace-pre-wrap text-xs">{{ subnet.vlan_notes }}</div>
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Custom Fields Column -->
{% if custom_fields %}
<div class="bg-gray-200 dark:bg-zinc-800 p-4 rounded-lg">
<h3 class="text-lg font-bold mb-3 flex items-center gap-2">
<i class="fas fa-list-ul"></i>
Custom Fields
</h3>
{% if can_edit_subnet %}
<form action="/subnet/{{ subnet.id }}/update_custom_fields" method="POST" id="custom-fields-form">
<div class="space-y-3">
{% for field in custom_fields %}
<div class="custom-field-item">
<label for="custom_field_{{ field.field_key }}" class="block mb-1 text-xs font-medium">
{{ field.name }}
{% if field.required %}<span class="text-red-500">*</span>{% endif %}
</label>
{% if field.field_type == 'textarea' %}
<textarea name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full resize-y text-sm"
{% if field.required %}required{% endif %}
placeholder="{{ field.help_text or '' }}">{{ field.current_value or field.default_value or '' }}</textarea>
{% elif field.field_type == 'boolean' %}
<div class="flex items-center space-x-2">
<input type="checkbox" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
value="true"
{% if field.current_value or (not field.current_value and field.default_value == 'true') %}checked{% endif %}
class="w-4 h-4">
<label for="custom_field_{{ field.field_key }}" class="text-sm">Yes</label>
</div>
{% elif field.field_type == 'select' %}
{% set options = [] %}
{% if field.validation_rules and field.validation_rules.select_options %}
{% set options = field.validation_rules.select_options %}
{% endif %}
<select name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full text-sm"
{% if field.required %}required{% endif %}>
{% if not field.required %}
<option value="">-- None --</option>
{% endif %}
{% for option in options %}
<option value="{{ option }}" {% if field.current_value == option or (not field.current_value and field.default_value == option) %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
{% elif field.field_type == 'date' %}
<input type="date" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full text-sm"
value="{{ field.current_value or field.default_value or '' }}"
{% if field.required %}required{% endif %}>
{% elif field.field_type == 'datetime' %}
<input type="datetime-local" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full text-sm"
value="{{ field.current_value or field.default_value or '' }}"
{% if field.required %}required{% endif %}>
{% elif field.field_type == 'number' %}
<input type="number" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full text-sm"
value="{{ field.current_value or field.default_value or '' }}"
{% if field.required %}required{% endif %}
{% if field.validation_rules and field.validation_rules.min_value %}min="{{ field.validation_rules.min_value }}"{% endif %}
{% if field.validation_rules and field.validation_rules.max_value %}max="{{ field.validation_rules.max_value }}"{% endif %}>
{% elif field.field_type == 'decimal' %}
<input type="number" step="any" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full text-sm"
value="{{ field.current_value or field.default_value or '' }}"
{% if field.required %}required{% endif %}
{% if field.validation_rules and field.validation_rules.min_value %}min="{{ field.validation_rules.min_value }}"{% endif %}
{% if field.validation_rules and field.validation_rules.max_value %}max="{{ field.validation_rules.max_value }}"{% endif %}>
{% elif field.field_type == 'ip_address' %}
<input type="text" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full font-mono text-sm"
value="{{ field.current_value or field.default_value or '' }}"
placeholder="192.168.1.1"
{% if field.required %}required{% endif %}>
{% elif field.field_type == 'email' %}
<input type="email" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full text-sm"
value="{{ field.current_value or field.default_value or '' }}"
placeholder="user@example.com"
{% if field.required %}required{% endif %}>
{% elif field.field_type == 'url' %}
<input type="url" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full text-sm"
value="{{ field.current_value or field.default_value or '' }}"
placeholder="https://example.com"
{% if field.required %}required{% endif %}>
{% else %}
<input type="text" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full text-sm"
value="{{ field.current_value or field.default_value or '' }}"
placeholder="{{ field.help_text or '' }}"
{% if field.required %}required{% endif %}
{% if field.validation_rules and field.validation_rules.min_length %}minlength="{{ field.validation_rules.min_length }}"{% endif %}
{% if field.validation_rules and field.validation_rules.max_length %}maxlength="{{ field.validation_rules.max_length }}"{% endif %}>
{% endif %}
</div>
{% endfor %}
</div>
</form>
{% else %}
<div class="space-y-3">
{% for field in custom_fields %}
<div class="custom-field-item">
<label for="custom_field_{{ field.field_key }}" class="block mb-1 text-sm font-medium">
{{ field.name }}
{% if field.required %}<span class="text-red-500">*</span>{% endif %}
{% if field.help_text %}
<span class="text-xs text-gray-500 dark:text-gray-400 ml-2" title="{{ field.help_text }}">
<i class="fas fa-info-circle"></i>
</span>
{% endif %}
</label>
{% if field.field_type == 'textarea' %}
<textarea name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full resize-y"
{% if field.required %}required{% endif %}
placeholder="{{ field.help_text or '' }}">{{ field.current_value or field.default_value or '' }}</textarea>
{% elif field.field_type == 'boolean' %}
<div class="flex items-center space-x-2">
<input type="checkbox" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
value="true"
{% if field.current_value or (not field.current_value and field.default_value == 'true') %}checked{% endif %}
class="w-4 h-4">
<label for="custom_field_{{ field.field_key }}" class="text-sm">Yes</label>
<label class="block mb-1 text-xs font-medium">{{ field.name }}</label>
<div class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full text-sm">
{{ field.current_value or field.default_value or '-' }}
</div>
{% elif field.field_type == 'select' %}
{% set options = [] %}
{% if field.validation_rules and field.validation_rules.select_options %}
{% set options = field.validation_rules.select_options %}
{% endif %}
<select name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full"
{% if field.required %}required{% endif %}>
{% if not field.required %}
<option value="">-- None --</option>
{% endif %}
{% for option in options %}
<option value="{{ option }}" {% if field.current_value == option or (not field.current_value and field.default_value == option) %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
{% elif field.field_type == 'date' %}
<input type="date" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full"
value="{{ field.current_value or field.default_value or '' }}"
{% if field.required %}required{% endif %}>
{% elif field.field_type == 'datetime' %}
<input type="datetime-local" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full"
value="{{ field.current_value or field.default_value or '' }}"
{% if field.required %}required{% endif %}>
{% elif field.field_type == 'number' %}
<input type="number" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full"
value="{{ field.current_value or field.default_value or '' }}"
{% if field.required %}required{% endif %}
{% if field.validation_rules and field.validation_rules.min_value %}min="{{ field.validation_rules.min_value }}"{% endif %}
{% if field.validation_rules and field.validation_rules.max_value %}max="{{ field.validation_rules.max_value }}"{% endif %}>
{% elif field.field_type == 'decimal' %}
<input type="number" step="any" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full"
value="{{ field.current_value or field.default_value or '' }}"
{% if field.required %}required{% endif %}
{% if field.validation_rules and field.validation_rules.min_value %}min="{{ field.validation_rules.min_value }}"{% endif %}
{% if field.validation_rules and field.validation_rules.max_value %}max="{{ field.validation_rules.max_value }}"{% endif %}>
{% elif field.field_type == 'ip_address' %}
<input type="text" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full font-mono"
value="{{ field.current_value or field.default_value or '' }}"
placeholder="192.168.1.1"
{% if field.required %}required{% endif %}>
{% elif field.field_type == 'email' %}
<input type="email" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full"
value="{{ field.current_value or field.default_value or '' }}"
placeholder="user@example.com"
{% if field.required %}required{% endif %}>
{% elif field.field_type == 'url' %}
<input type="url" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full"
value="{{ field.current_value or field.default_value or '' }}"
placeholder="https://example.com"
{% if field.required %}required{% endif %}>
{% else %}
<input type="text" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full"
value="{{ field.current_value or field.default_value or '' }}"
placeholder="{{ field.help_text or '' }}"
{% if field.required %}required{% endif %}
{% if field.validation_rules and field.validation_rules.min_length %}minlength="{{ field.validation_rules.min_length }}"{% endif %}
{% if field.validation_rules and field.validation_rules.max_length %}maxlength="{{ field.validation_rules.max_length }}"{% endif %}>
{% endif %}
</div>
{% endfor %}
</div>
</form>
{% else %}
<div class="space-y-4">
{% for field in custom_fields %}
<div class="custom-field-item">
<label class="block mb-1 text-sm font-medium">{{ field.name }}</label>
<div class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full">
{{ field.current_value or field.default_value or '-' }}
</div>
</div>
{% endfor %}
{% endif %}
</div>
{% endif %}
</div>
{% endif %}
<!-- Actions Row -->
<div class="flex justify-center mb-4 gap-2">
<a href="/subnet/{{ subnet.id }}/dhcp" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg shadow items-center gap-2 flex">
<i class="fas fa-network-wired"></i> <span class="hidden sm:inline">Define </span>DHCP Pool
</a>
</div>
<button id="toggle-desc" class="sm:hidden bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg mb-4 w-full">Show Descriptions</button>
<!-- IP Address Table -->
<form action="" method="POST">
<table class="table-auto w-full mb-6">
<thead>