refactor: 🎨 remove caching #48

Merged
jamie merged 15 commits from v2.0.0 into main 2026-05-23 21:04:45 +01:00
15 changed files with 59 additions and 503 deletions
Showing only changes of commit f01a81e558 - Show all commits
-5
View File
@@ -146,8 +146,6 @@ Subnets must be `/24` or smaller (prefix length ≥ 24).
## Racks ## Racks
Requires the racks feature to be enabled.
| Method | Endpoint | Permission | Description | | Method | Endpoint | Permission | Description |
|--------|----------|------------|-------------| |--------|----------|------------|-------------|
| GET | `/api/v1/racks` | `view_racks` | List all racks | | GET | `/api/v1/racks` | `view_racks` | List all racks |
@@ -161,8 +159,6 @@ Requires the racks feature to be enabled.
## Tags ## Tags
Requires the device tags feature to be enabled.
| Method | Endpoint | Permission | Description | | Method | Endpoint | Permission | Description |
|--------|----------|------------|-------------| |--------|----------|------------|-------------|
| GET | `/api/v1/tags` | `view_tags` | List all tags | | GET | `/api/v1/tags` | `view_tags` | List all tags |
@@ -206,7 +202,6 @@ Requires the device tags feature to be enabled.
"default_value": "", "default_value": "",
"help_text": "Optional help text", "help_text": "Optional help text",
"display_order": 0, "display_order": 0,
"searchable": true,
"validation_rules": { "validation_rules": {
"max_length": 32 "max_length": 32
} }
+17 -276
View File
@@ -16,7 +16,6 @@ from ipaddress import ip_network, ip_address, IPv4Address, IPv6Address
import pyotp import pyotp
import qrcode import qrcode
import requests
import mysql.connector import mysql.connector
from dotenv import load_dotenv from dotenv import load_dotenv
from flask import ( from flask import (
@@ -158,23 +157,6 @@ def permission_required(permission_name):
return decorated_function return decorated_function
return decorator return decorator
def is_feature_enabled(feature_key, conn=None):
"""Check if a feature flag is enabled"""
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()
cursor.execute('SELECT enabled FROM FeatureFlags WHERE feature_key = %s', (feature_key,))
result = cursor.fetchone()
# Default to True if feature flag doesn't exist (backward compatibility)
return result[0] if result else True
finally:
if close_conn:
conn.close()
def get_user_from_api_key(api_key): def get_user_from_api_key(api_key):
"""Get user from API key""" """Get user from API key"""
@@ -546,7 +528,7 @@ def get_custom_fields_for_entity(entity_type, entity_id, conn=None):
# Get field definitions for this entity type # Get field definitions for this entity type
cursor.execute(''' cursor.execute('''
SELECT id, entity_type, name, field_key, field_type, required, SELECT id, entity_type, name, field_key, field_type, required,
default_value, help_text, display_order, validation_rules, searchable default_value, help_text, display_order, validation_rules
FROM CustomFieldDefinition FROM CustomFieldDefinition
WHERE entity_type = %s WHERE entity_type = %s
ORDER BY display_order, name ORDER BY display_order, name
@@ -1313,13 +1295,12 @@ def devices():
tag_filter = request.args.get('tag') tag_filter = request.args.get('tag')
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
# Check if device tags feature is enabled tag_filter = request.args.get('tag')
tags_enabled = is_feature_enabled('device_tags', conn=conn)
cursor = conn.cursor() cursor = conn.cursor()
# Base device query # Base device query
if tag_filter and tags_enabled: if tag_filter:
cursor.execute(''' cursor.execute('''
SELECT DISTINCT d.id, d.name, dt.icon_class SELECT DISTINCT d.id, d.name, dt.icon_class
FROM Device d FROM Device d
@@ -1341,10 +1322,9 @@ def devices():
for row in cursor.fetchall(): for row in cursor.fetchall():
device_ips.setdefault(row[0], []).append((row[1], row[2])) device_ips.setdefault(row[0], []).append((row[1], row[2]))
# Get tags for each device (only if tags feature is enabled) # Get tags for each device
device_tags = {} device_tags = {}
all_tag_names = [] all_tag_names = []
if tags_enabled:
for device in devices: for device in devices:
cursor.execute(''' cursor.execute('''
SELECT t.id, t.name, t.color SELECT t.id, t.name, t.color
@@ -1355,7 +1335,6 @@ def devices():
''', (device[0],)) ''', (device[0],))
device_tags[device[0]] = [{'id': row[0], 'name': row[1], 'color': row[2]} for row in cursor.fetchall()] device_tags[device[0]] = [{'id': row[0], 'name': row[1], 'color': row[2]} for row in cursor.fetchall()]
# Get all available tags for filtering
cursor.execute('SELECT DISTINCT name FROM Tag ORDER BY name') cursor.execute('SELECT DISTINCT name FROM Tag ORDER BY name')
all_tag_names = [row[0] for row in cursor.fetchall()] all_tag_names = [row[0] for row in cursor.fetchall()]
@@ -1424,11 +1403,7 @@ def device(device_id):
cursor.execute('''SELECT DeviceIPAddress.id as device_ip_id, IPAddress.ip FROM DeviceIPAddress JOIN IPAddress ON DeviceIPAddress.ip_id = IPAddress.id WHERE DeviceIPAddress.device_id = %s''', (device_id,)) cursor.execute('''SELECT DeviceIPAddress.id as device_ip_id, IPAddress.ip FROM DeviceIPAddress JOIN IPAddress ON DeviceIPAddress.ip_id = IPAddress.id WHERE DeviceIPAddress.device_id = %s''', (device_id,))
device_ips = [{'device_ip_id': row[0], 'ip': row[1]} for row in cursor.fetchall()] device_ips = [{'device_ip_id': row[0], 'ip': row[1]} for row in cursor.fetchall()]
# Get device tags (only if tags feature is enabled) # Get device tags
tags_enabled = is_feature_enabled('device_tags', conn=conn)
device_tags = []
all_tags = []
if tags_enabled:
cursor.execute(''' cursor.execute('''
SELECT t.id, t.name, t.color SELECT t.id, t.name, t.color
FROM DeviceTag dt FROM DeviceTag dt
@@ -1438,7 +1413,6 @@ def device(device_id):
''', (device_id,)) ''', (device_id,))
device_tags = [{'id': row[0], 'name': row[1], 'color': row[2]} for row in cursor.fetchall()] device_tags = [{'id': row[0], 'name': row[1], 'color': row[2]} for row in cursor.fetchall()]
# Get all available tags
cursor.execute('SELECT id, name, color FROM Tag ORDER BY name') cursor.execute('SELECT id, name, color FROM Tag ORDER BY name')
all_tags = [{'id': row[0], 'name': row[1], 'color': row[2]} for row in cursor.fetchall()] all_tags = [{'id': row[0], 'name': row[1], 'color': row[2]} for row in cursor.fetchall()]
available_ips_by_subnet = {} available_ips_by_subnet = {}
@@ -1548,8 +1522,6 @@ def device_delete_ip(device_id):
def device_assign_tag(device_id): def device_assign_tag(device_id):
tag_id = request.form['tag_id'] tag_id = request.form['tag_id']
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
if not is_feature_enabled('device_tags', conn=conn):
abort(404)
try: try:
assign_tag_to_device(conn, device_id, tag_id, session['user_id']) assign_tag_to_device(conn, device_id, tag_id, session['user_id'])
conn.commit() conn.commit()
@@ -1562,8 +1534,6 @@ def device_assign_tag(device_id):
def device_remove_tag(device_id): def device_remove_tag(device_id):
tag_id = request.form['tag_id'] tag_id = request.form['tag_id']
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
if not is_feature_enabled('device_tags', conn=conn):
abort(404)
try: try:
remove_tag_from_device(conn, device_id, tag_id, session['user_id']) remove_tag_from_device(conn, device_id, tag_id, session['user_id'])
conn.commit() conn.commit()
@@ -1614,15 +1584,11 @@ def subnet(subnet_id):
'vlan_description': subnet[4] if len(subnet) > 4 else None, 'vlan_description': subnet[4] if len(subnet) > 4 else None,
'vlan_notes': subnet[5] if len(subnet) > 5 else None 'vlan_notes': subnet[5] if len(subnet) > 5 else None
} }
# Check if IP address notes feature is enabled
ip_notes_enabled = is_feature_enabled('ip_address_notes', conn=conn)
return render_with_user('subnet.html', subnet=subnet_dict, return render_with_user('subnet.html', subnet=subnet_dict,
ip_addresses=ip_addresses_with_device, ip_addresses=ip_addresses_with_device,
utilization=utilization_stats, utilization=utilization_stats,
custom_fields=custom_fields, custom_fields=custom_fields,
can_edit_subnet=has_permission('edit_subnet'), can_edit_subnet=has_permission('edit_subnet'))
ip_notes_enabled=ip_notes_enabled)
@app.route('/add_subnet', methods=['POST']) @app.route('/add_subnet', methods=['POST'])
@permission_required('add_subnet') @permission_required('add_subnet')
@@ -1750,49 +1716,14 @@ def admin():
} }
}) })
# Get feature flags (inside the connection context)
cursor.execute('SELECT feature_key, enabled, description FROM FeatureFlags ORDER BY feature_key')
feature_flags = []
for row in cursor.fetchall():
feature_flags.append({
'key': row[0],
'enabled': bool(row[1]),
'description': row[2]
})
result_data = { result_data = {
'subnets': subnets, 'subnets': subnets,
'can_add_subnet': has_permission('add_subnet'), 'can_add_subnet': has_permission('add_subnet'),
'can_edit_subnet': has_permission('edit_subnet'), 'can_edit_subnet': has_permission('edit_subnet'),
'can_delete_subnet': has_permission('delete_subnet'), 'can_delete_subnet': has_permission('delete_subnet'),
'feature_flags': feature_flags
} }
return render_with_user('admin.html', **result_data) return render_with_user('admin.html', **result_data)
@app.route('/admin/feature_flags', methods=['POST'])
@permission_required('manage_users')
def update_feature_flags():
"""Update feature flags"""
user_name = get_current_user_name()
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
# Get all feature flags
cursor.execute('SELECT feature_key FROM FeatureFlags')
all_features = [row[0] for row in cursor.fetchall()]
# Update each feature flag based on form data
for feature_key in all_features:
enabled = request.form.get(f'feature_{feature_key}') == 'on'
cursor.execute('UPDATE FeatureFlags SET enabled = %s WHERE feature_key = %s',
(enabled, feature_key))
logging.info(f"User {user_name} {'enabled' if enabled else 'disabled'} feature '{feature_key}'")
conn.commit()
# Clear admin cache to refresh feature flags
return redirect(url_for('admin'))
@app.route('/account', methods=['GET']) @app.route('/account', methods=['GET'])
@login_required @login_required
@@ -2066,9 +1997,6 @@ def users():
def tags(): def tags():
from flask import current_app from flask import current_app
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
# Check if device tags feature is enabled
if not is_feature_enabled('device_tags', conn=conn):
abort(404)
cursor = conn.cursor() cursor = conn.cursor()
error = None error = None
@@ -2158,15 +2086,8 @@ def custom_fields():
error = 'You do not have permission to add custom fields.' error = 'You do not have permission to add custom fields.'
else: else:
entity_type = request.form.get('entity_type', '').strip() 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']: if not entity_type or entity_type not in ['device', 'subnet']:
# Try to get from form data directly error = 'Invalid entity type. Must be "device" or "subnet".'
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: else:
name = request.form['name'].strip() name = request.form['name'].strip()
field_key = request.form.get('field_key', '').strip() field_key = request.form.get('field_key', '').strip()
@@ -2175,7 +2096,6 @@ def custom_fields():
default_value = request.form.get('default_value', '').strip() default_value = request.form.get('default_value', '').strip()
help_text = request.form.get('help_text', '').strip() help_text = request.form.get('help_text', '').strip()
display_order = int(request.form.get('display_order', 0)) display_order = int(request.form.get('display_order', 0))
searchable = 'searchable' in request.form
# Generate field_key from name if not provided # Generate field_key from name if not provided
if not field_key: if not field_key:
@@ -2193,10 +2113,10 @@ def custom_fields():
cursor.execute(''' cursor.execute('''
INSERT INTO CustomFieldDefinition INSERT INTO CustomFieldDefinition
(entity_type, name, field_key, field_type, required, default_value, (entity_type, name, field_key, field_type, required, default_value,
help_text, display_order, validation_rules, searchable) help_text, display_order, validation_rules)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
''', (entity_type, name, field_key, field_type, required, default_value, ''', (entity_type, name, field_key, field_type, required, default_value,
help_text, display_order, validation_rules_json, searchable)) help_text, display_order, validation_rules_json))
add_audit_log(session['user_id'], 'add_custom_field', add_audit_log(session['user_id'], 'add_custom_field',
f"Added custom field '{name}' for {entity_type}", conn=conn) f"Added custom field '{name}' for {entity_type}", conn=conn)
conn.commit() conn.commit()
@@ -2216,7 +2136,6 @@ def custom_fields():
default_value = request.form.get('default_value', '').strip() default_value = request.form.get('default_value', '').strip()
help_text = request.form.get('help_text', '').strip() help_text = request.form.get('help_text', '').strip()
display_order = int(request.form.get('display_order', 0)) display_order = int(request.form.get('display_order', 0))
searchable = 'searchable' in request.form
# Build validation_rules JSON # Build validation_rules JSON
validation_rules_json = build_validation_rules_from_form(request.form, field_type) validation_rules_json = build_validation_rules_from_form(request.form, field_type)
@@ -2232,10 +2151,10 @@ def custom_fields():
cursor.execute(''' cursor.execute('''
UPDATE CustomFieldDefinition UPDATE CustomFieldDefinition
SET name = %s, field_type = %s, required = %s, default_value = %s, SET name = %s, field_type = %s, required = %s, default_value = %s,
help_text = %s, display_order = %s, validation_rules = %s, searchable = %s help_text = %s, display_order = %s, validation_rules = %s
WHERE id = %s WHERE id = %s
''', (name, field_type, required, default_value, help_text, ''', (name, field_type, required, default_value, help_text,
display_order, validation_rules_json, searchable, field_id)) display_order, validation_rules_json, field_id))
add_audit_log(session['user_id'], 'edit_custom_field', add_audit_log(session['user_id'], 'edit_custom_field',
f"Updated custom field '{name}'", conn=conn) f"Updated custom field '{name}'", conn=conn)
conn.commit() conn.commit()
@@ -2274,7 +2193,7 @@ def custom_fields():
# Get all custom fields grouped by entity type # Get all custom fields grouped by entity type
cursor.execute(''' cursor.execute('''
SELECT id, entity_type, name, field_key, field_type, required, SELECT id, entity_type, name, field_key, field_type, required,
default_value, help_text, display_order, validation_rules, searchable default_value, help_text, display_order, validation_rules
FROM CustomFieldDefinition FROM CustomFieldDefinition
ORDER BY entity_type, display_order, name ORDER BY entity_type, display_order, name
''') ''')
@@ -2305,26 +2224,6 @@ def custom_fields():
can_manage=has_permission('manage_custom_fields'), can_manage=has_permission('manage_custom_fields'),
active_tab=active_tab) 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') @app.route('/audit')
@permission_required('view_audit') @permission_required('view_audit')
@@ -2370,60 +2269,6 @@ def export_audit_csv():
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
return csv_attachment(logs, ['User', 'Action', 'Details', 'Subnet', 'Timestamp'], f'audit_logs_{timestamp}.csv') return csv_attachment(logs, ['User', 'Action', 'Details', 'Subnet', 'Timestamp'], f'audit_logs_{timestamp}.csv')
@app.route('/check_update')
@login_required
def check_update():
"""Check for available updates from Gitea"""
try:
# Get current version from environment
current_version = os.environ.get('VERSION', 'unknown').lstrip('v')
# Fetch latest release from Gitea
response = requests.get('https://git.jdbnet.co.uk/api/v1/repos/jamie/ipam/releases/latest', timeout=5)
if response.status_code != 200:
return jsonify({'error': 'Failed to fetch release information'}), 500
release_data = response.json()
latest_version = release_data.get('tag_name', '').lstrip('v')
# Compare versions using semantic versioning
result = {'update_available': False}
if latest_version and latest_version != current_version:
# Simple semantic version comparison
def version_tuple(v):
"""Convert version string to tuple for comparison"""
parts = v.split('.')
return tuple(int(x) if x.isdigit() else 0 for x in parts[:3])
try:
current_tuple = version_tuple(current_version)
latest_tuple = version_tuple(latest_version)
# Only show update if latest is actually newer
if latest_tuple > current_tuple:
result = {
'update_available': True,
'current_version': current_version,
'latest_version': latest_version,
'release_url': release_data.get('html_url', '')
}
except (ValueError, AttributeError):
# Fallback to string comparison if parsing fails
if latest_version != current_version:
result = {
'update_available': True,
'current_version': current_version,
'latest_version': latest_version,
'release_url': release_data.get('html_url', '')
}
return jsonify(result)
except requests.RequestException as e:
logging.error(f"Error checking for updates: {e}")
return jsonify({'error': 'Failed to check for updates'}), 500
except Exception as e:
logging.error(f"Unexpected error checking for updates: {e}")
return jsonify({'error': 'Failed to check for updates'}), 500
@app.route('/get_available_ips') @app.route('/get_available_ips')
@permission_required('view_device') @permission_required('view_device')
@@ -2756,9 +2601,6 @@ def devices_by_tag(tag_id):
def racks(): def racks():
from flask import current_app from flask import current_app
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
# Check if racks feature is enabled
if not is_feature_enabled('racks', conn=conn):
abort(404)
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
cursor.execute('SELECT * FROM Rack') cursor.execute('SELECT * FROM Rack')
racks = cursor.fetchall() racks = cursor.fetchall()
@@ -2778,10 +2620,6 @@ def racks():
@permission_required('add_rack') @permission_required('add_rack')
def add_rack(): def add_rack():
from flask import current_app from flask import current_app
with get_db_connection(current_app) as conn:
# Check if racks feature is enabled
if not is_feature_enabled('racks', conn=conn):
abort(404)
if request.method == 'POST': if request.method == 'POST':
name = request.form['name'] name = request.form['name']
site = request.form['site'] site = request.form['site']
@@ -2802,8 +2640,6 @@ def add_rack():
def rack(rack_id): def rack(rack_id):
side = request.args.get('side', 'front') side = request.args.get('side', 'front')
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
if not is_feature_enabled('racks', conn=conn):
abort(404)
return render_rack_page(conn, rack_id, side) return render_rack_page(conn, rack_id, side)
@app.route('/rack/<int:rack_id>/add_device', methods=['POST']) @app.route('/rack/<int:rack_id>/add_device', methods=['POST'])
@@ -2814,8 +2650,6 @@ def rack_add_device(rack_id):
side = request.form['side'] side = request.form['side']
user_name = get_current_user_name() user_name = get_current_user_name()
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
if not is_feature_enabled('racks', conn=conn):
abort(404)
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
_, placement_error = check_rack_placement(cursor, rack_id, position_u, side) _, placement_error = check_rack_placement(cursor, rack_id, position_u, side)
if placement_error == 'Rack not found': if placement_error == 'Rack not found':
@@ -2848,8 +2682,6 @@ def rack_add_nonnet_device(rack_id):
side = request.form['side'] side = request.form['side']
user_name = get_current_user_name() user_name = get_current_user_name()
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
if not is_feature_enabled('racks', conn=conn):
abort(404)
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
_, placement_error = check_rack_placement(cursor, rack_id, position_u, side) _, placement_error = check_rack_placement(cursor, rack_id, position_u, side)
if placement_error == 'Rack not found': if placement_error == 'Rack not found':
@@ -2876,9 +2708,6 @@ def rack_remove_device(rack_id):
user_name = get_current_user_name() user_name = get_current_user_name()
from flask import current_app from flask import current_app
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
# Check if racks feature is enabled
if not is_feature_enabled('racks', conn=conn):
abort(404)
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
cursor.execute('SELECT device_id, nonnet_device_name, position_u, side FROM RackDevice WHERE id = %s', (rack_device_id,)) cursor.execute('SELECT device_id, nonnet_device_name, position_u, side FROM RackDevice WHERE id = %s', (rack_device_id,))
rd = cursor.fetchone() rd = cursor.fetchone()
@@ -2903,9 +2732,6 @@ def delete_rack(rack_id):
user_name = get_current_user_name() user_name = get_current_user_name()
from flask import current_app from flask import current_app
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
# Check if racks feature is enabled
if not is_feature_enabled('racks', conn=conn):
abort(404)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute('SELECT name FROM Rack WHERE id = %s', (rack_id,)) cursor.execute('SELECT name FROM Rack WHERE id = %s', (rack_id,))
rack_name = cursor.fetchone() rack_name = cursor.fetchone()
@@ -2920,9 +2746,6 @@ def delete_rack(rack_id):
def export_rack_csv(rack_id): def export_rack_csv(rack_id):
from flask import current_app from flask import current_app
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
# Check if racks feature is enabled
if not is_feature_enabled('racks', conn=conn):
abort(404)
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
cursor.execute('SELECT * FROM Rack WHERE id = %s', (rack_id,)) cursor.execute('SELECT * FROM Rack WHERE id = %s', (rack_id,))
rack = cursor.fetchone() rack = cursor.fetchone()
@@ -3546,9 +3369,6 @@ def api_racks():
"""Get all racks""" """Get all racks"""
from flask import current_app from flask import current_app
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
# Check if racks feature is enabled
if not is_feature_enabled('racks', conn=conn):
return jsonify({'error': 'Racks feature is disabled'}), 404
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
cursor.execute('SELECT id, name, site, height_u FROM Rack ORDER BY site, name') cursor.execute('SELECT id, name, site, height_u FROM Rack ORDER BY site, name')
racks = cursor.fetchall() racks = cursor.fetchall()
@@ -3574,9 +3394,6 @@ def api_rack(rack_id):
"""Get a specific rack""" """Get a specific rack"""
from flask import current_app from flask import current_app
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
# Check if racks feature is enabled
if not is_feature_enabled('racks', conn=conn):
return jsonify({'error': 'Racks feature is disabled'}), 404
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
cursor.execute('SELECT id, name, site, height_u FROM Rack WHERE id = %s', (rack_id,)) cursor.execute('SELECT id, name, site, height_u FROM Rack WHERE id = %s', (rack_id,))
rack = cursor.fetchone() rack = cursor.fetchone()
@@ -3613,9 +3430,6 @@ def api_add_rack():
return jsonify({'error': 'height_u must be greater than zero'}), 400 return jsonify({'error': 'height_u must be greater than zero'}), 400
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
# Check if racks feature is enabled
if not is_feature_enabled('racks', conn=conn):
return jsonify({'error': 'Racks feature is disabled'}), 404
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute('INSERT INTO Rack (name, site, height_u) VALUES (%s, %s, %s)', (name, site, height_u)) cursor.execute('INSERT INTO Rack (name, site, height_u) VALUES (%s, %s, %s)', (name, site, height_u))
rack_id = cursor.lastrowid rack_id = cursor.lastrowid
@@ -3629,9 +3443,6 @@ def api_delete_rack(rack_id):
"""Delete a rack""" """Delete a rack"""
from flask import current_app from flask import current_app
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
# Check if racks feature is enabled
if not is_feature_enabled('racks', conn=conn):
return jsonify({'error': 'Racks feature is disabled'}), 404
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute('SELECT name FROM Rack WHERE id = %s', (rack_id,)) cursor.execute('SELECT name FROM Rack WHERE id = %s', (rack_id,))
rack = cursor.fetchone() rack = cursor.fetchone()
@@ -3674,9 +3485,6 @@ def api_add_device_to_rack(rack_id):
return jsonify({'error': 'device_id must be an integer'}), 400 return jsonify({'error': 'device_id must be an integer'}), 400
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
# Check if racks feature is enabled
if not is_feature_enabled('racks', conn=conn):
return jsonify({'error': 'Racks feature is disabled'}), 404
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
cursor.execute('SELECT name, height_u FROM Rack WHERE id = %s', (rack_id,)) cursor.execute('SELECT name, height_u FROM Rack WHERE id = %s', (rack_id,))
rack = cursor.fetchone() rack = cursor.fetchone()
@@ -3736,9 +3544,6 @@ def api_remove_device_from_rack(rack_id, rack_device_id):
"""Remove a device from a rack""" """Remove a device from a rack"""
from flask import current_app from flask import current_app
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
# Check if racks feature is enabled
if not is_feature_enabled('racks', conn=conn):
return jsonify({'error': 'Racks feature is disabled'}), 404
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
cursor.execute(''' cursor.execute('''
SELECT rd.device_id, rd.nonnet_device_name, rd.position_u, rd.side, SELECT rd.device_id, rd.nonnet_device_name, rd.position_u, rd.side,
@@ -3778,7 +3583,7 @@ def api_custom_fields_by_type(entity_type):
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
cursor.execute(''' cursor.execute('''
SELECT id, entity_type, name, field_key, field_type, required, SELECT id, entity_type, name, field_key, field_type, required,
default_value, help_text, display_order, validation_rules, searchable default_value, help_text, display_order, validation_rules
FROM CustomFieldDefinition FROM CustomFieldDefinition
WHERE entity_type = %s WHERE entity_type = %s
ORDER BY display_order, name ORDER BY display_order, name
@@ -3820,12 +3625,12 @@ def api_add_custom_field():
cursor.execute(''' cursor.execute('''
INSERT INTO CustomFieldDefinition INSERT INTO CustomFieldDefinition
(entity_type, name, field_key, field_type, required, default_value, (entity_type, name, field_key, field_type, required, default_value,
help_text, display_order, validation_rules, searchable) help_text, display_order, validation_rules)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
''', (entity_type, data['name'], data['field_key'], data['field_type'], ''', (entity_type, data['name'], data['field_key'], data['field_type'],
data.get('required', False), data.get('default_value'), data.get('required', False), data.get('default_value'),
data.get('help_text'), data.get('display_order', 0), data.get('help_text'), data.get('display_order', 0),
validation_rules_json, data.get('searchable', False))) validation_rules_json))
field_id = cursor.lastrowid field_id = cursor.lastrowid
add_audit_log(request.api_user['id'], 'add_custom_field', add_audit_log(request.api_user['id'], 'add_custom_field',
f"Added custom field '{data['name']}' for {entity_type}", conn=conn) f"Added custom field '{data['name']}' for {entity_type}", conn=conn)
@@ -3874,9 +3679,6 @@ def api_update_custom_field(field_id):
validation_rules_json = json.dumps(data['validation_rules']) if data['validation_rules'] else None validation_rules_json = json.dumps(data['validation_rules']) if data['validation_rules'] else None
updates.append('validation_rules = %s') updates.append('validation_rules = %s')
values.append(validation_rules_json) values.append(validation_rules_json)
if 'searchable' in data:
updates.append('searchable = %s')
values.append(data['searchable'])
if not updates: if not updates:
return jsonify({'error': 'No changes to apply'}), 400 return jsonify({'error': 'No changes to apply'}), 400
@@ -3987,9 +3789,6 @@ def api_tags():
"""Get all tags""" """Get all tags"""
from flask import current_app from flask import current_app
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
# Check if device tags feature is enabled
if not is_feature_enabled('device_tags', conn=conn):
return jsonify({'error': 'Device tags feature is disabled'}), 404
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
cursor.execute('SELECT id, name, color, description, created_at FROM Tag ORDER BY name') cursor.execute('SELECT id, name, color, description, created_at FROM Tag ORDER BY name')
tags = cursor.fetchall() tags = cursor.fetchall()
@@ -4002,11 +3801,6 @@ def api_tags():
@api_permission_required('add_tag') @api_permission_required('add_tag')
def api_add_tag(): def api_add_tag():
"""Create a new tag""" """Create a new tag"""
from flask import current_app
with get_db_connection(current_app) as conn:
# Check if device tags feature is enabled
if not is_feature_enabled('device_tags', conn=conn):
return jsonify({'error': 'Device tags feature is disabled'}), 404
data = request.get_json() data = request.get_json()
if not data or 'name' not in data: if not data or 'name' not in data:
return jsonify({'error': 'Tag name is required'}), 400 return jsonify({'error': 'Tag name is required'}), 400
@@ -4018,7 +3812,6 @@ def api_add_tag():
color = data.get('color', '#6B7280') color = data.get('color', '#6B7280')
description = data.get('description', '') description = data.get('description', '')
from flask import current_app
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
cursor = conn.cursor() cursor = conn.cursor()
try: try:
@@ -4036,9 +3829,6 @@ def api_tag(tag_id):
"""Get a specific tag""" """Get a specific tag"""
from flask import current_app from flask import current_app
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
# Check if device tags feature is enabled
if not is_feature_enabled('device_tags', conn=conn):
return jsonify({'error': 'Device tags feature is disabled'}), 404
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
cursor.execute('SELECT id, name, color, description, created_at FROM Tag WHERE id = %s', (tag_id,)) cursor.execute('SELECT id, name, color, description, created_at FROM Tag WHERE id = %s', (tag_id,))
tag = cursor.fetchone() tag = cursor.fetchone()
@@ -4059,16 +3849,10 @@ def api_tag(tag_id):
@api_permission_required('edit_tag') @api_permission_required('edit_tag')
def api_update_tag(tag_id): def api_update_tag(tag_id):
"""Update a tag""" """Update a tag"""
from flask import current_app
with get_db_connection(current_app) as conn:
# Check if device tags feature is enabled
if not is_feature_enabled('device_tags', conn=conn):
return jsonify({'error': 'Device tags feature is disabled'}), 404
data = request.get_json() data = request.get_json()
if not data: if not data:
return jsonify({'error': 'Request body is required'}), 400 return jsonify({'error': 'Request body is required'}), 400
from flask import current_app
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute('SELECT name, color, description FROM Tag WHERE id = %s', (tag_id,)) cursor.execute('SELECT name, color, description FROM Tag WHERE id = %s', (tag_id,))
@@ -4113,9 +3897,6 @@ def api_delete_tag(tag_id):
"""Delete a tag""" """Delete a tag"""
from flask import current_app from flask import current_app
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
# Check if device tags feature is enabled
if not is_feature_enabled('device_tags', conn=conn):
return jsonify({'error': 'Device tags feature is disabled'}), 404
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute('SELECT name FROM Tag WHERE id = %s', (tag_id,)) cursor.execute('SELECT name FROM Tag WHERE id = %s', (tag_id,))
tag = cursor.fetchone() tag = cursor.fetchone()
@@ -4133,9 +3914,6 @@ def api_device_tags(device_id):
"""Get tags for a specific device""" """Get tags for a specific device"""
from flask import current_app from flask import current_app
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
# Check if device tags feature is enabled
if not is_feature_enabled('device_tags', conn=conn):
return jsonify({'error': 'Device tags feature is disabled'}), 404
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
cursor.execute('SELECT id, name FROM Device WHERE id = %s', (device_id,)) cursor.execute('SELECT id, name FROM Device WHERE id = %s', (device_id,))
if not cursor.fetchone(): if not cursor.fetchone():
@@ -4154,9 +3932,6 @@ def api_device_tags(device_id):
@api_permission_required('assign_device_tag') @api_permission_required('assign_device_tag')
def api_assign_device_tag(device_id): def api_assign_device_tag(device_id):
"""Assign a tag to a device""" """Assign a tag to a device"""
with get_db_connection(current_app) as conn:
if not is_feature_enabled('device_tags', conn=conn):
return jsonify({'error': 'Device tags feature is disabled'}), 404
data = request.get_json() data = request.get_json()
if not data or 'tag_id' not in data: if not data or 'tag_id' not in data:
return jsonify({'error': 'tag_id is required'}), 400 return jsonify({'error': 'tag_id is required'}), 400
@@ -4180,8 +3955,6 @@ def api_assign_device_tag(device_id):
def api_remove_device_tag(device_id, tag_id): def api_remove_device_tag(device_id, tag_id):
"""Remove a tag from a device""" """Remove a tag from a device"""
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
if not is_feature_enabled('device_tags', conn=conn):
return jsonify({'error': 'Device tags feature is disabled'}), 404
try: try:
remove_tag_from_device(conn, device_id, tag_id, request.api_user['id']) remove_tag_from_device(conn, device_id, tag_id, request.api_user['id'])
conn.commit() conn.commit()
@@ -4373,19 +4146,12 @@ def api_roles():
def bulk_operations(): def bulk_operations():
from flask import current_app from flask import current_app
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
# Check if bulk operations feature is enabled
if not is_feature_enabled('bulk_operations', conn=conn):
abort(404)
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute('SELECT id, name FROM Device ORDER BY name') cursor.execute('SELECT id, name FROM Device ORDER BY name')
devices = cursor.fetchall() devices = cursor.fetchall()
cursor.execute('SELECT id, name, cidr, site FROM Subnet ORDER BY site, name') cursor.execute('SELECT id, name, cidr, site FROM Subnet ORDER BY site, name')
subnets = cursor.fetchall() subnets = cursor.fetchall()
# Get tags only if device tags feature is enabled
tags_enabled = is_feature_enabled('device_tags', conn=conn)
tags = []
if tags_enabled:
cursor.execute('SELECT id, name FROM Tag ORDER BY name') cursor.execute('SELECT id, name FROM Tag ORDER BY name')
tags = cursor.fetchall() tags = cursor.fetchall()
@@ -4404,17 +4170,11 @@ def bulk_operations():
@app.route('/bulk/assign_ips', methods=['POST']) @app.route('/bulk/assign_ips', methods=['POST'])
@permission_required('add_device_ip') @permission_required('add_device_ip')
def bulk_assign_ips(): def bulk_assign_ips():
from flask import current_app
with get_db_connection(current_app) as conn:
# Check if bulk operations feature is enabled
if not is_feature_enabled('bulk_operations', conn=conn):
abort(404)
device_id = request.form['device_id'] device_id = request.form['device_id']
ip_ids = request.form.getlist('ip_ids[]') ip_ids = request.form.getlist('ip_ids[]')
user_name = get_current_user_name() user_name = get_current_user_name()
results = {'success': [], 'failed': []} results = {'success': [], 'failed': []}
from flask import current_app
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
cursor = conn.cursor() cursor = conn.cursor()
if not get_device_name(cursor, device_id): if not get_device_name(cursor, device_id):
@@ -4436,17 +4196,11 @@ def bulk_assign_ips():
@app.route('/bulk/create_devices', methods=['POST']) @app.route('/bulk/create_devices', methods=['POST'])
@permission_required('add_device') @permission_required('add_device')
def bulk_create_devices(): def bulk_create_devices():
from flask import current_app
with get_db_connection(current_app) as conn:
# Check if bulk operations feature is enabled
if not is_feature_enabled('bulk_operations', conn=conn):
abort(404)
device_names = request.form.get('device_names', '').strip().split('\n') device_names = request.form.get('device_names', '').strip().split('\n')
device_type_id = int(request.form.get('device_type', 1)) device_type_id = int(request.form.get('device_type', 1))
user_name = get_current_user_name() user_name = get_current_user_name()
results = {'success': [], 'failed': []} results = {'success': [], 'failed': []}
from flask import current_app
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
cursor = conn.cursor() cursor = conn.cursor()
for name in device_names: for name in device_names:
@@ -4467,20 +4221,11 @@ def bulk_create_devices():
@app.route('/bulk/assign_tags', methods=['POST']) @app.route('/bulk/assign_tags', methods=['POST'])
@permission_required('assign_device_tag') @permission_required('assign_device_tag')
def bulk_assign_tags(): def bulk_assign_tags():
from flask import current_app
with get_db_connection(current_app) as conn:
# Check if bulk operations feature is enabled
if not is_feature_enabled('bulk_operations', conn=conn):
abort(404)
# Check if device tags feature is enabled
if not is_feature_enabled('device_tags', conn=conn):
abort(404)
device_ids = request.form.getlist('device_ids[]') device_ids = request.form.getlist('device_ids[]')
tag_ids = request.form.getlist('tag_ids[]') tag_ids = request.form.getlist('tag_ids[]')
user_name = get_current_user_name() user_name = get_current_user_name()
results = {'success': [], 'failed': []} results = {'success': [], 'failed': []}
from flask import current_app
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
cursor = conn.cursor() cursor = conn.cursor()
for device_id in device_ids: for device_id in device_ids:
@@ -4517,9 +4262,6 @@ def bulk_assign_tags():
@app.route('/bulk/export_subnets', methods=['POST']) @app.route('/bulk/export_subnets', methods=['POST'])
@permission_required('export_subnet_csv') @permission_required('export_subnet_csv')
def bulk_export_subnets(): def bulk_export_subnets():
with get_db_connection(current_app) as conn:
if not is_feature_enabled('bulk_operations', conn=conn):
abort(404)
subnet_ids = request.form.getlist('subnet_ids[]') subnet_ids = request.form.getlist('subnet_ids[]')
output = StringIO() output = StringIO()
writer = csv.writer(output) writer = csv.writer(output)
@@ -4566,7 +4308,6 @@ def inject_env_vars():
'LOGO_PNG': os.environ.get('LOGO_PNG', 'https://assets.jdbnet.co.uk/logo/128x128.png'), 'LOGO_PNG': os.environ.get('LOGO_PNG', 'https://assets.jdbnet.co.uk/logo/128x128.png'),
'VERSION': version, 'VERSION': version,
'has_permission': has_permission, 'has_permission': has_permission,
'is_feature_enabled': is_feature_enabled,
} }
init_db(app) init_db(app)
-27
View File
@@ -313,7 +313,6 @@ def init_db(app=None):
help_text TEXT, help_text TEXT,
display_order INTEGER DEFAULT 0, display_order INTEGER DEFAULT 0,
validation_rules TEXT, validation_rules TEXT,
searchable BOOLEAN DEFAULT FALSE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) )
@@ -351,32 +350,6 @@ def init_db(app=None):
if not cursor.fetchone(): if not cursor.fetchone():
cursor.execute('ALTER TABLE Subnet ADD COLUMN vlan_notes TEXT DEFAULT NULL') cursor.execute('ALTER TABLE Subnet ADD COLUMN vlan_notes TEXT DEFAULT NULL')
# Create FeatureFlags table
cursor.execute('''
CREATE TABLE IF NOT EXISTS FeatureFlags (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
feature_key VARCHAR(255) NOT NULL UNIQUE,
enabled BOOLEAN DEFAULT TRUE,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
''')
# Initialize default feature flags
default_features = [
('racks', True, 'Enable rack management functionality'),
('ip_address_notes', True, 'Enable IP address notes/descriptions editing on subnet page'),
('device_tags', True, 'Enable device tagging functionality'),
('bulk_operations', True, 'Enable bulk operations for devices and IPs')
]
for feature_key, enabled, description in default_features:
cursor.execute('SELECT id FROM FeatureFlags WHERE feature_key = %s', (feature_key,))
if not cursor.fetchone():
cursor.execute('INSERT INTO FeatureFlags (feature_key, enabled, description) VALUES (%s, %s, %s)',
(feature_key, enabled, description))
# Define all permissions with categories # Define all permissions with categories
permissions = [ permissions = [
# View permissions # View permissions
-1
View File
@@ -2,6 +2,5 @@ Flask
mysql-connector-python mysql-connector-python
dotenv dotenv
gunicorn gunicorn
requests
pyotp pyotp
qrcode[pil] qrcode[pil]
-2
View File
@@ -99,7 +99,6 @@ function showAddFieldModal(entityType) {
document.getElementById('field-default-value').value = ''; document.getElementById('field-default-value').value = '';
document.getElementById('field-help-text').value = ''; document.getElementById('field-help-text').value = '';
document.getElementById('field-display-order').value = '0'; document.getElementById('field-display-order').value = '0';
document.getElementById('field-searchable').checked = false;
// Reset validation fields // Reset validation fields
document.getElementById('field-min-length').value = ''; document.getElementById('field-min-length').value = '';
@@ -181,7 +180,6 @@ function populateEditForm(field, entityType) {
document.getElementById('field-default-value').value = field.default_value || ''; document.getElementById('field-default-value').value = field.default_value || '';
document.getElementById('field-help-text').value = field.help_text || ''; document.getElementById('field-help-text').value = field.help_text || '';
document.getElementById('field-display-order').value = field.display_order || 0; document.getElementById('field-display-order').value = field.display_order || 0;
document.getElementById('field-searchable').checked = field.searchable || false;
// Parse validation rules // Parse validation rules
let validationRules = {}; let validationRules = {};
+1 -1
View File
File diff suppressed because one or more lines are too long
-40
View File
@@ -1,40 +0,0 @@
document.addEventListener('DOMContentLoaded', function() {
// Check if toast was dismissed in this session
const toastDismissed = sessionStorage.getItem('update-toast-dismissed');
if (toastDismissed) {
return;
}
// Check for updates
fetch('/check_update')
.then(response => response.json())
.then(data => {
if (data.update_available) {
const toast = document.getElementById('update-toast');
const currentVersionEl = document.getElementById('toast-current-version');
const latestVersionEl = document.getElementById('toast-latest-version');
const compareLink = document.getElementById('toast-compare-link');
const closeBtn = document.getElementById('toast-close');
// Set versions (don't add 'v' prefix for dev versions)
currentVersionEl.textContent = (data.current_version === 'dev' ? '' : 'v') + data.current_version;
latestVersionEl.textContent = (data.latest_version === 'dev' ? '' : 'v') + data.latest_version;
// Set compare link (current version to latest version)
compareLink.href = `https://git.jdbnet.co.uk/jamie/ipam/compare/v${data.current_version}...v${data.latest_version}`;
// Show toast
toast.classList.remove('hidden');
// Close button handler
closeBtn.addEventListener('click', function() {
toast.classList.add('hidden');
sessionStorage.setItem('update-toast-dismissed', 'true');
});
}
})
.catch(error => {
console.error('Error checking for updates:', error);
});
});
-1
View File
@@ -1 +0,0 @@
document.addEventListener("DOMContentLoaded",function(){let e=sessionStorage.getItem("update-toast-dismissed");!e&&fetch("/check_update").then(e=>e.json()).then(e=>{if(e.update_available){let t=document.getElementById("update-toast"),n=document.getElementById("toast-current-version"),s=document.getElementById("toast-latest-version"),a=document.getElementById("toast-compare-link"),d=document.getElementById("toast-close");n.textContent=("dev"===e.current_version?"":"v")+e.current_version,s.textContent=("dev"===e.latest_version?"":"v")+e.latest_version,a.href=`https://git.jdbnet.co.uk/jamie/ipam/compare/v${e.current_version}...v${e.latest_version}`,t.classList.remove("hidden"),d.addEventListener("click",function(){t.classList.add("hidden"),sessionStorage.setItem("update-toast-dismissed","true")})}}).catch(e=>{console.error("Error checking for updates:",e)})});
+1 -34
View File
@@ -42,7 +42,7 @@
</div> </div>
<i class="fas fa-chevron-right text-gray-400"></i> <i class="fas fa-chevron-right text-gray-400"></i>
</a> </a>
{% if has_permission('view_tags') and is_feature_enabled('device_tags') %} {% if has_permission('view_tags') %}
<a href="/tags" 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"> <a href="/tags" 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"> <div class="flex items-center space-x-4">
<i class="fas fa-tags text-3xl text-gray-600 dark:text-gray-400"></i> <i class="fas fa-tags text-3xl text-gray-600 dark:text-gray-400"></i>
@@ -148,39 +148,6 @@
{% endif %} {% endif %}
</div> </div>
<!-- Feature Flags Section -->
{% if has_permission('manage_users') %}
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md mt-8">
<h2 class="text-2xl font-bold mb-6">Feature Flags</h2>
<form action="/admin/feature_flags" method="POST">
<div class="space-y-4">
{% for flag in feature_flags %}
<div class="flex items-center justify-between p-4 bg-gray-300 dark:bg-zinc-700 rounded-lg">
<div class="flex-1">
<label for="feature_{{ flag.key }}" class="text-lg font-semibold cursor-pointer">
{{ flag.key|replace('_', ' ')|title }}
</label>
{% if flag.description %}
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">{{ flag.description }}</p>
{% endif %}
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" name="feature_{{ flag.key }}" id="feature_{{ flag.key }}"
class="sr-only peer" {% if flag.enabled %}checked{% endif %}>
<div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-gray-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-gray-500 dark:peer-checked:bg-zinc-600"></div>
</label>
</div>
{% endfor %}
</div>
<div class="flex justify-end mt-6">
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg font-medium">
Save Changes
</button>
</div>
</form>
</div>
{% endif %}
</div> </div>
</div> </div>
-4
View File
@@ -22,9 +22,7 @@
<div class="flex space-x-4"> <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('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('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>
{% if is_feature_enabled('device_tags') %}
<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('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>
{% endif %}
<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> <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>
</div> </div>
@@ -96,7 +94,6 @@
</div> </div>
<!-- Bulk Tag Assignment --> <!-- Bulk Tag Assignment -->
{% if is_feature_enabled('device_tags') %}
<div id="panel-assign-tags" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md"> <div id="panel-assign-tags" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
{% if can_assign_device_tag %} {% if can_assign_device_tag %}
<h2 class="text-2xl font-bold mb-4">Bulk Tag Assignment</h2> <h2 class="text-2xl font-bold mb-4">Bulk Tag Assignment</h2>
@@ -126,7 +123,6 @@
<p class="text-gray-500">You don't have permission to assign tags to devices.</p> <p class="text-gray-500">You don't have permission to assign tags to devices.</p>
{% endif %} {% endif %}
</div> </div>
{% endif %}
<!-- Bulk Export --> <!-- Bulk Export -->
<div id="panel-export" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md"> <div id="panel-export" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
-20
View File
@@ -52,7 +52,6 @@
<th class="text-left p-3">Field Key</th> <th class="text-left p-3">Field Key</th>
<th class="text-left p-3">Type</th> <th class="text-left p-3">Type</th>
<th class="text-center p-3">Required</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> <th class="text-center p-3">Actions</th>
</tr> </tr>
</thead> </thead>
@@ -80,13 +79,6 @@
<i class="fas fa-times text-gray-400"></i> <i class="fas fa-times text-gray-400"></i>
{% endif %} {% endif %}
</td> </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"> <td class="p-3 text-center">
<div class="flex items-center justify-center space-x-2"> <div class="flex items-center justify-center space-x-2">
{% if can_manage %} {% if can_manage %}
@@ -132,7 +124,6 @@
<th class="text-left p-3">Field Key</th> <th class="text-left p-3">Field Key</th>
<th class="text-left p-3">Type</th> <th class="text-left p-3">Type</th>
<th class="text-center p-3">Required</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> <th class="text-center p-3">Actions</th>
</tr> </tr>
</thead> </thead>
@@ -160,13 +151,6 @@
<i class="fas fa-times text-gray-400"></i> <i class="fas fa-times text-gray-400"></i>
{% endif %} {% endif %}
</td> </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"> <td class="p-3 text-center">
<div class="flex items-center justify-center space-x-2"> <div class="flex items-center justify-center space-x-2">
{% if can_manage %} {% if can_manage %}
@@ -262,10 +246,6 @@
<input type="number" name="display_order" id="field-display-order" value="0" min="0" <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"> 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>
<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 --> <!-- Validation Rules Section -->
<div id="validation-rules-section" class="border-t border-gray-600 pt-4"> <div id="validation-rules-section" class="border-t border-gray-600 pt-4">
-2
View File
@@ -115,7 +115,6 @@
{% endif %} {% endif %}
<!-- Tags Section --> <!-- Tags Section -->
{% if is_feature_enabled('device_tags') %}
<div class="tags-section mb-6"> <div class="tags-section mb-6">
<h3 class="text-lg font-bold mb-2">Tags:</h3> <h3 class="text-lg font-bold mb-2">Tags:</h3>
<div class="flex flex-wrap gap-2 mb-4"> <div class="flex flex-wrap gap-2 mb-4">
@@ -156,7 +155,6 @@
</form> </form>
{% endif %} {% endif %}
</div> </div>
{% endif %}
<form action="/update_device_description" method="POST" class="mb-6 mt-4"> <form action="/update_device_description" method="POST" class="mb-6 mt-4">
<input type="hidden" name="device_id" value="{{ device.id }}"> <input type="hidden" name="device_id" value="{{ device.id }}">
+1 -5
View File
@@ -16,16 +16,14 @@
<h1 class="text-3xl font-bold mb-6 text-center">Device Manager</h1> <h1 class="text-3xl font-bold mb-6 text-center">Device Manager</h1>
<div class="flex flex-row justify-center gap-4 mb-6 flex-wrap"> <div class="flex flex-row justify-center gap-4 mb-6 flex-wrap">
<a href="/add_device" class="text-center bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">Add New Device</a> <a href="/add_device" class="text-center bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">Add New Device</a>
{% if is_feature_enabled('bulk_operations') %}
<a href="/bulk" class="text-center bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">Bulk Operations</a> <a href="/bulk" class="text-center bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">Bulk Operations</a>
{% endif %}
<a href="/device_type_stats" class="text-center bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">View Device Stats</a> <a href="/device_type_stats" class="text-center bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">View Device Stats</a>
</div> </div>
<!-- Filters Section --> <!-- Filters Section -->
<div class="mb-6 space-y-4"> <div class="mb-6 space-y-4">
<!-- Tag Filter --> <!-- Tag Filter -->
{% if is_feature_enabled('device_tags') and all_tag_names %} {% if all_tag_names %}
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<label class="text-sm font-medium">Filter by tag:</label> <label class="text-sm font-medium">Filter by tag:</label>
<select id="tag-filter" class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600"> <select id="tag-filter" class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600">
@@ -69,7 +67,6 @@
</span> </span>
</div> </div>
<!-- Tags --> <!-- Tags -->
{% if is_feature_enabled('device_tags') %}
{% set tags = device_tags.get(device.id, []) %} {% set tags = device_tags.get(device.id, []) %}
{% if tags %} {% if tags %}
<div class="flex flex-wrap gap-1 mt-2"> <div class="flex flex-wrap gap-1 mt-2">
@@ -81,7 +78,6 @@
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
{% endif %}
</a> </a>
</li> </li>
{% endfor %} {% endfor %}
+2 -43
View File
@@ -25,7 +25,7 @@
<span>Devices</span> <span>Devices</span>
</a> </a>
{% endif %} {% endif %}
{% if has_permission('view_racks') and is_feature_enabled('racks') %} {% if has_permission('view_racks') %}
<a href="/racks" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2"> <a href="/racks" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
<i class="fas fa-th"></i> <i class="fas fa-th"></i>
<span>Racks</span> <span>Racks</span>
@@ -73,7 +73,7 @@
<span>Devices</span> <span>Devices</span>
</a> </a>
{% endif %} {% endif %}
{% if has_permission('view_racks') and is_feature_enabled('racks') %} {% if has_permission('view_racks') %}
<a href="/racks" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2"> <a href="/racks" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
<i class="fas fa-th"></i> <i class="fas fa-th"></i>
<span>Racks</span> <span>Racks</span>
@@ -122,45 +122,4 @@
{% endif %} {% endif %}
<script src="/static/js/header.min.js"></script> <script src="/static/js/header.min.js"></script>
<!-- Update Available Toast -->
<div id="update-toast" class="hidden fixed bottom-4 right-4 bg-gray-200 dark:bg-zinc-800 border border-gray-400 dark:border-zinc-600 rounded-lg shadow-lg max-w-md z-50">
<div class="p-4">
<div class="flex items-start justify-between mb-2">
<div class="flex items-center gap-2">
<i class="fas fa-bell text-gray-900 dark:text-gray-100"></i>
<h3 class="font-semibold text-lg text-gray-900 dark:text-gray-100">Update Available</h3>
</div>
<button id="toast-close" class="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:cursor-pointer">
<i class="fas fa-times"></i>
</button>
</div>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
Version <span id="toast-latest-version" class="font-semibold"></span> is now available. You're currently on <span id="toast-current-version" class="font-semibold"></span>.
</p>
<div class="flex gap-2 mt-3">
<a id="toast-compare-link" href="#" target="_blank" rel="noopener noreferrer" class="flex-1 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 text-gray-900 dark:text-gray-100 px-3 py-2 rounded text-center text-sm hover:cursor-pointer">
View Changes
</a>
</div>
</div>
</div>
<style>
#update-toast {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
</style>
<script src="/static/js/update_toast.min.js"></script>
</header> </header>
-5
View File
@@ -251,7 +251,6 @@
<td class="text-left align-top hidden sm:table-cell desc-col"> <td class="text-left align-top hidden sm:table-cell desc-col">
{% set device_desc = ip[4] if ip[4] else '' %} {% set device_desc = ip[4] if ip[4] else '' %}
{% set ip_notes = ip[5] if ip|length > 5 and ip[5] else '' %} {% set ip_notes = ip[5] if ip|length > 5 and ip[5] else '' %}
{% if ip_notes_enabled %}
{% set combined_desc = '' %} {% set combined_desc = '' %}
{% if device_desc %} {% if device_desc %}
{% set combined_desc = device_desc %} {% set combined_desc = device_desc %}
@@ -268,10 +267,6 @@
{% else %} {% else %}
<textarea readonly rows="1" class="border border-gray-600 rounded w-full cursor-pointer p-2" style="overflow: hidden; resize: none;">{{ combined_desc }}</textarea> <textarea readonly rows="1" class="border border-gray-600 rounded w-full cursor-pointer p-2" style="overflow: hidden; resize: none;">{{ combined_desc }}</textarea>
{% endif %} {% endif %}
{% else %}
{# IP notes disabled - show only device description, read-only #}
<textarea readonly rows="1" class="border border-gray-600 rounded w-full cursor-pointer p-2 bg-gray-200 dark:bg-zinc-800" style="overflow: hidden; resize: none;">{{ device_desc }}</textarea>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}