5 Commits

Author SHA1 Message Date
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
7 changed files with 188 additions and 6 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
".": "1.6.1" ".": "1.7.0"
} }
+13
View File
@@ -1,5 +1,18 @@
# Changelog # Changelog
## [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.7.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:
+96
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():
@@ -3106,6 +3167,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 +3221,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 +3265,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 +3337,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 +3359,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 +3415,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 +3445,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 +3670,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 +3689,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 +3734,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 +3764,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 +3809,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 +3898,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)
+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
@@ -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 %}