Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b5fa9ef6ae | |||
| 19e7e978aa | |||
| 64ae4be6d5 | |||
| d7fcffd4b5 | |||
| 283c445263 | |||
| 2af3584d80 | |||
| 59ded14858 | |||
| 9c0e6d035c | |||
| 8242e9d758 | |||
| 47208b31ee |
@@ -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",
|
||||||
|
|||||||
@@ -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,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "1.6.1"
|
".": "1.8.0"
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,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 {
|
||||||
|
|||||||
@@ -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>';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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
@@ -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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user