Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 671b750bc4 | |||
| bc1078f673 | |||
| ad1e576da4 |
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "1.4.2"
|
".": "1.5.0"
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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 -->
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
+36
-2
@@ -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,7 +53,8 @@
|
|||||||
<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">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
<span><i class="fas {{ device.icon_class or 'fa-server' }} mr-2"></i>{{ device.name }}</span>
|
<span><i class="fas {{ device.icon_class or 'fa-server' }} mr-2"></i>{{ device.name }}</span>
|
||||||
{% set ips = device_ips.get(device.id, []) %}
|
{% set ips = device_ips.get(device.id, []) %}
|
||||||
<span class="flex flex-row flex-wrap justify-end items-center ml-4 text-xs text-blue-300 font-normal align-middle">
|
<span class="flex flex-row flex-wrap justify-end items-center ml-4 text-xs text-blue-300 font-normal align-middle">
|
||||||
@@ -45,6 +66,19 @@
|
|||||||
<span class="text-gray-400">No IPs</span>
|
<span class="text-gray-400">No IPs</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user