10 Commits

Author SHA1 Message Date
Jamie b5fa9ef6ae Merge pull request #30 from JDB-NET/release-please--branches--main
chore(main): release 1.8.0
2025-12-23 01:07:23 +00:00
github-actions[bot] 19e7e978aa chore(main): release 1.8.0 2025-12-23 01:06:45 +00:00
jamie 64ae4be6d5 feat: get next available ip by api 2025-12-23 01:06:25 +00:00
jamie d7fcffd4b5 build: 🚀 redeploy 2025-12-20 11:20:25 +00:00
jamie 283c445263 fix: 🐛 global search missing from devices 2025-12-05 12:07:12 +00:00
Jamie 2af3584d80 Merge pull request #28 from JDB-NET/release-please--branches--main
chore(main): release 1.7.0
2025-12-05 01:38:23 +00:00
github-actions[bot] 59ded14858 chore(main): release 1.7.0 2025-12-05 01:38:06 +00:00
jamie 9c0e6d035c feat: add devices by tag page 2025-12-05 01:37:45 +00:00
jamie 8242e9d758 fix: 🐛 invalidate linked cache 2025-12-05 01:33:47 +00:00
jamie 47208b31ee fix: 🐛 invalidate cache when device type is added 2025-12-05 01:24:05 +00:00
15 changed files with 242 additions and 63 deletions
+5 -1
View File
@@ -6,7 +6,11 @@
"settings": {}, "settings": {},
"customizations": { "customizations": {
"vscode": { "vscode": {
"extensions": ["ms-python.python"] "extensions": [
"ms-python.python",
"vivaxy.vscode-conventional-commits",
"esbenp.prettier-vscode"
]
} }
}, },
"postCreateCommand": "pip install -r requirements.txt; curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64; chmod +x tailwindcss-linux-x64; mv tailwindcss-linux-x64 tailwindcss", "postCreateCommand": "pip install -r requirements.txt; curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64; chmod +x tailwindcss-linux-x64; mv tailwindcss-linux-x64 tailwindcss",
+1 -1
View File
@@ -62,7 +62,7 @@ jobs:
name: Deploy to Kubernetes name: Deploy to Kubernetes
needs: release-please needs: release-please
if: ${{ needs.release-please.outputs.release_created }} if: ${{ needs.release-please.outputs.release_created }}
runs-on: [ k3s-lan-01 ] runs-on: [ k3s-internal-htz-01 ]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
".": "1.6.1" ".": "1.8.0"
} }
+30
View File
@@ -1,5 +1,35 @@
# Changelog # Changelog
## [1.8.0](https://github.com/JDB-NET/ipam/compare/v1.7.0...v1.8.0) (2025-12-23)
### Features
* :sparkles: get next available ip by api ([64ae4be](https://github.com/JDB-NET/ipam/commit/64ae4be6d5997ff0b16ff5232237d38f2fec5b64))
### Bug Fixes
* :bug: global search missing from devices ([283c445](https://github.com/JDB-NET/ipam/commit/283c445263b7dc992448d907e682e53b7720b610))
### Build System
* :rocket: redeploy ([d7fcffd](https://github.com/JDB-NET/ipam/commit/d7fcffd4b5598b682dede864ba526b1257584f6a))
## [1.7.0](https://github.com/JDB-NET/ipam/compare/v1.6.1...v1.7.0) (2025-12-05)
### Features
* :sparkles: add devices by tag page ([9c0e6d0](https://github.com/JDB-NET/ipam/commit/9c0e6d035c8dda68281b2bfe2b7a61802353f7a7))
### Bug Fixes
* :bug: invalidate cache when device type is added ([47208b3](https://github.com/JDB-NET/ipam/commit/47208b31eed51f0cf0d7c8c411093bda1c84cf1b))
* :bug: invalidate linked cache ([8242e9d](https://github.com/JDB-NET/ipam/commit/8242e9d758ef19030b516e4a51f0cfb556f4e5ba))
## [1.6.1](https://github.com/JDB-NET/ipam/compare/v1.6.0...v1.6.1) (2025-12-05) ## [1.6.1](https://github.com/JDB-NET/ipam/compare/v1.6.0...v1.6.1) (2025-12-05)
+1 -1
View File
@@ -1 +1 @@
1.6.1 1.8.0
+12 -3
View File
@@ -133,6 +133,7 @@ def init_db(app=None):
FOREIGN KEY (device_id) REFERENCES Device(id) ON DELETE CASCADE FOREIGN KEY (device_id) REFERENCES Device(id) ON DELETE CASCADE
) )
''') ''')
# Initialize default device types only if table is empty
cursor.execute('SELECT COUNT(*) FROM DeviceType') cursor.execute('SELECT COUNT(*) FROM DeviceType')
if cursor.fetchone()[0] == 0: if cursor.fetchone()[0] == 0:
cursor.executemany('INSERT INTO DeviceType (name, icon_class) VALUES (%s, %s)', [ cursor.executemany('INSERT INTO DeviceType (name, icon_class) VALUES (%s, %s)', [
@@ -144,12 +145,20 @@ def init_db(app=None):
('Printer', 'fa-print'), ('Printer', 'fa-print'),
('Other', 'fa-question') ('Other', 'fa-question')
]) ])
conn.commit() # Commit the inserts before querying
# Add device_type_id column if it doesn't exist
cursor.execute("SHOW COLUMNS FROM Device LIKE 'device_type_id'") cursor.execute("SHOW COLUMNS FROM Device LIKE 'device_type_id'")
if not cursor.fetchone(): if not cursor.fetchone():
cursor.execute('ALTER TABLE Device ADD COLUMN device_type_id INTEGER DEFAULT NULL') cursor.execute('ALTER TABLE Device ADD COLUMN device_type_id INTEGER DEFAULT NULL')
cursor.execute("SELECT id FROM DeviceType WHERE name='Other'")
other_id = cursor.fetchone()[0] # Set default device_type_id for devices that don't have one
cursor.execute('UPDATE Device SET device_type_id = %s WHERE device_type_id IS NULL', (other_id,)) # Use the first available device type, or leave NULL if no types exist
cursor.execute('SELECT id FROM DeviceType ORDER BY id LIMIT 1')
first_type_result = cursor.fetchone()
if first_type_result:
first_type_id = first_type_result[0]
cursor.execute('UPDATE Device SET device_type_id = %s WHERE device_type_id IS NULL', (first_type_id,))
try: try:
cursor.execute('ALTER TABLE Device ADD CONSTRAINT fk_device_type FOREIGN KEY (device_type_id) REFERENCES DeviceType(id)') cursor.execute('ALTER TABLE Device ADD CONSTRAINT fk_device_type FOREIGN KEY (device_type_id) REFERENCES DeviceType(id)')
except mysql.connector.Error as e: except mysql.connector.Error as e:
+1 -1
View File
@@ -24,7 +24,7 @@ spec:
- name: SECRET_KEY - name: SECRET_KEY
value: "41TbN7v5peFLZPrdwSCc64J3mjmiUk5fkVWsmb2m" value: "41TbN7v5peFLZPrdwSCc64J3mjmiUk5fkVWsmb2m"
- name: MYSQL_HOST - name: MYSQL_HOST
value: "10.10.2.27" value: "10.10.25.4"
- name: MYSQL_USER - name: MYSQL_USER
value: "ipam" value: "ipam"
- name: MYSQL_PASSWORD - name: MYSQL_PASSWORD
+123
View File
@@ -1285,6 +1285,9 @@ def register_routes(app):
(name, color, description)) (name, color, description))
add_audit_log(session['user_id'], 'add_tag', f"Added tag '{name}'", conn=conn) add_audit_log(session['user_id'], 'add_tag', f"Added tag '{name}'", conn=conn)
conn.commit() conn.commit()
# Invalidate device caches since they contain tags
cache.clear('device:')
cache.clear('devices')
except mysql.connector.IntegrityError: except mysql.connector.IntegrityError:
error = 'Tag name already exists.' error = 'Tag name already exists.'
@@ -1305,6 +1308,9 @@ def register_routes(app):
(name, color, description, tag_id)) (name, color, description, tag_id))
add_audit_log(session['user_id'], 'edit_tag', f"Updated tag '{name}'", conn=conn) add_audit_log(session['user_id'], 'edit_tag', f"Updated tag '{name}'", conn=conn)
conn.commit() conn.commit()
# Invalidate device caches since they contain tags
cache.clear('device:')
cache.clear('devices')
except mysql.connector.IntegrityError: except mysql.connector.IntegrityError:
error = 'Tag name already exists.' error = 'Tag name already exists.'
@@ -1318,6 +1324,9 @@ def register_routes(app):
cursor.execute('DELETE FROM Tag WHERE id = %s', (tag_id,)) cursor.execute('DELETE FROM Tag WHERE id = %s', (tag_id,))
add_audit_log(session['user_id'], 'delete_tag', f"Deleted tag '{tag_name}'", conn=conn) add_audit_log(session['user_id'], 'delete_tag', f"Deleted tag '{tag_name}'", conn=conn)
conn.commit() conn.commit()
# Invalidate device caches since they contain tags
cache.clear('device:')
cache.clear('devices')
# Get all tags with device counts # Get all tags with device counts
cursor.execute(''' cursor.execute('''
@@ -1890,6 +1899,10 @@ def register_routes(app):
conn.commit() conn.commit()
dhcp_pool = None dhcp_pool = None
add_audit_log(session['user_id'], 'dhcp_pool_remove', f"Removed DHCP pool for subnet {subnet[1]} ({subnet[2]})", subnet_id, conn=conn) add_audit_log(session['user_id'], 'dhcp_pool_remove', f"Removed DHCP pool for subnet {subnet[1]} ({subnet[2]})", subnet_id, conn=conn)
# Invalidate subnet cache and related caches
cache.invalidate_subnet(subnet_id)
cache.clear('index')
cache.clear('admin')
else: else:
start_ip = request.form['start_ip'] start_ip = request.form['start_ip']
end_ip = request.form['end_ip'] end_ip = request.form['end_ip']
@@ -1920,6 +1933,10 @@ def register_routes(app):
conn.commit() conn.commit()
dhcp_pool = {'start_ip': start_ip, 'end_ip': end_ip, 'excluded_ips': excluded_ips} dhcp_pool = {'start_ip': start_ip, 'end_ip': end_ip, 'excluded_ips': excluded_ips}
add_audit_log(session['user_id'], action, details, subnet_id, conn=conn) add_audit_log(session['user_id'], action, details, subnet_id, conn=conn)
# Invalidate subnet cache and related caches
cache.invalidate_subnet(subnet_id)
cache.clear('index')
cache.clear('admin')
return render_with_user('dhcp.html', subnet={'id': subnet[0], 'name': subnet[1]}, dhcp_pool=dhcp_pool, error=error) return render_with_user('dhcp.html', subnet={'id': subnet[0], 'name': subnet[1]}, dhcp_pool=dhcp_pool, error=error)
@app.route('/device_type_stats') @app.route('/device_type_stats')
@@ -1962,6 +1979,10 @@ def register_routes(app):
try: try:
cursor.execute('INSERT INTO DeviceType (name, icon_class) VALUES (%s, %s)', (name, icon_class)) cursor.execute('INSERT INTO DeviceType (name, icon_class) VALUES (%s, %s)', (name, icon_class))
conn.commit() conn.commit()
# Invalidate all device caches since they contain device_types list
cache.clear('device:')
cache.clear('devices')
cache.clear('device_list')
logging.info(f"User {user_name} added device type '{name}' with icon '{icon_class}'.") logging.info(f"User {user_name} added device type '{name}' with icon '{icon_class}'.")
except mysql.connector.IntegrityError as e: except mysql.connector.IntegrityError as e:
if e.errno == 1062: # Duplicate entry if e.errno == 1062: # Duplicate entry
@@ -1983,6 +2004,9 @@ def register_routes(app):
try: try:
cursor.execute('UPDATE DeviceType SET name = %s, icon_class = %s WHERE id = %s', (name, icon_class, device_type_id)) cursor.execute('UPDATE DeviceType SET name = %s, icon_class = %s WHERE id = %s', (name, icon_class, device_type_id))
conn.commit() conn.commit()
cache.clear('device:')
cache.clear('devices')
cache.clear('device_list')
logging.info(f"User {user_name} edited device type {device_type_id} to '{name}' with icon '{icon_class}'.") logging.info(f"User {user_name} edited device type {device_type_id} to '{name}' with icon '{icon_class}'.")
except mysql.connector.IntegrityError as e: except mysql.connector.IntegrityError as e:
if e.errno == 1062: # Duplicate entry if e.errno == 1062: # Duplicate entry
@@ -2006,6 +2030,10 @@ def register_routes(app):
device_type_name = cursor.fetchone()[0] device_type_name = cursor.fetchone()[0]
cursor.execute('DELETE FROM DeviceType WHERE id = %s', (device_type_id,)) cursor.execute('DELETE FROM DeviceType WHERE id = %s', (device_type_id,))
conn.commit() conn.commit()
# Invalidate all device caches since they contain device_types list
cache.clear('device:')
cache.clear('devices')
cache.clear('device_list')
logging.info(f"User {user_name} deleted device type '{device_type_name}'.") logging.info(f"User {user_name} deleted device type '{device_type_name}'.")
cursor.execute('SELECT id, name, icon_class FROM DeviceType ORDER BY name') cursor.execute('SELECT id, name, icon_class FROM DeviceType ORDER BY name')
device_types = cursor.fetchall() device_types = cursor.fetchall()
@@ -2043,6 +2071,39 @@ def register_routes(app):
site_devices[site].append({'id': device_id, 'name': name, 'description': description}) site_devices[site].append({'id': device_id, 'name': name, 'description': description})
return render_with_user('devices_by_type.html', device_type=device_type, icon_class=icon_class, site_devices=site_devices) return render_with_user('devices_by_type.html', device_type=device_type, icon_class=icon_class, site_devices=site_devices)
@app.route('/devices/tag/<int:tag_id>')
@permission_required('view_devices')
def devices_by_tag(tag_id):
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT id, name, color FROM Tag WHERE id = %s', (tag_id,))
row = cursor.fetchone()
if not row:
return f"Tag not found", 404
tag_id_db, tag_name, tag_color = row
cursor.execute('''
SELECT DISTINCT Device.id, Device.name, Device.description, Subnet.site
FROM Device
JOIN DeviceTag ON Device.id = DeviceTag.device_id
LEFT JOIN DeviceIPAddress ON Device.id = DeviceIPAddress.device_id
LEFT JOIN IPAddress ON DeviceIPAddress.ip_id = IPAddress.id
LEFT JOIN Subnet ON IPAddress.subnet_id = Subnet.id
WHERE DeviceTag.tag_id = %s
''', (tag_id,))
devices = cursor.fetchall()
seen_ids = set()
site_devices = {}
for device_id, name, description, site in devices:
if device_id in seen_ids:
continue
seen_ids.add(device_id)
site = site or 'Unassigned'
if site not in site_devices:
site_devices[site] = []
site_devices[site].append({'id': device_id, 'name': name, 'description': description})
return render_with_user('devices_by_tag.html', tag_name=tag_name, tag_color=tag_color, site_devices=site_devices)
@app.route('/racks') @app.route('/racks')
@permission_required('view_racks') @permission_required('view_racks')
def racks(): def racks():
@@ -2744,6 +2805,33 @@ def register_routes(app):
subnet['ip_addresses'] = cursor.fetchall() subnet['ip_addresses'] = cursor.fetchall()
return jsonify(subnet) return jsonify(subnet)
@app.route('/api/v1/subnets/<int:subnet_id>/next_free_ip', methods=['GET'])
@api_permission_required('view_subnet')
def api_subnet_next_free_ip(subnet_id):
"""Get the next free IP address in a subnet"""
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor(dictionary=True)
# First check if subnet exists
cursor.execute('SELECT id FROM Subnet WHERE id = %s', (subnet_id,))
if not cursor.fetchone():
return jsonify({'error': 'Subnet not found'}), 404
# Find the first IP in the subnet that is not assigned to any device
cursor.execute('''
SELECT ip.id, ip.ip
FROM IPAddress ip
LEFT JOIN DeviceIPAddress dia ON ip.id = dia.ip_id
WHERE ip.subnet_id = %s AND dia.ip_id IS NULL
ORDER BY INET_ATON(ip.ip)
LIMIT 1
''', (subnet_id,))
result = cursor.fetchone()
if not result:
return jsonify({'error': 'No free IP addresses available in this subnet'}), 404
return jsonify({'id': result['id'], 'ip': result['ip']})
@app.route('/api/v1/subnets', methods=['POST']) @app.route('/api/v1/subnets', methods=['POST'])
@api_permission_required('add_subnet') @api_permission_required('add_subnet')
def api_add_subnet(): def api_add_subnet():
@@ -3106,6 +3194,10 @@ def register_routes(app):
conn=conn conn=conn
) )
conn.commit() conn.commit()
# Invalidate subnet cache and related caches
cache.invalidate_subnet(subnet_id)
cache.clear('index')
cache.clear('admin')
return jsonify({'message': 'DHCP pool removed successfully'}) return jsonify({'message': 'DHCP pool removed successfully'})
pools = data.get('pools') pools = data.get('pools')
@@ -3156,6 +3248,10 @@ def register_routes(app):
add_audit_log(request.api_user['id'], action, details, subnet_id, conn=conn) add_audit_log(request.api_user['id'], action, details, subnet_id, conn=conn)
conn.commit() conn.commit()
# Invalidate subnet cache and related caches
cache.invalidate_subnet(subnet_id)
cache.clear('index')
cache.clear('admin')
return jsonify({'message': 'DHCP pools configured successfully', 'pool': {'start_ip': start_ip, 'end_ip': end_ip, 'excluded_ips': excluded_list}}) return jsonify({'message': 'DHCP pools configured successfully', 'pool': {'start_ip': start_ip, 'end_ip': end_ip, 'excluded_ips': excluded_list}})
# Tags API # Tags API
@@ -3196,6 +3292,9 @@ def register_routes(app):
tag_id = cursor.lastrowid tag_id = cursor.lastrowid
add_audit_log(request.api_user['id'], 'add_tag', f"Added tag '{name}'", conn=conn) add_audit_log(request.api_user['id'], 'add_tag', f"Added tag '{name}'", conn=conn)
conn.commit() conn.commit()
# Invalidate device caches since they contain tags
cache.clear('device:')
cache.clear('devices')
return jsonify({'id': tag_id, 'name': name, 'color': color, 'description': description}), 201 return jsonify({'id': tag_id, 'name': name, 'color': color, 'description': description}), 201
except mysql.connector.IntegrityError: except mysql.connector.IntegrityError:
return jsonify({'error': 'Tag name already exists'}), 400 return jsonify({'error': 'Tag name already exists'}), 400
@@ -3265,6 +3364,9 @@ def register_routes(app):
cursor.execute(f'UPDATE Tag SET {", ".join(updates)} WHERE id = %s', values) cursor.execute(f'UPDATE Tag SET {", ".join(updates)} WHERE id = %s', values)
add_audit_log(request.api_user['id'], 'edit_tag', f"Updated tag '{current_name}'", conn=conn) add_audit_log(request.api_user['id'], 'edit_tag', f"Updated tag '{current_name}'", conn=conn)
conn.commit() conn.commit()
# Invalidate device caches since they contain tags
cache.clear('device:')
cache.clear('devices')
return jsonify({'message': 'Tag updated successfully'}) return jsonify({'message': 'Tag updated successfully'})
except mysql.connector.IntegrityError: except mysql.connector.IntegrityError:
return jsonify({'error': 'Tag name already exists'}), 400 return jsonify({'error': 'Tag name already exists'}), 400
@@ -3284,6 +3386,9 @@ def register_routes(app):
cursor.execute('DELETE FROM Tag WHERE id = %s', (tag_id,)) cursor.execute('DELETE FROM Tag WHERE id = %s', (tag_id,))
add_audit_log(request.api_user['id'], 'delete_tag', f"Deleted tag '{tag_name}'", conn=conn) add_audit_log(request.api_user['id'], 'delete_tag', f"Deleted tag '{tag_name}'", conn=conn)
conn.commit() conn.commit()
# Invalidate device caches since they contain tags
cache.clear('device:')
cache.clear('devices')
return jsonify({'message': 'Tag deleted successfully'}) return jsonify({'message': 'Tag deleted successfully'})
@app.route('/api/v1/devices/<int:device_id>/tags', methods=['GET']) @app.route('/api/v1/devices/<int:device_id>/tags', methods=['GET'])
@@ -3337,6 +3442,8 @@ def register_routes(app):
cursor.execute('INSERT INTO DeviceTag (device_id, tag_id) VALUES (%s, %s)', (device_id, tag_id)) cursor.execute('INSERT INTO DeviceTag (device_id, tag_id) VALUES (%s, %s)', (device_id, tag_id))
add_audit_log(request.api_user['id'], 'assign_device_tag', f"Assigned tag '{tag_name}' to device '{device_name}'", conn=conn) add_audit_log(request.api_user['id'], 'assign_device_tag', f"Assigned tag '{tag_name}' to device '{device_name}'", conn=conn)
conn.commit() conn.commit()
invalidate_cache_for_device(device_id)
cache.clear('devices')
return jsonify({'message': 'Tag assigned successfully'}) return jsonify({'message': 'Tag assigned successfully'})
@app.route('/api/v1/devices/<int:device_id>/tags/<int:tag_id>', methods=['DELETE']) @app.route('/api/v1/devices/<int:device_id>/tags/<int:tag_id>', methods=['DELETE'])
@@ -3365,6 +3472,8 @@ def register_routes(app):
cursor.execute('DELETE FROM DeviceTag WHERE device_id = %s AND tag_id = %s', (device_id, tag_id)) cursor.execute('DELETE FROM DeviceTag WHERE device_id = %s AND tag_id = %s', (device_id, tag_id))
add_audit_log(request.api_user['id'], 'remove_device_tag', f"Removed tag '{tag_name}' from device '{device_name}'", conn=conn) add_audit_log(request.api_user['id'], 'remove_device_tag', f"Removed tag '{tag_name}' from device '{device_name}'", conn=conn)
conn.commit() conn.commit()
invalidate_cache_for_device(device_id)
cache.clear('devices')
return jsonify({'message': 'Tag removed successfully'}) return jsonify({'message': 'Tag removed successfully'})
@app.route('/api/v1/devices/by-tag/<tag_identifier>', methods=['GET']) @app.route('/api/v1/devices/by-tag/<tag_identifier>', methods=['GET'])
@@ -3588,6 +3697,7 @@ def register_routes(app):
results = {'success': [], 'failed': []} results = {'success': [], 'failed': []}
from flask import current_app from flask import current_app
subnet_ids_to_invalidate = set()
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 FROM Device WHERE id = %s', (device_id,)) cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
@@ -3606,6 +3716,7 @@ def register_routes(app):
continue continue
ip, subnet_id = ip_row[0], ip_row[1] ip, subnet_id = ip_row[0], ip_row[1]
subnet_ids_to_invalidate.add(subnet_id)
# Check if IP is already assigned # Check if IP is already assigned
cursor.execute('SELECT id FROM DeviceIPAddress WHERE ip_id = %s', (ip_id,)) cursor.execute('SELECT id FROM DeviceIPAddress WHERE ip_id = %s', (ip_id,))
@@ -3650,6 +3761,10 @@ def register_routes(app):
conn.commit() conn.commit()
# Invalidate device and subnet caches
invalidate_cache_for_device(device_id)
for subnet_id in subnet_ids_to_invalidate:
cache.invalidate_subnet(subnet_id)
return jsonify(results) return jsonify(results)
@app.route('/bulk/create_devices', methods=['POST']) @app.route('/bulk/create_devices', methods=['POST'])
@@ -3676,6 +3791,9 @@ def register_routes(app):
results['failed'].append({'name': name, 'reason': str(e)}) results['failed'].append({'name': name, 'reason': str(e)})
conn.commit() conn.commit()
# Invalidate devices cache
cache.clear('devices')
cache.clear('device_list')
logging.info(f"User {user_name} bulk created {len(results['success'])} devices.") logging.info(f"User {user_name} bulk created {len(results['success'])} devices.")
return jsonify(results) return jsonify(results)
@@ -3718,6 +3836,10 @@ def register_routes(app):
results['failed'].append({'device_id': device_id, 'tag_id': tag_id, 'reason': str(e)}) results['failed'].append({'device_id': device_id, 'tag_id': tag_id, 'reason': str(e)})
conn.commit() conn.commit()
# Invalidate device caches for all affected devices
for device_id in device_ids:
invalidate_cache_for_device(device_id)
cache.clear('devices')
logging.info(f"User {user_name} bulk assigned tags to {len(device_ids)} devices.") logging.info(f"User {user_name} bulk assigned tags to {len(device_ids)} devices.")
return jsonify(results) return jsonify(results)
@@ -3803,6 +3925,7 @@ def register_routes(app):
app.add_url_rule('/device_type_stats', 'device_type_stats', device_type_stats) app.add_url_rule('/device_type_stats', 'device_type_stats', device_type_stats)
app.add_url_rule('/device_types', 'device_types', device_types, methods=['GET', 'POST']) app.add_url_rule('/device_types', 'device_types', device_types, methods=['GET', 'POST'])
app.add_url_rule('/devices/type/<device_type>', 'devices_by_type', devices_by_type) app.add_url_rule('/devices/type/<device_type>', 'devices_by_type', devices_by_type)
app.add_url_rule('/devices/tag/<int:tag_id>', 'devices_by_tag', devices_by_tag)
app.add_url_rule('/racks', 'racks', racks) app.add_url_rule('/racks', 'racks', racks)
app.add_url_rule('/rack/add', 'add_rack', add_rack, methods=['GET', 'POST']) app.add_url_rule('/rack/add', 'add_rack', add_rack, methods=['GET', 'POST'])
app.add_url_rule('/rack/<int:rack_id>', 'rack', rack) app.add_url_rule('/rack/<int:rack_id>', 'rack', rack)
+1 -1
View File
@@ -1,7 +1,7 @@
h2 { h2 {
cursor: pointer; cursor: pointer;
} }
form:not(.mb-6), .mt-4 { .container form:not(.mb-6), .mt-4 {
display: none; display: none;
} }
.allocated-ips { .allocated-ips {
-50
View File
@@ -30,56 +30,6 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}); });
// Search functionality
const searchInput = document.getElementById('search');
searchInput.addEventListener('keypress', function(event) {
if (event.key === 'Enter') {
event.preventDefault();
const query = this.value.toLowerCase();
document.querySelectorAll('.site-group').forEach(siteGroup => {
let anyVisible = false;
siteGroup.querySelectorAll('.device-list li').forEach(li => {
const deviceName = li.querySelector('span').textContent.toLowerCase();
const ipSpans = li.querySelectorAll('span.inline-block');
let match = deviceName.includes(query);
if (!match) {
ipSpans.forEach(ipSpan => {
if (ipSpan.textContent.toLowerCase().includes(query)) {
match = true;
}
});
}
li.style.display = match ? '' : 'none';
const card = li.querySelector('a');
if (match) {
anyVisible = true;
siteGroup.querySelector('.device-list').classList.remove('hidden');
const icon = siteGroup.querySelector('.expand-btn i');
if (icon && icon.classList.contains('fa-chevron-down')) {
icon.classList.remove('fa-chevron-down');
icon.classList.add('fa-chevron-up');
}
if (card) {
card.style.transition = 'background-color 0.3s';
card.style.backgroundColor = '#2563eb';
card.style.color = '#fff';
setTimeout(() => {
card.style.backgroundColor = '';
card.style.color = '';
}, 2000);
}
} else {
if (card) {
card.style.backgroundColor = '';
card.style.color = '';
}
}
});
siteGroup.style.display = anyVisible ? '' : 'none';
});
}
});
// Scroll to Top Button // Scroll to Top Button
const scrollToTopButton = document.createElement('button'); const scrollToTopButton = document.createElement('button');
scrollToTopButton.innerHTML = '<i class="fas fa-arrow-up"></i>'; scrollToTopButton.innerHTML = '<i class="fas fa-arrow-up"></i>';
+1
View File
@@ -177,6 +177,7 @@
<ul class="list-disc list-inside space-y-1 text-sm"> <ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/subnets</code> - List all subnets</li> <li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/subnets</code> - List all subnets</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/subnets/{id}</code> - Get subnet details</li> <li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/subnets/{id}</code> - Get subnet details</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/subnets/{id}/next_free_ip</code> - Get next free IP address</li>
</ul> </ul>
</div> </div>
<div> <div>
-2
View File
@@ -22,8 +22,6 @@
<!-- Filters Section --> <!-- Filters Section -->
<div class="mb-6 space-y-4"> <div class="mb-6 space-y-4">
<input type="text" id="search" placeholder="Search devices or IPs..." class="border p-3 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full">
<!-- Tag Filter --> <!-- Tag Filter -->
{% if all_tag_names %} {% if all_tag_names %}
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
+64
View File
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ tag_name }} - Tagged Devices</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-3xl pt-20">
<div class="flex items-center mb-6 relative">
<a href="/tags" class="absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 flex items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
<h1 class="text-3xl font-bold text-center w-full">
<div class="flex items-center justify-center space-x-2">
<div class="w-5 h-5 rounded-full border border-gray-600" style="background-color: {{ tag_color }}"></div>
<span>{{ tag_name }} - Tagged Devices</span>
</div>
</h1>
</div>
{% if site_devices %}
{% for site, devices in site_devices.items() %}
<div class="mb-8">
<h2 class="text-xl font-bold mb-4 dark:text-white">{{ site }}</h2>
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-4">
<table class="w-full table-auto mb-2">
<thead>
<tr class="bg-gray-200 dark:bg-zinc-700">
<th class="px-4 py-2 text-left">Device Name</th>
<th class="px-4 py-2 text-left">Description</th>
</tr>
</thead>
<tbody>
{% for device in devices %}
<tr class="border-b border-gray-700">
<td class="px-4 py-3">
<a href="/device/{{ device.id }}" class="hover:underline dark:text-white hover:cursor-pointer">{{ device.name }}</a>
</td>
<td class="px-4 py-3">{{ device.description or '' }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="bg-gray-200 dark:bg-zinc-900 font-bold">
<td class="px-4 py-2 text-right" colspan="2">Total: {{ devices|length }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
{% endfor %}
{% else %}
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-8 text-center">
<p class="text-gray-500">No devices found with this tag.</p>
</div>
{% endif %}
</div>
</div>
</body>
</html>
+1 -1
View File
@@ -6,7 +6,7 @@
</a> </a>
<a href="https://github.com/JDB-NET/ipam/releases" target="_blank" rel="noopener noreferrer" class="text-sm font-normal text-gray-300 hover:text-gray-100 -ml-1 mt-3">v{{ VERSION }}</a> <a href="https://github.com/JDB-NET/ipam/releases" target="_blank" rel="noopener noreferrer" class="text-sm font-normal text-gray-300 hover:text-gray-100 -ml-1 mt-3">v{{ VERSION }}</a>
</div> </div>
<div class="hidden lg:flex items-center justify-center absolute left-1/2 transform -translate-x-1/2"> <div class="hidden lg:flex items-center justify-center absolute left-1/2" style="transform: translateX(calc(-50% + 1.5rem));">
<form action="/search" method="GET" class="flex items-center space-x-2"> <form action="/search" method="GET" class="flex items-center space-x-2">
<input type="text" name="q" id="search-input" placeholder="Search..." <input type="text" name="q" id="search-input" placeholder="Search..."
class="bg-zinc-700 text-white placeholder-gray-400 px-4 py-2 rounded-md text-base focus:outline-none focus:ring-2 focus:ring-gray-500 w-100" class="bg-zinc-700 text-white placeholder-gray-400 px-4 py-2 rounded-md text-base focus:outline-none focus:ring-2 focus:ring-gray-500 w-100"
+1 -1
View File
@@ -58,7 +58,7 @@
</td> </td>
<td class="p-3 text-center"> <td class="p-3 text-center">
{% if tag.device_count > 0 %} {% if tag.device_count > 0 %}
<a href="/api/v1/devices/by-tag/{{ tag.name }}" class="text-blue-400 hover:text-blue-600"> <a href="/devices/tag/{{ tag.id }}" class="text-blue-400 hover:text-blue-600 hover:cursor-pointer">
{{ tag.device_count }} {{ tag.device_count }}
</a> </a>
{% else %} {% else %}