feat: custom fields by device or subnet

This commit is contained in:
2025-12-27 22:07:49 +00:00
parent 53dc19a549
commit b23cda48af
11 changed files with 1786 additions and 26 deletions
+45 -2
View File
@@ -299,6 +299,39 @@ def init_db(app=None):
)
''')
# Create CustomFieldDefinition table
cursor.execute('''
CREATE TABLE IF NOT EXISTS CustomFieldDefinition (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
entity_type ENUM('device', 'subnet') NOT NULL,
name VARCHAR(255) NOT NULL,
field_key VARCHAR(255) NOT NULL UNIQUE,
field_type VARCHAR(50) NOT NULL,
required BOOLEAN DEFAULT FALSE,
default_value TEXT,
help_text TEXT,
display_order INTEGER DEFAULT 0,
validation_rules TEXT,
searchable BOOLEAN DEFAULT FALSE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
''')
# Add custom_fields column to Device table if it doesn't exist
cursor.execute("SHOW COLUMNS FROM Device LIKE 'custom_fields'")
if not cursor.fetchone():
cursor.execute('ALTER TABLE Device ADD COLUMN custom_fields TEXT DEFAULT NULL')
# Initialize existing records with empty JSON object
cursor.execute("UPDATE Device SET custom_fields = '{}' WHERE custom_fields IS NULL")
# Add custom_fields column to Subnet table if it doesn't exist
cursor.execute("SHOW COLUMNS FROM Subnet LIKE 'custom_fields'")
if not cursor.fetchone():
cursor.execute('ALTER TABLE Subnet ADD COLUMN custom_fields TEXT DEFAULT NULL')
# Initialize existing records with empty JSON object
cursor.execute("UPDATE Subnet SET custom_fields = '{}' WHERE custom_fields IS NULL")
# Define all permissions with categories
permissions = [
# View permissions
@@ -354,6 +387,10 @@ def init_db(app=None):
('assign_device_tag', 'Assign tag to device', 'Tag'),
('remove_device_tag', 'Remove tag from device', 'Tag'),
# Custom Fields permissions
('view_custom_fields', 'View custom fields', 'Custom Fields'),
('manage_custom_fields', 'Manage custom fields (add, edit, delete)', 'Custom Fields'),
# Admin permissions
('manage_users', 'Manage users (add, edit, delete)', 'Admin'),
('manage_roles', 'Manage roles and permissions', 'Admin'),
@@ -415,7 +452,8 @@ def init_db(app=None):
'add_nonnet_device_to_rack', 'export_rack_csv',
'configure_dhcp',
'add_device_type', 'edit_device_type', 'delete_device_type',
'view_tags', 'add_tag', 'edit_tag', 'delete_tag', 'assign_device_tag', 'remove_device_tag'
'view_tags', 'add_tag', 'edit_tag', 'delete_tag', 'assign_device_tag', 'remove_device_tag',
'view_custom_fields', 'manage_custom_fields'
]
for perm_name in non_admin_permissions:
@@ -434,7 +472,7 @@ def init_db(app=None):
view_only_permissions = [
'view_index', 'view_devices', 'view_device', 'view_subnet', 'view_racks', 'view_rack',
'view_audit', 'view_device_types', 'view_device_type_stats', 'view_devices_by_type',
'view_dhcp', 'view_help', 'view_tags'
'view_dhcp', 'view_help', 'view_tags', 'view_custom_fields'
]
for perm_name in view_only_permissions:
@@ -527,6 +565,11 @@ def init_db(app=None):
# User table indexes (api_key already has UNIQUE index)
create_index_if_not_exists(cursor, 'idx_user_role_id', 'User', 'role_id')
# CustomFieldDefinition table indexes
create_index_if_not_exists(cursor, 'idx_customfield_entity_type', 'CustomFieldDefinition', 'entity_type')
create_index_if_not_exists(cursor, 'idx_customfield_field_key', 'CustomFieldDefinition', 'field_key')
create_index_if_not_exists(cursor, 'idx_customfield_entity_order', 'CustomFieldDefinition', 'entity_type, display_order')
logging.info("Database indexes created successfully")
conn.commit()
conn.close()
+783 -5
View File
@@ -13,6 +13,10 @@ import shutil
from datetime import datetime
from werkzeug.utils import secure_filename
from cache import cache
import json
import re
from ipaddress import ip_address, IPv4Address, IPv6Address
from urllib.parse import urlparse
app = None
@@ -280,6 +284,208 @@ def invalidate_cache_for_subnet(subnet_id):
cache.clear('index')
cache.clear('admin')
def validate_custom_field_value(field_def, value):
"""
Validate a custom field value against its field definition.
Returns (is_valid, error_message)
"""
if value is None or value == '':
if field_def.get('required', False):
return False, f"{field_def.get('name', 'Field')} is required"
return True, None
field_type = field_def.get('field_type', 'text')
validation_rules = field_def.get('validation_rules')
# Parse validation rules if it's a JSON string
if isinstance(validation_rules, str):
try:
validation_rules = json.loads(validation_rules)
except json.JSONDecodeError:
validation_rules = {}
elif validation_rules is None:
validation_rules = {}
# Type-specific validation
if field_type == 'ip_address':
try:
ip_address(value)
except ValueError:
return False, f"Invalid IP address format: {value}"
elif field_type == 'email':
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if not re.match(email_pattern, value):
return False, f"Invalid email format: {value}"
elif field_type == 'url':
try:
result = urlparse(value)
if not all([result.scheme, result.netloc]):
return False, f"Invalid URL format: {value}"
except Exception:
return False, f"Invalid URL format: {value}"
elif field_type == 'date':
try:
datetime.strptime(value, '%Y-%m-%d')
except ValueError:
return False, f"Invalid date format. Expected YYYY-MM-DD: {value}"
elif field_type == 'datetime':
try:
datetime.fromisoformat(value.replace('Z', '+00:00'))
except ValueError:
return False, f"Invalid datetime format. Expected ISO format: {value}"
elif field_type == 'number':
try:
int(value)
except ValueError:
return False, f"Invalid integer: {value}"
if 'min_value' in validation_rules:
if int(value) < validation_rules['min_value']:
return False, f"Value must be at least {validation_rules['min_value']}"
if 'max_value' in validation_rules:
if int(value) > validation_rules['max_value']:
return False, f"Value must be at most {validation_rules['max_value']}"
elif field_type == 'decimal':
try:
float(value)
except ValueError:
return False, f"Invalid decimal number: {value}"
if 'min_value' in validation_rules:
if float(value) < validation_rules['min_value']:
return False, f"Value must be at least {validation_rules['min_value']}"
if 'max_value' in validation_rules:
if float(value) > validation_rules['max_value']:
return False, f"Value must be at most {validation_rules['max_value']}"
elif field_type == 'boolean':
if value not in [True, False, 'true', 'false', '1', '0', 1, 0]:
return False, f"Invalid boolean value: {value}"
elif field_type == 'select':
if 'select_options' in validation_rules:
if value not in validation_rules['select_options']:
return False, f"Value must be one of: {', '.join(validation_rules['select_options'])}"
# Text length validation (applies to text, textarea, and string-based types)
if field_type in ['text', 'textarea', 'ip_address', 'email', 'url']:
if 'min_length' in validation_rules:
if len(str(value)) < validation_rules['min_length']:
return False, f"Value must be at least {validation_rules['min_length']} characters"
if 'max_length' in validation_rules:
if len(str(value)) > validation_rules['max_length']:
return False, f"Value must be at most {validation_rules['max_length']} characters"
# Regex pattern validation
if 'regex_pattern' in validation_rules:
try:
if not re.match(validation_rules['regex_pattern'], str(value)):
return False, f"Value does not match required pattern"
except re.error:
# Invalid regex pattern, skip validation
pass
return True, None
def parse_custom_field_value(field_type, raw_value):
"""Parse and normalize a custom field value based on its type"""
if raw_value is None or raw_value == '':
return None
if field_type == 'number':
try:
return int(raw_value)
except ValueError:
return None
elif field_type == 'decimal':
try:
return float(raw_value)
except ValueError:
return None
elif field_type == 'boolean':
if isinstance(raw_value, bool):
return raw_value
if str(raw_value).lower() in ['true', '1', 'yes']:
return True
if str(raw_value).lower() in ['false', '0', 'no']:
return False
return None
elif field_type in ['date', 'datetime']:
# Return as string, validation ensures format
return str(raw_value)
else:
# text, textarea, ip_address, email, url, select
return str(raw_value)
def get_custom_fields_for_entity(entity_type, entity_id, conn=None):
"""
Retrieve custom field definitions with their values for an entity.
Returns list of dicts with field definition and current value.
"""
close_conn = False
if conn is None:
from flask import current_app
conn = get_db_connection(current_app)
close_conn = True
try:
cursor = conn.cursor(dictionary=True)
# Get field definitions for this entity type
cursor.execute('''
SELECT id, entity_type, name, field_key, field_type, required,
default_value, help_text, display_order, validation_rules, searchable
FROM CustomFieldDefinition
WHERE entity_type = %s
ORDER BY display_order, name
''', (entity_type,))
field_defs = cursor.fetchall()
# Get current values from entity table
table_name = 'Device' if entity_type == 'device' else 'Subnet'
cursor.execute(f'SELECT custom_fields FROM {table_name} WHERE id = %s', (entity_id,))
result = cursor.fetchone()
current_values = {}
if result and result.get('custom_fields'):
try:
current_values = json.loads(result['custom_fields'])
except (json.JSONDecodeError, TypeError):
current_values = {}
# Merge definitions with values
fields_with_values = []
for field_def in field_defs:
field_key = field_def['field_key']
current_value = current_values.get(field_key)
# Use default value if no current value
if current_value is None and field_def.get('default_value'):
current_value = field_def['default_value']
# Parse validation_rules if it's a JSON string
validation_rules = field_def.get('validation_rules')
if isinstance(validation_rules, str):
try:
validation_rules = json.loads(validation_rules)
except (json.JSONDecodeError, TypeError):
validation_rules = {}
elif validation_rules is None:
validation_rules = {}
field_def['current_value'] = current_value
field_def['validation_rules'] = validation_rules
fields_with_values.append(field_def)
return fields_with_values
finally:
if close_conn:
conn.close()
def prewarm_cache(app):
"""Pre-warm cache in background by loading all data"""
import threading
@@ -906,6 +1112,10 @@ def register_routes(app, limiter=None):
# Device was deleted, clear cache and redirect
cache.delete(cache_key)
return redirect(url_for('devices'))
# Get custom fields for device (not cached)
custom_fields = get_custom_fields_for_entity('device', device_id, conn=conn)
cached_result['custom_fields'] = custom_fields
cached_result['can_edit_device'] = has_permission('edit_device')
return render_with_user('device.html', **cached_result)
from flask import current_app
@@ -962,8 +1172,12 @@ def register_routes(app, limiter=None):
in_range = False
ips = filtered_ips
available_ips_by_subnet[subnet['id']] = ips
# Get IP history for this device
ip_history = get_ip_history_from_audit_logs(device_id=device_id, conn=conn)
# Get custom fields for device
custom_fields = get_custom_fields_for_entity('device', device_id, conn=conn)
# Get IP history for this device
ip_history = get_ip_history_from_audit_logs(device_id=device_id, conn=conn)
return render_with_user('device.html',
device={'id': device[0], 'name': device[1], 'description': device[2], 'device_type_id': device[3]},
@@ -971,7 +1185,9 @@ def register_routes(app, limiter=None):
device_types=device_types, device_tags=device_tags, all_tags=all_tags,
can_assign_device_tag=has_permission('assign_device_tag'),
can_remove_device_tag=has_permission('remove_device_tag'),
ip_history=ip_history)
ip_history=ip_history,
custom_fields=custom_fields,
can_edit_device=has_permission('edit_device'))
@app.route('/api/device/<int:device_id>/ip_history')
@permission_required('view_device')
@@ -1200,9 +1416,14 @@ def register_routes(app, limiter=None):
cache_key = f'subnet:{subnet_id}'
cached_result = cache.get(cache_key)
if cached_result is not 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'],
ip_addresses=cached_result['ip_addresses'],
utilization=cached_result['utilization'])
utilization=cached_result['utilization'],
custom_fields=custom_fields,
can_edit_subnet=has_permission('edit_subnet'))
from flask import current_app
with get_db_connection(current_app) as conn:
@@ -1240,6 +1461,9 @@ def register_routes(app, limiter=None):
'percent': round(utilization_percent, 1)
}
# Get custom fields for subnet
custom_fields = get_custom_fields_for_entity('subnet', subnet_id, conn=conn)
cursor.execute('SELECT id, name, description FROM Device')
devices = cursor.fetchall()
device_name_map = {name.lower(): (id, description) for id, name, description in devices}
@@ -1264,7 +1488,9 @@ def register_routes(app, limiter=None):
cache.set(cache_key, result, ttl=10800)
return render_with_user('subnet.html', subnet=subnet_dict,
ip_addresses=ip_addresses_with_device,
utilization=utilization_stats)
utilization=utilization_stats,
custom_fields=custom_fields,
can_edit_subnet=has_permission('edit_subnet'))
@app.route('/add_subnet', methods=['POST'])
@permission_required('add_subnet')
@@ -1822,6 +2048,229 @@ def register_routes(app, limiter=None):
can_edit_tag=has_permission('edit_tag'),
can_delete_tag=has_permission('delete_tag'))
@app.route('/custom_fields', methods=['GET', 'POST'])
@permission_required('view_custom_fields')
def custom_fields():
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor(dictionary=True)
error = None
if request.method == 'POST':
action = request.form.get('action')
if action == 'add_field':
if not has_permission('manage_custom_fields', conn=conn):
error = 'You do not have permission to add custom fields.'
else:
entity_type = request.form.get('entity_type', '').strip()
# Debug logging
logging.info(f"Received entity_type: '{entity_type}' (type: {type(entity_type)})")
logging.info(f"Form data keys: {list(request.form.keys())}")
if not entity_type or entity_type not in ['device', 'subnet']:
# Try to get from form data directly
entity_type_raw = request.form.get('entity_type')
logging.error(f"Invalid entity_type received: '{entity_type}' (raw: '{entity_type_raw}')")
# Show more helpful error
error = f'Invalid entity type: "{entity_type}". Must be "device" or "subnet". Please try again.'
else:
name = request.form['name'].strip()
field_key = request.form.get('field_key', '').strip()
field_type = request.form['field_type']
required = 'required' in request.form
default_value = request.form.get('default_value', '').strip()
help_text = request.form.get('help_text', '').strip()
display_order = int(request.form.get('display_order', 0))
searchable = 'searchable' in request.form
# Generate field_key from name if not provided
if not field_key:
field_key = re.sub(r'[^a-z0-9_]+', '_', name.lower()).strip('_')
# Build validation_rules JSON
validation_rules = {}
if field_type in ['text', 'textarea']:
if request.form.get('min_length'):
validation_rules['min_length'] = int(request.form['min_length'])
if request.form.get('max_length'):
validation_rules['max_length'] = int(request.form['max_length'])
if request.form.get('regex_pattern'):
validation_rules['regex_pattern'] = request.form['regex_pattern']
elif field_type in ['number', 'decimal']:
if request.form.get('min_value'):
validation_rules['min_value'] = float(request.form['min_value'])
if request.form.get('max_value'):
validation_rules['max_value'] = float(request.form['max_value'])
elif field_type == 'select':
options = request.form.get('select_options', '').strip()
if options:
validation_rules['select_options'] = [opt.strip() for opt in options.split(',') if opt.strip()]
validation_rules_json = json.dumps(validation_rules) if validation_rules else None
if not name:
error = 'Field name is required.'
elif not field_key:
error = 'Field key is required.'
else:
try:
cursor.execute('''
INSERT INTO CustomFieldDefinition
(entity_type, name, field_key, field_type, required, default_value,
help_text, display_order, validation_rules, searchable)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
''', (entity_type, name, field_key, field_type, required, default_value,
help_text, display_order, validation_rules_json, searchable))
add_audit_log(session['user_id'], 'add_custom_field',
f"Added custom field '{name}' for {entity_type}", conn=conn)
conn.commit()
# Redirect to preserve tab state
return redirect(url_for('custom_fields', tab=entity_type))
except mysql.connector.IntegrityError:
error = f'Field key "{field_key}" already exists.'
elif action == 'edit_field':
if not has_permission('manage_custom_fields', conn=conn):
error = 'You do not have permission to edit custom fields.'
else:
field_id = request.form['field_id']
name = request.form['name'].strip()
field_type = request.form['field_type']
required = 'required' in request.form
default_value = request.form.get('default_value', '').strip()
help_text = request.form.get('help_text', '').strip()
display_order = int(request.form.get('display_order', 0))
searchable = 'searchable' in request.form
# Build validation_rules JSON
validation_rules = {}
if field_type in ['text', 'textarea']:
if request.form.get('min_length'):
validation_rules['min_length'] = int(request.form['min_length'])
if request.form.get('max_length'):
validation_rules['max_length'] = int(request.form['max_length'])
if request.form.get('regex_pattern'):
validation_rules['regex_pattern'] = request.form['regex_pattern']
elif field_type in ['number', 'decimal']:
if request.form.get('min_value'):
validation_rules['min_value'] = float(request.form['min_value'])
if request.form.get('max_value'):
validation_rules['max_value'] = float(request.form['max_value'])
elif field_type == 'select':
options = request.form.get('select_options', '').strip()
if options:
validation_rules['select_options'] = [opt.strip() for opt in options.split(',') if opt.strip()]
validation_rules_json = json.dumps(validation_rules) if validation_rules else None
if not name:
error = 'Field name is required.'
else:
# Get entity_type of the field being edited
cursor.execute('SELECT entity_type FROM CustomFieldDefinition WHERE id = %s', (field_id,))
field_row = cursor.fetchone()
entity_type = field_row['entity_type'] if field_row else 'device'
cursor.execute('''
UPDATE CustomFieldDefinition
SET name = %s, field_type = %s, required = %s, default_value = %s,
help_text = %s, display_order = %s, validation_rules = %s, searchable = %s
WHERE id = %s
''', (name, field_type, required, default_value, help_text,
display_order, validation_rules_json, searchable, field_id))
add_audit_log(session['user_id'], 'edit_custom_field',
f"Updated custom field '{name}'", conn=conn)
conn.commit()
# Redirect to preserve tab state
return redirect(url_for('custom_fields', tab=entity_type))
elif action == 'delete_field':
if not has_permission('manage_custom_fields', conn=conn):
error = 'You do not have permission to delete custom fields.'
else:
field_id = request.form['field_id']
cursor.execute('SELECT name, entity_type FROM CustomFieldDefinition WHERE id = %s', (field_id,))
field = cursor.fetchone()
if field:
field_name = field['name']
entity_type = field['entity_type']
cursor.execute('DELETE FROM CustomFieldDefinition WHERE id = %s', (field_id,))
add_audit_log(session['user_id'], 'delete_custom_field',
f"Deleted custom field '{field_name}'", conn=conn)
conn.commit()
# Redirect to preserve tab state
return redirect(url_for('custom_fields', tab=entity_type))
elif action == 'reorder':
if not has_permission('manage_custom_fields', conn=conn):
error = 'You do not have permission to reorder custom fields.'
else:
entity_type = request.form['entity_type']
field_orders = json.loads(request.form['field_orders'])
for field_id, order in field_orders.items():
cursor.execute('UPDATE CustomFieldDefinition SET display_order = %s WHERE id = %s AND entity_type = %s',
(order, field_id, entity_type))
conn.commit()
# Redirect to preserve tab state
return redirect(url_for('custom_fields', tab=entity_type))
# Redirect to preserve tab state
return redirect(url_for('custom_fields', tab=entity_type))
# Get all custom fields grouped by entity type
cursor.execute('''
SELECT id, entity_type, name, field_key, field_type, required,
default_value, help_text, display_order, validation_rules, searchable
FROM CustomFieldDefinition
ORDER BY entity_type, display_order, name
''')
all_fields = cursor.fetchall()
# Parse validation_rules JSON strings to objects
for field in all_fields:
if field['validation_rules']:
try:
field['validation_rules'] = json.loads(field['validation_rules'])
except (json.JSONDecodeError, TypeError):
field['validation_rules'] = {}
else:
field['validation_rules'] = {}
device_fields = [f for f in all_fields if f['entity_type'] == 'device']
subnet_fields = [f for f in all_fields if f['entity_type'] == 'subnet']
# Get active tab from query parameter
active_tab = request.args.get('tab', 'device')
if active_tab not in ['device', 'subnet']:
active_tab = 'device'
return render_with_user('custom_fields.html',
device_fields=device_fields,
subnet_fields=subnet_fields,
error=error,
can_manage=has_permission('manage_custom_fields'),
active_tab=active_tab)
@app.route('/custom_fields/<entity_type>')
@permission_required('view_custom_fields')
def custom_fields_by_type(entity_type):
"""Get custom fields for a specific entity type"""
if entity_type not in ['device', 'subnet']:
abort(404)
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute('''
SELECT id, entity_type, name, field_key, field_type, required,
default_value, help_text, display_order, validation_rules, searchable
FROM CustomFieldDefinition
WHERE entity_type = %s
ORDER BY display_order, name
''', (entity_type,))
fields = cursor.fetchall()
return jsonify({'fields': fields})
@app.route('/audit')
@permission_required('view_audit')
def audit():
@@ -2307,6 +2756,150 @@ def register_routes(app, limiter=None):
logging.info(f"User {user_name} updated description for device {device_id}.")
return redirect(url_for('device', device_id=device_id))
@app.route('/device/<int:device_id>/update_custom_fields', methods=['POST'])
@permission_required('edit_device')
def update_device_custom_fields(device_id):
"""Update custom field values for a device"""
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor(dictionary=True)
# Get all field definitions for devices
cursor.execute('''
SELECT id, field_key, field_type, required, validation_rules
FROM CustomFieldDefinition
WHERE entity_type = 'device'
''')
field_defs = {f['field_key']: f for f in cursor.fetchall()}
# Get current custom fields
cursor.execute('SELECT custom_fields FROM Device WHERE id = %s', (device_id,))
result = cursor.fetchone()
current_values = {}
if result and result.get('custom_fields'):
try:
current_values = json.loads(result['custom_fields'])
except (json.JSONDecodeError, TypeError):
current_values = {}
# Process submitted values
new_values = {}
errors = []
for field_key, field_def in field_defs.items():
submitted_value = request.form.get(f'custom_field_{field_key}', '')
# Parse validation rules
validation_rules = field_def.get('validation_rules')
if isinstance(validation_rules, str):
try:
validation_rules = json.loads(validation_rules)
except json.JSONDecodeError:
validation_rules = {}
elif validation_rules is None:
validation_rules = {}
field_def['validation_rules'] = validation_rules
# Validate value
if submitted_value == '' and not field_def.get('required'):
# Optional field left empty - remove from values
continue
is_valid, error_msg = validate_custom_field_value(field_def, submitted_value)
if not is_valid:
errors.append(error_msg)
else:
parsed_value = parse_custom_field_value(field_def['field_type'], submitted_value)
if parsed_value is not None:
new_values[field_key] = parsed_value
if errors:
return jsonify({'error': 'Validation errors', 'errors': errors}), 400
# Update custom_fields JSON
custom_fields_json = json.dumps(new_values)
cursor.execute('UPDATE Device SET custom_fields = %s WHERE id = %s', (custom_fields_json, device_id))
add_audit_log(session['user_id'], 'update_device_custom_fields',
f"Updated custom fields for device {device_id}", conn=conn)
conn.commit()
invalidate_cache_for_device(device_id)
if request.headers.get('Content-Type') == 'application/json':
return jsonify({'success': True, 'message': 'Custom fields updated successfully'})
return redirect(url_for('device', device_id=device_id))
@app.route('/subnet/<int:subnet_id>/update_custom_fields', methods=['POST'])
@permission_required('edit_subnet')
def update_subnet_custom_fields(subnet_id):
"""Update custom field values for a subnet"""
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor(dictionary=True)
# Get all field definitions for subnets
cursor.execute('''
SELECT id, field_key, field_type, required, validation_rules
FROM CustomFieldDefinition
WHERE entity_type = 'subnet'
''')
field_defs = {f['field_key']: f for f in cursor.fetchall()}
# Get current custom fields
cursor.execute('SELECT custom_fields FROM Subnet WHERE id = %s', (subnet_id,))
result = cursor.fetchone()
current_values = {}
if result and result.get('custom_fields'):
try:
current_values = json.loads(result['custom_fields'])
except (json.JSONDecodeError, TypeError):
current_values = {}
# Process submitted values
new_values = {}
errors = []
for field_key, field_def in field_defs.items():
submitted_value = request.form.get(f'custom_field_{field_key}', '')
# Parse validation rules
validation_rules = field_def.get('validation_rules')
if isinstance(validation_rules, str):
try:
validation_rules = json.loads(validation_rules)
except json.JSONDecodeError:
validation_rules = {}
elif validation_rules is None:
validation_rules = {}
field_def['validation_rules'] = validation_rules
# Validate value
if submitted_value == '' and not field_def.get('required'):
# Optional field left empty - remove from values
continue
is_valid, error_msg = validate_custom_field_value(field_def, submitted_value)
if not is_valid:
errors.append(error_msg)
else:
parsed_value = parse_custom_field_value(field_def['field_type'], submitted_value)
if parsed_value is not None:
new_values[field_key] = parsed_value
if errors:
return jsonify({'error': 'Validation errors', 'errors': errors}), 400
# Update custom_fields JSON
custom_fields_json = json.dumps(new_values)
cursor.execute('UPDATE Subnet SET custom_fields = %s WHERE id = %s', (custom_fields_json, subnet_id))
add_audit_log(session['user_id'], 'update_subnet_custom_fields',
f"Updated custom fields for subnet {subnet_id}", conn=conn)
conn.commit()
invalidate_cache_for_subnet(subnet_id)
if request.headers.get('Content-Type') == 'application/json':
return jsonify({'success': True, 'message': 'Custom fields updated successfully'})
return redirect(url_for('subnet', subnet_id=subnet_id))
@app.route('/subnet/<int:subnet_id>/export_csv')
@permission_required('export_subnet_csv')
def export_subnet_csv(subnet_id):
@@ -3010,6 +3603,16 @@ def register_routes(app, limiter=None):
ORDER BY t.name
''', (device['id'],))
device['tags'] = cursor.fetchall()
# Get custom fields
cursor.execute('SELECT custom_fields FROM Device WHERE id = %s', (device['id'],))
cf_result = cursor.fetchone()
if cf_result and cf_result.get('custom_fields'):
try:
device['custom_fields'] = json.loads(cf_result['custom_fields'])
except (json.JSONDecodeError, TypeError):
device['custom_fields'] = {}
else:
device['custom_fields'] = {}
return jsonify({'devices': devices})
@app.route('/api/v1/devices/<int:device_id>', methods=['GET'])
@@ -3045,6 +3648,16 @@ def register_routes(app, limiter=None):
ORDER BY t.name
''', (device_id,))
device['tags'] = cursor.fetchall()
# Get custom fields
cursor.execute('SELECT custom_fields FROM Device WHERE id = %s', (device_id,))
cf_result = cursor.fetchone()
if cf_result and cf_result.get('custom_fields'):
try:
device['custom_fields'] = json.loads(cf_result['custom_fields'])
except (json.JSONDecodeError, TypeError):
device['custom_fields'] = {}
else:
device['custom_fields'] = {}
return jsonify(device)
@app.route('/api/v1/devices', methods=['POST'])
@@ -3267,6 +3880,16 @@ def register_routes(app, limiter=None):
subnet['total_ips'] = stats['total']
subnet['used_ips'] = stats['used']
subnet['available_ips'] = stats['total'] - stats['used']
# Get custom fields
cursor.execute('SELECT custom_fields FROM Subnet WHERE id = %s', (subnet['id'],))
cf_result = cursor.fetchone()
if cf_result and cf_result.get('custom_fields'):
try:
subnet['custom_fields'] = json.loads(cf_result['custom_fields'])
except (json.JSONDecodeError, TypeError):
subnet['custom_fields'] = {}
else:
subnet['custom_fields'] = {}
return jsonify({'subnets': subnets})
@app.route('/api/v1/subnets/<int:subnet_id>', methods=['GET'])
@@ -3290,6 +3913,16 @@ def register_routes(app, limiter=None):
ORDER BY ip.ip
''', (subnet_id,))
subnet['ip_addresses'] = cursor.fetchall()
# Get custom fields
cursor.execute('SELECT custom_fields FROM Subnet WHERE id = %s', (subnet_id,))
cf_result = cursor.fetchone()
if cf_result and cf_result.get('custom_fields'):
try:
subnet['custom_fields'] = json.loads(cf_result['custom_fields'])
except (json.JSONDecodeError, TypeError):
subnet['custom_fields'] = {}
else:
subnet['custom_fields'] = {}
return jsonify(subnet)
@app.route('/api/v1/subnets/<int:subnet_id>/next_free_ip', methods=['GET'])
@@ -3639,6 +4272,151 @@ def register_routes(app, limiter=None):
conn.commit()
return jsonify({'message': 'Device removed from rack successfully', 'rack_device_id': rack_device_id})
# Custom Fields API
@app.route('/api/v1/custom_fields/<entity_type>', methods=['GET'])
@rate_limit("100 per minute")
@api_permission_required('view_custom_fields')
def api_custom_fields_by_type(entity_type):
"""Get custom field definitions for a specific entity type"""
if entity_type not in ['device', 'subnet']:
return jsonify({'error': 'Invalid entity type. Must be "device" or "subnet"'}), 400
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute('''
SELECT id, entity_type, name, field_key, field_type, required,
default_value, help_text, display_order, validation_rules, searchable
FROM CustomFieldDefinition
WHERE entity_type = %s
ORDER BY display_order, name
''', (entity_type,))
fields = cursor.fetchall()
# Parse validation_rules JSON strings
for field in fields:
if field.get('validation_rules'):
try:
field['validation_rules'] = json.loads(field['validation_rules'])
except (json.JSONDecodeError, TypeError):
field['validation_rules'] = {}
return jsonify({'fields': fields})
@app.route('/api/v1/custom_fields', methods=['POST'])
@rate_limit("50 per minute")
@api_permission_required('manage_custom_fields')
def api_add_custom_field():
"""Create a new custom field definition"""
data = request.get_json()
if not data:
return jsonify({'error': 'Request body is required'}), 400
required_fields = ['entity_type', 'name', 'field_key', 'field_type']
for field in required_fields:
if field not in data:
return jsonify({'error': f'{field} is required'}), 400
entity_type = data['entity_type']
if entity_type not in ['device', 'subnet']:
return jsonify({'error': 'Invalid entity_type. Must be "device" or "subnet"'}), 400
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor(dictionary=True)
try:
validation_rules = data.get('validation_rules', {})
validation_rules_json = json.dumps(validation_rules) if validation_rules else None
cursor.execute('''
INSERT INTO CustomFieldDefinition
(entity_type, name, field_key, field_type, required, default_value,
help_text, display_order, validation_rules, searchable)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
''', (entity_type, data['name'], data['field_key'], data['field_type'],
data.get('required', False), data.get('default_value'),
data.get('help_text'), data.get('display_order', 0),
validation_rules_json, data.get('searchable', False)))
field_id = cursor.lastrowid
add_audit_log(request.api_user['id'], 'add_custom_field',
f"Added custom field '{data['name']}' for {entity_type}", conn=conn)
conn.commit()
return jsonify({'id': field_id, 'message': 'Custom field created successfully'}), 201
except mysql.connector.IntegrityError:
return jsonify({'error': f'Field key "{data["field_key"]}" already exists'}), 400
@app.route('/api/v1/custom_fields/<int:field_id>', methods=['PUT'])
@rate_limit("50 per minute")
@api_permission_required('manage_custom_fields')
def api_update_custom_field(field_id):
"""Update a custom field definition"""
data = request.get_json()
if not data:
return jsonify({'error': 'Request body is required'}), 400
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute('SELECT id FROM CustomFieldDefinition WHERE id = %s', (field_id,))
if not cursor.fetchone():
return jsonify({'error': 'Custom field not found'}), 404
updates = []
values = []
if 'name' in data:
updates.append('name = %s')
values.append(data['name'])
if 'field_type' in data:
updates.append('field_type = %s')
values.append(data['field_type'])
if 'required' in data:
updates.append('required = %s')
values.append(data['required'])
if 'default_value' in data:
updates.append('default_value = %s')
values.append(data['default_value'])
if 'help_text' in data:
updates.append('help_text = %s')
values.append(data['help_text'])
if 'display_order' in data:
updates.append('display_order = %s')
values.append(data['display_order'])
if 'validation_rules' in data:
validation_rules_json = json.dumps(data['validation_rules']) if data['validation_rules'] else None
updates.append('validation_rules = %s')
values.append(validation_rules_json)
if 'searchable' in data:
updates.append('searchable = %s')
values.append(data['searchable'])
if not updates:
return jsonify({'error': 'No changes to apply'}), 400
values.append(field_id)
cursor.execute(f'UPDATE CustomFieldDefinition SET {", ".join(updates)} WHERE id = %s', values)
add_audit_log(request.api_user['id'], 'edit_custom_field',
f"Updated custom field {field_id}", conn=conn)
conn.commit()
return jsonify({'message': 'Custom field updated successfully'})
@app.route('/api/v1/custom_fields/<int:field_id>', methods=['DELETE'])
@rate_limit("50 per minute")
@api_permission_required('manage_custom_fields')
def api_delete_custom_field(field_id):
"""Delete a custom field definition"""
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute('SELECT name FROM CustomFieldDefinition WHERE id = %s', (field_id,))
field = cursor.fetchone()
if not field:
return jsonify({'error': 'Custom field not found'}), 404
cursor.execute('DELETE FROM CustomFieldDefinition WHERE id = %s', (field_id,))
add_audit_log(request.api_user['id'], 'delete_custom_field',
f"Deleted custom field '{field['name']}'", conn=conn)
conn.commit()
return jsonify({'message': 'Custom field deleted successfully'})
# Device Types API
@app.route('/api/v1/device-types', methods=['GET'])
@rate_limit("100 per minute")
+12 -4
View File
@@ -1,12 +1,20 @@
function showTab(tabName) {
// Hide all panels
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.add('hidden'));
// Remove active class from all tabs
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
// Update all tab buttons to inactive state
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
btn.classList.add('border-transparent', 'text-gray-500');
});
// Show selected panel
document.getElementById('panel-' + tabName).classList.remove('hidden');
// Add active class to selected tab
document.getElementById('tab-' + tabName).classList.add('active');
// Update selected tab to active state
const activeTab = document.getElementById('tab-' + tabName);
activeTab.classList.remove('border-transparent', 'text-gray-500');
activeTab.classList.add('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
}
document.addEventListener('DOMContentLoaded', function() {
+1 -1
View File
@@ -1 +1 @@
function showTab(e){document.querySelectorAll(".tab-panel").forEach(e=>e.classList.add("hidden")),document.querySelectorAll(".tab-btn").forEach(e=>e.classList.remove("active")),document.getElementById("panel-"+e).classList.remove("hidden"),document.getElementById("tab-"+e).classList.add("active")}document.addEventListener("DOMContentLoaded",function(){document.getElementById("bulk-ip-select")?.addEventListener("change",function(){document.getElementById("selected-ip-count").textContent=this.selectedOptions.length}),document.getElementById("bulk-tag-device-select")?.addEventListener("change",function(){document.getElementById("selected-tag-device-count").textContent=this.selectedOptions.length}),document.getElementById("bulk-subnet-select")?.addEventListener("change",function(){let e=this.value,t=document.getElementById("bulk-ip-select");if(!e){t.innerHTML='<option value="" disabled>Select a subnet first...</option>',document.getElementById("selected-ip-count").textContent="0";return}t.innerHTML='<option value="" disabled>Loading...</option>',fetch(`/get_available_ips?subnet_id=${e}`).then(e=>e.json()).then(e=>{t.innerHTML="",0===e.available_ips.length?t.innerHTML='<option value="" disabled>No available IPs in this subnet</option>':e.available_ips.forEach(e=>{let s=document.createElement("option");s.value=e.id,s.textContent=e.ip,t.appendChild(s)}),document.getElementById("selected-ip-count").textContent="0"}).catch(()=>{t.innerHTML='<option value="" disabled>Error loading IPs</option>'})}),document.getElementById("bulk-assign-ips-form")?.addEventListener("submit",function(e){e.preventDefault();let t=new FormData(this),s=document.getElementById("assign-ips-result");s.classList.remove("hidden"),s.innerHTML='<p class="text-blue-500">Processing...</p>',fetch("/bulk/assign_ips",{method:"POST",body:t}).then(e=>e.json()).then(e=>{let t='<div class="space-y-2">';if(e.success.length>0&&(t+=`<div class="text-green-600 dark:text-green-400"><strong>Successfully assigned ${e.success.length} IP(s):</strong><ul class="list-disc list-inside mt-2">`,e.success.forEach(e=>{t+=`<li>${e.ip}</li>`}),t+="</ul></div>"),e.failed.length>0&&(t+=`<div class="text-red-600 dark:text-red-400"><strong>Failed ${e.failed.length} assignment(s):</strong><ul class="list-disc list-inside mt-2">`,e.failed.forEach(e=>{let s=e.ip?` (${e.ip})`:"";t+=`<li>IP ID ${e.ip_id}${s}: ${e.reason}</li>`}),t+="</ul></div>"),t+="</div>",s.innerHTML=t,e.success.length>0){let n=document.getElementById("bulk-subnet-select");n.value&&n.dispatchEvent(new Event("change"))}}).catch(e=>{s.innerHTML=`<p class="text-red-600">Error: ${e.message}</p>`})}),document.getElementById("bulk-create-devices-form")?.addEventListener("submit",function(e){e.preventDefault();let t=new FormData(this),s=document.getElementById("create-devices-result");s.classList.remove("hidden"),s.innerHTML='<p class="text-blue-500">Processing...</p>',fetch("/bulk/create_devices",{method:"POST",body:t}).then(e=>e.json()).then(e=>{let t='<div class="space-y-2">';e.success.length>0&&(t+=`<div class="text-green-600 dark:text-green-400"><strong>Successfully created ${e.success.length} device(s):</strong><ul class="list-disc list-inside mt-2">`,e.success.forEach(e=>{t+=`<li>${e.name}</li>`}),t+="</ul></div>"),e.failed.length>0&&(t+=`<div class="text-red-600 dark:text-red-400"><strong>Failed ${e.failed.length} creation(s):</strong><ul class="list-disc list-inside mt-2">`,e.failed.forEach(e=>{t+=`<li>${e.name}: ${e.reason}</li>`}),t+="</ul></div>"),t+="</div>",s.innerHTML=t,e.success.length>0&&setTimeout(()=>window.location.reload(),2e3)}).catch(e=>{s.innerHTML=`<p class="text-red-600">Error: ${e.message}</p>`})}),document.getElementById("bulk-assign-tags-form")?.addEventListener("submit",function(e){e.preventDefault();let t=new FormData(this),s=document.getElementById("assign-tags-result");s.classList.remove("hidden"),s.innerHTML='<p class="text-blue-500">Processing...</p>',fetch("/bulk/assign_tags",{method:"POST",body:t}).then(e=>e.json()).then(e=>{let t='<div class="space-y-2">';e.success.length>0&&(t+=`<div class="text-green-600 dark:text-green-400"><strong>Successfully assigned ${e.success.length} tag(s):</strong><ul class="list-disc list-inside mt-2">`,e.success.forEach(e=>{t+=`<li>${e.device_name}: ${e.tag_name}</li>`}),t+="</ul></div>"),e.failed.length>0&&(t+=`<div class="text-red-600 dark:text-red-400"><strong>Failed ${e.failed.length} assignment(s):</strong><ul class="list-disc list-inside mt-2">`,e.failed.forEach(e=>{t+=`<li>Device ID ${e.device_id}, Tag ID ${e.tag_id}: ${e.reason}</li>`}),t+="</ul></div>"),t+="</div>",s.innerHTML=t}).catch(e=>{s.innerHTML=`<p class="text-red-600">Error: ${e.message}</p>`})})});
function showTab(e){document.querySelectorAll(".tab-panel").forEach(e=>e.classList.add("hidden")),document.querySelectorAll(".tab-btn").forEach(e=>{e.classList.remove("border-gray-600","text-gray-900","dark:text-gray-100"),e.classList.add("border-transparent","text-gray-500")}),document.getElementById("panel-"+e).classList.remove("hidden");let t=document.getElementById("tab-"+e);t.classList.remove("border-transparent","text-gray-500"),t.classList.add("border-gray-600","text-gray-900","dark:text-gray-100")}document.addEventListener("DOMContentLoaded",function(){document.getElementById("bulk-ip-select")?.addEventListener("change",function(){document.getElementById("selected-ip-count").textContent=this.selectedOptions.length}),document.getElementById("bulk-tag-device-select")?.addEventListener("change",function(){document.getElementById("selected-tag-device-count").textContent=this.selectedOptions.length}),document.getElementById("bulk-subnet-select")?.addEventListener("change",function(){let e=this.value,t=document.getElementById("bulk-ip-select");if(!e){t.innerHTML='<option value="" disabled>Select a subnet first...</option>',document.getElementById("selected-ip-count").textContent="0";return}t.innerHTML='<option value="" disabled>Loading...</option>',fetch(`/get_available_ips?subnet_id=${e}`).then(e=>e.json()).then(e=>{t.innerHTML="",0===e.available_ips.length?t.innerHTML='<option value="" disabled>No available IPs in this subnet</option>':e.available_ips.forEach(e=>{let s=document.createElement("option");s.value=e.id,s.textContent=e.ip,t.appendChild(s)}),document.getElementById("selected-ip-count").textContent="0"}).catch(()=>{t.innerHTML='<option value="" disabled>Error loading IPs</option>'})}),document.getElementById("bulk-assign-ips-form")?.addEventListener("submit",function(e){e.preventDefault();let t=new FormData(this),s=document.getElementById("assign-ips-result");s.classList.remove("hidden"),s.innerHTML='<p class="text-blue-500">Processing...</p>',fetch("/bulk/assign_ips",{method:"POST",body:t}).then(e=>e.json()).then(e=>{let t='<div class="space-y-2">';if(e.success.length>0&&(t+=`<div class="text-green-600 dark:text-green-400"><strong>Successfully assigned ${e.success.length} IP(s):</strong><ul class="list-disc list-inside mt-2">`,e.success.forEach(e=>{t+=`<li>${e.ip}</li>`}),t+="</ul></div>"),e.failed.length>0&&(t+=`<div class="text-red-600 dark:text-red-400"><strong>Failed ${e.failed.length} assignment(s):</strong><ul class="list-disc list-inside mt-2">`,e.failed.forEach(e=>{let s=e.ip?` (${e.ip})`:"";t+=`<li>IP ID ${e.ip_id}${s}: ${e.reason}</li>`}),t+="</ul></div>"),t+="</div>",s.innerHTML=t,e.success.length>0){let n=document.getElementById("bulk-subnet-select");n.value&&n.dispatchEvent(new Event("change"))}}).catch(e=>{s.innerHTML=`<p class="text-red-600">Error: ${e.message}</p>`})}),document.getElementById("bulk-create-devices-form")?.addEventListener("submit",function(e){e.preventDefault();let t=new FormData(this),s=document.getElementById("create-devices-result");s.classList.remove("hidden"),s.innerHTML='<p class="text-blue-500">Processing...</p>',fetch("/bulk/create_devices",{method:"POST",body:t}).then(e=>e.json()).then(e=>{let t='<div class="space-y-2">';e.success.length>0&&(t+=`<div class="text-green-600 dark:text-green-400"><strong>Successfully created ${e.success.length} device(s):</strong><ul class="list-disc list-inside mt-2">`,e.success.forEach(e=>{t+=`<li>${e.name}</li>`}),t+="</ul></div>"),e.failed.length>0&&(t+=`<div class="text-red-600 dark:text-red-400"><strong>Failed ${e.failed.length} creation(s):</strong><ul class="list-disc list-inside mt-2">`,e.failed.forEach(e=>{t+=`<li>${e.name}: ${e.reason}</li>`}),t+="</ul></div>"),t+="</div>",s.innerHTML=t,e.success.length>0&&setTimeout(()=>window.location.reload(),2e3)}).catch(e=>{s.innerHTML=`<p class="text-red-600">Error: ${e.message}</p>`})}),document.getElementById("bulk-assign-tags-form")?.addEventListener("submit",function(e){e.preventDefault();let t=new FormData(this),s=document.getElementById("assign-tags-result");s.classList.remove("hidden"),s.innerHTML='<p class="text-blue-500">Processing...</p>',fetch("/bulk/assign_tags",{method:"POST",body:t}).then(e=>e.json()).then(e=>{let t='<div class="space-y-2">';e.success.length>0&&(t+=`<div class="text-green-600 dark:text-green-400"><strong>Successfully assigned ${e.success.length} tag(s):</strong><ul class="list-disc list-inside mt-2">`,e.success.forEach(e=>{t+=`<li>${e.device_name}: ${e.tag_name}</li>`}),t+="</ul></div>"),e.failed.length>0&&(t+=`<div class="text-red-600 dark:text-red-400"><strong>Failed ${e.failed.length} assignment(s):</strong><ul class="list-disc list-inside mt-2">`,e.failed.forEach(e=>{t+=`<li>Device ID ${e.device_id}, Tag ID ${e.tag_id}: ${e.reason}</li>`}),t+="</ul></div>"),t+="</div>",s.innerHTML=t}).catch(e=>{s.innerHTML=`<p class="text-red-600">Error: ${e.message}</p>`})})});
+328
View File
@@ -0,0 +1,328 @@
// Custom Fields Management JavaScript
// Get initial tab from URL parameter or default to 'device'
const urlParams = new URLSearchParams(window.location.search);
let currentTab = urlParams.get('tab') || 'device';
if (currentTab !== 'device' && currentTab !== 'subnet') {
currentTab = 'device';
}
// Switch to the correct tab on page load
if (currentTab === 'subnet') {
switchTab('subnet');
} else {
// Ensure device tab is active on load
switchTab('device');
}
// Function to get current active tab
function getCurrentTab() {
return currentTab;
}
let fieldData = {};
// Tab switching
function switchTab(entityType) {
currentTab = entityType;
// Update tab buttons
document.getElementById('tab-device').classList.remove('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
document.getElementById('tab-device').classList.add('border-transparent', 'text-gray-500');
document.getElementById('tab-subnet').classList.remove('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
document.getElementById('tab-subnet').classList.add('border-transparent', 'text-gray-500');
if (entityType === 'device') {
document.getElementById('tab-device').classList.remove('border-transparent', 'text-gray-500');
document.getElementById('tab-device').classList.add('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
document.getElementById('device-fields-tab').classList.remove('hidden');
document.getElementById('subnet-fields-tab').classList.add('hidden');
} else {
document.getElementById('tab-subnet').classList.remove('border-transparent', 'text-gray-500');
document.getElementById('tab-subnet').classList.add('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
document.getElementById('device-fields-tab').classList.add('hidden');
document.getElementById('subnet-fields-tab').classList.remove('hidden');
}
// Update URL without reloading page
const newUrl = new URL(window.location);
newUrl.searchParams.set('tab', entityType);
window.history.pushState({}, '', newUrl);
}
// Show add field modal
function showAddFieldModal(entityType) {
// Determine the target entity type - prioritize explicit parameter, then read from DOM
let targetEntityType = entityType;
if (!targetEntityType) {
// Read from active tab button - check which tab has the active styling
const deviceTab = document.getElementById('tab-device');
const subnetTab = document.getElementById('tab-subnet');
if (deviceTab && deviceTab.classList.contains('border-gray-600')) {
targetEntityType = 'device';
} else if (subnetTab && subnetTab.classList.contains('border-gray-600')) {
targetEntityType = 'subnet';
} else {
// Fallback to currentTab variable
targetEntityType = currentTab || 'device';
}
}
// Ensure targetEntityType is valid
if (targetEntityType !== 'device' && targetEntityType !== 'subnet') {
targetEntityType = 'device';
}
// Ensure we're on the correct tab
if (targetEntityType !== currentTab) {
switchTab(targetEntityType);
}
document.getElementById('modal-title').textContent = 'Add Custom Field';
document.getElementById('form-action').value = 'add_field';
document.getElementById('form-field-id').value = '';
// Always set entity_type explicitly - double check it's set
const entityTypeInput = document.getElementById('form-entity-type');
entityTypeInput.value = targetEntityType;
// Debug: log to verify
console.log('Opening modal for entity type:', targetEntityType, 'currentTab:', currentTab, 'input value:', entityTypeInput.value);
// Reset form
document.getElementById('field-name').value = '';
document.getElementById('field-key').value = '';
document.getElementById('field-type').value = 'text';
document.getElementById('field-required').checked = false;
document.getElementById('field-default-value').value = '';
document.getElementById('field-help-text').value = '';
document.getElementById('field-display-order').value = '0';
document.getElementById('field-searchable').checked = false;
// Reset validation fields
document.getElementById('field-min-length').value = '';
document.getElementById('field-max-length').value = '';
document.getElementById('field-regex-pattern').value = '';
document.getElementById('field-min-value').value = '';
document.getElementById('field-max-value').value = '';
document.getElementById('field-select-options').value = '';
updateFieldTypeOptions();
document.getElementById('field-modal').classList.remove('hidden');
}
// Close field modal
function closeFieldModal() {
document.getElementById('field-modal').classList.add('hidden');
}
// Update field type options visibility
function updateFieldTypeOptions() {
const fieldType = document.getElementById('field-type').value;
// Hide all validation sections
document.getElementById('text-validation').classList.add('hidden');
document.getElementById('number-validation').classList.add('hidden');
document.getElementById('select-validation').classList.add('hidden');
// Show relevant validation section
if (fieldType === 'text' || fieldType === 'textarea') {
document.getElementById('text-validation').classList.remove('hidden');
} else if (fieldType === 'number' || fieldType === 'decimal') {
document.getElementById('number-validation').classList.remove('hidden');
} else if (fieldType === 'select') {
document.getElementById('select-validation').classList.remove('hidden');
}
}
// Auto-generate field key from name
function generateFieldKey(name) {
return name.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
// Edit field
function editField(fieldId, entityType) {
// Get field data from embedded JSON
const fieldsDataElement = document.getElementById('fields-data');
if (!fieldsDataElement) {
console.error('Fields data not found');
return;
}
try {
const fieldsData = JSON.parse(fieldsDataElement.textContent);
const fields = fieldsData[entityType] || [];
const field = fields.find(f => f.id === fieldId);
if (field) {
populateEditForm(field, entityType);
} else {
console.error('Field not found:', fieldId, entityType);
}
} catch (error) {
console.error('Error parsing fields data:', error);
}
}
function populateEditForm(field, entityType) {
document.getElementById('modal-title').textContent = 'Edit Custom Field';
document.getElementById('form-action').value = 'edit_field';
document.getElementById('form-field-id').value = field.id;
document.getElementById('form-entity-type').value = entityType;
document.getElementById('field-name').value = field.name || '';
document.getElementById('field-key').value = field.field_key || '';
document.getElementById('field-type').value = field.field_type || 'text';
document.getElementById('field-required').checked = field.required || false;
document.getElementById('field-default-value').value = field.default_value || '';
document.getElementById('field-help-text').value = field.help_text || '';
document.getElementById('field-display-order').value = field.display_order || 0;
document.getElementById('field-searchable').checked = field.searchable || false;
// Parse validation rules
let validationRules = {};
if (field.validation_rules) {
if (typeof field.validation_rules === 'string') {
try {
validationRules = JSON.parse(field.validation_rules);
} catch (e) {
validationRules = {};
}
} else {
validationRules = field.validation_rules;
}
}
// Populate validation fields
document.getElementById('field-min-length').value = validationRules.min_length || '';
document.getElementById('field-max-length').value = validationRules.max_length || '';
document.getElementById('field-regex-pattern').value = validationRules.regex_pattern || '';
document.getElementById('field-min-value').value = validationRules.min_value || '';
document.getElementById('field-max-value').value = validationRules.max_value || '';
if (validationRules.select_options) {
document.getElementById('field-select-options').value = validationRules.select_options.join(', ');
} else {
document.getElementById('field-select-options').value = '';
}
updateFieldTypeOptions();
document.getElementById('field-modal').classList.remove('hidden');
}
// Move field up/down
function moveField(entityType, fieldId, direction) {
// Get all fields for this entity type
const tbody = document.getElementById(`${entityType}-fields-tbody`);
const rows = Array.from(tbody.querySelectorAll('tr'));
const currentIndex = rows.findIndex(row => row.dataset.fieldId == fieldId);
if (currentIndex === -1) return;
let targetIndex;
if (direction === 'up' && currentIndex > 0) {
targetIndex = currentIndex - 1;
} else if (direction === 'down' && currentIndex < rows.length - 1) {
targetIndex = currentIndex + 1;
} else {
return;
}
// Swap rows
const currentRow = rows[currentIndex];
const targetRow = rows[targetIndex];
tbody.insertBefore(currentRow, direction === 'up' ? targetRow : targetRow.nextSibling);
// Update display orders and submit
const fieldOrders = {};
Array.from(tbody.querySelectorAll('tr')).forEach((row, index) => {
fieldOrders[row.dataset.fieldId] = index;
});
// Submit reorder
const form = document.createElement('form');
form.method = 'POST';
form.action = '/custom_fields';
const actionInput = document.createElement('input');
actionInput.type = 'hidden';
actionInput.name = 'action';
actionInput.value = 'reorder';
form.appendChild(actionInput);
const entityTypeInput = document.createElement('input');
entityTypeInput.type = 'hidden';
entityTypeInput.name = 'entity_type';
entityTypeInput.value = entityType;
form.appendChild(entityTypeInput);
const ordersInput = document.createElement('input');
ordersInput.type = 'hidden';
ordersInput.name = 'field_orders';
ordersInput.value = JSON.stringify(fieldOrders);
form.appendChild(ordersInput);
document.body.appendChild(form);
form.submit();
}
// Event listeners
document.addEventListener('DOMContentLoaded', function() {
// Auto-generate field key from name
const nameInput = document.getElementById('field-name');
const keyInput = document.getElementById('field-key');
if (nameInput && keyInput) {
nameInput.addEventListener('input', function() {
// Only auto-generate if key is empty or matches previous generated value
if (!keyInput.value || keyInput.dataset.autoGenerated === 'true') {
keyInput.value = generateFieldKey(this.value);
keyInput.dataset.autoGenerated = 'true';
}
});
keyInput.addEventListener('input', function() {
// Mark as manually edited
this.dataset.autoGenerated = 'false';
});
}
// Update field type options when type changes
const fieldTypeSelect = document.getElementById('field-type');
if (fieldTypeSelect) {
fieldTypeSelect.addEventListener('change', updateFieldTypeOptions);
}
// Ensure entity_type is set correctly before form submission
const fieldForm = document.getElementById('field-form');
if (fieldForm) {
fieldForm.addEventListener('submit', function(e) {
const entityTypeInput = document.getElementById('form-entity-type');
// Always ensure entity_type is set to currentTab
// This handles cases where the modal was opened without explicitly setting it
if (!entityTypeInput.value || entityTypeInput.value.trim() === '') {
entityTypeInput.value = currentTab;
console.log('Entity type was empty, setting to:', currentTab);
}
// Double-check it's a valid value
if (entityTypeInput.value !== 'device' && entityTypeInput.value !== 'subnet') {
entityTypeInput.value = currentTab;
console.log('Entity type was invalid, setting to currentTab:', currentTab);
}
console.log('Submitting form with entity_type:', entityTypeInput.value, 'currentTab:', currentTab);
});
}
});
// Close modal when clicking outside
window.onclick = function(event) {
const modal = document.getElementById('field-modal');
if (event.target === modal) {
closeFieldModal();
}
}
File diff suppressed because one or more lines are too long
+12
View File
@@ -54,6 +54,18 @@
<i class="fas fa-chevron-right text-gray-400"></i>
</a>
{% endif %}
{% if has_permission('view_custom_fields') %}
<a href="/custom_fields" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
<div class="flex items-center space-x-4">
<i class="fas fa-list-ul text-3xl text-gray-600 dark:text-gray-400"></i>
<div>
<h3 class="text-lg font-bold">Custom Fields</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Manage custom fields for devices and subnets</p>
</div>
</div>
<i class="fas fa-chevron-right text-gray-400"></i>
</a>
{% endif %}
<a href="/api-docs" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
<div class="flex items-center space-x-4">
<i class="fas fa-code text-3xl text-gray-600 dark:text-gray-400"></i>
+7 -14
View File
@@ -18,11 +18,13 @@
</div>
<!-- Tabs -->
<div class="flex flex-wrap gap-2 mb-6 justify-center border-b border-gray-600">
<button onclick="showTab('assign-ips')" id="tab-assign-ips" class="tab-btn px-4 py-2 rounded-t-lg bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 hover:cursor-pointer active">Bulk IP Assignment</button>
<button onclick="showTab('create-devices')" id="tab-create-devices" class="tab-btn px-4 py-2 rounded-t-lg bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 hover:cursor-pointer">Bulk Device Creation</button>
<button onclick="showTab('assign-tags')" id="tab-assign-tags" class="tab-btn px-4 py-2 rounded-t-lg bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 hover:cursor-pointer">Bulk Tag Assignment</button>
<button onclick="showTab('export')" id="tab-export" class="tab-btn px-4 py-2 rounded-t-lg bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 hover:cursor-pointer">Bulk Export</button>
<div class="mb-6 border-b border-gray-600">
<div class="flex space-x-4">
<button onclick="showTab('assign-ips')" id="tab-assign-ips" class="tab-btn px-4 py-2 font-medium border-b-2 border-gray-600 text-gray-900 dark:text-gray-100 hover:cursor-pointer">Bulk IP Assignment</button>
<button onclick="showTab('create-devices')" id="tab-create-devices" class="tab-btn px-4 py-2 font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:cursor-pointer">Bulk Device Creation</button>
<button onclick="showTab('assign-tags')" id="tab-assign-tags" class="tab-btn px-4 py-2 font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:cursor-pointer">Bulk Tag Assignment</button>
<button onclick="showTab('export')" id="tab-export" class="tab-btn px-4 py-2 font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:cursor-pointer">Bulk Export</button>
</div>
</div>
<!-- Bulk IP Assignment -->
@@ -146,15 +148,6 @@
</div>
<script src="/static/js/bulk_operations.min.js"></script>
<style>
.tab-btn.active {
background-color: rgb(156 163 175);
color: white;
}
.dark .tab-btn.active {
background-color: rgb(63 63 70);
}
</style>
</body>
</html>
+336
View File
@@ -0,0 +1,336 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Custom Fields Management</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 mx-4 py-8 pt-20">
<div class="container max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Custom Fields Management</h1>
{% if can_manage %}
<button onclick="showAddFieldModal()" id="add-field-btn" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg font-medium">
<i class="fas fa-plus mr-2"></i>Add Field
</button>
{% endif %}
</div>
{% if error %}
<div class="bg-red-200 dark:bg-red-800 text-red-800 dark:text-red-200 p-4 rounded-lg mb-6">
{{ error }}
</div>
{% endif %}
<!-- Tabs -->
<div class="mb-6 border-b border-gray-600">
<div class="flex space-x-4">
<button onclick="switchTab('device')" id="tab-device" class="tab-btn px-4 py-2 font-medium border-b-2 {% if active_tab == 'device' %}border-gray-600 text-gray-900 dark:text-gray-100{% else %}border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300{% endif %} hover:cursor-pointer">
Device Fields
</button>
<button onclick="switchTab('subnet')" id="tab-subnet" class="tab-btn px-4 py-2 font-medium border-b-2 {% if active_tab == 'subnet' %}border-gray-600 text-gray-900 dark:text-gray-100{% else %}border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300{% endif %} hover:cursor-pointer">
Subnet Fields
</button>
</div>
</div>
<!-- Device Fields Tab -->
<div id="device-fields-tab" class="tab-content {% if active_tab != 'device' %}hidden{% endif %}">
{% if device_fields %}
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-gray-600">
<th class="text-left p-3">Order</th>
<th class="text-left p-3">Name</th>
<th class="text-left p-3">Field Key</th>
<th class="text-left p-3">Type</th>
<th class="text-center p-3">Required</th>
<th class="text-center p-3">Searchable</th>
<th class="text-center p-3">Actions</th>
</tr>
</thead>
<tbody id="device-fields-tbody">
{% for field in device_fields %}
<tr class="border-b border-gray-600 hover:bg-gray-300 dark:hover:bg-zinc-700" data-field-id="{{ field.id }}">
<td class="p-3">
<div class="flex items-center space-x-2">
<button onclick="moveField('device', {{ field.id }}, 'up')" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" title="Move Up">
<i class="fas fa-arrow-up text-xs"></i>
</button>
<span class="text-sm">{{ field.display_order }}</span>
<button onclick="moveField('device', {{ field.id }}, 'down')" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" title="Move Down">
<i class="fas fa-arrow-down text-xs"></i>
</button>
</div>
</td>
<td class="p-3 font-medium">{{ field.name }}</td>
<td class="p-3 font-mono text-sm">{{ field.field_key }}</td>
<td class="p-3 text-sm">{{ field.field_type }}</td>
<td class="p-3 text-center">
{% if field.required %}
<i class="fas fa-check text-green-500"></i>
{% else %}
<i class="fas fa-times text-gray-400"></i>
{% endif %}
</td>
<td class="p-3 text-center">
{% if field.searchable %}
<i class="fas fa-check text-green-500"></i>
{% else %}
<i class="fas fa-times text-gray-400"></i>
{% endif %}
</td>
<td class="p-3 text-center">
<div class="flex items-center justify-center space-x-2">
{% if can_manage %}
<button onclick="editField({{ field.id }}, 'device')" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Edit Field">
<i class="fas fa-edit"></i>
</button>
<form action="/custom_fields" method="POST" onsubmit="return confirm('Are you sure you want to delete this field? Existing data will be preserved.');" class="inline">
<input type="hidden" name="action" value="delete_field">
<input type="hidden" name="field_id" value="{{ field.id }}">
<button type="submit" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Delete Field">
<i class="fas fa-trash"></i>
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
<div class="text-center py-8 text-gray-500">
<i class="fas fa-list text-4xl mb-4"></i>
<p>No device custom fields defined. Add your first field to get started.</p>
</div>
</div>
{% endif %}
</div>
<!-- Subnet Fields Tab -->
<div id="subnet-fields-tab" class="tab-content {% if active_tab != 'subnet' %}hidden{% endif %}">
{% if subnet_fields %}
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-gray-600">
<th class="text-left p-3">Order</th>
<th class="text-left p-3">Name</th>
<th class="text-left p-3">Field Key</th>
<th class="text-left p-3">Type</th>
<th class="text-center p-3">Required</th>
<th class="text-center p-3">Searchable</th>
<th class="text-center p-3">Actions</th>
</tr>
</thead>
<tbody id="subnet-fields-tbody">
{% for field in subnet_fields %}
<tr class="border-b border-gray-600 hover:bg-gray-300 dark:hover:bg-zinc-700" data-field-id="{{ field.id }}">
<td class="p-3">
<div class="flex items-center space-x-2">
<button onclick="moveField('subnet', {{ field.id }}, 'up')" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" title="Move Up">
<i class="fas fa-arrow-up text-xs"></i>
</button>
<span class="text-sm">{{ field.display_order }}</span>
<button onclick="moveField('subnet', {{ field.id }}, 'down')" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" title="Move Down">
<i class="fas fa-arrow-down text-xs"></i>
</button>
</div>
</td>
<td class="p-3 font-medium">{{ field.name }}</td>
<td class="p-3 font-mono text-sm">{{ field.field_key }}</td>
<td class="p-3 text-sm">{{ field.field_type }}</td>
<td class="p-3 text-center">
{% if field.required %}
<i class="fas fa-check text-green-500"></i>
{% else %}
<i class="fas fa-times text-gray-400"></i>
{% endif %}
</td>
<td class="p-3 text-center">
{% if field.searchable %}
<i class="fas fa-check text-green-500"></i>
{% else %}
<i class="fas fa-times text-gray-400"></i>
{% endif %}
</td>
<td class="p-3 text-center">
<div class="flex items-center justify-center space-x-2">
{% if can_manage %}
<button onclick="editField({{ field.id }}, 'subnet')" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Edit Field">
<i class="fas fa-edit"></i>
</button>
<form action="/custom_fields" method="POST" onsubmit="return confirm('Are you sure you want to delete this field? Existing data will be preserved.');" class="inline">
<input type="hidden" name="action" value="delete_field">
<input type="hidden" name="field_id" value="{{ field.id }}">
<button type="submit" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Delete Field">
<i class="fas fa-trash"></i>
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
<div class="text-center py-8 text-gray-500">
<i class="fas fa-list text-4xl mb-4"></i>
<p>No subnet custom fields defined. Add your first field to get started.</p>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Add/Edit Field Modal -->
<div id="field-modal" class="hidden fixed inset-0 bg-black/30 backdrop-blur-md flex items-center justify-center z-50">
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-lg max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold" id="modal-title">Add Custom Field</h2>
<button onclick="closeFieldModal()" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-times"></i>
</button>
</div>
<form id="field-form" action="/custom_fields" method="POST">
<input type="hidden" name="action" id="form-action" value="add_field">
<input type="hidden" name="field_id" id="form-field-id">
<input type="hidden" name="entity_type" id="form-entity-type">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Field Name *</label>
<input type="text" name="name" id="field-name" placeholder="e.g., Serial Number"
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>
</div>
<div>
<label class="block text-sm font-medium mb-1">Field Key *</label>
<input type="text" name="field_key" id="field-key" placeholder="e.g., serial_number"
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 font-mono text-sm" required>
<p class="text-xs text-gray-500 mt-1">Internal identifier (lowercase, underscores only)</p>
</div>
<div>
<label class="block text-sm font-medium mb-1">Field Type *</label>
<select name="field_type" id="field-type"
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 onchange="updateFieldTypeOptions()">
<option value="text">Text</option>
<option value="textarea">Textarea</option>
<option value="ip_address">IP Address</option>
<option value="date">Date</option>
<option value="datetime">Date & Time</option>
<option value="number">Number (Integer)</option>
<option value="decimal">Decimal/Float</option>
<option value="email">Email</option>
<option value="url">URL</option>
<option value="boolean">Boolean/Checkbox</option>
<option value="select">Select/Dropdown</option>
</select>
</div>
<div class="flex items-center space-x-2">
<input type="checkbox" name="required" id="field-required" class="w-4 h-4">
<label for="field-required" class="text-sm font-medium">Required</label>
</div>
<div>
<label class="block text-sm font-medium mb-1">Default Value</label>
<input type="text" name="default_value" id="field-default-value" placeholder="Default value (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">
</div>
<div>
<label class="block text-sm font-medium mb-1">Help Text</label>
<textarea name="help_text" id="field-help-text" placeholder="Help text/description (optional)" rows="2"
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>
</div>
<div>
<label class="block text-sm font-medium mb-1">Display Order</label>
<input type="number" name="display_order" id="field-display-order" value="0" min="0"
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">
</div>
<div class="flex items-center space-x-2">
<input type="checkbox" name="searchable" id="field-searchable" class="w-4 h-4">
<label for="field-searchable" class="text-sm font-medium">Searchable</label>
</div>
<!-- Validation Rules Section -->
<div id="validation-rules-section" class="border-t border-gray-600 pt-4">
<h3 class="text-lg font-medium mb-3">Validation Rules</h3>
<!-- Text/Textarea validation -->
<div id="text-validation" class="hidden space-y-2">
<div>
<label class="block text-sm font-medium mb-1">Min Length</label>
<input type="number" name="min_length" id="field-min-length" min="0"
class="border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full">
</div>
<div>
<label class="block text-sm font-medium mb-1">Max Length</label>
<input type="number" name="max_length" id="field-max-length" min="1"
class="border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full">
</div>
<div>
<label class="block text-sm font-medium mb-1">Regex Pattern</label>
<input type="text" name="regex_pattern" id="field-regex-pattern" placeholder="^[A-Z].*$"
class="border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full font-mono text-sm">
</div>
</div>
<!-- Number/Decimal validation -->
<div id="number-validation" class="hidden space-y-2">
<div>
<label class="block text-sm font-medium mb-1">Min Value</label>
<input type="number" name="min_value" id="field-min-value" step="any"
class="border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full">
</div>
<div>
<label class="block text-sm font-medium mb-1">Max Value</label>
<input type="number" name="max_value" id="field-max-value" step="any"
class="border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full">
</div>
</div>
<!-- Select validation -->
<div id="select-validation" class="hidden">
<label class="block text-sm font-medium mb-1">Options (comma-separated) *</label>
<input type="text" name="select_options" id="field-select-options" placeholder="option1, option2, option3"
class="border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full">
<p class="text-xs text-gray-500 mt-1">Enter options separated by commas</p>
</div>
</div>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button type="button" onclick="closeFieldModal()"
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>
<button type="submit"
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">Save Field</button>
</div>
</form>
</div>
</div>
<!-- Embed field data for JavaScript -->
<script type="application/json" id="fields-data">
{
"device": {{ device_fields|tojson }},
"subnet": {{ subnet_fields|tojson }}
}
</script>
<script src="/static/js/custom_fields.min.js"></script>
</body>
</html>
+128
View File
@@ -162,6 +162,134 @@
<textarea id="description" name="description" rows="3" class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full resize-y" placeholder="Enter device description...">{{ device.description or '' }}</textarea>
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg w-full mt-2">Save Description</button>
</form>
<!-- Custom Fields Section -->
{% if custom_fields %}
<div class="custom-fields-section mb-6">
<h3 class="text-lg font-bold mb-4">Custom Fields</h3>
{% if can_edit_device %}
<form action="/device/{{ device.id }}/update_custom_fields" method="POST" id="custom-fields-form">
<div class="space-y-4">
{% 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>
</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>
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg w-full mt-4">Save Custom Fields</button>
</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 %}
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
<script src="/static/js/device.min.js"></script>
+133
View File
@@ -38,6 +38,139 @@
</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">
{% 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>
</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>
<div class="flex justify-center mt-4">
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
<i class="fas fa-save"></i>
<span>Save</span>
</button>
</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 %}
</div>
{% endif %}
</div>
{% endif %}
<form action="" method="POST">
<table class="table-auto w-full mb-6">
<thead>