4 Commits

Author SHA1 Message Date
jamie c1b0a7084b feat: feature flags 2025-12-31 01:08:30 +00:00
Jamie 9558baf84e Merge pull request #33 from JDB-NET/release-please--branches--main
chore(main): release 1.9.1
2025-12-29 18:24:31 +00:00
github-actions[bot] 5912bc6367 chore(main): release 1.9.1 2025-12-29 18:24:13 +00:00
jamie 83c1b21c04 fix: 🐛 device page dictionary 2025-12-29 18:23:50 +00:00
15 changed files with 403 additions and 81 deletions
-13
View File
@@ -57,16 +57,3 @@ jobs:
ghcr.io/jdb-net/ipam:latest
build-args: |
VERSION=${{ env.VERSION }}
deploy:
name: Deploy to Kubernetes
needs: release-please
if: ${{ needs.release-please.outputs.release_created }}
runs-on: [ k3s-internal-htz-01 ]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Apply manifests
run: |
sudo kubectl replace -f deployment.yml --grace-period=60 --force
+46
View File
@@ -0,0 +1,46 @@
name: Staging
on:
push:
branches: [ main ]
jobs:
build-and-push:
name: Build and Push
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/jdb-net/ipam:staging
deploy:
name: Deploy to Kubernetes
needs: build-and-push
runs-on: [ k3s-internal-htz-01 ]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Apply manifests
run: |
sudo kubectl replace -f deployment.yml --grace-period=60 --force
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.9.0"
".": "1.9.1"
}
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## [1.9.1](https://github.com/JDB-NET/ipam/compare/v1.9.0...v1.9.1) (2025-12-29)
### Bug Fixes
* :bug: device page dictionary ([83c1b21](https://github.com/JDB-NET/ipam/commit/83c1b21c04163e22c25831ee8064f3cd5ea2c99d))
## [1.9.0](https://github.com/JDB-NET/ipam/compare/v1.8.0...v1.9.0) (2025-12-27)
+1 -1
View File
@@ -1 +1 @@
1.9.0
1.9.1
+4 -3
View File
@@ -36,14 +36,15 @@ def inject_env_vars():
except Exception:
pass
# Import has_permission from routes after routes are registered
from routes import has_permission
# Import has_permission and is_feature_enabled from routes after routes are registered
from routes import has_permission, is_feature_enabled
return {
'NAME': os.environ.get('NAME', 'JDB-NET'),
'LOGO_PNG': os.environ.get('LOGO_PNG', 'https://assets.s3.jdbnet.co.uk/logo/128x128.png'),
'VERSION': version,
'has_permission': has_permission
'has_permission': has_permission,
'is_feature_enabled': is_feature_enabled
}
register_routes(app, limiter)
+26
View File
@@ -350,6 +350,32 @@ 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
+1 -1
View File
@@ -15,7 +15,7 @@ spec:
spec:
containers:
- name: ipam
image: ghcr.io/jdb-net/ipam:latest
image: ghcr.io/jdb-net/ipam:staging
imagePullPolicy: Always
ports:
- containerPort: 5000
+248 -42
View File
@@ -75,6 +75,24 @@ 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"""
from flask import current_app
@@ -207,7 +225,7 @@ def get_ip_history_from_audit_logs(device_id=None, ip_address=None, conn=None):
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
device_result = cursor.fetchone()
if device_result:
device_name = device_result[0]
device_name = device_result['name']
else:
# Device doesn't exist, return empty history
return []
@@ -687,17 +705,22 @@ def prewarm_cache(app):
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()]
cursor.execute('''
SELECT t.id, t.name, t.color
FROM DeviceTag dt
JOIN Tag t ON dt.tag_id = t.id
WHERE dt.device_id = %s
ORDER BY t.name
''', (device_id,))
device_tags = [{'id': row[0], 'name': row[1], 'color': row[2]} for row in cursor.fetchall()]
# Get tags for device (only if tags feature is enabled)
tags_enabled = is_feature_enabled('device_tags', conn=conn)
device_tags = []
all_tags = []
if tags_enabled:
cursor.execute('''
SELECT t.id, t.name, t.color
FROM DeviceTag dt
JOIN Tag t ON dt.tag_id = t.id
WHERE dt.device_id = %s
ORDER BY t.name
''', (device_id,))
device_tags = [{'id': row[0], 'name': row[1], 'color': row[2]} for row in cursor.fetchall()]
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()]
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 = {}
for subnet in subnets:
@@ -1026,10 +1049,13 @@ def register_routes(app, limiter=None):
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)
cursor = conn.cursor()
# Base device query
if tag_filter:
if tag_filter and tags_enabled:
cursor.execute('''
SELECT DISTINCT d.id, d.name, dt.icon_class
FROM Device d
@@ -1051,21 +1077,23 @@ def register_routes(app, limiter=None):
for row in cursor.fetchall():
device_ips.setdefault(row[0], []).append((row[1], row[2]))
# Get tags for each device
# Get tags for each device (only if tags feature is enabled)
device_tags = {}
for device in devices:
cursor.execute('''
SELECT t.id, t.name, t.color
FROM DeviceTag dt
JOIN Tag t ON dt.tag_id = t.id
WHERE dt.device_id = %s
ORDER BY t.name
''', (device[0],))
device_tags[device[0]] = [{'id': row[0], 'name': row[1], 'color': row[2]} for row in cursor.fetchall()]
all_tag_names = []
if tags_enabled:
for device in devices:
cursor.execute('''
SELECT t.id, t.name, t.color
FROM DeviceTag dt
JOIN Tag t ON dt.tag_id = t.id
WHERE dt.device_id = %s
ORDER BY t.name
''', (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()]
# 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()]
# Optimize: Get device sites in a single query instead of N+1
sites_devices = {}
@@ -1153,19 +1181,23 @@ def register_routes(app, limiter=None):
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
cursor.execute('''
SELECT t.id, t.name, t.color
FROM DeviceTag dt
JOIN Tag t ON dt.tag_id = t.id
WHERE dt.device_id = %s
ORDER BY t.name
''', (device_id,))
device_tags = [{'id': row[0], 'name': row[1], 'color': row[2]} 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:
cursor.execute('''
SELECT t.id, t.name, t.color
FROM DeviceTag dt
JOIN Tag t ON dt.tag_id = t.id
WHERE dt.device_id = %s
ORDER BY t.name
''', (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()]
# 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 = {}
for subnet in subnets:
cursor.execute('''
@@ -1338,6 +1370,9 @@ def register_routes(app, limiter=None):
tag_id = request.form['tag_id']
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()
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
device = cursor.fetchone()
@@ -1369,6 +1404,9 @@ def register_routes(app, limiter=None):
tag_id = request.form['tag_id']
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()
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
device = cursor.fetchone()
@@ -1448,11 +1486,14 @@ def register_routes(app, limiter=None):
subnet_dict['vlan_id'] = vlan_row[0]
subnet_dict['vlan_description'] = vlan_row[1]
subnet_dict['vlan_notes'] = vlan_row[2]
# 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=cached_result['ip_addresses'],
utilization=cached_result['utilization'],
custom_fields=custom_fields,
can_edit_subnet=has_permission('edit_subnet'))
can_edit_subnet=has_permission('edit_subnet'),
ip_notes_enabled=ip_notes_enabled)
from flask import current_app
with get_db_connection(current_app) as conn:
@@ -1525,11 +1566,16 @@ def register_routes(app, limiter=None):
}
# Cache for 3 hours
cache.set(cache_key, result, ttl=10800)
# 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'))
can_edit_subnet=has_permission('edit_subnet'),
ip_notes_enabled=ip_notes_enabled)
@app.route('/add_subnet', methods=['POST'])
@permission_required('add_subnet')
@@ -1654,6 +1700,19 @@ def register_routes(app, limiter=None):
cached_result = None
if cached_result is not None:
# Always fetch feature flags fresh (they might have changed)
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
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]
})
cached_result['feature_flags'] = feature_flags
return render_with_user('admin.html', **cached_result)
from flask import current_app
@@ -1701,16 +1760,55 @@ def register_routes(app, limiter=None):
'total': total_ips
}
})
# 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')
'can_delete_subnet': has_permission('delete_subnet'),
'feature_flags': feature_flags
}
# Cache for 3 hours
cache.set(cache_key, result_data, ttl=10800)
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
cache.clear('admin')
return redirect(url_for('admin'))
@app.route('/api-docs')
@permission_required('view_admin')
def api_docs():
@@ -2047,6 +2145,9 @@ def register_routes(app, limiter=None):
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
@@ -3324,6 +3425,9 @@ def register_routes(app, limiter=None):
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()
@@ -3343,6 +3447,10 @@ def register_routes(app, limiter=None):
@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']
@@ -3364,6 +3472,9 @@ def register_routes(app, limiter=None):
from flask import current_app, request
side = request.args.get('side', 'front')
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()
@@ -3404,6 +3515,9 @@ def register_routes(app, limiter=None):
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 height_u FROM Rack WHERE id = %s', (rack_id,))
rack = cursor.fetchone()
@@ -3464,6 +3578,9 @@ def register_routes(app, limiter=None):
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 height_u FROM Rack WHERE id = %s', (rack_id,))
rack = cursor.fetchone()
@@ -3521,6 +3638,9 @@ def register_routes(app, limiter=None):
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()
@@ -3545,6 +3665,9 @@ def register_routes(app, limiter=None):
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()
@@ -3559,6 +3682,9 @@ def register_routes(app, limiter=None):
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()
@@ -4274,6 +4400,9 @@ def register_routes(app, limiter=None):
"""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()
@@ -4300,6 +4429,9 @@ def register_routes(app, limiter=None):
"""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()
@@ -4337,6 +4469,9 @@ def register_routes(app, limiter=None):
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
@@ -4351,6 +4486,9 @@ def register_routes(app, limiter=None):
"""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()
@@ -4394,6 +4532,9 @@ def register_routes(app, limiter=None):
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()
@@ -4454,6 +4595,9 @@ def register_routes(app, limiter=None):
"""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,
@@ -4748,6 +4892,9 @@ def register_routes(app, limiter=None):
"""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()
@@ -4761,6 +4908,11 @@ def register_routes(app, limiter=None):
@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
@@ -4794,6 +4946,9 @@ def register_routes(app, limiter=None):
"""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()
@@ -4815,6 +4970,11 @@ def register_routes(app, limiter=None):
@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
@@ -4868,6 +5028,9 @@ def register_routes(app, limiter=None):
"""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()
@@ -4889,6 +5052,9 @@ def register_routes(app, limiter=None):
"""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():
@@ -4908,6 +5074,11 @@ def register_routes(app, limiter=None):
@api_permission_required('assign_device_tag')
def api_assign_device_tag(device_id):
"""Assign a tag to a 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
data = request.get_json()
if not data or 'tag_id' not in data:
return jsonify({'error': 'tag_id is required'}), 400
@@ -4946,6 +5117,9 @@ def register_routes(app, limiter=None):
"""Remove a tag from a 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()
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
device = cursor.fetchone()
@@ -5167,13 +5341,22 @@ def register_routes(app, limiter=None):
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()
cursor.execute('SELECT id, name FROM Tag ORDER BY name')
tags = 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()
cursor.execute('SELECT id, name FROM DeviceType ORDER BY name')
device_types = cursor.fetchall()
return render_with_user('bulk_operations.html',
@@ -5189,6 +5372,11 @@ def register_routes(app, limiter=None):
@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()
@@ -5268,6 +5456,11 @@ def register_routes(app, limiter=None):
@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()
@@ -5298,6 +5491,14 @@ def register_routes(app, limiter=None):
@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()
@@ -5344,6 +5545,11 @@ def register_routes(app, limiter=None):
@app.route('/bulk/export_subnets', methods=['POST'])
@permission_required('export_subnet_csv')
def bulk_export_subnets():
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)
subnet_ids = request.form.getlist('subnet_ids[]')
from flask import current_app
output = StringIO()
+35 -1
View File
@@ -42,7 +42,7 @@
</div>
<i class="fas fa-chevron-right text-gray-400"></i>
</a>
{% if has_permission('view_tags') %}
{% if has_permission('view_tags') and is_feature_enabled('device_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>
@@ -167,6 +167,40 @@
</div>
{% 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>
+4
View File
@@ -22,7 +22,9 @@
<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>
@@ -94,6 +96,7 @@
</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>
@@ -123,6 +126,7 @@
<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">
+2
View File
@@ -115,6 +115,7 @@
{% 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">
@@ -155,6 +156,7 @@
</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 }}">
+5 -1
View File
@@ -16,14 +16,16 @@
<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 all_tag_names %}
{% if is_feature_enabled('device_tags') and 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">
@@ -67,6 +69,7 @@
</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">
@@ -78,6 +81,7 @@
{% endfor %}
</div>
{% endif %}
{% endif %}
</a>
</li>
{% endfor %}
+2 -2
View File
@@ -29,7 +29,7 @@
<span>Devices</span>
</a>
{% endif %}
{% if has_permission('view_racks') %}
{% if has_permission('view_racks') and is_feature_enabled('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>
@@ -86,7 +86,7 @@
<span>Devices</span>
</a>
{% endif %}
{% if has_permission('view_racks') %}
{% if has_permission('view_racks') and is_feature_enabled('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>
+18 -13
View File
@@ -251,21 +251,26 @@
<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 '' %}
{% set combined_desc = '' %}
{% if device_desc %}
{% set combined_desc = device_desc %}
{% endif %}
{% if ip_notes %}
{% if combined_desc %}
{% set combined_desc = combined_desc + '\n' + ip_notes %}
{% else %}
{% set combined_desc = ip_notes %}
{% if ip_notes_enabled %}
{% set combined_desc = '' %}
{% if device_desc %}
{% set combined_desc = device_desc %}
{% endif %}
{% if ip_notes %}
{% if combined_desc %}
{% set combined_desc = combined_desc + '\n' + ip_notes %}
{% else %}
{% set combined_desc = ip_notes %}
{% endif %}
{% endif %}
{% if can_edit_subnet %}
<textarea data-ip-id="{{ ip[0] }}" data-device-desc="{{ device_desc|e }}" rows="1" class="ip-notes-textarea border border-gray-600 rounded w-full p-2 bg-gray-200 dark:bg-zinc-800" style="overflow: hidden; resize: none;">{{ combined_desc }}</textarea>
{% 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 %}
{% endif %}
{% if can_edit_subnet %}
<textarea data-ip-id="{{ ip[0] }}" data-device-desc="{{ device_desc|e }}" rows="1" class="ip-notes-textarea border border-gray-600 rounded w-full p-2 bg-gray-200 dark:bg-zinc-800" style="overflow: hidden; resize: none;">{{ combined_desc }}</textarea>
{% 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>
{# 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>