refactor: 🎨 remove caching #48
@@ -146,8 +146,6 @@ Subnets must be `/24` or smaller (prefix length ≥ 24).
|
||||
|
||||
## Racks
|
||||
|
||||
Requires the racks feature to be enabled.
|
||||
|
||||
| Method | Endpoint | Permission | Description |
|
||||
|--------|----------|------------|-------------|
|
||||
| GET | `/api/v1/racks` | `view_racks` | List all racks |
|
||||
@@ -161,8 +159,6 @@ Requires the racks feature to be enabled.
|
||||
|
||||
## Tags
|
||||
|
||||
Requires the device tags feature to be enabled.
|
||||
|
||||
| Method | Endpoint | Permission | Description |
|
||||
|--------|----------|------------|-------------|
|
||||
| GET | `/api/v1/tags` | `view_tags` | List all tags |
|
||||
@@ -206,7 +202,6 @@ Requires the device tags feature to be enabled.
|
||||
"default_value": "",
|
||||
"help_text": "Optional help text",
|
||||
"display_order": 0,
|
||||
"searchable": true,
|
||||
"validation_rules": {
|
||||
"max_length": 32
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ from ipaddress import ip_network, ip_address, IPv4Address, IPv6Address
|
||||
|
||||
import pyotp
|
||||
import qrcode
|
||||
import requests
|
||||
import mysql.connector
|
||||
from dotenv import load_dotenv
|
||||
from flask import (
|
||||
@@ -158,23 +157,6 @@ def permission_required(permission_name):
|
||||
return decorated_function
|
||||
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):
|
||||
"""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
|
||||
cursor.execute('''
|
||||
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
|
||||
WHERE entity_type = %s
|
||||
ORDER BY display_order, name
|
||||
@@ -1313,13 +1295,12 @@ def devices():
|
||||
tag_filter = request.args.get('tag')
|
||||
|
||||
with get_db_connection(current_app) as conn:
|
||||
# Check if device tags feature is enabled
|
||||
tags_enabled = is_feature_enabled('device_tags', conn=conn)
|
||||
tag_filter = request.args.get('tag')
|
||||
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Base device query
|
||||
if tag_filter and tags_enabled:
|
||||
if tag_filter:
|
||||
cursor.execute('''
|
||||
SELECT DISTINCT d.id, d.name, dt.icon_class
|
||||
FROM Device d
|
||||
@@ -1341,10 +1322,9 @@ def devices():
|
||||
for row in cursor.fetchall():
|
||||
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 = {}
|
||||
all_tag_names = []
|
||||
if tags_enabled:
|
||||
for device in devices:
|
||||
cursor.execute('''
|
||||
SELECT t.id, t.name, t.color
|
||||
@@ -1355,7 +1335,6 @@ def devices():
|
||||
''', (device[0],))
|
||||
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')
|
||||
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,))
|
||||
device_ips = [{'device_ip_id': row[0], 'ip': row[1]} for row in cursor.fetchall()]
|
||||
|
||||
# Get device tags (only if tags feature is enabled)
|
||||
tags_enabled = is_feature_enabled('device_tags', conn=conn)
|
||||
device_tags = []
|
||||
all_tags = []
|
||||
if tags_enabled:
|
||||
# Get device tags
|
||||
cursor.execute('''
|
||||
SELECT t.id, t.name, t.color
|
||||
FROM DeviceTag dt
|
||||
@@ -1438,7 +1413,6 @@ def device(device_id):
|
||||
''', (device_id,))
|
||||
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')
|
||||
all_tags = [{'id': row[0], 'name': row[1], 'color': row[2]} for row in cursor.fetchall()]
|
||||
available_ips_by_subnet = {}
|
||||
@@ -1548,8 +1522,6 @@ def device_delete_ip(device_id):
|
||||
def device_assign_tag(device_id):
|
||||
tag_id = request.form['tag_id']
|
||||
with get_db_connection(current_app) as conn:
|
||||
if not is_feature_enabled('device_tags', conn=conn):
|
||||
abort(404)
|
||||
try:
|
||||
assign_tag_to_device(conn, device_id, tag_id, session['user_id'])
|
||||
conn.commit()
|
||||
@@ -1562,8 +1534,6 @@ def device_assign_tag(device_id):
|
||||
def device_remove_tag(device_id):
|
||||
tag_id = request.form['tag_id']
|
||||
with get_db_connection(current_app) as conn:
|
||||
if not is_feature_enabled('device_tags', conn=conn):
|
||||
abort(404)
|
||||
try:
|
||||
remove_tag_from_device(conn, device_id, tag_id, session['user_id'])
|
||||
conn.commit()
|
||||
@@ -1614,15 +1584,11 @@ def subnet(subnet_id):
|
||||
'vlan_description': subnet[4] if len(subnet) > 4 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,
|
||||
ip_addresses=ip_addresses_with_device,
|
||||
utilization=utilization_stats,
|
||||
custom_fields=custom_fields,
|
||||
can_edit_subnet=has_permission('edit_subnet'),
|
||||
ip_notes_enabled=ip_notes_enabled)
|
||||
can_edit_subnet=has_permission('edit_subnet'))
|
||||
|
||||
@app.route('/add_subnet', methods=['POST'])
|
||||
@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 = {
|
||||
'subnets': subnets,
|
||||
'can_add_subnet': has_permission('add_subnet'),
|
||||
'can_edit_subnet': has_permission('edit_subnet'),
|
||||
'can_delete_subnet': has_permission('delete_subnet'),
|
||||
'feature_flags': feature_flags
|
||||
}
|
||||
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'])
|
||||
@login_required
|
||||
@@ -2066,9 +1997,6 @@ def users():
|
||||
def tags():
|
||||
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):
|
||||
abort(404)
|
||||
cursor = conn.cursor()
|
||||
error = None
|
||||
|
||||
@@ -2158,15 +2086,8 @@ def custom_fields():
|
||||
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.'
|
||||
error = 'Invalid entity type. Must be "device" or "subnet".'
|
||||
else:
|
||||
name = request.form['name'].strip()
|
||||
field_key = request.form.get('field_key', '').strip()
|
||||
@@ -2175,7 +2096,6 @@ def custom_fields():
|
||||
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:
|
||||
@@ -2193,10 +2113,10 @@ def custom_fields():
|
||||
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)
|
||||
help_text, display_order, validation_rules)
|
||||
VALUES (%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))
|
||||
help_text, display_order, validation_rules_json))
|
||||
add_audit_log(session['user_id'], 'add_custom_field',
|
||||
f"Added custom field '{name}' for {entity_type}", conn=conn)
|
||||
conn.commit()
|
||||
@@ -2216,7 +2136,6 @@ def custom_fields():
|
||||
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_json = build_validation_rules_from_form(request.form, field_type)
|
||||
@@ -2232,10 +2151,10 @@ def custom_fields():
|
||||
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
|
||||
help_text = %s, display_order = %s, validation_rules = %s
|
||||
WHERE id = %s
|
||||
''', (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',
|
||||
f"Updated custom field '{name}'", conn=conn)
|
||||
conn.commit()
|
||||
@@ -2274,7 +2193,7 @@ def custom_fields():
|
||||
# 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
|
||||
default_value, help_text, display_order, validation_rules
|
||||
FROM CustomFieldDefinition
|
||||
ORDER BY entity_type, display_order, name
|
||||
''')
|
||||
@@ -2305,26 +2224,6 @@ def custom_fields():
|
||||
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')
|
||||
@@ -2370,60 +2269,6 @@ def export_audit_csv():
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
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')
|
||||
@permission_required('view_device')
|
||||
@@ -2756,9 +2601,6 @@ def devices_by_tag(tag_id):
|
||||
def racks():
|
||||
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)
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute('SELECT * FROM Rack')
|
||||
racks = cursor.fetchall()
|
||||
@@ -2778,10 +2620,6 @@ def racks():
|
||||
@permission_required('add_rack')
|
||||
def add_rack():
|
||||
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':
|
||||
name = request.form['name']
|
||||
site = request.form['site']
|
||||
@@ -2802,8 +2640,6 @@ def add_rack():
|
||||
def rack(rack_id):
|
||||
side = request.args.get('side', 'front')
|
||||
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)
|
||||
|
||||
@app.route('/rack/<int:rack_id>/add_device', methods=['POST'])
|
||||
@@ -2814,8 +2650,6 @@ def rack_add_device(rack_id):
|
||||
side = request.form['side']
|
||||
user_name = get_current_user_name()
|
||||
with get_db_connection(current_app) as conn:
|
||||
if not is_feature_enabled('racks', conn=conn):
|
||||
abort(404)
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
_, placement_error = check_rack_placement(cursor, rack_id, position_u, side)
|
||||
if placement_error == 'Rack not found':
|
||||
@@ -2848,8 +2682,6 @@ def rack_add_nonnet_device(rack_id):
|
||||
side = request.form['side']
|
||||
user_name = get_current_user_name()
|
||||
with get_db_connection(current_app) as conn:
|
||||
if not is_feature_enabled('racks', conn=conn):
|
||||
abort(404)
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
_, placement_error = check_rack_placement(cursor, rack_id, position_u, side)
|
||||
if placement_error == 'Rack not found':
|
||||
@@ -2876,9 +2708,6 @@ def rack_remove_device(rack_id):
|
||||
user_name = get_current_user_name()
|
||||
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)
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute('SELECT device_id, nonnet_device_name, position_u, side FROM RackDevice WHERE id = %s', (rack_device_id,))
|
||||
rd = cursor.fetchone()
|
||||
@@ -2903,9 +2732,6 @@ def delete_rack(rack_id):
|
||||
user_name = get_current_user_name()
|
||||
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)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT name FROM Rack WHERE id = %s', (rack_id,))
|
||||
rack_name = cursor.fetchone()
|
||||
@@ -2920,9 +2746,6 @@ def delete_rack(rack_id):
|
||||
def export_rack_csv(rack_id):
|
||||
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)
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute('SELECT * FROM Rack WHERE id = %s', (rack_id,))
|
||||
rack = cursor.fetchone()
|
||||
@@ -3546,9 +3369,6 @@ def api_racks():
|
||||
"""Get all racks"""
|
||||
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):
|
||||
return jsonify({'error': 'Racks feature is disabled'}), 404
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute('SELECT id, name, site, height_u FROM Rack ORDER BY site, name')
|
||||
racks = cursor.fetchall()
|
||||
@@ -3574,9 +3394,6 @@ def api_rack(rack_id):
|
||||
"""Get a specific rack"""
|
||||
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):
|
||||
return jsonify({'error': 'Racks feature is disabled'}), 404
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute('SELECT id, name, site, height_u FROM Rack WHERE id = %s', (rack_id,))
|
||||
rack = cursor.fetchone()
|
||||
@@ -3613,9 +3430,6 @@ def api_add_rack():
|
||||
return jsonify({'error': 'height_u must be greater than zero'}), 400
|
||||
|
||||
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.execute('INSERT INTO Rack (name, site, height_u) VALUES (%s, %s, %s)', (name, site, height_u))
|
||||
rack_id = cursor.lastrowid
|
||||
@@ -3629,9 +3443,6 @@ def api_delete_rack(rack_id):
|
||||
"""Delete a rack"""
|
||||
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):
|
||||
return jsonify({'error': 'Racks feature is disabled'}), 404
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT name FROM Rack WHERE id = %s', (rack_id,))
|
||||
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
|
||||
|
||||
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.execute('SELECT name, height_u FROM Rack WHERE id = %s', (rack_id,))
|
||||
rack = cursor.fetchone()
|
||||
@@ -3736,9 +3544,6 @@ def api_remove_device_from_rack(rack_id, rack_device_id):
|
||||
"""Remove a device from a rack"""
|
||||
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):
|
||||
return jsonify({'error': 'Racks feature is disabled'}), 404
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute('''
|
||||
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.execute('''
|
||||
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
|
||||
WHERE entity_type = %s
|
||||
ORDER BY display_order, name
|
||||
@@ -3820,12 +3625,12 @@ def api_add_custom_field():
|
||||
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)
|
||||
help_text, display_order, validation_rules)
|
||||
VALUES (%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)))
|
||||
validation_rules_json))
|
||||
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)
|
||||
@@ -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
|
||||
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
|
||||
@@ -3987,9 +3789,6 @@ def api_tags():
|
||||
"""Get all tags"""
|
||||
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
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute('SELECT id, name, color, description, created_at FROM Tag ORDER BY name')
|
||||
tags = cursor.fetchall()
|
||||
@@ -4002,11 +3801,6 @@ def api_tags():
|
||||
@api_permission_required('add_tag')
|
||||
def api_add_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()
|
||||
if not data or 'name' not in data:
|
||||
return jsonify({'error': 'Tag name is required'}), 400
|
||||
@@ -4018,7 +3812,6 @@ def api_add_tag():
|
||||
color = data.get('color', '#6B7280')
|
||||
description = data.get('description', '')
|
||||
|
||||
from flask import current_app
|
||||
with get_db_connection(current_app) as conn:
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
@@ -4036,9 +3829,6 @@ def api_tag(tag_id):
|
||||
"""Get a specific 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
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute('SELECT id, name, color, description, created_at FROM Tag WHERE id = %s', (tag_id,))
|
||||
tag = cursor.fetchone()
|
||||
@@ -4059,16 +3849,10 @@ def api_tag(tag_id):
|
||||
@api_permission_required('edit_tag')
|
||||
def api_update_tag(tag_id):
|
||||
"""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()
|
||||
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()
|
||||
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"""
|
||||
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
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT name FROM Tag WHERE id = %s', (tag_id,))
|
||||
tag = cursor.fetchone()
|
||||
@@ -4133,9 +3914,6 @@ def api_device_tags(device_id):
|
||||
"""Get tags for a specific device"""
|
||||
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
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute('SELECT id, name FROM Device WHERE id = %s', (device_id,))
|
||||
if not cursor.fetchone():
|
||||
@@ -4154,9 +3932,6 @@ def api_device_tags(device_id):
|
||||
@api_permission_required('assign_device_tag')
|
||||
def api_assign_device_tag(device_id):
|
||||
"""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()
|
||||
if not data or 'tag_id' not in data:
|
||||
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):
|
||||
"""Remove a tag from 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
|
||||
try:
|
||||
remove_tag_from_device(conn, device_id, tag_id, request.api_user['id'])
|
||||
conn.commit()
|
||||
@@ -4373,19 +4146,12 @@ def api_roles():
|
||||
def bulk_operations():
|
||||
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)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT id, name FROM Device ORDER BY name')
|
||||
devices = cursor.fetchall()
|
||||
cursor.execute('SELECT id, name, cidr, site FROM Subnet ORDER BY site, name')
|
||||
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')
|
||||
tags = cursor.fetchall()
|
||||
|
||||
@@ -4404,17 +4170,11 @@ def bulk_operations():
|
||||
@app.route('/bulk/assign_ips', methods=['POST'])
|
||||
@permission_required('add_device_ip')
|
||||
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']
|
||||
ip_ids = request.form.getlist('ip_ids[]')
|
||||
user_name = get_current_user_name()
|
||||
results = {'success': [], 'failed': []}
|
||||
|
||||
from flask import current_app
|
||||
with get_db_connection(current_app) as conn:
|
||||
cursor = conn.cursor()
|
||||
if not get_device_name(cursor, device_id):
|
||||
@@ -4436,17 +4196,11 @@ def bulk_assign_ips():
|
||||
@app.route('/bulk/create_devices', methods=['POST'])
|
||||
@permission_required('add_device')
|
||||
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_type_id = int(request.form.get('device_type', 1))
|
||||
user_name = get_current_user_name()
|
||||
results = {'success': [], 'failed': []}
|
||||
|
||||
from flask import current_app
|
||||
with get_db_connection(current_app) as conn:
|
||||
cursor = conn.cursor()
|
||||
for name in device_names:
|
||||
@@ -4467,20 +4221,11 @@ def bulk_create_devices():
|
||||
@app.route('/bulk/assign_tags', methods=['POST'])
|
||||
@permission_required('assign_device_tag')
|
||||
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[]')
|
||||
tag_ids = request.form.getlist('tag_ids[]')
|
||||
user_name = get_current_user_name()
|
||||
results = {'success': [], 'failed': []}
|
||||
|
||||
from flask import current_app
|
||||
with get_db_connection(current_app) as conn:
|
||||
cursor = conn.cursor()
|
||||
for device_id in device_ids:
|
||||
@@ -4517,9 +4262,6 @@ def bulk_assign_tags():
|
||||
@app.route('/bulk/export_subnets', methods=['POST'])
|
||||
@permission_required('export_subnet_csv')
|
||||
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[]')
|
||||
output = StringIO()
|
||||
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'),
|
||||
'VERSION': version,
|
||||
'has_permission': has_permission,
|
||||
'is_feature_enabled': is_feature_enabled,
|
||||
}
|
||||
|
||||
init_db(app)
|
||||
|
||||
@@ -313,7 +313,6 @@ def init_db(app=None):
|
||||
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
|
||||
)
|
||||
@@ -351,32 +350,6 @@ def init_db(app=None):
|
||||
if not cursor.fetchone():
|
||||
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
|
||||
permissions = [
|
||||
# View permissions
|
||||
|
||||
@@ -2,6 +2,5 @@ Flask
|
||||
mysql-connector-python
|
||||
dotenv
|
||||
gunicorn
|
||||
requests
|
||||
pyotp
|
||||
qrcode[pil]
|
||||
@@ -99,7 +99,6 @@ function showAddFieldModal(entityType) {
|
||||
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 = '';
|
||||
@@ -181,7 +180,6 @@ function populateEditForm(field, entityType) {
|
||||
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 = {};
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
-1
@@ -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
@@ -42,7 +42,7 @@
|
||||
</div>
|
||||
<i class="fas fa-chevron-right text-gray-400"></i>
|
||||
</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">
|
||||
<div class="flex items-center space-x-4">
|
||||
<i class="fas fa-tags text-3xl text-gray-600 dark:text-gray-400"></i>
|
||||
@@ -148,39 +148,6 @@
|
||||
{% endif %}
|
||||
</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>
|
||||
|
||||
|
||||
@@ -22,9 +22,7 @@
|
||||
<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>
|
||||
{% 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>
|
||||
{% 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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,7 +94,6 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
{% if can_assign_device_tag %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Bulk Export -->
|
||||
<div id="panel-export" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
|
||||
|
||||
@@ -52,7 +52,6 @@
|
||||
<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>
|
||||
@@ -80,13 +79,6 @@
|
||||
<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 %}
|
||||
@@ -132,7 +124,6 @@
|
||||
<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>
|
||||
@@ -160,13 +151,6 @@
|
||||
<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 %}
|
||||
@@ -262,10 +246,6 @@
|
||||
<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">
|
||||
|
||||
@@ -115,7 +115,6 @@
|
||||
{% endif %}
|
||||
|
||||
<!-- Tags Section -->
|
||||
{% if is_feature_enabled('device_tags') %}
|
||||
<div class="tags-section mb-6">
|
||||
<h3 class="text-lg font-bold mb-2">Tags:</h3>
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
@@ -156,7 +155,6 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form action="/update_device_description" method="POST" class="mb-6 mt-4">
|
||||
<input type="hidden" name="device_id" value="{{ device.id }}">
|
||||
|
||||
@@ -16,16 +16,14 @@
|
||||
<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">
|
||||
<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>
|
||||
{% 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>
|
||||
</div>
|
||||
|
||||
<!-- Filters Section -->
|
||||
<div class="mb-6 space-y-4">
|
||||
<!-- Tag Filter -->
|
||||
{% if is_feature_enabled('device_tags') and all_tag_names %}
|
||||
{% if all_tag_names %}
|
||||
<div class="flex items-center space-x-2">
|
||||
<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">
|
||||
@@ -69,7 +67,6 @@
|
||||
</span>
|
||||
</div>
|
||||
<!-- Tags -->
|
||||
{% if is_feature_enabled('device_tags') %}
|
||||
{% set tags = device_tags.get(device.id, []) %}
|
||||
{% if tags %}
|
||||
<div class="flex flex-wrap gap-1 mt-2">
|
||||
@@ -81,7 +78,6 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
+2
-43
@@ -25,7 +25,7 @@
|
||||
<span>Devices</span>
|
||||
</a>
|
||||
{% 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">
|
||||
<i class="fas fa-th"></i>
|
||||
<span>Racks</span>
|
||||
@@ -73,7 +73,7 @@
|
||||
<span>Devices</span>
|
||||
</a>
|
||||
{% 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">
|
||||
<i class="fas fa-th"></i>
|
||||
<span>Racks</span>
|
||||
@@ -122,45 +122,4 @@
|
||||
{% endif %}
|
||||
|
||||
<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>
|
||||
|
||||
@@ -251,7 +251,6 @@
|
||||
<td class="text-left align-top hidden sm:table-cell desc-col">
|
||||
{% set device_desc = ip[4] if ip[4] else '' %}
|
||||
{% set ip_notes = ip[5] if ip|length > 5 and ip[5] else '' %}
|
||||
{% if ip_notes_enabled %}
|
||||
{% set combined_desc = '' %}
|
||||
{% if device_desc %}
|
||||
{% set combined_desc = device_desc %}
|
||||
@@ -268,10 +267,6 @@
|
||||
{% 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>
|
||||
{% 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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
Reference in New Issue
Block a user