feat: ✨ custom fields by device or subnet
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Vendored
+1
-1
@@ -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>`})})});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user