3 Commits

Author SHA1 Message Date
Jamie 671b750bc4 Merge pull request #9 from JDB-NET/release-please--branches--main
chore(main): release 1.5.0
2025-11-21 20:43:15 +00:00
github-actions[bot] bc1078f673 chore(main): release 1.5.0 2025-11-21 20:42:49 +00:00
jamie ad1e576da4 feat: device tags 2025-11-21 20:42:20 +00:00
15 changed files with 1441 additions and 108 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
".": "1.4.2" ".": "1.5.0"
} }
+7
View File
@@ -1,5 +1,12 @@
# Changelog # Changelog
## [1.5.0](https://github.com/JDB-NET/ipam/compare/v1.4.2...v1.5.0) (2025-11-21)
### Features
* :sparkles: device tags ([ad1e576](https://github.com/JDB-NET/ipam/commit/ad1e576da42bf90c59347f7f7a4cce13c6842204))
## [1.4.2](https://github.com/JDB-NET/ipam/compare/v1.4.1...v1.4.2) (2025-11-08) ## [1.4.2](https://github.com/JDB-NET/ipam/compare/v1.4.1...v1.4.2) (2025-11-08)
+35 -6
View File
@@ -11,10 +11,11 @@ A Flask-based web application for comprehensive IP Address Management (IPAM). Ma
- **Subnet Management**: Create and manage IP subnets with CIDR notation (supports /24 to /32) - **Subnet Management**: Create and manage IP subnets with CIDR notation (supports /24 to /32)
- **IP Address Tracking**: Automatic IP address generation and tracking for each subnet - **IP Address Tracking**: Automatic IP address generation and tracking for each subnet
- **Device Management**: Track devices with types (Server, VM, Switch, Firewall, WiFi AP, Printer, Other) - **Device Management**: Track devices with types (Server, VM, Switch, Firewall, WiFi AP, Printer, Other)
- **Device Tagging**: Organize devices with customizable tags featuring colors and descriptions
- **IP Assignment**: Assign IP addresses to devices with automatic hostname updates - **IP Assignment**: Assign IP addresses to devices with automatic hostname updates
- **DHCP Pool Configuration**: Configure DHCP pools with start/end IP ranges and excluded IPs - **DHCP Pool Configuration**: Configure DHCP pools with start/end IP ranges and excluded IPs
- **Rack Management**: Physical infrastructure tracking with U positions and front/back sides - **Rack Management**: Physical infrastructure tracking with U positions and front/back sides
- **Site Organization**: Organize subnets and devices by site/location - **Site Organisation**: Organize subnets and devices by site/location
- **Audit Logging**: Complete audit trail of all changes with user, action, details, and timestamps - **Audit Logging**: Complete audit trail of all changes with user, action, details, and timestamps
- **User Management**: Multi-user support with secure password authentication - **User Management**: Multi-user support with secure password authentication
- **Role-Based Access Control (RBAC)**: Granular permission system with default roles (admin, user, view_only) and custom role creation - **Role-Based Access Control (RBAC)**: Granular permission system with default roles (admin, user, view_only) and custom role creation
@@ -36,7 +37,7 @@ docker run -d \
-e MYSQL_PASSWORD=your_password \ -e MYSQL_PASSWORD=your_password \
-e MYSQL_DATABASE=ipam \ -e MYSQL_DATABASE=ipam \
-e SECRET_KEY=your_secret_key \ -e SECRET_KEY=your_secret_key \
-e NAME="Your Organization" \ -e NAME="Your Organisation" \
-e LOGO_PNG="https://example.com/logo.png" \ -e LOGO_PNG="https://example.com/logo.png" \
ghcr.io/jdb-net/ipam:latest ghcr.io/jdb-net/ipam:latest
``` ```
@@ -59,7 +60,7 @@ services:
- MYSQL_PASSWORD=your_password - MYSQL_PASSWORD=your_password
- MYSQL_DATABASE=ipam - MYSQL_DATABASE=ipam
- SECRET_KEY=your_secret_key - SECRET_KEY=your_secret_key
- NAME=Your Organization - NAME=Your Organisation
- LOGO_PNG=https://example.com/logo.png - LOGO_PNG=https://example.com/logo.png
``` ```
@@ -72,8 +73,8 @@ services:
- `MYSQL_PASSWORD`: Database password (default: password) - `MYSQL_PASSWORD`: Database password (default: password)
- `MYSQL_DATABASE`: Database name (default: ipam) - `MYSQL_DATABASE`: Database name (default: ipam)
- `SECRET_KEY`: Flask secret key for sessions (**REQUIRED in production!**) - `SECRET_KEY`: Flask secret key for sessions (**REQUIRED in production!**)
- `NAME`: Organization name displayed in header (default: JDB-NET) - `NAME`: Organisation name displayed in header (default: JDB-NET)
- `LOGO_PNG`: URL or path to organization logo (default: JDB-NET logo) - `LOGO_PNG`: URL or path to organisation logo (default: JDB-NET logo)
### Database Setup ### Database Setup
@@ -135,6 +136,22 @@ FLUSH PRIVILEGES;
- **Height**: Rack height in U units - **Height**: Rack height in U units
3. Open a rack to assign devices to specific U positions (front or back) 3. Open a rack to assign devices to specific U positions (front or back)
### Device Tagging
1. **Managing Tags** (Admin only):
- Navigate to "Admin" > "Tag Management"
- Click "Add Tag" to create new tags with custom colors and descriptions
- Edit or delete existing tags as needed
2. **Assigning Tags to Devices**:
- Open any device from the Devices page
- Use the tag assignment dropdown to add multiple tags
- Remove tags by clicking the × button next to the tag name
3. **Filtering by Tags**:
- Use the tag filter dropdown on the Devices page to view devices with specific tags
- Tags appear as colored badges throughout the interface for easy identification
### Audit Log ### Audit Log
View all changes and actions in the "Audit Log" section, with filtering by user, subnet, action type, or device name. View all changes and actions in the "Audit Log" section, with filtering by user, subnet, action type, or device name.
@@ -182,6 +199,9 @@ The application includes a comprehensive REST API for programmatic access:
3. **Available Endpoints**: 3. **Available Endpoints**:
- **Devices**: `GET`, `POST`, `PUT`, `DELETE /api/v1/devices` - **Devices**: `GET`, `POST`, `PUT`, `DELETE /api/v1/devices`
- **Device Tags**: `GET /api/v1/devices/by-tag/{tag}` (filter devices by tag)
- **Tags**: `GET`, `POST`, `PUT`, `DELETE /api/v1/tags` (with `?format=simple` option)
- **Tag Assignment**: `GET`, `POST /api/v1/devices/{id}/tags`, `DELETE /api/v1/devices/{id}/tags/{tag_id}`
- **Subnets**: `GET`, `POST`, `PUT`, `DELETE /api/v1/subnets` - **Subnets**: `GET`, `POST`, `PUT`, `DELETE /api/v1/subnets`
- **Racks**: `GET`, `POST`, `DELETE /api/v1/racks` - **Racks**: `GET`, `POST`, `DELETE /api/v1/racks`
- **Device Types**: `GET /api/v1/device-types` - **Device Types**: `GET /api/v1/device-types`
@@ -193,10 +213,19 @@ The application includes a comprehensive REST API for programmatic access:
5. **Documentation**: Full API documentation is available in the Help page of the web interface. 5. **Documentation**: Full API documentation is available in the Help page of the web interface.
**Example API Request**: **Example API Requests**:
```bash ```bash
# List all devices
curl -H "X-API-Key: your_api_key" \ curl -H "X-API-Key: your_api_key" \
https://your-server:5000/api/v1/devices https://your-server:5000/api/v1/devices
# Get devices with a specific tag
curl -H "X-API-Key: your_api_key" \
https://your-server:5000/api/v1/devices/by-tag/production
# List all tags in simple format
curl -H "X-API-Key: your_api_key" \
https://your-server:5000/api/v1/tags?format=simple
``` ```
## Kubernetes Deployment ## Kubernetes Deployment
+1 -1
View File
@@ -1 +1 @@
1.4.2 1.5.0
+35 -2
View File
@@ -199,6 +199,30 @@ def init_db(app=None):
if not cursor.fetchone(): if not cursor.fetchone():
cursor.execute('ALTER TABLE User ADD COLUMN api_key VARCHAR(255) DEFAULT NULL UNIQUE') cursor.execute('ALTER TABLE User ADD COLUMN api_key VARCHAR(255) DEFAULT NULL UNIQUE')
# Create Tag table
cursor.execute('''
CREATE TABLE IF NOT EXISTS Tag (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL UNIQUE,
color VARCHAR(7) DEFAULT '#6B7280',
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''')
# Create DeviceTag junction table
cursor.execute('''
CREATE TABLE IF NOT EXISTS DeviceTag (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
device_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY unique_device_tag (device_id, tag_id),
FOREIGN KEY (device_id) REFERENCES Device(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES Tag(id) ON DELETE CASCADE
)
''')
# Define all permissions with categories # Define all permissions with categories
permissions = [ permissions = [
# View permissions # View permissions
@@ -246,6 +270,14 @@ def init_db(app=None):
('edit_device_type', 'Edit device type', 'Device Type'), ('edit_device_type', 'Edit device type', 'Device Type'),
('delete_device_type', 'Delete device type', 'Device Type'), ('delete_device_type', 'Delete device type', 'Device Type'),
# Tag permissions
('view_tags', 'View tags', 'Tag'),
('add_tag', 'Add new tag', 'Tag'),
('edit_tag', 'Edit tag', 'Tag'),
('delete_tag', 'Delete tag', 'Tag'),
('assign_device_tag', 'Assign tag to device', 'Tag'),
('remove_device_tag', 'Remove tag from device', 'Tag'),
# Admin permissions # Admin permissions
('manage_users', 'Manage users (add, edit, delete)', 'Admin'), ('manage_users', 'Manage users (add, edit, delete)', 'Admin'),
('manage_roles', 'Manage roles and permissions', 'Admin'), ('manage_roles', 'Manage roles and permissions', 'Admin'),
@@ -306,7 +338,8 @@ def init_db(app=None):
'add_rack', 'delete_rack', 'add_device_to_rack', 'remove_device_from_rack', 'add_rack', 'delete_rack', 'add_device_to_rack', 'remove_device_from_rack',
'add_nonnet_device_to_rack', 'export_rack_csv', 'add_nonnet_device_to_rack', 'export_rack_csv',
'configure_dhcp', 'configure_dhcp',
'add_device_type', 'edit_device_type', 'delete_device_type' 'add_device_type', 'edit_device_type', 'delete_device_type',
'view_tags', 'add_tag', 'edit_tag', 'delete_tag', 'assign_device_tag', 'remove_device_tag'
] ]
for perm_name in non_admin_permissions: for perm_name in non_admin_permissions:
@@ -325,7 +358,7 @@ def init_db(app=None):
view_only_permissions = [ view_only_permissions = [
'view_index', 'view_devices', 'view_device', 'view_subnet', 'view_racks', 'view_rack', 'view_index', 'view_devices', 'view_device', 'view_subnet', 'view_racks', 'view_rack',
'view_audit', 'view_device_types', 'view_device_type_stats', 'view_devices_by_type', 'view_audit', 'view_device_types', 'view_device_type_stats', 'view_devices_by_type',
'view_dhcp', 'view_help' 'view_dhcp', 'view_help', 'view_tags'
] ]
for perm_name in view_only_permissions: for perm_name in view_only_permissions:
+543 -3
View File
@@ -188,16 +188,50 @@ def register_routes(app):
@permission_required('view_devices') @permission_required('view_devices')
def devices(): def devices():
from flask import current_app from flask import current_app
tag_filter = request.args.get('tag')
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 Device.id, Device.name, DeviceType.icon_class FROM Device LEFT JOIN DeviceType ON Device.device_type_id = DeviceType.id''')
# Base device query
if tag_filter:
cursor.execute('''
SELECT DISTINCT d.id, d.name, dt.icon_class
FROM Device d
LEFT JOIN DeviceType dt ON d.device_type_id = dt.id
JOIN DeviceTag dtag ON d.id = dtag.device_id
JOIN Tag t ON dtag.tag_id = t.id
WHERE t.name = %s
ORDER BY d.name
''', (tag_filter,))
else:
cursor.execute('''SELECT Device.id, Device.name, DeviceType.icon_class FROM Device LEFT JOIN DeviceType ON Device.device_type_id = DeviceType.id ORDER BY Device.name''')
devices = cursor.fetchall() devices = cursor.fetchall()
cursor.execute('SELECT id, name, cidr, site FROM Subnet') cursor.execute('SELECT id, name, cidr, site FROM Subnet')
subnets = cursor.fetchall() subnets = cursor.fetchall()
cursor.execute('SELECT DeviceIPAddress.device_id, IPAddress.id, IPAddress.ip FROM DeviceIPAddress JOIN IPAddress ON DeviceIPAddress.ip_id = IPAddress.id') cursor.execute('SELECT DeviceIPAddress.device_id, IPAddress.id, IPAddress.ip FROM DeviceIPAddress JOIN IPAddress ON DeviceIPAddress.ip_id = IPAddress.id')
device_ips = {} device_ips = {}
for row in cursor.fetchall(): for row in cursor.fetchall():
device_ips.setdefault(row[0], []).append((row[1], row[2])) device_ips.setdefault(row[0], []).append((row[1], row[2]))
# Get tags for each device
device_tags = {}
for device in devices:
cursor.execute('''
SELECT t.id, t.name, t.color
FROM DeviceTag dt
JOIN Tag t ON dt.tag_id = t.id
WHERE dt.device_id = %s
ORDER BY t.name
''', (device[0],))
device_tags[device[0]] = [{'id': row[0], 'name': row[1], 'color': row[2]} for row in cursor.fetchall()]
# Get all available tags for filtering
cursor.execute('SELECT DISTINCT name FROM Tag ORDER BY name')
all_tag_names = [row[0] for row in cursor.fetchall()]
sites_devices = {} sites_devices = {}
for device in devices: for device in devices:
cursor.execute('''SELECT Subnet.site FROM DeviceIPAddress JOIN IPAddress ON DeviceIPAddress.ip_id = IPAddress.id JOIN Subnet ON IPAddress.subnet_id = Subnet.id WHERE DeviceIPAddress.device_id = %s LIMIT 1''', (device[0],)) cursor.execute('''SELECT Subnet.site FROM DeviceIPAddress JOIN IPAddress ON DeviceIPAddress.ip_id = IPAddress.id JOIN Subnet ON IPAddress.subnet_id = Subnet.id WHERE DeviceIPAddress.device_id = %s LIMIT 1''', (device[0],))
@@ -206,7 +240,10 @@ def register_routes(app):
if site not in sites_devices: if site not in sites_devices:
sites_devices[site] = [] sites_devices[site] = []
sites_devices[site].append({'id': device[0], 'name': device[1], 'icon_class': device[2]}) sites_devices[site].append({'id': device[0], 'name': device[1], 'icon_class': device[2]})
return render_with_user('devices.html', sites_devices=sites_devices, device_ips=device_ips)
return render_with_user('devices.html', sites_devices=sites_devices, device_ips=device_ips,
device_tags=device_tags, all_tag_names=all_tag_names,
current_tag_filter=tag_filter)
@app.route('/add_device', methods=['GET', 'POST']) @app.route('/add_device', methods=['GET', 'POST'])
@permission_required('add_device') @permission_required('add_device')
@@ -242,6 +279,20 @@ def register_routes(app):
subnets = [dict(id=row[0], name=row[1], cidr=row[2], site=row[3]) for row in cursor.fetchall()] subnets = [dict(id=row[0], name=row[1], cidr=row[2], site=row[3]) for row in cursor.fetchall()]
cursor.execute('''SELECT DeviceIPAddress.id as device_ip_id, IPAddress.ip FROM DeviceIPAddress JOIN IPAddress ON DeviceIPAddress.ip_id = IPAddress.id WHERE DeviceIPAddress.device_id = %s''', (device_id,)) cursor.execute('''SELECT DeviceIPAddress.id as device_ip_id, IPAddress.ip FROM DeviceIPAddress JOIN IPAddress ON DeviceIPAddress.ip_id = IPAddress.id WHERE DeviceIPAddress.device_id = %s''', (device_id,))
device_ips = [{'device_ip_id': row[0], 'ip': row[1]} for row in cursor.fetchall()] device_ips = [{'device_ip_id': row[0], 'ip': row[1]} for row in cursor.fetchall()]
# Get device tags
cursor.execute('''
SELECT t.id, t.name, t.color
FROM DeviceTag dt
JOIN Tag t ON dt.tag_id = t.id
WHERE dt.device_id = %s
ORDER BY t.name
''', (device_id,))
device_tags = [{'id': row[0], 'name': row[1], 'color': row[2]} for row in cursor.fetchall()]
# Get all available tags
cursor.execute('SELECT id, name, color FROM Tag ORDER BY name')
all_tags = [{'id': row[0], 'name': row[1], 'color': row[2]} for row in cursor.fetchall()]
available_ips_by_subnet = {} available_ips_by_subnet = {}
for subnet in subnets: for subnet in subnets:
cursor.execute('SELECT id, ip FROM IPAddress WHERE subnet_id = %s AND id NOT IN (SELECT ip_id FROM DeviceIPAddress)', (subnet['id'],)) cursor.execute('SELECT id, ip FROM IPAddress WHERE subnet_id = %s AND id NOT IN (SELECT ip_id FROM DeviceIPAddress)', (subnet['id'],))
@@ -263,7 +314,12 @@ def register_routes(app):
in_range = False in_range = False
ips = filtered_ips ips = filtered_ips
available_ips_by_subnet[subnet['id']] = ips available_ips_by_subnet[subnet['id']] = ips
return render_with_user('device.html', device={'id': device[0], 'name': device[1], 'description': device[2], 'device_type_id': device[3]}, subnets=subnets, device_ips=device_ips, available_ips_by_subnet=available_ips_by_subnet, device_types=device_types) return render_with_user('device.html',
device={'id': device[0], 'name': device[1], 'description': device[2], 'device_type_id': device[3]},
subnets=subnets, device_ips=device_ips, available_ips_by_subnet=available_ips_by_subnet,
device_types=device_types, device_tags=device_tags, all_tags=all_tags,
can_assign_device_tag=has_permission('assign_device_tag'),
can_remove_device_tag=has_permission('remove_device_tag'))
@app.route('/update_device_type', methods=['POST']) @app.route('/update_device_type', methods=['POST'])
@permission_required('edit_device') @permission_required('edit_device')
@@ -363,6 +419,58 @@ def register_routes(app):
logging.info(f"User {user_name} removed IP {ip} from device {device_id}.") logging.info(f"User {user_name} removed IP {ip} from device {device_id}.")
return redirect(url_for('device', device_id=device_id)) return redirect(url_for('device', device_id=device_id))
@app.route('/device/<int:device_id>/assign_tag', methods=['POST'])
@permission_required('assign_device_tag')
def device_assign_tag(device_id):
tag_id = request.form['tag_id']
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
device = cursor.fetchone()
if not device:
return redirect(url_for('devices'))
device_name = device[0]
cursor.execute('SELECT name FROM Tag WHERE id = %s', (tag_id,))
tag = cursor.fetchone()
if not tag:
return redirect(url_for('device', device_id=device_id))
tag_name = tag[0]
cursor.execute('SELECT id FROM DeviceTag WHERE device_id = %s AND tag_id = %s', (device_id, tag_id))
if cursor.fetchone():
return redirect(url_for('device', device_id=device_id)) # Already assigned
cursor.execute('INSERT INTO DeviceTag (device_id, tag_id) VALUES (%s, %s)', (device_id, tag_id))
add_audit_log(session['user_id'], 'assign_device_tag', f"Assigned tag '{tag_name}' to device '{device_name}'", conn=conn)
conn.commit()
return redirect(url_for('device', device_id=device_id))
@app.route('/device/<int:device_id>/remove_tag', methods=['POST'])
@permission_required('remove_device_tag')
def device_remove_tag(device_id):
tag_id = request.form['tag_id']
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
device = cursor.fetchone()
if not device:
return redirect(url_for('devices'))
device_name = device[0]
cursor.execute('SELECT name FROM Tag WHERE id = %s', (tag_id,))
tag = cursor.fetchone()
if not tag:
return redirect(url_for('device', device_id=device_id))
tag_name = tag[0]
cursor.execute('DELETE FROM DeviceTag WHERE device_id = %s AND tag_id = %s', (device_id, tag_id))
add_audit_log(session['user_id'], 'remove_device_tag', f"Removed tag '{tag_name}' from device '{device_name}'", conn=conn)
conn.commit()
return redirect(url_for('device', device_id=device_id))
@app.route('/delete_device', methods=['POST']) @app.route('/delete_device', methods=['POST'])
@permission_required('delete_device') @permission_required('delete_device')
def delete_device(): def delete_device():
@@ -492,6 +600,21 @@ def register_routes(app):
can_edit_subnet=has_permission('edit_subnet'), can_edit_subnet=has_permission('edit_subnet'),
can_delete_subnet=has_permission('delete_subnet')) can_delete_subnet=has_permission('delete_subnet'))
@app.route('/api-docs')
@permission_required('view_admin')
def api_docs():
# Get current user's API key
from flask import current_app
api_key = None
if 'user_id' in session:
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT api_key FROM User WHERE id = %s', (session['user_id'],))
result = cursor.fetchone()
if result:
api_key = result[0]
return render_with_user('api_docs.html', api_key=api_key)
@app.route('/users', methods=['GET', 'POST']) @app.route('/users', methods=['GET', 'POST'])
@permission_required('view_users') @permission_required('view_users')
def users(): def users():
@@ -658,6 +781,84 @@ def register_routes(app):
return render_with_user('users.html', users=users, roles=roles, permissions=permissions, role_permissions=role_permissions, error=error, return render_with_user('users.html', users=users, roles=roles, permissions=permissions, role_permissions=role_permissions, error=error,
can_manage_users=has_permission('manage_users'), can_manage_roles=has_permission('manage_roles')) can_manage_users=has_permission('manage_users'), can_manage_roles=has_permission('manage_roles'))
@app.route('/tags', methods=['GET', 'POST'])
@permission_required('view_tags')
def tags():
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
error = None
if request.method == 'POST':
action = request.form['action']
if action == 'add_tag':
if not has_permission('add_tag', conn=conn):
error = 'You do not have permission to add tags.'
else:
name = request.form['name'].strip()
color = request.form.get('color', '#6B7280')
description = request.form.get('description', '').strip()
if not name:
error = 'Tag name is required.'
else:
try:
cursor.execute('INSERT INTO Tag (name, color, description) VALUES (%s, %s, %s)',
(name, color, description))
add_audit_log(session['user_id'], 'add_tag', f"Added tag '{name}'", conn=conn)
conn.commit()
except mysql.connector.IntegrityError:
error = 'Tag name already exists.'
elif action == 'edit_tag':
if not has_permission('edit_tag', conn=conn):
error = 'You do not have permission to edit tags.'
else:
tag_id = request.form['tag_id']
name = request.form['name'].strip()
color = request.form.get('color', '#6B7280')
description = request.form.get('description', '').strip()
if not name:
error = 'Tag name is required.'
else:
try:
cursor.execute('UPDATE Tag SET name = %s, color = %s, description = %s WHERE id = %s',
(name, color, description, tag_id))
add_audit_log(session['user_id'], 'edit_tag', f"Updated tag '{name}'", conn=conn)
conn.commit()
except mysql.connector.IntegrityError:
error = 'Tag name already exists.'
elif action == 'delete_tag':
if not has_permission('delete_tag', conn=conn):
error = 'You do not have permission to delete tags.'
else:
tag_id = request.form['tag_id']
cursor.execute('SELECT name FROM Tag WHERE id = %s', (tag_id,))
tag_name = cursor.fetchone()[0]
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)
conn.commit()
# Get all tags with device counts
cursor.execute('''
SELECT t.id, t.name, t.color, t.description, t.created_at,
COUNT(dt.device_id) as device_count
FROM Tag t
LEFT JOIN DeviceTag dt ON t.id = dt.tag_id
GROUP BY t.id, t.name, t.color, t.description, t.created_at
ORDER BY t.name
''')
tags = [dict(id=row[0], name=row[1], color=row[2], description=row[3],
created_at=row[4], device_count=row[5]) for row in cursor.fetchall()]
return render_with_user('tags.html', tags=tags, error=error,
can_add_tag=has_permission('add_tag'),
can_edit_tag=has_permission('edit_tag'),
can_delete_tag=has_permission('delete_tag'))
@app.route('/audit') @app.route('/audit')
@permission_required('view_audit') @permission_required('view_audit')
def audit(): def audit():
@@ -1295,6 +1496,14 @@ def register_routes(app):
WHERE dia.device_id = %s WHERE dia.device_id = %s
''', (device['id'],)) ''', (device['id'],))
device['ip_addresses'] = cursor.fetchall() device['ip_addresses'] = cursor.fetchall()
cursor.execute('''
SELECT t.id, t.name, t.color
FROM DeviceTag dt
JOIN Tag t ON dt.tag_id = t.id
WHERE dt.device_id = %s
ORDER BY t.name
''', (device['id'],))
device['tags'] = cursor.fetchall()
return jsonify({'devices': devices}) return jsonify({'devices': devices})
@app.route('/api/v1/devices/<int:device_id>', methods=['GET']) @app.route('/api/v1/devices/<int:device_id>', methods=['GET'])
@@ -1321,6 +1530,14 @@ def register_routes(app):
WHERE dia.device_id = %s WHERE dia.device_id = %s
''', (device_id,)) ''', (device_id,))
device['ip_addresses'] = cursor.fetchall() device['ip_addresses'] = cursor.fetchall()
cursor.execute('''
SELECT t.id, t.name, t.color
FROM DeviceTag dt
JOIN Tag t ON dt.tag_id = t.id
WHERE dt.device_id = %s
ORDER BY t.name
''', (device_id,))
device['tags'] = cursor.fetchall()
return jsonify(device) return jsonify(device)
@app.route('/api/v1/devices', methods=['POST']) @app.route('/api/v1/devices', methods=['POST'])
@@ -1963,6 +2180,326 @@ def register_routes(app):
conn.commit() conn.commit()
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
@app.route('/api/v1/tags', methods=['GET'])
@api_permission_required('view_tags')
def api_tags():
"""Get all tags"""
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute('SELECT id, name, color, description, created_at FROM Tag ORDER BY name')
tags = cursor.fetchall()
for tag in tags:
cursor.execute('SELECT COUNT(*) as device_count FROM DeviceTag WHERE tag_id = %s', (tag['id'],))
tag['device_count'] = cursor.fetchone()['device_count']
return jsonify({'tags': tags})
@app.route('/api/v1/tags', methods=['POST'])
@api_permission_required('add_tag')
def api_add_tag():
"""Create a new tag"""
data = request.get_json()
if not data or 'name' not in data:
return jsonify({'error': 'Tag name is required'}), 400
name = data['name'].strip()
if not name:
return jsonify({'error': 'Tag name cannot be empty'}), 400
color = data.get('color', '#6B7280')
description = data.get('description', '')
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
try:
cursor.execute('INSERT INTO Tag (name, color, description) VALUES (%s, %s, %s)', (name, color, description))
tag_id = cursor.lastrowid
add_audit_log(request.api_user['id'], 'add_tag', f"Added tag '{name}'", conn=conn)
conn.commit()
return jsonify({'id': tag_id, 'name': name, 'color': color, 'description': description}), 201
except mysql.connector.IntegrityError:
return jsonify({'error': 'Tag name already exists'}), 400
@app.route('/api/v1/tags/<int:tag_id>', methods=['GET'])
@api_permission_required('view_tags')
def api_tag(tag_id):
"""Get a specific tag"""
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute('SELECT id, name, color, description, created_at FROM Tag WHERE id = %s', (tag_id,))
tag = cursor.fetchone()
if not tag:
return jsonify({'error': 'Tag not found'}), 404
cursor.execute('''
SELECT d.id, d.name, d.description, dt.name as device_type
FROM DeviceTag dtag
JOIN Device d ON dtag.device_id = d.id
LEFT JOIN DeviceType dt ON d.device_type_id = dt.id
WHERE dtag.tag_id = %s
ORDER BY d.name
''', (tag_id,))
tag['devices'] = cursor.fetchall()
return jsonify(tag)
@app.route('/api/v1/tags/<int:tag_id>', methods=['PUT'])
@api_permission_required('edit_tag')
def api_update_tag(tag_id):
"""Update a tag"""
data = request.get_json()
if not data:
return jsonify({'error': 'Request body is required'}), 400
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT name, color, description FROM Tag WHERE id = %s', (tag_id,))
current = cursor.fetchone()
if not current:
return jsonify({'error': 'Tag not found'}), 404
current_name, current_color, current_description = current
updates = []
values = []
if 'name' in data and data['name'].strip() != current_name:
new_name = data['name'].strip()
if not new_name:
return jsonify({'error': 'Tag name cannot be empty'}), 400
updates.append('name = %s')
values.append(new_name)
if 'color' in data and data['color'] != current_color:
updates.append('color = %s')
values.append(data['color'])
if 'description' in data and data['description'] != current_description:
updates.append('description = %s')
values.append(data['description'])
if not updates:
return jsonify({'error': 'No changes to apply'}), 400
values.append(tag_id)
try:
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)
conn.commit()
return jsonify({'message': 'Tag updated successfully'})
except mysql.connector.IntegrityError:
return jsonify({'error': 'Tag name already exists'}), 400
@app.route('/api/v1/tags/<int:tag_id>', methods=['DELETE'])
@api_permission_required('delete_tag')
def api_delete_tag(tag_id):
"""Delete a tag"""
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT name FROM Tag WHERE id = %s', (tag_id,))
tag = cursor.fetchone()
if not tag:
return jsonify({'error': 'Tag not found'}), 404
tag_name = tag[0]
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)
conn.commit()
return jsonify({'message': 'Tag deleted successfully'})
@app.route('/api/v1/devices/<int:device_id>/tags', methods=['GET'])
@api_permission_required('view_device')
def api_device_tags(device_id):
"""Get tags for a specific device"""
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute('SELECT id, name FROM Device WHERE id = %s', (device_id,))
if not cursor.fetchone():
return jsonify({'error': 'Device not found'}), 404
cursor.execute('''
SELECT t.id, t.name, t.color, t.description, dt.created_at
FROM DeviceTag dt
JOIN Tag t ON dt.tag_id = t.id
WHERE dt.device_id = %s
ORDER BY t.name
''', (device_id,))
tags = cursor.fetchall()
return jsonify({'tags': tags})
@app.route('/api/v1/devices/<int:device_id>/tags', methods=['POST'])
@api_permission_required('assign_device_tag')
def api_assign_device_tag(device_id):
"""Assign a tag to a device"""
data = request.get_json()
if not data or 'tag_id' not in data:
return jsonify({'error': 'tag_id is required'}), 400
tag_id = data['tag_id']
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
device = cursor.fetchone()
if not device:
return jsonify({'error': 'Device not found'}), 404
device_name = device[0]
cursor.execute('SELECT name FROM Tag WHERE id = %s', (tag_id,))
tag = cursor.fetchone()
if not tag:
return jsonify({'error': 'Tag not found'}), 404
tag_name = tag[0]
cursor.execute('SELECT id FROM DeviceTag WHERE device_id = %s AND tag_id = %s', (device_id, tag_id))
if cursor.fetchone():
return jsonify({'error': 'Tag already assigned to device'}), 400
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)
conn.commit()
return jsonify({'message': 'Tag assigned successfully'})
@app.route('/api/v1/devices/<int:device_id>/tags/<int:tag_id>', methods=['DELETE'])
@api_permission_required('remove_device_tag')
def api_remove_device_tag(device_id, tag_id):
"""Remove a tag from a device"""
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
device = cursor.fetchone()
if not device:
return jsonify({'error': 'Device not found'}), 404
device_name = device[0]
cursor.execute('SELECT name FROM Tag WHERE id = %s', (tag_id,))
tag = cursor.fetchone()
if not tag:
return jsonify({'error': 'Tag not found'}), 404
tag_name = tag[0]
cursor.execute('SELECT id FROM DeviceTag WHERE device_id = %s AND tag_id = %s', (device_id, tag_id))
if not cursor.fetchone():
return jsonify({'error': 'Tag not assigned to device'}), 404
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)
conn.commit()
return jsonify({'message': 'Tag removed successfully'})
@app.route('/api/v1/devices/by-tag/<tag_identifier>', methods=['GET'])
@api_permission_required('view_devices')
def api_devices_by_tag(tag_identifier):
"""Get devices by tag name or ID. Use ?format=simple for simplified response."""
from flask import current_app
simple_format = request.args.get('format') == 'simple'
with get_db_connection(current_app) as conn:
cursor = conn.cursor(dictionary=True)
# Check if tag_identifier is numeric (tag ID) or string (tag name)
try:
tag_id = int(tag_identifier)
# Query by tag ID
if simple_format:
cursor.execute('''
SELECT d.id, d.name
FROM DeviceTag dtag
JOIN Device d ON dtag.device_id = d.id
WHERE dtag.tag_id = %s
ORDER BY d.name
''', (tag_id,))
else:
cursor.execute('''
SELECT d.id, d.name, d.description, dt.name as device_type, dt.icon_class
FROM DeviceTag dtag
JOIN Device d ON dtag.device_id = d.id
LEFT JOIN DeviceType dt ON d.device_type_id = dt.id
WHERE dtag.tag_id = %s
ORDER BY d.name
''', (tag_id,))
# Get tag name for response
cursor.execute('SELECT name FROM Tag WHERE id = %s', (tag_id,))
tag_result = cursor.fetchone()
if not tag_result:
return jsonify({'error': 'Tag not found'}), 404
tag_name = tag_result['name']
except ValueError:
# Query by tag name
tag_name = tag_identifier
if simple_format:
cursor.execute('''
SELECT d.id, d.name
FROM DeviceTag dtag
JOIN Device d ON dtag.device_id = d.id
JOIN Tag t ON dtag.tag_id = t.id
WHERE t.name = %s
ORDER BY d.name
''', (tag_name,))
else:
cursor.execute('''
SELECT d.id, d.name, d.description, dt.name as device_type, dt.icon_class
FROM DeviceTag dtag
JOIN Device d ON dtag.device_id = d.id
JOIN Tag t ON dtag.tag_id = t.id
LEFT JOIN DeviceType dt ON d.device_type_id = dt.id
WHERE t.name = %s
ORDER BY d.name
''', (tag_name,))
devices = cursor.fetchall()
if not devices:
return jsonify({'devices': [], 'tag_name': tag_name, 'count': 0})
if simple_format:
# Simple format: just name and first IP as clean array
simple_devices = []
for device in devices:
cursor.execute('''
SELECT ip.ip
FROM DeviceIPAddress dia
JOIN IPAddress ip ON dia.ip_id = ip.id
WHERE dia.device_id = %s
ORDER BY ip.ip
LIMIT 1
''', (device['id'],))
ip_result = cursor.fetchone()
first_ip = ip_result['ip'] if ip_result else None
# Only include devices that have an IP address
if first_ip:
simple_devices.append({
'device': device['name'],
'ip': first_ip
})
return jsonify(simple_devices)
else:
# Full format: complete device information
for device in devices:
cursor.execute('''
SELECT ip.id, ip.ip, ip.hostname, s.id as subnet_id, s.name as subnet_name, s.cidr, s.site
FROM DeviceIPAddress dia
JOIN IPAddress ip ON dia.ip_id = ip.id
JOIN Subnet s ON ip.subnet_id = s.id
WHERE dia.device_id = %s
''', (device['id'],))
device['ip_addresses'] = cursor.fetchall()
cursor.execute('''
SELECT t.id, t.name, t.color
FROM DeviceTag dtag
JOIN Tag t ON dtag.tag_id = t.id
WHERE dtag.device_id = %s
ORDER BY t.name
''', (device['id'],))
device['tags'] = cursor.fetchall()
return jsonify({'devices': devices, 'tag_name': tag_name, 'count': len(devices)})
# Audit Log API # Audit Log API
@app.route('/api/v1/audit', methods=['GET']) @app.route('/api/v1/audit', methods=['GET'])
@api_permission_required('view_audit') @api_permission_required('view_audit')
@@ -2047,6 +2584,8 @@ def register_routes(app):
app.add_url_rule('/device/<int:device_id>', 'device', device) app.add_url_rule('/device/<int:device_id>', 'device', device)
app.add_url_rule('/device/<int:device_id>/add_ip', 'device_add_ip', device_add_ip, methods=['POST']) app.add_url_rule('/device/<int:device_id>/add_ip', 'device_add_ip', device_add_ip, methods=['POST'])
app.add_url_rule('/device/<int:device_id>/delete_ip', 'device_delete_ip', device_delete_ip, methods=['POST']) app.add_url_rule('/device/<int:device_id>/delete_ip', 'device_delete_ip', device_delete_ip, methods=['POST'])
app.add_url_rule('/device/<int:device_id>/assign_tag', 'device_assign_tag', device_assign_tag, methods=['POST'])
app.add_url_rule('/device/<int:device_id>/remove_tag', 'device_remove_tag', device_remove_tag, methods=['POST'])
app.add_url_rule('/delete_device', 'delete_device', delete_device, methods=['POST']) app.add_url_rule('/delete_device', 'delete_device', delete_device, methods=['POST'])
app.add_url_rule('/subnet/<int:subnet_id>', 'subnet', subnet) app.add_url_rule('/subnet/<int:subnet_id>', 'subnet', subnet)
app.add_url_rule('/add_subnet', 'add_subnet', add_subnet, methods=['POST']) app.add_url_rule('/add_subnet', 'add_subnet', add_subnet, methods=['POST'])
@@ -2054,6 +2593,7 @@ def register_routes(app):
app.add_url_rule('/delete_subnet', 'delete_subnet', delete_subnet, methods=['POST']) app.add_url_rule('/delete_subnet', 'delete_subnet', delete_subnet, methods=['POST'])
app.add_url_rule('/admin', 'admin', admin, methods=['GET', 'POST']) app.add_url_rule('/admin', 'admin', admin, methods=['GET', 'POST'])
app.add_url_rule('/users', 'users', users, methods=['GET', 'POST']) app.add_url_rule('/users', 'users', users, methods=['GET', 'POST'])
app.add_url_rule('/tags', 'tags', tags, methods=['GET', 'POST'])
app.add_url_rule('/audit', 'audit', audit) app.add_url_rule('/audit', 'audit', audit)
app.add_url_rule('/get_available_ips', 'get_available_ips', get_available_ips) app.add_url_rule('/get_available_ips', 'get_available_ips', get_available_ips)
app.add_url_rule('/rename_device', 'rename_device', rename_device, methods=['POST']) app.add_url_rule('/rename_device', 'rename_device', rename_device, methods=['POST'])
+79
View File
@@ -0,0 +1,79 @@
// API Documentation Interactive Functions
function getApiKey() {
return document.getElementById('apiKey').value;
}
function showStatus(message, isError = false) {
const status = document.getElementById('connectionStatus');
status.textContent = message;
status.className = `mt-2 text-sm ${isError ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}`;
}
async function testConnection() {
const apiKey = getApiKey();
if (!apiKey) {
showStatus('Please enter your API key', true);
return;
}
try {
const response = await axios.get('/api/v1/devices', {
headers: { 'X-API-Key': apiKey }
});
showStatus('✓ Connection successful');
} catch (error) {
if (error.response?.status === 401) {
showStatus('✗ Invalid API key', true);
} else if (error.response?.status === 403) {
showStatus('✗ Insufficient permissions', true);
} else {
showStatus('✗ Connection failed', true);
}
}
}
async function tryEndpoint(method, url, data, responseId) {
const apiKey = getApiKey();
if (!apiKey) {
showStatus('Please enter your API key first', true);
return;
}
try {
const config = {
method: method,
url: url,
headers: { 'X-API-Key': apiKey }
};
if (data) {
config.data = data;
}
const response = await axios(config);
document.getElementById(responseId + '-response').classList.remove('hidden');
document.getElementById(responseId).textContent = JSON.stringify(response.data, null, 2);
} catch (error) {
document.getElementById(responseId + '-response').classList.remove('hidden');
const errorMessage = error.response?.data?.error || error.message;
document.getElementById(responseId).textContent = `Error (${error.response?.status || 'Network'}): ${errorMessage}`;
}
}
async function tryEndpointWithId(method, baseUrl, inputId, responseId) {
const id = document.getElementById(inputId).value;
if (!id) {
alert('Please enter an ID');
return;
}
await tryEndpoint(method, baseUrl + encodeURIComponent(id), null, responseId);
}
// Auto-populate API key if user is logged in
document.addEventListener('DOMContentLoaded', function() {
const apiKeyInput = document.getElementById('apiKey');
if (apiKeyInput && apiKeyInput.value) {
testConnection();
}
});
+13
View File
@@ -1,5 +1,18 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Tag filter functionality
const tagFilter = document.getElementById('tag-filter');
if (tagFilter) {
tagFilter.addEventListener('change', function() {
const selectedTag = this.value;
if (selectedTag) {
window.location.href = '/devices?tag=' + encodeURIComponent(selectedTag);
} else {
window.location.href = '/devices';
}
});
}
// Expand/collapse site groups // Expand/collapse site groups
document.querySelectorAll('.site-header').forEach(header => { document.querySelectorAll('.site-header').forEach(header => {
header.addEventListener('click', function(e) { header.addEventListener('click', function(e) {
+69
View File
@@ -0,0 +1,69 @@
// Tag Management JavaScript
function showAddTagModal() {
document.getElementById('add-tag-modal').classList.remove('hidden');
document.getElementById('add-tag-name').value = '';
document.getElementById('add-tag-color').value = '#6B7280';
document.getElementById('add-tag-description').value = '';
updateColorPreview('add');
}
function closeAddTagModal() {
document.getElementById('add-tag-modal').classList.add('hidden');
}
function editTag(tagId, name, color, description) {
document.getElementById('edit-tag-id').value = tagId;
document.getElementById('edit-tag-name').value = name;
document.getElementById('edit-tag-color').value = color;
document.getElementById('edit-tag-description').value = description || '';
updateColorPreview('edit');
document.getElementById('edit-tag-modal').classList.remove('hidden');
}
function closeEditTagModal() {
document.getElementById('edit-tag-modal').classList.add('hidden');
}
function updateColorPreview(mode) {
const colorInput = document.getElementById(`${mode}-tag-color`);
const preview = document.getElementById(`${mode}-color-preview`);
preview.textContent = colorInput.value.toUpperCase();
}
// Event listeners
document.addEventListener('DOMContentLoaded', function() {
const addColorInput = document.getElementById('add-tag-color');
const editColorInput = document.getElementById('edit-tag-color');
if (addColorInput) {
addColorInput.addEventListener('input', () => updateColorPreview('add'));
}
if (editColorInput) {
editColorInput.addEventListener('input', () => updateColorPreview('edit'));
}
// Handle edit tag button clicks
document.querySelectorAll('.edit-tag-btn').forEach(button => {
button.addEventListener('click', function() {
const tagId = this.dataset.tagId;
const tagName = this.dataset.tagName;
const tagColor = this.dataset.tagColor;
const tagDescription = this.dataset.tagDescription;
editTag(tagId, tagName, tagColor, tagDescription);
});
});
});
// Close modals when clicking outside
window.onclick = function(event) {
const addModal = document.getElementById('add-tag-modal');
const editModal = document.getElementById('edit-tag-modal');
if (event.target === addModal) {
closeAddTagModal();
}
if (event.target === editModal) {
closeEditTagModal();
}
}
+23 -1
View File
@@ -21,7 +21,7 @@
{% endif %} {% endif %}
<!-- Quick Links --> <!-- Quick Links -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 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-gray-600 dark:text-gray-400"></i> <i class="fas fa-clipboard-list text-3xl text-gray-600 dark:text-gray-400"></i>
@@ -42,6 +42,28 @@
</div> </div>
<i class="fas fa-chevron-right text-gray-400"></i> <i class="fas fa-chevron-right text-gray-400"></i>
</a> </a>
{% if has_permission('view_tags') %}
<a href="/tags" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
<div class="flex items-center space-x-4">
<i class="fas fa-tags text-3xl text-gray-600 dark:text-gray-400"></i>
<div>
<h3 class="text-lg font-bold">Tag Management</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Manage device tags</p>
</div>
</div>
<i class="fas fa-chevron-right text-gray-400"></i>
</a>
{% endif %}
<a href="/api-docs" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
<div class="flex items-center space-x-4">
<i class="fas fa-code text-3xl text-gray-600 dark:text-gray-400"></i>
<div>
<h3 class="text-lg font-bold">API Documentation</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Interactive API reference</p>
</div>
</div>
<i class="fas fa-chevron-right text-gray-400"></i>
</a>
</div> </div>
<!-- Subnet Management Section --> <!-- Subnet Management Section -->
+330
View File
@@ -0,0 +1,330 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Documentation - IPAM</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">
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</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 mx-4 py-8 pt-20">
<div class="container max-w-7xl mx-auto">
<h1 class="text-3xl font-bold mb-8 text-center">API Documentation</h1>
<!-- Authentication Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8 mb-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">Authentication</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<p class="mb-4">All API requests require authentication using an API key. You can provide the API key in one of three ways:</p>
<ul class="list-disc list-inside space-y-2 ml-4">
<li><strong>Header:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">X-API-Key: your_api_key</code></li>
<li><strong>Authorization Header:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">Authorization: Bearer your_api_key</code></li>
<li><strong>Query Parameter:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">?api_key=your_api_key</code></li>
</ul>
<p class="mt-4"><strong>Base URL:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">/api/v1</code></p>
</div>
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h3 class="font-semibold mb-2">Your API Key</h3>
<div class="flex items-center space-x-2">
<input type="text" id="apiKey" value="{{ api_key or '' }}" readonly
class="flex-1 px-3 py-2 bg-gray-100 dark:bg-zinc-600 border border-gray-400 dark:border-zinc-500 rounded text-sm font-mono"
placeholder="API key not found">
<button onclick="testConnection()" class="px-4 py-2 bg-gray-500 hover:bg-gray-600 dark:bg-zinc-600 dark:hover:bg-zinc-500 text-white rounded text-sm transition-colors">
<i class="fas fa-plug mr-2"></i>Test
</button>
</div>
<div id="connectionStatus" class="mt-2 text-sm"></div>
</div>
</div>
</div>
<!-- Interactive Endpoints -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8 mb-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
<i class="fas fa-play-circle mr-2"></i>Interactive Testing
</h2>
<p class="text-gray-600 dark:text-gray-300 mb-6">Test GET endpoints directly in your browser. Other methods are documented below.</p>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- GET /devices -->
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-2">
<span class="px-2 py-1 bg-green-600 text-white rounded text-xs font-mono">GET</span>
<code class="text-sm">/api/v1/devices</code>
</div>
<button onclick="tryEndpoint('GET', '/api/v1/devices', null, 'devices-list')"
class="px-3 py-1 bg-gray-500 hover:bg-gray-600 dark:bg-zinc-600 dark:hover:bg-zinc-500 text-white rounded text-xs transition-colors">
<i class="fas fa-play mr-1"></i>Try
</button>
</div>
<p class="text-xs text-gray-600 dark:text-gray-300 mb-2">List all devices</p>
<div id="devices-list-response" class="hidden">
<pre class="bg-gray-100 dark:bg-zinc-600 p-2 rounded text-xs overflow-x-auto max-h-32" id="devices-list"></pre>
</div>
</div>
<!-- GET /devices/{id} -->
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-2">
<span class="px-2 py-1 bg-green-600 text-white rounded text-xs font-mono">GET</span>
<code class="text-sm">/api/v1/devices/{id}</code>
</div>
<div class="flex items-center space-x-1">
<input type="number" id="device-id" placeholder="ID" class="px-2 py-1 border rounded text-xs w-16">
<button onclick="tryEndpointWithId('GET', '/api/v1/devices/', 'device-id', 'device-detail')"
class="px-3 py-1 bg-gray-500 hover:bg-gray-600 dark:bg-zinc-600 dark:hover:bg-zinc-500 text-white rounded text-xs transition-colors">
<i class="fas fa-play mr-1"></i>Try
</button>
</div>
</div>
<p class="text-xs text-gray-600 dark:text-gray-300 mb-2">Get device by ID</p>
<div id="device-detail-response" class="hidden">
<pre class="bg-gray-100 dark:bg-zinc-600 p-2 rounded text-xs overflow-x-auto max-h-32" id="device-detail"></pre>
</div>
</div>
<!-- GET /devices/by-tag/{tag} -->
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-2">
<span class="px-2 py-1 bg-green-600 text-white rounded text-xs font-mono">GET</span>
<code class="text-sm">/api/v1/devices/by-tag/{tag}</code>
</div>
<div class="flex items-center space-x-1">
<input type="text" id="tag-name" placeholder="Tag" class="px-2 py-1 border rounded text-xs w-20">
<button onclick="tryEndpointWithId('GET', '/api/v1/devices/by-tag/', 'tag-name', 'devices-by-tag')"
class="px-3 py-1 bg-gray-500 hover:bg-gray-600 dark:bg-zinc-600 dark:hover:bg-zinc-500 text-white rounded text-xs transition-colors">
<i class="fas fa-play mr-1"></i>Try
</button>
</div>
</div>
<p class="text-xs text-gray-600 dark:text-gray-300 mb-2">Filter devices by tag</p>
<div id="devices-by-tag-response" class="hidden">
<pre class="bg-gray-100 dark:bg-zinc-600 p-2 rounded text-xs overflow-x-auto max-h-32" id="devices-by-tag"></pre>
</div>
</div>
<!-- GET /tags -->
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-2">
<span class="px-2 py-1 bg-green-600 text-white rounded text-xs font-mono">GET</span>
<code class="text-sm">/api/v1/tags</code>
</div>
<button onclick="tryEndpoint('GET', '/api/v1/tags', null, 'tags-list')"
class="px-3 py-1 bg-gray-500 hover:bg-gray-600 dark:bg-zinc-600 dark:hover:bg-zinc-500 text-white rounded text-xs transition-colors">
<i class="fas fa-play mr-1"></i>Try
</button>
</div>
<p class="text-xs text-gray-600 dark:text-gray-300 mb-2">List all tags</p>
<div id="tags-list-response" class="hidden">
<pre class="bg-gray-100 dark:bg-zinc-600 p-2 rounded text-xs overflow-x-auto max-h-32" id="tags-list"></pre>
</div>
</div>
</div>
</div>
<!-- Complete API Documentation -->
<div class="space-y-6">
<!-- Devices Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
<i class="fas fa-server mr-2"></i>Devices
</h2>
<div class="space-y-4">
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<h4 class="font-semibold mb-2">Read Operations</h4>
<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/devices</code> - List all devices</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/devices/{id}</code> - Get device details</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/devices/by-tag/{tag}</code> - Get devices by tag</li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-2">Write Operations</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/devices</code> - Create device</li>
<li><code class="bg-blue-200 dark:bg-blue-800 px-2 py-1 rounded text-xs">PUT /api/v1/devices/{id}</code> - Update device</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/devices/{id}</code> - Delete device</li>
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/devices/{id}/ips</code> - Add IP to device</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/devices/{id}/ips/{ip_id}</code> - Remove IP</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Subnets Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
<i class="fas fa-network-wired mr-2"></i>Subnets
</h2>
<div class="space-y-4">
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<h4 class="font-semibold mb-2">Read Operations</h4>
<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/{id}</code> - Get subnet details</li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-2">Write Operations</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/subnets</code> - Create subnet</li>
<li><code class="bg-blue-200 dark:bg-blue-800 px-2 py-1 rounded text-xs">PUT /api/v1/subnets/{id}</code> - Update subnet</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/subnets/{id}</code> - Delete subnet</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Racks Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
<i class="fas fa-building mr-2"></i>Racks
</h2>
<div class="space-y-4">
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<h4 class="font-semibold mb-2">Read Operations</h4>
<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/racks</code> - List all racks</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/racks/{id}</code> - Get rack details</li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-2">Write Operations</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/racks</code> - Create rack</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/racks/{id}</code> - Delete rack</li>
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/racks/{id}/devices</code> - Add device to rack</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/racks/{id}/devices/{device_id}</code> - Remove device</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Tags Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
<i class="fas fa-tags mr-2"></i>Tags
</h2>
<div class="space-y-4">
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<h4 class="font-semibold mb-2">Read Operations</h4>
<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/tags</code> - List all tags</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/tags?format=simple</code> - List tags in simple format</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/tags/{id}</code> - Get tag details</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/devices/{id}/tags</code> - Get device tags</li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-2">Write Operations</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/tags</code> - Create tag</li>
<li><code class="bg-blue-200 dark:bg-blue-800 px-2 py-1 rounded text-xs">PUT /api/v1/tags/{id}</code> - Update tag</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/tags/{id}</code> - Delete tag</li>
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/devices/{id}/tags</code> - Assign tag to device</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/devices/{id}/tags/{tag_id}</code> - Remove tag</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Additional Endpoints -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
<i class="fas fa-cogs mr-2"></i>Additional Endpoints
</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h4 class="font-semibold mb-3"><i class="fas fa-info-circle mr-2"></i>System Information</h4>
<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/info</code> - System information</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/device-types</code> - List device types</li>
</ul>
</div>
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h4 class="font-semibold mb-3"><i class="fas fa-dharmachakra mr-2"></i>DHCP Management</h4>
<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/{id}/dhcp</code> - Get DHCP config</li>
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/subnets/{id}/dhcp</code> - Generate DHCP config</li>
</ul>
</div>
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h4 class="font-semibold mb-3"><i class="fas fa-users mr-2"></i>User & Role Management</h4>
<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/users</code> - List users</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/roles</code> - List roles</li>
</ul>
</div>
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h4 class="font-semibold mb-3"><i class="fas fa-clipboard-list mr-2"></i>Audit Log</h4>
<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/audit</code> - List audit entries</li>
</ul>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-2">Supports filtering with query parameters</p>
</div>
</div>
</div>
<!-- Response Format & Permissions -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
<i class="fas fa-info-circle mr-2"></i>Response Format & Permissions
</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-3">Success Responses</h3>
<p class="mb-3 text-sm">All API responses are in JSON format. Successful requests return:</p>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-green-200 dark:bg-green-800 px-2 py-1 rounded text-xs">200 OK</code> - Request successful</li>
<li><code class="bg-green-200 dark:bg-green-800 px-2 py-1 rounded text-xs">201 Created</code> - Resource created</li>
<li><code class="bg-green-200 dark:bg-green-800 px-2 py-1 rounded text-xs">204 No Content</code> - Success with no response body</li>
</ul>
</div>
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-3">Error Responses</h3>
<p class="mb-3 text-sm">Error responses include descriptive messages:</p>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">400 Bad Request</code> - Invalid request data</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">401 Unauthorized</code> - Missing or invalid API key</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">403 Forbidden</code> - Insufficient permissions</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">404 Not Found</code> - Resource not found</li>
</ul>
</div>
</div>
<div class="mt-6 bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-3"><i class="fas fa-shield-alt mr-2"></i>Permissions</h3>
<p class="text-sm">API endpoints respect the same role-based permissions as the web interface. Users can only perform actions that their role allows. If a user lacks the required permission, the API will return a <code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">403 Forbidden</code> error with details about the missing permission.</p>
</div>
</div>
</div>
</div>
</div>
<script src="/static/js/api_docs.js"></script>
</body>
</html>
+44 -1
View File
@@ -60,7 +60,7 @@
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg w-full">Add IP</button> <button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg w-full">Add IP</button>
</div> </div>
</form> </form>
<div class="allocated-ips"> <div class="allocated-ips mb-6">
<h3 class="text-lg font-bold mb-2">Allocated IPs:</h3> <h3 class="text-lg font-bold mb-2">Allocated IPs:</h3>
<ul class="space-y-2"> <ul class="space-y-2">
{% for ip in device_ips %} {% for ip in device_ips %}
@@ -74,6 +74,49 @@
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
<!-- Tags Section -->
<div class="tags-section mb-6">
<h3 class="text-lg font-bold mb-2">Tags:</h3>
<div class="flex flex-wrap gap-2 mb-4">
{% if device_tags %}
{% for tag in device_tags %}
<div class="flex items-center space-x-1 px-3 py-1 rounded-full text-sm" style="background-color: {{ tag.color }}20; border: 1px solid {{ tag.color }}">
<div class="w-2 h-2 rounded-full" style="background-color: {{ tag.color }}"></div>
<span>{{ tag.name }}</span>
{% if can_remove_device_tag %}
<form action="/device/{{ device.id }}/remove_tag" method="POST" class="inline">
<input type="hidden" name="tag_id" value="{{ tag.id }}">
<button type="submit" class="ml-1 text-red-500 hover:text-red-700 text-xs" title="Remove tag">
<i class="fas fa-times"></i>
</button>
</form>
{% endif %}
</div>
{% endfor %}
{% else %}
<span class="text-gray-500">No tags assigned</span>
{% endif %}
</div>
{% if can_assign_device_tag and all_tags %}
<form action="/device/{{ device.id }}/assign_tag" method="POST" class="flex gap-2">
<select name="tag_id" class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 flex-1" required>
<option value="" disabled selected>Select a tag to assign...</option>
{% for tag in all_tags %}
{% set already_assigned = device_tags|selectattr('id', 'equalto', tag.id)|list|length > 0 %}
{% if not already_assigned %}
<option value="{{ tag.id }}">{{ tag.name }}</option>
{% endif %}
{% endfor %}
</select>
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">
<i class="fas fa-plus mr-1"></i>Assign Tag
</button>
</form>
{% endif %}
</div>
<form action="/update_device_description" method="POST" class="mb-6 mt-4"> <form action="/update_device_description" method="POST" class="mb-6 mt-4">
<input type="hidden" name="device_id" value="{{ device.id }}"> <input type="hidden" name="device_id" value="{{ device.id }}">
<label for="description" class="block mb-2 text-lg font-bold">Description</label> <label for="description" class="block mb-2 text-lg font-bold">Description</label>
+47 -13
View File
@@ -18,8 +18,28 @@
<a href="/add_device" class="text-center bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">Add New Device</a> <a href="/add_device" class="text-center bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">Add New Device</a>
<a href="/device_type_stats" class="text-center bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">View Device Stats</a> <a href="/device_type_stats" class="text-center bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">View Device Stats</a>
</div> </div>
<div class="mb-6">
<!-- Filters Section -->
<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"> <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 -->
{% if all_tag_names %}
<div class="flex items-center space-x-2">
<label class="text-sm font-medium">Filter by tag:</label>
<select id="tag-filter" class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600">
<option value="">All devices</option>
{% for tag_name in all_tag_names %}
<option value="{{ tag_name }}" {% if current_tag_filter == tag_name %}selected{% endif %}>{{ tag_name }}</option>
{% endfor %}
</select>
{% if current_tag_filter %}
<a href="/devices" class="text-red-500 hover:text-red-700 text-sm">
<i class="fas fa-times"></i> Clear filter
</a>
{% endif %}
</div>
{% endif %}
</div> </div>
<div id="site-list" class="space-y-6"> <div id="site-list" class="space-y-6">
{% for site, devices in sites_devices.items() %} {% for site, devices in sites_devices.items() %}
@@ -33,18 +53,32 @@
<ul class="device-list hidden px-6 pb-4"> <ul class="device-list hidden px-6 pb-4">
{% for device in devices %} {% for device in devices %}
<li class="my-2"> <li class="my-2">
<a href="/device/{{ device.id }}" class="flex items-center justify-between bg-gray-300 hover:bg-gray-100 dark:bg-zinc-900 dark:hover:bg-gray-700 dark:text-gray-200 font-semibold rounded-lg px-4 py-2 shadow transition-colors duration-150"> <a href="/device/{{ device.id }}" class="block bg-gray-300 hover:bg-gray-100 dark:bg-zinc-900 dark:hover:bg-gray-700 dark:text-gray-200 font-semibold rounded-lg px-4 py-2 shadow transition-colors duration-150">
<span><i class="fas {{ device.icon_class or 'fa-server' }} mr-2"></i>{{ device.name }}</span> <div class="flex items-center justify-between">
{% set ips = device_ips.get(device.id, []) %} <span><i class="fas {{ device.icon_class or 'fa-server' }} mr-2"></i>{{ device.name }}</span>
<span class="flex flex-row flex-wrap justify-end items-center ml-4 text-xs text-blue-300 font-normal align-middle"> {% set ips = device_ips.get(device.id, []) %}
{% if ips|length > 0 %} <span class="flex flex-row flex-wrap justify-end items-center ml-4 text-xs text-blue-300 font-normal align-middle">
{% for ip in ips %} {% if ips|length > 0 %}
<span class="inline-block bg-gray-200 dark:bg-zinc-800 text-gray-900 dark:text-white rounded px-2 py-1 ml-1 font-mono">{{ ip[1] }}</span> {% for ip in ips %}
{% endfor %} <span class="inline-block bg-gray-200 dark:bg-zinc-800 text-gray-900 dark:text-white rounded px-2 py-1 ml-1 font-mono">{{ ip[1] }}</span>
{% else %} {% endfor %}
<span class="text-gray-400">No IPs</span> {% else %}
{% endif %} <span class="text-gray-400">No IPs</span>
</span> {% endif %}
</span>
</div>
<!-- Tags -->
{% set tags = device_tags.get(device.id, []) %}
{% if tags %}
<div class="flex flex-wrap gap-1 mt-2">
{% for tag in tags %}
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs" style="background-color: {{ tag.color }}20; border: 1px solid {{ tag.color }}; color: {{ tag.color }}">
<div class="w-1.5 h-1.5 rounded-full mr-1" style="background-color: {{ tag.color }}"></div>
{{ tag.name }}
</span>
{% endfor %}
</div>
{% endif %}
</a> </a>
</li> </li>
{% endfor %} {% endfor %}
+33 -79
View File
@@ -10,10 +10,11 @@
</head> </head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col"> <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' %} {% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4"> <div class="flex-1 mx-4 py-8 pt-20">
<div class="container py-8 max-w-2xl pt-20"> <div class="container max-w-full mx-auto lg:px-32">
<h1 class="text-3xl font-bold mb-8 text-center">Help & User Guide</h1> <h1 class="text-3xl font-bold mb-8 text-center">Help & User Guide</h1>
<div class="space-y-10 text-lg">
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 mb-8">
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8"> <div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">Subnets & Devices</h2> <h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">Subnets & Devices</h2>
<div class="space-y-4"> <div class="space-y-4">
@@ -52,6 +53,35 @@
</div> </div>
</div> </div>
</div> </div>
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">Device Tags</h2>
<div class="space-y-4">
<div>
<h3 class="text-xl font-semibold mb-1">Managing Tags</h3>
<p>Tags help you organize and categorize your devices. Administrators can manage tags from the <a href="/tags" class="text-blue-600 dark:text-blue-400 hover:underline">Tag Management</a> page accessible from the Admin panel. Each tag has a name, customizable colour, and optional description.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Creating Tags</h3>
<p>Click <span class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded">Add Tag</span> to create a new tag. Choose a descriptive name (e.g., "Production", "Development", "Critical") and select a colour to visually distinguish the tag. Tags can be used to group devices by environment, importance, location, or any other criteria.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Assigning Tags to Devices</h3>
<p>From any device's detail page, you can assign multiple tags using the tag assignment dropdown. Each device can have unlimited tags, and removing a tag is as simple as clicking the × button next to the tag name.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Filtering Devices by Tags</h3>
<p>On the <a href="/devices" class="text-blue-600 dark:text-blue-400 hover:underline">Devices</a> page, use the tag filter dropdown to view only devices with a specific tag. This makes it easy to focus on devices in a particular environment or category.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Tag Colours and Visual Organisation</h3>
<p>Tags appear as coloured badges throughout the interface. Use consistent colour schemes (e.g., red for production, blue for development) to create visual patterns that help users quickly identify device categories.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Tag Permissions</h3>
<p>Tag management respects role-based permissions. Users need appropriate permissions to view, create, edit, delete tags, or assign/remove tags from devices. View-only users can see tags but cannot modify them.</p>
</div>
</div>
</div>
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8"> <div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">User Management & Audit</h2> <h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">User Management & Audit</h2>
<div class="space-y-4"> <div class="space-y-4">
@@ -86,82 +116,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">API Documentation</h2>
<div class="space-y-4">
<div>
<h3 class="text-xl font-semibold mb-1">Authentication</h3>
<p>All API requests require authentication using an API key. You can provide the API key in one of three ways:</p>
<ul class="list-disc list-inside mt-2 space-y-1 ml-4">
<li><strong>Header:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">X-API-Key: your_api_key</code></li>
<li><strong>Authorization Header:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">Authorization: Bearer your_api_key</code></li>
<li><strong>Query Parameter:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">?api_key=your_api_key</code></li>
</ul>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Base URL</h3>
<p>All API endpoints are prefixed with <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">/api/v1</code></p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Available Endpoints</h3>
<div class="space-y-3 mt-2">
<div>
<h4 class="font-semibold">Devices</h4>
<ul class="list-disc list-inside ml-4 text-sm space-y-1">
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/devices</code> - List all devices</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/devices/{id}</code> - Get device details</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/devices</code> - Create device</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">PUT /api/v1/devices/{id}</code> - Update device</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">DELETE /api/v1/devices/{id}</code> - Delete device</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/devices/{id}/ips</code> - Add IP to device</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">DELETE /api/v1/devices/{id}/ips/{ip_id}</code> - Remove IP from device</li>
</ul>
</div>
<div>
<h4 class="font-semibold">Subnets</h4>
<ul class="list-disc list-inside ml-4 text-sm space-y-1">
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/subnets</code> - List all subnets</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/subnets/{id}</code> - Get subnet details</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/subnets</code> - Create subnet</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">PUT /api/v1/subnets/{id}</code> - Update subnet</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">DELETE /api/v1/subnets/{id}</code> - Delete subnet</li>
</ul>
</div>
<div>
<h4 class="font-semibold">Racks</h4>
<ul class="list-disc list-inside ml-4 text-sm space-y-1">
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/racks</code> - List all racks</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/racks/{id}</code> - Get rack details</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/racks</code> - Create rack</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">DELETE /api/v1/racks/{id}</code> - Delete rack</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/racks/{id}/devices</code> - Add device to rack</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">DELETE /api/v1/racks/{id}/devices/{rack_device_id}</code> - Remove device from rack</li>
</ul>
</div>
<div>
<h4 class="font-semibold">Other</h4>
<ul class="list-disc list-inside ml-4 text-sm space-y-1">
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/info</code> - Get API info and user details</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/device-types</code> - List device types</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/subnets/{id}/dhcp</code> - Get DHCP pools</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/subnets/{id}/dhcp</code> - Configure DHCP pools</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/audit</code> - Get audit log</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/users</code> - List users (admin only)</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/roles</code> - List roles (admin only)</li>
</ul>
</div>
</div>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Permissions</h3>
<p>API endpoints respect the same role-based permissions as the web interface. Users can only perform actions that their role allows. If a user lacks the required permission, the API will return a <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">403 Forbidden</code> error.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Response Format</h3>
<p>All API responses are in JSON format. Successful requests return <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">200 OK</code> or <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">201 Created</code> with the requested data. Errors return appropriate HTTP status codes with an error message in the response body.</p>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
+180
View File
@@ -0,0 +1,180 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tag Management</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 mx-4 py-8 pt-20">
<div class="container max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Tag Management</h1>
{% if can_add_tag %}
<button onclick="showAddTagModal()" 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">
<i class="fas fa-plus mr-2"></i>Add Tag
</button>
{% endif %}
</div>
{% if error %}
<div class="bg-red-200 dark:bg-red-800 text-red-800 dark:text-red-200 p-4 rounded-lg mb-6">
{{ error }}
</div>
{% endif %}
{% if tags %}
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-gray-600">
<th class="text-left p-3">Name</th>
<th class="text-left p-3">Colour</th>
<th class="text-left p-3">Description</th>
<th class="text-center p-3">Devices</th>
<th class="text-center p-3">Created</th>
<th class="text-center p-3">Actions</th>
</tr>
</thead>
<tbody>
{% for tag in tags %}
<tr class="border-b border-gray-600 hover:bg-gray-300 dark:hover:bg-zinc-700">
<td class="p-3">
<div class="flex items-center space-x-2">
<div class="w-4 h-4 rounded-full border border-gray-600" style="background-color: {{ tag.color }}"></div>
<span class="font-medium">{{ tag.name }}</span>
</div>
</td>
<td class="p-3">
<span class="font-mono text-sm">{{ tag.color }}</span>
</td>
<td class="p-3">
<span class="text-sm">{{ tag.description or '-' }}</span>
</td>
<td class="p-3 text-center">
{% if tag.device_count > 0 %}
<a href="/api/v1/devices/by-tag/{{ tag.name }}" class="text-blue-400 hover:text-blue-600">
{{ tag.device_count }}
</a>
{% else %}
<span class="text-gray-500">0</span>
{% endif %}
</td>
<td class="p-3 text-center text-sm">
{{ tag.created_at.strftime('%Y-%m-%d') if tag.created_at else '-' }}
</td>
<td class="p-3 text-center">
<div class="flex items-center justify-center space-x-2">
{% if can_edit_tag %}
<button class="edit-tag-btn text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer"
title="Edit Tag"
data-tag-id="{{ tag.id }}"
data-tag-name="{{ tag.name }}"
data-tag-color="{{ tag.color }}"
data-tag-description="{{ tag.description or '' }}">
<i class="fas fa-edit"></i>
</button>
{% endif %}
{% if can_delete_tag %}
<form action="/tags" method="POST" onsubmit="return confirm('Are you sure you want to delete this tag? This will remove it from all devices.');" class="inline">
<input type="hidden" name="action" value="delete_tag">
<input type="hidden" name="tag_id" value="{{ tag.id }}">
<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 Tag">
<i class="fas fa-trash"></i>
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
<div class="text-center py-8 text-gray-500">
<i class="fas fa-tags text-4xl mb-4"></i>
<p>No tags found. Add your first tag to get started.</p>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Add Tag Modal -->
<div id="add-tag-modal" class="hidden fixed inset-0 bg-black/30 backdrop-blur-md flex items-center justify-center z-50">
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-lg max-w-md w-full mx-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">Add New Tag</h2>
<button onclick="closeAddTagModal()" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-times"></i>
</button>
</div>
<form action="/tags" method="POST">
<input type="hidden" name="action" value="add_tag">
<div class="space-y-4">
<input type="text" name="name" id="add-tag-name" placeholder="Tag Name"
class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
<div class="flex items-center space-x-2">
<label class="text-sm font-medium">Colour:</label>
<input type="color" name="color" id="add-tag-color" value="#6B7280"
class="w-12 h-10 border border-gray-600 rounded cursor-pointer">
<span id="add-color-preview" class="text-sm font-mono">#6B7280</span>
</div>
<textarea name="description" id="add-tag-description" placeholder="Description (optional)" rows="3"
class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full resize-y"></textarea>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button type="button" onclick="closeAddTagModal()"
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-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Add Tag</button>
</div>
</form>
</div>
</div>
<!-- Edit Tag Modal -->
<div id="edit-tag-modal" class="hidden fixed inset-0 bg-black/30 backdrop-blur-md flex items-center justify-center z-50">
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-lg max-w-md w-full mx-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">Edit Tag</h2>
<button onclick="closeEditTagModal()" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-times"></i>
</button>
</div>
<form action="/tags" method="POST">
<input type="hidden" name="action" value="edit_tag">
<input type="hidden" name="tag_id" id="edit-tag-id">
<div class="space-y-4">
<input type="text" name="name" id="edit-tag-name" placeholder="Tag Name"
class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
<div class="flex items-center space-x-2">
<label class="text-sm font-medium">Colour:</label>
<input type="color" name="color" id="edit-tag-color"
class="w-12 h-10 border border-gray-600 rounded cursor-pointer">
<span id="edit-color-preview" class="text-sm font-mono"></span>
</div>
<textarea name="description" id="edit-tag-description" placeholder="Description (optional)" rows="3"
class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full resize-y"></textarea>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button type="button" onclick="closeEditTagModal()"
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="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">Save Changes</button>
</div>
</form>
</div>
</div>
<script src="/static/js/tags.js"></script>
</body>
</html>