Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0029abb8cd | |||
| ee72a89287 | |||
| 5c1ad03990 | |||
| 4b21fdc5cf | |||
| b381195200 | |||
| 80b6de395f | |||
| d56e0647f7 |
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "1.4.0"
|
".": "1.4.2"
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,20 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.4.2](https://github.com/JDB-NET/ipam/compare/v1.4.1...v1.4.2) (2025-11-08)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* :bug: ensure all fields are updated by api ([5c1ad03](https://github.com/JDB-NET/ipam/commit/5c1ad039904b2c8c8629242b5558b03da5ad782c))
|
||||||
|
|
||||||
|
## [1.4.1](https://github.com/JDB-NET/ipam/compare/v1.4.0...v1.4.1) (2025-11-06)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* :bug: pagination no longer gets out of control ([80b6de3](https://github.com/JDB-NET/ipam/commit/80b6de395fc4ddb4e7cd3ece89b423af2667d298))
|
||||||
|
* :bug: styling of admin and users pages ([d56e064](https://github.com/JDB-NET/ipam/commit/d56e0647f74fba1db1f504e02364406691ede9f3))
|
||||||
|
|
||||||
## [1.4.0](https://github.com/JDB-NET/ipam/compare/v1.3.0...v1.4.0) (2025-11-06)
|
## [1.4.0](https://github.com/JDB-NET/ipam/compare/v1.3.0...v1.4.0) (2025-11-06)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1356,30 +1356,41 @@ def register_routes(app):
|
|||||||
from flask import current_app
|
from flask import current_app
|
||||||
with get_db_connection(current_app) as conn:
|
with get_db_connection(current_app) as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
|
cursor.execute('SELECT name, description, device_type_id FROM Device WHERE id = %s', (device_id,))
|
||||||
if not cursor.fetchone():
|
current = cursor.fetchone()
|
||||||
|
if not current:
|
||||||
return jsonify({'error': 'Device not found'}), 404
|
return jsonify({'error': 'Device not found'}), 404
|
||||||
|
current_name, current_description, current_device_type = current
|
||||||
|
|
||||||
updates = []
|
updates = []
|
||||||
values = []
|
values = []
|
||||||
|
rename = False
|
||||||
|
new_name = current_name
|
||||||
if 'name' in data:
|
if 'name' in data:
|
||||||
updates.append('name = %s')
|
new_name = data['name']
|
||||||
values.append(data['name'])
|
if new_name != current_name:
|
||||||
if 'description' in data:
|
updates.append('name = %s')
|
||||||
|
values.append(new_name)
|
||||||
|
rename = True
|
||||||
|
if 'description' in data and data['description'] != current_description:
|
||||||
updates.append('description = %s')
|
updates.append('description = %s')
|
||||||
values.append(data['description'])
|
values.append(data['description'])
|
||||||
if 'device_type_id' in data:
|
if 'device_type_id' in data and data['device_type_id'] != current_device_type:
|
||||||
updates.append('device_type_id = %s')
|
updates.append('device_type_id = %s')
|
||||||
values.append(data['device_type_id'])
|
values.append(data['device_type_id'])
|
||||||
|
|
||||||
if not updates:
|
if not updates:
|
||||||
return jsonify({'error': 'No fields to update'}), 400
|
return jsonify({'error': 'No changes to apply'}), 400
|
||||||
|
|
||||||
values.append(device_id)
|
values.append(device_id)
|
||||||
cursor.execute(f'UPDATE Device SET {", ".join(updates)} WHERE id = %s', values)
|
cursor.execute(f'UPDATE Device SET {", ".join(updates)} WHERE id = %s', values)
|
||||||
add_audit_log(request.api_user['id'], 'edit_device', f"Updated device {device_id}", conn=conn)
|
|
||||||
|
if rename:
|
||||||
|
cursor.execute('UPDATE IPAddress SET hostname = %s WHERE hostname = %s', (new_name, current_name))
|
||||||
|
add_audit_log(request.api_user['id'], 'rename_device', f"Renamed device '{current_name}' to '{new_name}'", conn=conn)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return jsonify({'message': 'Device updated successfully'})
|
return jsonify({'message': 'Device updated successfully', 'device': {'id': device_id, 'name': new_name}})
|
||||||
|
|
||||||
@app.route('/api/v1/devices/<int:device_id>', methods=['DELETE'])
|
@app.route('/api/v1/devices/<int:device_id>', methods=['DELETE'])
|
||||||
@api_permission_required('delete_device')
|
@api_permission_required('delete_device')
|
||||||
@@ -1392,10 +1403,16 @@ def register_routes(app):
|
|||||||
device = cursor.fetchone()
|
device = cursor.fetchone()
|
||||||
if not device:
|
if not device:
|
||||||
return jsonify({'error': 'Device not found'}), 404
|
return jsonify({'error': 'Device not found'}), 404
|
||||||
|
device_name = device[0]
|
||||||
|
cursor.execute('SELECT ip_id FROM DeviceIPAddress WHERE device_id = %s', (device_id,))
|
||||||
|
ip_ids = [row[0] for row in cursor.fetchall()]
|
||||||
|
if ip_ids:
|
||||||
|
cursor.executemany('UPDATE IPAddress SET hostname = NULL WHERE id = %s', [(ip_id,) for ip_id in ip_ids])
|
||||||
|
cursor.execute('DELETE FROM DeviceIPAddress WHERE device_id = %s', (device_id,))
|
||||||
cursor.execute('DELETE FROM Device WHERE id = %s', (device_id,))
|
cursor.execute('DELETE FROM Device WHERE id = %s', (device_id,))
|
||||||
add_audit_log(request.api_user['id'], 'delete_device', f"Deleted device {device[0]}", conn=conn)
|
add_audit_log(request.api_user['id'], 'delete_device', f"Deleted device {device_name}", conn=conn)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return jsonify({'message': 'Device deleted successfully'})
|
return jsonify({'message': 'Device deleted successfully', 'device': {'id': device_id, 'name': device_name}})
|
||||||
|
|
||||||
@app.route('/api/v1/devices/<int:device_id>/ips', methods=['POST'])
|
@app.route('/api/v1/devices/<int:device_id>/ips', methods=['POST'])
|
||||||
@api_permission_required('add_device_ip')
|
@api_permission_required('add_device_ip')
|
||||||
@@ -1409,19 +1426,57 @@ def register_routes(app):
|
|||||||
from flask import current_app
|
from flask import current_app
|
||||||
with get_db_connection(current_app) as conn:
|
with get_db_connection(current_app) as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute('SELECT id FROM Device WHERE id = %s', (device_id,))
|
cursor.execute('SELECT id, name FROM Device WHERE id = %s', (device_id,))
|
||||||
if not cursor.fetchone():
|
device_row = cursor.fetchone()
|
||||||
|
if not device_row:
|
||||||
return jsonify({'error': 'Device not found'}), 404
|
return jsonify({'error': 'Device not found'}), 404
|
||||||
cursor.execute('SELECT id FROM IPAddress WHERE id = %s', (ip_id,))
|
device_name = device_row[1]
|
||||||
if not cursor.fetchone():
|
|
||||||
|
cursor.execute('SELECT ip, subnet_id FROM IPAddress WHERE id = %s', (ip_id,))
|
||||||
|
ip_row = cursor.fetchone()
|
||||||
|
if not ip_row:
|
||||||
return jsonify({'error': 'IP address not found'}), 404
|
return jsonify({'error': 'IP address not found'}), 404
|
||||||
cursor.execute('SELECT id FROM DeviceIPAddress WHERE device_id = %s AND ip_id = %s', (device_id, ip_id))
|
ip, subnet_id = ip_row
|
||||||
|
|
||||||
|
cursor.execute('SELECT id FROM DeviceIPAddress WHERE ip_id = %s', (ip_id,))
|
||||||
if cursor.fetchone():
|
if cursor.fetchone():
|
||||||
return jsonify({'error': 'IP address already assigned to this device'}), 400
|
return jsonify({'error': 'IP address already assigned to a device'}), 400
|
||||||
|
|
||||||
|
cursor.execute('SELECT start_ip, end_ip, excluded_ips FROM DHCPPool WHERE subnet_id = %s', (subnet_id,))
|
||||||
|
dhcp_row = cursor.fetchone()
|
||||||
|
if dhcp_row:
|
||||||
|
start_ip, end_ip, excluded_ips = dhcp_row
|
||||||
|
excluded_list = [x for x in (excluded_ips or '').replace(' ', '').split(',') if x]
|
||||||
|
if ip not in excluded_list:
|
||||||
|
cursor.execute('SELECT ip FROM IPAddress WHERE subnet_id = %s ORDER BY ip', (subnet_id,))
|
||||||
|
all_ips = [row[0] for row in cursor.fetchall()]
|
||||||
|
in_range = False
|
||||||
|
reserved_for_dhcp = False
|
||||||
|
for candidate_ip in all_ips:
|
||||||
|
if candidate_ip == start_ip:
|
||||||
|
in_range = True
|
||||||
|
if in_range and candidate_ip == ip:
|
||||||
|
reserved_for_dhcp = True
|
||||||
|
break
|
||||||
|
if candidate_ip == end_ip:
|
||||||
|
if candidate_ip == ip:
|
||||||
|
reserved_for_dhcp = True
|
||||||
|
in_range = False
|
||||||
|
if reserved_for_dhcp:
|
||||||
|
return jsonify({'error': 'This IP is reserved for DHCP and cannot be assigned to a device'}), 400
|
||||||
|
|
||||||
cursor.execute('INSERT INTO DeviceIPAddress (device_id, ip_id) VALUES (%s, %s)', (device_id, ip_id))
|
cursor.execute('INSERT INTO DeviceIPAddress (device_id, ip_id) VALUES (%s, %s)', (device_id, ip_id))
|
||||||
add_audit_log(request.api_user['id'], 'add_device_ip', f"Added IP to device {device_id}", conn=conn)
|
cursor.execute('UPDATE IPAddress SET hostname = %s WHERE id = %s', (device_name, ip_id))
|
||||||
|
cursor.execute('SELECT name, cidr FROM Subnet WHERE id = %s', (subnet_id,))
|
||||||
|
subnet_row = cursor.fetchone()
|
||||||
|
if subnet_row:
|
||||||
|
subnet_name, subnet_cidr = subnet_row
|
||||||
|
details = f"Assigned IP {ip} ({subnet_name} {subnet_cidr}) to device {device_name}"
|
||||||
|
else:
|
||||||
|
details = f"Assigned IP {ip} to device {device_name}"
|
||||||
|
add_audit_log(request.api_user['id'], 'device_add_ip', details, subnet_id, conn=conn)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return jsonify({'message': 'IP address added to device successfully'}), 201
|
return jsonify({'message': 'IP address added to device successfully', 'ip_id': ip_id}), 201
|
||||||
|
|
||||||
@app.route('/api/v1/devices/<int:device_id>/ips/<int:ip_id>', methods=['DELETE'])
|
@app.route('/api/v1/devices/<int:device_id>/ips/<int:ip_id>', methods=['DELETE'])
|
||||||
@api_permission_required('remove_device_ip')
|
@api_permission_required('remove_device_ip')
|
||||||
@@ -1430,12 +1485,29 @@ def register_routes(app):
|
|||||||
from flask import current_app
|
from flask import current_app
|
||||||
with get_db_connection(current_app) as conn:
|
with get_db_connection(current_app) as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute('DELETE FROM DeviceIPAddress WHERE device_id = %s AND ip_id = %s', (device_id, ip_id))
|
cursor.execute('''
|
||||||
if cursor.rowcount == 0:
|
SELECT ip.ip, ip.subnet_id, d.name
|
||||||
|
FROM DeviceIPAddress dia
|
||||||
|
JOIN IPAddress ip ON dia.ip_id = ip.id
|
||||||
|
JOIN Device d ON dia.device_id = d.id
|
||||||
|
WHERE dia.device_id = %s AND dia.ip_id = %s
|
||||||
|
''', (device_id, ip_id))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
return jsonify({'error': 'IP address not found on device'}), 404
|
return jsonify({'error': 'IP address not found on device'}), 404
|
||||||
add_audit_log(request.api_user['id'], 'remove_device_ip', f"Removed IP from device {device_id}", conn=conn)
|
ip, subnet_id, device_name = row
|
||||||
|
cursor.execute('DELETE FROM DeviceIPAddress WHERE device_id = %s AND ip_id = %s', (device_id, ip_id))
|
||||||
|
cursor.execute('UPDATE IPAddress SET hostname = NULL WHERE id = %s', (ip_id,))
|
||||||
|
cursor.execute('SELECT name, cidr FROM Subnet WHERE id = %s', (subnet_id,))
|
||||||
|
subnet_row = cursor.fetchone()
|
||||||
|
if subnet_row:
|
||||||
|
subnet_name, subnet_cidr = subnet_row
|
||||||
|
details = f"Removed IP {ip} ({subnet_name} {subnet_cidr}) from device {device_name}"
|
||||||
|
else:
|
||||||
|
details = f"Removed IP {ip} from device {device_name}"
|
||||||
|
add_audit_log(request.api_user['id'], 'device_delete_ip', details, subnet_id, conn=conn)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return jsonify({'message': 'IP address removed from device successfully'})
|
return jsonify({'message': 'IP address removed from device successfully', 'ip_id': ip_id})
|
||||||
|
|
||||||
# Subnets API
|
# Subnets API
|
||||||
@app.route('/api/v1/subnets', methods=['GET'])
|
@app.route('/api/v1/subnets', methods=['GET'])
|
||||||
@@ -1518,31 +1590,42 @@ def register_routes(app):
|
|||||||
from flask import current_app
|
from flask import current_app
|
||||||
with get_db_connection(current_app) as conn:
|
with get_db_connection(current_app) as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute('SELECT name, cidr FROM Subnet WHERE id = %s', (subnet_id,))
|
cursor.execute('SELECT name, cidr, site FROM Subnet WHERE id = %s', (subnet_id,))
|
||||||
old_subnet = cursor.fetchone()
|
old_subnet = cursor.fetchone()
|
||||||
if not old_subnet:
|
if not old_subnet:
|
||||||
return jsonify({'error': 'Subnet not found'}), 404
|
return jsonify({'error': 'Subnet not found'}), 404
|
||||||
|
old_name, old_cidr, old_site = old_subnet
|
||||||
|
|
||||||
|
new_name = data.get('name', old_name)
|
||||||
|
new_cidr = data.get('cidr', old_cidr)
|
||||||
|
new_site = data.get('site', old_site)
|
||||||
|
|
||||||
updates = []
|
updates = []
|
||||||
values = []
|
values = []
|
||||||
if 'name' in data:
|
if new_name != old_name:
|
||||||
updates.append('name = %s')
|
updates.append('name = %s')
|
||||||
values.append(data['name'])
|
values.append(new_name)
|
||||||
if 'cidr' in data:
|
if new_cidr != old_cidr:
|
||||||
updates.append('cidr = %s')
|
updates.append('cidr = %s')
|
||||||
values.append(data['cidr'])
|
values.append(new_cidr)
|
||||||
if 'site' in data:
|
if new_site != old_site:
|
||||||
updates.append('site = %s')
|
updates.append('site = %s')
|
||||||
values.append(data['site'])
|
values.append(new_site)
|
||||||
|
|
||||||
if not updates:
|
if not updates:
|
||||||
return jsonify({'error': 'No fields to update'}), 400
|
return jsonify({'error': 'No changes to apply'}), 400
|
||||||
|
|
||||||
values.append(subnet_id)
|
values.append(subnet_id)
|
||||||
cursor.execute(f'UPDATE Subnet SET {", ".join(updates)} WHERE id = %s', values)
|
cursor.execute(f'UPDATE Subnet SET {", ".join(updates)} WHERE id = %s', values)
|
||||||
add_audit_log(request.api_user['id'], 'edit_subnet', f"Updated subnet {subnet_id}", subnet_id, conn=conn)
|
add_audit_log(
|
||||||
|
request.api_user['id'],
|
||||||
|
'edit_subnet',
|
||||||
|
f"Edited subnet from {old_name} ({old_cidr}) to {new_name} ({new_cidr}) at site {new_site or 'Unassigned'}",
|
||||||
|
subnet_id,
|
||||||
|
conn=conn
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return jsonify({'message': 'Subnet updated successfully'})
|
return jsonify({'message': 'Subnet updated successfully', 'subnet': {'id': subnet_id, 'name': new_name, 'cidr': new_cidr, 'site': new_site}})
|
||||||
|
|
||||||
@app.route('/api/v1/subnets/<int:subnet_id>', methods=['DELETE'])
|
@app.route('/api/v1/subnets/<int:subnet_id>', methods=['DELETE'])
|
||||||
@api_permission_required('delete_subnet')
|
@api_permission_required('delete_subnet')
|
||||||
@@ -1551,14 +1634,22 @@ def register_routes(app):
|
|||||||
from flask import current_app
|
from flask import current_app
|
||||||
with get_db_connection(current_app) as conn:
|
with get_db_connection(current_app) as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute('SELECT name FROM Subnet WHERE id = %s', (subnet_id,))
|
cursor.execute('SELECT name, cidr FROM Subnet WHERE id = %s', (subnet_id,))
|
||||||
subnet = cursor.fetchone()
|
subnet = cursor.fetchone()
|
||||||
if not subnet:
|
if not subnet:
|
||||||
return jsonify({'error': 'Subnet not found'}), 404
|
return jsonify({'error': 'Subnet not found'}), 404
|
||||||
|
subnet_name, subnet_cidr = subnet
|
||||||
|
cursor.execute('SELECT id FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
|
||||||
|
ip_ids = [row[0] for row in cursor.fetchall()]
|
||||||
|
if ip_ids:
|
||||||
|
cursor.executemany('DELETE FROM DeviceIPAddress WHERE ip_id = %s', [(ip_id,) for ip_id in ip_ids])
|
||||||
|
cursor.execute('DELETE FROM DHCPPool WHERE subnet_id = %s', (subnet_id,))
|
||||||
|
cursor.execute('UPDATE AuditLog SET subnet_id = NULL WHERE subnet_id = %s', (subnet_id,))
|
||||||
|
cursor.execute('DELETE FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
|
||||||
cursor.execute('DELETE FROM Subnet WHERE id = %s', (subnet_id,))
|
cursor.execute('DELETE FROM Subnet WHERE id = %s', (subnet_id,))
|
||||||
add_audit_log(request.api_user['id'], 'delete_subnet', f"Deleted subnet {subnet[0]}", subnet_id, conn=conn)
|
add_audit_log(request.api_user['id'], 'delete_subnet', f"Deleted subnet {subnet_name} ({subnet_cidr})", subnet_id, conn=conn)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return jsonify({'message': 'Subnet deleted successfully'})
|
return jsonify({'message': 'Subnet deleted successfully', 'subnet': {'id': subnet_id, 'name': subnet_name, 'cidr': subnet_cidr}})
|
||||||
|
|
||||||
# Racks API
|
# Racks API
|
||||||
@app.route('/api/v1/racks', methods=['GET'])
|
@app.route('/api/v1/racks', methods=['GET'])
|
||||||
@@ -1571,6 +1662,10 @@ def register_routes(app):
|
|||||||
cursor.execute('SELECT id, name, site, height_u FROM Rack ORDER BY site, name')
|
cursor.execute('SELECT id, name, site, height_u FROM Rack ORDER BY site, name')
|
||||||
racks = cursor.fetchall()
|
racks = cursor.fetchall()
|
||||||
for rack in racks:
|
for rack in racks:
|
||||||
|
cursor.execute('SELECT COUNT(*) as used FROM RackDevice WHERE rack_id = %s AND side = %s', (rack['id'], 'front'))
|
||||||
|
usage_row = cursor.fetchone()
|
||||||
|
rack['used_u'] = usage_row['used'] if usage_row and 'used' in usage_row else 0
|
||||||
|
rack['percent_full'] = int((rack['used_u'] / rack['height_u']) * 100) if rack['height_u'] else 0
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
SELECT rd.id, rd.position_u, rd.side, rd.device_id, rd.nonnet_device_name,
|
SELECT rd.id, rd.position_u, rd.side, rd.device_id, rd.nonnet_device_name,
|
||||||
d.name as device_name
|
d.name as device_name
|
||||||
@@ -1608,6 +1703,7 @@ def register_routes(app):
|
|||||||
@api_permission_required('add_rack')
|
@api_permission_required('add_rack')
|
||||||
def api_add_rack():
|
def api_add_rack():
|
||||||
"""Create a new rack"""
|
"""Create a new rack"""
|
||||||
|
from flask import current_app
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
if not data or 'name' not in data or 'site' not in data or 'height_u' not in data:
|
if not data or 'name' not in data or 'site' not in data or 'height_u' not in data:
|
||||||
return jsonify({'error': 'Name, site, and height_u are required'}), 400
|
return jsonify({'error': 'Name, site, and height_u are required'}), 400
|
||||||
@@ -1615,13 +1711,18 @@ def register_routes(app):
|
|||||||
name = data['name']
|
name = data['name']
|
||||||
site = data['site']
|
site = data['site']
|
||||||
height_u = data['height_u']
|
height_u = data['height_u']
|
||||||
|
try:
|
||||||
|
height_u = int(height_u)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({'error': 'height_u must be an integer'}), 400
|
||||||
|
if height_u <= 0:
|
||||||
|
return jsonify({'error': 'height_u must be greater than zero'}), 400
|
||||||
|
|
||||||
from flask import current_app
|
|
||||||
with get_db_connection(current_app) as conn:
|
with get_db_connection(current_app) as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute('INSERT INTO Rack (name, site, height_u) VALUES (%s, %s, %s)', (name, site, height_u))
|
cursor.execute('INSERT INTO Rack (name, site, height_u) VALUES (%s, %s, %s)', (name, site, height_u))
|
||||||
rack_id = cursor.lastrowid
|
rack_id = cursor.lastrowid
|
||||||
add_audit_log(request.api_user['id'], 'add_rack', f"Added rack {name}", conn=conn)
|
add_audit_log(request.api_user['id'], 'add_rack', f"Added rack '{name}' at site '{site}' ({height_u}U)", conn=conn)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return jsonify({'id': rack_id, 'name': name, 'site': site, 'height_u': height_u}), 201
|
return jsonify({'id': rack_id, 'name': name, 'site': site, 'height_u': height_u}), 201
|
||||||
|
|
||||||
@@ -1636,15 +1737,17 @@ def register_routes(app):
|
|||||||
rack = cursor.fetchone()
|
rack = cursor.fetchone()
|
||||||
if not rack:
|
if not rack:
|
||||||
return jsonify({'error': 'Rack not found'}), 404
|
return jsonify({'error': 'Rack not found'}), 404
|
||||||
|
rack_name = rack[0]
|
||||||
cursor.execute('DELETE FROM Rack WHERE id = %s', (rack_id,))
|
cursor.execute('DELETE FROM Rack WHERE id = %s', (rack_id,))
|
||||||
add_audit_log(request.api_user['id'], 'delete_rack', f"Deleted rack {rack[0]}", conn=conn)
|
add_audit_log(request.api_user['id'], 'delete_rack', f"Deleted rack '{rack_name}'", conn=conn)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return jsonify({'message': 'Rack deleted successfully'})
|
return jsonify({'message': 'Rack deleted successfully', 'rack': {'id': rack_id, 'name': rack_name}})
|
||||||
|
|
||||||
@app.route('/api/v1/racks/<int:rack_id>/devices', methods=['POST'])
|
@app.route('/api/v1/racks/<int:rack_id>/devices', methods=['POST'])
|
||||||
@api_permission_required('add_device_to_rack')
|
@api_permission_required('add_device_to_rack')
|
||||||
def api_add_device_to_rack(rack_id):
|
def api_add_device_to_rack(rack_id):
|
||||||
"""Add a device to a rack"""
|
"""Add a device to a rack"""
|
||||||
|
from flask import current_app
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
if not data or 'position_u' not in data or 'side' not in data:
|
if not data or 'position_u' not in data or 'side' not in data:
|
||||||
return jsonify({'error': 'position_u and side are required'}), 400
|
return jsonify({'error': 'position_u and side are required'}), 400
|
||||||
@@ -1654,20 +1757,75 @@ def register_routes(app):
|
|||||||
device_id = data.get('device_id')
|
device_id = data.get('device_id')
|
||||||
nonnet_device_name = data.get('nonnet_device_name')
|
nonnet_device_name = data.get('nonnet_device_name')
|
||||||
|
|
||||||
if not device_id and not nonnet_device_name:
|
if device_id is None and not nonnet_device_name:
|
||||||
return jsonify({'error': 'Either device_id or nonnet_device_name is required'}), 400
|
return jsonify({'error': 'Either device_id or nonnet_device_name is required'}), 400
|
||||||
|
|
||||||
from flask import current_app
|
try:
|
||||||
|
position_u = int(position_u)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({'error': 'position_u must be an integer'}), 400
|
||||||
|
side = str(side).lower()
|
||||||
|
if side not in ('front', 'back'):
|
||||||
|
return jsonify({'error': "side must be either 'front' or 'back'"}), 400
|
||||||
|
if device_id is not None:
|
||||||
|
try:
|
||||||
|
device_id = int(device_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({'error': 'device_id must be an integer'}), 400
|
||||||
|
|
||||||
with get_db_connection(current_app) as conn:
|
with get_db_connection(current_app) as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor(dictionary=True)
|
||||||
cursor.execute('SELECT id FROM Rack WHERE id = %s', (rack_id,))
|
cursor.execute('SELECT name, height_u FROM Rack WHERE id = %s', (rack_id,))
|
||||||
if not cursor.fetchone():
|
rack = cursor.fetchone()
|
||||||
|
if not rack:
|
||||||
return jsonify({'error': 'Rack not found'}), 404
|
return jsonify({'error': 'Rack not found'}), 404
|
||||||
cursor.execute('INSERT INTO RackDevice (rack_id, device_id, position_u, side, nonnet_device_name) VALUES (%s, %s, %s, %s, %s)',
|
if position_u < 1 or position_u > rack['height_u']:
|
||||||
(rack_id, device_id, position_u, side, nonnet_device_name))
|
return jsonify({'error': f'Invalid U position: {position_u}. Rack is {rack["height_u"]}U tall.'}), 400
|
||||||
add_audit_log(request.api_user['id'], 'add_device_to_rack', f"Added device to rack {rack_id}", conn=conn)
|
|
||||||
|
cursor.execute('SELECT COUNT(*) as occupied_count FROM RackDevice WHERE rack_id = %s AND position_u = %s AND side = %s', (rack_id, position_u, side))
|
||||||
|
occupied = cursor.fetchone()
|
||||||
|
if occupied and occupied['occupied_count'] > 0:
|
||||||
|
return jsonify({'error': f'U{position_u} on the {side} is already occupied.'}), 400
|
||||||
|
|
||||||
|
if device_id is not None:
|
||||||
|
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
|
||||||
|
device_row = cursor.fetchone()
|
||||||
|
if not device_row:
|
||||||
|
return jsonify({'error': 'Device not found'}), 404
|
||||||
|
device_name = device_row['name']
|
||||||
|
cursor.execute(
|
||||||
|
'INSERT INTO RackDevice (rack_id, device_id, position_u, side, nonnet_device_name) VALUES (%s, %s, %s, %s, NULL)',
|
||||||
|
(rack_id, device_id, position_u, side)
|
||||||
|
)
|
||||||
|
action = 'rack_add_device'
|
||||||
|
details = f"Assigned device '{device_name}' to rack '{rack['name']}' U{position_u} ({side})"
|
||||||
|
else:
|
||||||
|
nonnet_device_name = (nonnet_device_name or '').strip()
|
||||||
|
if not nonnet_device_name:
|
||||||
|
return jsonify({'error': 'nonnet_device_name is required when device_id is not provided'}), 400
|
||||||
|
cursor.execute(
|
||||||
|
'INSERT INTO RackDevice (rack_id, device_id, position_u, side, nonnet_device_name) VALUES (%s, NULL, %s, %s, %s)',
|
||||||
|
(rack_id, position_u, side, nonnet_device_name)
|
||||||
|
)
|
||||||
|
device_name = nonnet_device_name
|
||||||
|
action = 'rack_add_nonnet_device'
|
||||||
|
details = f"Added non-networked device '{device_name}' to rack '{rack['name']}' U{position_u} ({side})"
|
||||||
|
|
||||||
|
rack_device_id = cursor.lastrowid
|
||||||
|
add_audit_log(request.api_user['id'], action, details, conn=conn)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return jsonify({'message': 'Device added to rack successfully'}), 201
|
return jsonify({
|
||||||
|
'message': 'Device added to rack successfully',
|
||||||
|
'rack_device': {
|
||||||
|
'id': rack_device_id,
|
||||||
|
'rack_id': rack_id,
|
||||||
|
'device_id': device_id,
|
||||||
|
'nonnet_device_name': device_name if device_id is None else None,
|
||||||
|
'device_name': device_name,
|
||||||
|
'position_u': position_u,
|
||||||
|
'side': side
|
||||||
|
}
|
||||||
|
}), 201
|
||||||
|
|
||||||
@app.route('/api/v1/racks/<int:rack_id>/devices/<int:rack_device_id>', methods=['DELETE'])
|
@app.route('/api/v1/racks/<int:rack_id>/devices/<int:rack_device_id>', methods=['DELETE'])
|
||||||
@api_permission_required('remove_device_from_rack')
|
@api_permission_required('remove_device_from_rack')
|
||||||
@@ -1675,13 +1833,31 @@ def register_routes(app):
|
|||||||
"""Remove a device from a rack"""
|
"""Remove a device from a rack"""
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
with get_db_connection(current_app) as conn:
|
with get_db_connection(current_app) as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor(dictionary=True)
|
||||||
cursor.execute('DELETE FROM RackDevice WHERE id = %s AND rack_id = %s', (rack_device_id, rack_id))
|
cursor.execute('''
|
||||||
if cursor.rowcount == 0:
|
SELECT rd.device_id, rd.nonnet_device_name, rd.position_u, rd.side,
|
||||||
|
d.name AS device_name, r.name AS rack_name
|
||||||
|
FROM RackDevice rd
|
||||||
|
JOIN Rack r ON rd.rack_id = r.id
|
||||||
|
LEFT JOIN Device d ON rd.device_id = d.id
|
||||||
|
WHERE rd.id = %s AND rd.rack_id = %s
|
||||||
|
''', (rack_device_id, rack_id))
|
||||||
|
rd = cursor.fetchone()
|
||||||
|
if not rd:
|
||||||
return jsonify({'error': 'Device not found in rack'}), 404
|
return jsonify({'error': 'Device not found in rack'}), 404
|
||||||
add_audit_log(request.api_user['id'], 'remove_device_from_rack', f"Removed device from rack {rack_id}", conn=conn)
|
if rd['device_id']:
|
||||||
|
device_label = rd['device_name'] or str(rd['device_id'])
|
||||||
|
else:
|
||||||
|
device_label = rd['nonnet_device_name']
|
||||||
|
cursor.execute('DELETE FROM RackDevice WHERE id = %s AND rack_id = %s', (rack_device_id, rack_id))
|
||||||
|
add_audit_log(
|
||||||
|
request.api_user['id'],
|
||||||
|
'rack_remove_device',
|
||||||
|
f"Removed device '{device_label}' from rack '{rd['rack_name']}' U{rd['position_u']} ({rd['side']})",
|
||||||
|
conn=conn
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return jsonify({'message': 'Device removed from rack successfully'})
|
return jsonify({'message': 'Device removed from rack successfully', 'rack_device_id': rack_device_id})
|
||||||
|
|
||||||
# Device Types API
|
# Device Types API
|
||||||
@app.route('/api/v1/device-types', methods=['GET'])
|
@app.route('/api/v1/device-types', methods=['GET'])
|
||||||
@@ -1712,22 +1888,80 @@ def register_routes(app):
|
|||||||
def api_configure_dhcp(subnet_id):
|
def api_configure_dhcp(subnet_id):
|
||||||
"""Configure DHCP pools for a subnet"""
|
"""Configure DHCP pools for a subnet"""
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
if not data or 'pools' not in data:
|
if not data:
|
||||||
return jsonify({'error': 'pools array is required'}), 400
|
return jsonify({'error': 'Request body is required'}), 400
|
||||||
|
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
with get_db_connection(current_app) as conn:
|
with get_db_connection(current_app) as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute('DELETE FROM DHCPPool WHERE subnet_id = %s', (subnet_id,))
|
cursor.execute('SELECT name, cidr FROM Subnet WHERE id = %s', (subnet_id,))
|
||||||
for pool in data['pools']:
|
subnet = cursor.fetchone()
|
||||||
if 'start_ip' not in pool or 'end_ip' not in pool:
|
if not subnet:
|
||||||
continue
|
return jsonify({'error': 'Subnet not found'}), 404
|
||||||
excluded_ips = ','.join(pool.get('excluded_ips', []))
|
subnet_name, subnet_cidr = subnet
|
||||||
cursor.execute('INSERT INTO DHCPPool (subnet_id, start_ip, end_ip, excluded_ips) VALUES (%s, %s, %s, %s)',
|
|
||||||
(subnet_id, pool['start_ip'], pool['end_ip'], excluded_ips))
|
if data.get('remove'):
|
||||||
add_audit_log(request.api_user['id'], 'configure_dhcp', f"Configured DHCP for subnet {subnet_id}", subnet_id, conn=conn)
|
cursor.execute('DELETE FROM DHCPPool WHERE subnet_id = %s', (subnet_id,))
|
||||||
|
cursor.execute('UPDATE IPAddress SET hostname = NULL WHERE subnet_id = %s AND hostname = %s', (subnet_id, 'DHCP'))
|
||||||
|
add_audit_log(
|
||||||
|
request.api_user['id'],
|
||||||
|
'dhcp_pool_remove',
|
||||||
|
f"Removed DHCP pool for subnet {subnet_name} ({subnet_cidr})",
|
||||||
|
subnet_id,
|
||||||
|
conn=conn
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return jsonify({'message': 'DHCP pool removed successfully'})
|
||||||
|
|
||||||
|
pools = data.get('pools')
|
||||||
|
if not pools or not isinstance(pools, list):
|
||||||
|
return jsonify({'error': 'pools array is required'}), 400
|
||||||
|
pool = pools[0]
|
||||||
|
start_ip = pool.get('start_ip')
|
||||||
|
end_ip = pool.get('end_ip')
|
||||||
|
if not start_ip or not end_ip:
|
||||||
|
return jsonify({'error': 'start_ip and end_ip are required'}), 400
|
||||||
|
excluded_ips = pool.get('excluded_ips', [])
|
||||||
|
if not isinstance(excluded_ips, list):
|
||||||
|
return jsonify({'error': 'excluded_ips must be a list of IP strings'}), 400
|
||||||
|
excluded_list = [ip.strip() for ip in excluded_ips if ip.strip()]
|
||||||
|
excluded_str = ','.join(excluded_list)
|
||||||
|
|
||||||
|
cursor.execute('SELECT ip FROM IPAddress WHERE subnet_id = %s ORDER BY ip', (subnet_id,))
|
||||||
|
all_ips = [row[0] for row in cursor.fetchall()]
|
||||||
|
if start_ip not in all_ips or end_ip not in all_ips:
|
||||||
|
return jsonify({'error': 'start_ip and end_ip must be addresses within the subnet'}), 400
|
||||||
|
|
||||||
|
cursor.execute('SELECT id FROM DHCPPool WHERE subnet_id = %s', (subnet_id,))
|
||||||
|
existing = cursor.fetchone()
|
||||||
|
cursor.execute('UPDATE IPAddress SET hostname = NULL WHERE subnet_id = %s AND hostname = %s', (subnet_id, 'DHCP'))
|
||||||
|
if existing:
|
||||||
|
cursor.execute(
|
||||||
|
'UPDATE DHCPPool SET start_ip = %s, end_ip = %s, excluded_ips = %s WHERE subnet_id = %s',
|
||||||
|
(start_ip, end_ip, excluded_str, subnet_id)
|
||||||
|
)
|
||||||
|
action = 'dhcp_pool_update'
|
||||||
|
details = f"Updated DHCP pool for subnet {subnet_name} ({subnet_cidr}): {start_ip} - {end_ip}, excluded: {excluded_str}"
|
||||||
|
else:
|
||||||
|
cursor.execute(
|
||||||
|
'INSERT INTO DHCPPool (subnet_id, start_ip, end_ip, excluded_ips) VALUES (%s, %s, %s, %s)',
|
||||||
|
(subnet_id, start_ip, end_ip, excluded_str)
|
||||||
|
)
|
||||||
|
action = 'dhcp_pool_create'
|
||||||
|
details = f"Created DHCP pool for subnet {subnet_name} ({subnet_cidr}): {start_ip} - {end_ip}, excluded: {excluded_str}"
|
||||||
|
|
||||||
|
in_range = False
|
||||||
|
for candidate_ip in all_ips:
|
||||||
|
if candidate_ip == start_ip:
|
||||||
|
in_range = True
|
||||||
|
if in_range and candidate_ip not in excluded_list:
|
||||||
|
cursor.execute('UPDATE IPAddress SET hostname = %s WHERE subnet_id = %s AND ip = %s', ('DHCP', subnet_id, candidate_ip))
|
||||||
|
if candidate_ip == end_ip:
|
||||||
|
break
|
||||||
|
|
||||||
|
add_audit_log(request.api_user['id'], action, details, subnet_id, conn=conn)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return jsonify({'message': 'DHCP pools configured successfully'})
|
return jsonify({'message': 'DHCP pools configured successfully', 'pool': {'start_ip': start_ip, 'end_ip': end_ip, 'excluded_ips': excluded_list}})
|
||||||
|
|
||||||
# Audit Log API
|
# Audit Log API
|
||||||
@app.route('/api/v1/audit', methods=['GET'])
|
@app.route('/api/v1/audit', methods=['GET'])
|
||||||
|
|||||||
+18
-18
@@ -24,7 +24,7 @@
|
|||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
|
||||||
<a href="/audit" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
|
<a href="/audit" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<i class="fas fa-clipboard-list text-3xl text-blue-500"></i>
|
<i class="fas fa-clipboard-list text-3xl text-gray-600 dark:text-gray-400"></i>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-bold">Audit Log</h3>
|
<h3 class="text-lg font-bold">Audit Log</h3>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">View system activity</p>
|
<p class="text-sm text-gray-600 dark:text-gray-400">View system activity</p>
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<a href="/users" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
|
<a href="/users" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<i class="fas fa-users text-3xl text-green-500"></i>
|
<i class="fas fa-users text-3xl text-gray-600 dark:text-gray-400"></i>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-bold">User Management</h3>
|
<h3 class="text-lg font-bold">User Management</h3>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">Manage users & roles</p>
|
<p class="text-sm text-gray-600 dark:text-gray-400">Manage users & roles</p>
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="text-2xl font-bold">Subnet Management</h2>
|
<h2 class="text-2xl font-bold">Subnet Management</h2>
|
||||||
{% if can_add_subnet %}
|
{% if can_add_subnet %}
|
||||||
<button onclick="showAddSubnetModal()" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg font-medium">
|
<button onclick="showAddSubnetModal()" 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">
|
||||||
<i class="fas fa-plus mr-2"></i>Add Subnet
|
<i class="fas fa-plus mr-2"></i>Add Subnet
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -60,34 +60,34 @@
|
|||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-gray-600">
|
<tr class="border-b border-gray-600">
|
||||||
<th class="text-left p-3">Name</th>
|
<th class="text-center p-3">Name</th>
|
||||||
<th class="text-left p-3">CIDR</th>
|
<th class="text-center p-3">CIDR</th>
|
||||||
<th class="text-left p-3">Site</th>
|
<th class="text-center p-3">Site</th>
|
||||||
<th class="text-left p-3">Actions</th>
|
<th class="text-center p-3">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for subnet in subnets %}
|
{% for subnet in subnets %}
|
||||||
<tr class="border-b border-gray-600 hover:bg-gray-300 dark:hover:bg-zinc-700">
|
<tr class="border-b border-gray-600 hover:bg-gray-300 dark:hover:bg-zinc-700">
|
||||||
<td class="p-3 font-medium">{{ subnet.name }}</td>
|
<td class="p-3 font-medium text-center">{{ subnet.name }}</td>
|
||||||
<td class="p-3 font-mono text-sm">{{ subnet.cidr }}</td>
|
<td class="p-3 font-mono text-sm text-center">{{ subnet.cidr }}</td>
|
||||||
<td class="p-3">
|
<td class="p-3 text-center">
|
||||||
<span class="px-2 py-1 bg-blue-200 dark:bg-blue-800 rounded text-sm">{{ subnet.site }}</span>
|
<span class="px-2 py-1 bg-gray-300 dark:bg-zinc-700 rounded text-sm">{{ subnet.site }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="p-3">
|
<td class="p-3 text-center">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center justify-center space-x-2">
|
||||||
<a href="/subnet/{{ subnet.id }}" class="text-blue-500 hover:text-blue-700" title="View Subnet">
|
<a href="/subnet/{{ subnet.id }}" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300" title="View Subnet">
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
{% if can_edit_subnet %}
|
{% if can_edit_subnet %}
|
||||||
<button onclick="editSubnet({{ subnet.id }}, '{{ subnet.name|replace("'", "\\'") }}', '{{ subnet.cidr|replace("'", "\\'") }}', '{{ subnet.site|replace("'", "\\'") }}')" class="text-green-500 hover:text-green-700" title="Edit Subnet">
|
<button onclick="editSubnet({{ subnet.id }}, '{{ subnet.name|replace("'", "\\'") }}', '{{ subnet.cidr|replace("'", "\\'") }}', '{{ subnet.site|replace("'", "\\'") }}')" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Edit Subnet">
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if can_delete_subnet %}
|
{% if can_delete_subnet %}
|
||||||
<form action="/delete_subnet" method="POST" onsubmit="return confirm('Are you sure you want to delete this subnet and all its IPs? This action is irreversible.');" class="inline">
|
<form action="/delete_subnet" method="POST" onsubmit="return confirm('Are you sure you want to delete this subnet and all its IPs? This action is irreversible.');" class="inline">
|
||||||
<input type="hidden" name="subnet_id" value="{{ subnet.id }}">
|
<input type="hidden" name="subnet_id" value="{{ subnet.id }}">
|
||||||
<button type="submit" class="text-red-500 hover:text-red-700" title="Delete Subnet">
|
<button type="submit" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Delete Subnet">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -126,8 +126,8 @@
|
|||||||
<span id="cidr-error" class="text-red-500 text-sm hidden"></span>
|
<span id="cidr-error" class="text-red-500 text-sm hidden"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-2 mt-6">
|
<div class="flex justify-end space-x-2 mt-6">
|
||||||
<button type="button" onclick="closeAddSubnetModal()" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
|
<button type="button" onclick="closeAddSubnetModal()" class="px-4 py-2 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
|
||||||
<button type="submit" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Add Subnet</button>
|
<button type="submit" class="px-4 py-2 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Add Subnet</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+32
-4
@@ -75,11 +75,39 @@
|
|||||||
<span class="hidden sm:inline">Prev</span>
|
<span class="hidden sm:inline">Prev</span>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for p in range(1, total_pages+1) %}
|
|
||||||
{% set page_args = query_args.copy() %}
|
{# Smart pagination logic #}
|
||||||
{% set _ = page_args.update({'page': p}) %}
|
{% set delta = 2 %}
|
||||||
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded {{ 'bg-gray-200 dark:bg-gray-500' if p == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">{{ p }}</a>
|
{% set start_page = [1, page - delta]|max %}
|
||||||
|
{% set end_page = [total_pages, page + delta]|min %}
|
||||||
|
|
||||||
|
{# Show first page if we're not near the start #}
|
||||||
|
{% if start_page > 1 %}
|
||||||
|
{% set page_args = query_args.copy() %}
|
||||||
|
{% set _ = page_args.update({'page': 1}) %}
|
||||||
|
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded {{ 'bg-gray-200 dark:bg-gray-500' if 1 == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">1</a>
|
||||||
|
{% if start_page > 2 %}
|
||||||
|
<span class="px-3 py-1 text-gray-600 dark:text-gray-400">…</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Show pages around current page #}
|
||||||
|
{% for p in range(start_page, end_page + 1) %}
|
||||||
|
{% set page_args = query_args.copy() %}
|
||||||
|
{% set _ = page_args.update({'page': p}) %}
|
||||||
|
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded {{ 'bg-gray-200 dark:bg-gray-500' if p == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">{{ p }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
{# Show last page if we're not near the end #}
|
||||||
|
{% if end_page < total_pages %}
|
||||||
|
{% if end_page < total_pages - 1 %}
|
||||||
|
<span class="px-3 py-1 text-gray-600 dark:text-gray-400">…</span>
|
||||||
|
{% endif %}
|
||||||
|
{% set page_args = query_args.copy() %}
|
||||||
|
{% set _ = page_args.update({'page': total_pages}) %}
|
||||||
|
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded {{ 'bg-gray-200 dark:bg-gray-500' if total_pages == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">{{ total_pages }}</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if page < total_pages %}
|
{% if page < total_pages %}
|
||||||
{% set next_args = query_args.copy() %}
|
{% set next_args = query_args.copy() %}
|
||||||
{% set _ = next_args.update({'page': page+1}) %}
|
{% set _ = next_args.update({'page': page+1}) %}
|
||||||
|
|||||||
@@ -77,20 +77,20 @@
|
|||||||
</td>
|
</td>
|
||||||
{% if can_manage_users %}
|
{% if can_manage_users %}
|
||||||
<td class="p-2 text-center">
|
<td class="p-2 text-center">
|
||||||
<button onclick="editUser({{ user[0] }}, '{{ user[1]|replace("'", "\\'") }}', '{{ user[2]|replace("'", "\\'") }}', {{ user[3] if user[3] else 'null' }}, '{{ user[5]|replace("'", "\\'") if user[5] else '' }}')" class="text-blue-500 hover:text-blue-700 mr-2 hover:cursor-pointer" title="Edit User">
|
<button onclick="editUser({{ user[0] }}, '{{ user[1]|replace("'", "\\'") }}', '{{ user[2]|replace("'", "\\'") }}', {{ user[3] if user[3] else 'null' }}, '{{ user[5]|replace("'", "\\'") if user[5] else '' }}')" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 mr-2 hover:cursor-pointer" title="Edit User">
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
</button>
|
</button>
|
||||||
<form action="/users" method="POST" onsubmit="return confirm('Are you sure you want to regenerate the API key for this user?');" class="inline mr-2">
|
<form action="/users" method="POST" onsubmit="return confirm('Are you sure you want to regenerate the API key for this user?');" class="inline mr-2">
|
||||||
<input type="hidden" name="action" value="regenerate_api_key">
|
<input type="hidden" name="action" value="regenerate_api_key">
|
||||||
<input type="hidden" name="user_id" value="{{ user[0] }}">
|
<input type="hidden" name="user_id" value="{{ user[0] }}">
|
||||||
<button type="submit" class="text-yellow-500 hover:text-yellow-700 hover:cursor-pointer" title="Regenerate API Key">
|
<button type="submit" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Regenerate API Key">
|
||||||
<i class="fas fa-key"></i>
|
<i class="fas fa-key"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form action="/users" method="POST" onsubmit="return confirm('Are you sure you want to delete this user?');" class="inline">
|
<form action="/users" method="POST" onsubmit="return confirm('Are you sure you want to delete this user?');" class="inline">
|
||||||
<input type="hidden" name="action" value="delete_user">
|
<input type="hidden" name="action" value="delete_user">
|
||||||
<input type="hidden" name="user_id" value="{{ user[0] }}">
|
<input type="hidden" name="user_id" value="{{ user[0] }}">
|
||||||
<button type="submit" class="text-red-500 hover:text-red-700 hover:cursor-pointer" title="Delete User">
|
<button type="submit" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Delete User">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -127,10 +127,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if can_manage_roles %}
|
{% if can_manage_roles %}
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<button type="button" onclick="editRole({{ role[0] }}, '{{ role[1]|replace("'", "\\'") }}', '{{ (role[2] or '')|replace("'", "\\'") }}'); return false;" class="text-blue-500 hover:text-blue-700 hover:cursor-pointer" title="Edit Role">
|
<button type="button" onclick="editRole({{ role[0] }}, '{{ role[1]|replace("'", "\\'") }}', '{{ (role[2] or '')|replace("'", "\\'") }}'); return false;" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Edit Role">
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onclick="deleteRole({{ role[0] }}, '{{ role[1]|replace("'", "\\'") }}'); return false;" class="text-red-500 hover:text-red-700 hover:cursor-pointer" title="Delete Role">
|
<button type="button" onclick="deleteRole({{ role[0] }}, '{{ role[1]|replace("'", "\\'") }}'); return false;" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Delete Role">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user