refactor: 🎨 remove caching #48

Merged
jamie merged 15 commits from v2.0.0 into main 2026-05-23 21:04:45 +01:00
14 changed files with 784 additions and 256 deletions
Showing only changes of commit 71d0b7fed6 - Show all commits
+1
View File
@@ -31,6 +31,7 @@ jobs:
run: | run: |
VERSION=${{ steps.get_version.outputs.VERSION }} VERSION=${{ steps.get_version.outputs.VERSION }}
docker build -t cr.jdbnet.co.uk/public/ipam:$VERSION \ docker build -t cr.jdbnet.co.uk/public/ipam:$VERSION \
-t cr.jdbnet.co.uk/public/ipam:v2 \
-t cr.jdbnet.co.uk/public/ipam:latest \ -t cr.jdbnet.co.uk/public/ipam:latest \
--build-arg VERSION=$VERSION \ --build-arg VERSION=$VERSION \
. .
+28 -4
View File
@@ -21,26 +21,50 @@ All endpoints are JSON under `/api/v2`. The Vue SPA uses **session cookies** (`c
| POST | `/api/v2/account/disable-2fa` | | POST | `/api/v2/account/disable-2fa` |
| POST | `/api/v2/account/regenerate-backup-codes` | | POST | `/api/v2/account/regenerate-backup-codes` |
## Core resources ## List response format
List endpoints return `{ "items": [...] }` unless noted. List endpoints return `{ "items": [...] }`. Exceptions:
- **Audit log** — `{ "items": [...], "total": N }` (supports pagination)
- **Devices by tag** — `{ "items": [...], "meta": { "tag_name", "count" } }`
- **Single resources** (device, subnet, rack) — flat object, not wrapped in `items`
## Core resources
| Resource | Endpoints | | Resource | Endpoints |
|----------|-----------| |----------|-----------|
| Dashboard | `GET /api/v2/dashboard` | | Dashboard | `GET /api/v2/dashboard` |
| Search | `GET /api/v2/search?q=` | | Search | `GET /api/v2/search?q=` |
| Devices | CRUD + `/ips`, `/tags`, `/ip-history`, `/custom-fields` | | Devices | CRUD + `/ips`, `/tags`, `/ip-history`, `/custom-fields` |
| Subnets | CRUD + `/available-ips`, `/export`, `/dhcp`, `/custom-fields` | | Subnets | CRUD + `/available-ips`, `/next_free_ip`, `/export`, `/dhcp`, `/custom-fields` |
| IP addresses | `PATCH /api/v2/ip-addresses/{id}` (notes) | | IP addresses | `PATCH /api/v2/ip-addresses/{id}` (notes) |
| IP history | `GET /api/v2/ips/{ip}/history` | | IP history | `GET /api/v2/ips/{ip}/history` |
| Tags | CRUD + device tag assign/remove | | Tags | CRUD + device tag assign/remove |
| Racks | CRUD + `/devices`, `/export` | | Racks | CRUD + `/devices`, `/export` |
| Custom fields | CRUD + `POST /custom-fields/reorder` | | Custom fields | CRUD + `POST /custom-fields/reorder` |
| Audit | `GET /audit`, `GET /audit/export` | | Audit | `GET /audit`, `GET /audit/actions`, `GET /audit/export` |
| Users & roles | CRUD + `POST /users/{id}/regenerate-api-key` | | Users & roles | CRUD + `POST /users/{id}/regenerate-api-key` |
| Permissions | `GET /permissions` | | Permissions | `GET /permissions` |
| Bulk | `POST /bulk/assign-ips`, `/create-devices`, `/assign-tags`, `/export-subnets` | | Bulk | `POST /bulk/assign-ips`, `/create-devices`, `/assign-tags`, `/export-subnets` |
### Subnet IP helpers
- `GET /api/v2/subnets/{id}/available-ips` — unassigned IPs outside DHCP pools
- `GET /api/v2/subnets/{id}/next_free_ip` — first available IP (also excludes DHCP pools)
### Audit query parameters
| Param | Description |
|-------|-------------|
| `limit` | Page size (default 100) |
| `offset` | Offset for pagination (default 0) |
| `user` | Filter by user name (partial match) |
| `action` | Exact action match (see `GET /audit/actions` for values) |
| `from` | Start date (`YYYY-MM-DD`) |
| `to` | End date (`YYYY-MM-DD`) |
Export (`GET /audit/export`) accepts the same filter params.
See route handlers in `app.py` for required permissions and request bodies. See route handlers in `app.py` for required permissions and request bodies.
## Example ## Example
+24 -45
View File
@@ -10,7 +10,7 @@ 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 names, descriptions, tags, and custom fields
- **Device Tagging**: Organize devices with customizable tags featuring colors and descriptions - **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
@@ -113,8 +113,8 @@ See [v1-to-v2-breaking-changes.md](v1-to-v2-breaking-changes.md) for removed fea
### Managing Subnets ### Managing Subnets
1. Navigate to "Admin" from the main menu 1. Navigate to **Subnet Management** from the main menu (`/subnets/manage`)
2. Click "Add Subnet" and fill in: 2. Click **Add Subnet** and fill in:
- **Name**: Friendly name for the subnet (e.g., "Office LAN") - **Name**: Friendly name for the subnet (e.g., "Office LAN")
- **CIDR**: Subnet in CIDR notation (e.g., `192.168.1.0/24`) - **CIDR**: Subnet in CIDR notation (e.g., `192.168.1.0/24`)
- **Site**: Site/location identifier - **Site**: Site/location identifier
@@ -135,8 +135,8 @@ See [v1-to-v2-breaking-changes.md](v1-to-v2-breaking-changes.md) for removed fea
### Configuring DHCP Pools ### Configuring DHCP Pools
1. Open a subnet view 1. Open a subnet from the dashboard or subnet list
2. Click "Configure DHCP Pool" 2. Click **DHCP** to open the DHCP pool modal
3. Set the start and end IP addresses 3. Set the start and end IP addresses
4. Optionally specify excluded IPs (comma-separated) 4. Optionally specify excluded IPs (comma-separated)
5. IPs within the pool range are automatically marked as "DHCP" 5. IPs within the pool range are automatically marked as "DHCP"
@@ -152,10 +152,10 @@ See [v1-to-v2-breaking-changes.md](v1-to-v2-breaking-changes.md) for removed fea
### Device Tagging ### Device Tagging
1. **Managing Tags** (Admin only): 1. **Managing Tags** (requires `add_tag` / `edit_tag` / `delete_tag` permissions):
- Navigate to "Admin" > "Tag Management" - Navigate to **Tags** from the main menu
- Click "Add Tag" to create new tags with custom colors and descriptions - Create tags with custom colours and descriptions
- Edit or delete existing tags as needed - Edit or delete existing tags as permitted by your role
2. **Assigning Tags to Devices**: 2. **Assigning Tags to Devices**:
- Open any device from the Devices page - Open any device from the Devices page
@@ -168,7 +168,7 @@ See [v1-to-v2-breaking-changes.md](v1-to-v2-breaking-changes.md) for removed fea
### 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 changes in **Audit Log**. Filter by user name, action type, and date range. Export filtered results to CSV.
### Exporting Data ### Exporting Data
@@ -202,50 +202,29 @@ The system uses a granular role-based access control (RBAC) system to manage use
### REST API ### REST API
The application includes a comprehensive REST API for programmatic access: Programmatic access uses **`/api/v2`**. The browser SPA uses session cookies; automation uses API keys via:
1. **Authentication**: All API requests require an API key, which can be provided via: - `X-API-Key` header
- `X-API-Key` header - `Authorization: Bearer <api_key>` header
- `Authorization: Bearer <api_key>` header - `?api_key=<api_key>` query parameter
- `?api_key=<api_key>` query parameter
2. **Base URL**: All API endpoints are prefixed with `/api/v1` Each user has an API key (view/regenerate on the Users page). Keys respect the same RBAC permissions as the web UI.
3. **Available Endpoints**: Full endpoint reference: [API.md](API.md)
- **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`
- **Racks**: `GET`, `POST`, `DELETE /api/v1/racks`
- **DHCP**: `GET`, `POST /api/v1/subnets/{id}/dhcp`
- **Audit Log**: `GET /api/v1/audit`
- **Users & Roles**: `GET /api/v1/users`, `GET /api/v1/roles` (admin only)
4. **API Keys**: Each user has a unique API key that can be viewed and regenerated from the Users page. API keys respect the same role-based permissions as the web interface.
5. **Documentation**: See [API.md](API.md) for the full REST API reference.
**Example API Requests**:
```bash ```bash
# List all devices # List devices
curl -H "X-API-Key: your_api_key" \ curl -H "X-API-Key: YOUR_KEY" https://your-server:5000/api/v2/devices
https://your-server:5000/api/v1/devices
# Get devices with a specific tag # Session login (browser-style)
curl -H "X-API-Key: your_api_key" \ curl -c cookies.txt -X POST -H "Content-Type: application/json" \
https://your-server:5000/api/v1/devices/by-tag/production -d '{"email":"admin@example.com","password":"password"}' \
https://your-server:5000/api/v2/auth/login
# 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
The project includes a Kubernetes deployment manifest. See `deployment.yml` for details. Example deployment manifest:
**Example Kubernetes deployment:**
```yaml ```yaml
apiVersion: apps/v1 apiVersion: apps/v1
@@ -313,7 +292,7 @@ spec:
### Subnet or IP Not Appearing ### Subnet or IP Not Appearing
- Verify CIDR notation is correct (supports /24 to /32) - Verify CIDR notation is correct (supports /24 to /32)
- Check subnet was created successfully (view in Admin page) - Check subnet was created successfully (Subnet Management page)
- Ensure you're logged in with appropriate permissions - Ensure you're logged in with appropriate permissions
- Check application logs for errors - Check application logs for errors
+69 -24
View File
@@ -259,6 +259,30 @@ def items_response(items):
return jsonify({'items': items}) return jsonify({'items': items})
def build_audit_filters():
"""Build WHERE clause and params for audit log queries from request args."""
clauses = []
params = []
user = request.args.get('user', '').strip()
if user:
clauses.append('u.name LIKE %s')
params.append(f'%{user}%')
action = request.args.get('action', '').strip()
if action:
clauses.append('al.action = %s')
params.append(action)
from_date = request.args.get('from', '').strip()
if from_date:
clauses.append('al.timestamp >= %s')
params.append(f'{from_date} 00:00:00')
to_date = request.args.get('to', '').strip()
if to_date:
clauses.append('al.timestamp <= %s')
params.append(f'{to_date} 23:59:59')
where_sql = ('WHERE ' + ' AND '.join(clauses)) if clauses else ''
return where_sql, params
def get_current_user_id(): def get_current_user_id():
user = current_user() user = current_user()
return user['id'] if user else session.get('user_id') return user['id'] if user else session.get('user_id')
@@ -1073,7 +1097,7 @@ def enrich_devices_batch(cursor, devices):
placeholders = ','.join(['%s'] * len(device_ids)) placeholders = ','.join(['%s'] * len(device_ids))
cursor.execute(f''' cursor.execute(f'''
SELECT dia.device_id, ip.id, ip.ip, ip.hostname, s.id as subnet_id, s.name as subnet_name, s.cidr, s.site SELECT dia.device_id, ip.id, ip.ip, ip.hostname, ip.notes, s.id as subnet_id, s.name as subnet_name, s.cidr, s.site
FROM DeviceIPAddress dia FROM DeviceIPAddress dia
JOIN IPAddress ip ON dia.ip_id = ip.id JOIN IPAddress ip ON dia.ip_id = ip.id
JOIN Subnet s ON ip.subnet_id = s.id JOIN Subnet s ON ip.subnet_id = s.id
@@ -1484,7 +1508,7 @@ def api_device(device_id):
if not device: if not device:
return jsonify({'error': 'Device not found'}), 404 return jsonify({'error': 'Device not found'}), 404
cursor.execute(''' cursor.execute('''
SELECT ip.id, ip.ip, ip.hostname, s.id as subnet_id, s.name as subnet_name, s.cidr, s.site SELECT ip.id, ip.ip, ip.hostname, ip.notes, s.id as subnet_id, s.name as subnet_name, s.cidr, s.site
FROM DeviceIPAddress dia FROM DeviceIPAddress dia
JOIN IPAddress ip ON dia.ip_id = ip.id JOIN IPAddress ip ON dia.ip_id = ip.id
JOIN Subnet s ON ip.subnet_id = s.id JOIN Subnet s ON ip.subnet_id = s.id
@@ -1704,20 +1728,20 @@ def api_subnet_next_free_ip(subnet_id):
if not cursor.fetchone(): if not cursor.fetchone():
return jsonify({'error': 'Subnet not found'}), 404 return jsonify({'error': 'Subnet not found'}), 404
# Find the first IP in the subnet that is not assigned to any device # Find the first unassigned IP outside DHCP pools
cursor.execute(''' cursor.execute('''
SELECT ip.id, ip.ip SELECT ip.id, ip.ip
FROM IPAddress ip FROM IPAddress ip
LEFT JOIN DeviceIPAddress dia ON ip.id = dia.ip_id LEFT JOIN DeviceIPAddress dia ON ip.id = dia.ip_id
WHERE ip.subnet_id = %s AND dia.ip_id IS NULL WHERE ip.subnet_id = %s AND dia.ip_id IS NULL
ORDER BY INET_ATON(ip.ip) ORDER BY INET_ATON(ip.ip)
LIMIT 1
''', (subnet_id,)) ''', (subnet_id,))
result = cursor.fetchone() ips = [{'id': r['id'], 'ip': r['ip']} for r in cursor.fetchall()]
if not result: ips = filter_ips_outside_dhcp(cursor, subnet_id, ips)
if not ips:
return jsonify({'error': 'No free IP addresses available in this subnet'}), 404 return jsonify({'error': 'No free IP addresses available in this subnet'}), 404
return jsonify({'id': result['id'], 'ip': result['ip']}) return jsonify({'id': ips[0]['id'], 'ip': ips[0]['ip']})
@app.route('/api/v2/subnets', methods=['POST']) @app.route('/api/v2/subnets', methods=['POST'])
@require_permission('add_subnet') @require_permission('add_subnet')
@@ -1901,7 +1925,7 @@ def api_racks():
ORDER BY rd.position_u, rd.side ORDER BY rd.position_u, rd.side
''', (rack['id'],)) ''', (rack['id'],))
rack['devices'] = cursor.fetchall() rack['devices'] = cursor.fetchall()
return jsonify({'racks': racks}) return items_response(racks)
@app.route('/api/v2/racks/<int:rack_id>', methods=['GET']) @app.route('/api/v2/racks/<int:rack_id>', methods=['GET'])
@require_permission('view_rack') @require_permission('view_rack')
@@ -2140,7 +2164,7 @@ def api_custom_fields_by_type(entity_type):
field['validation_rules'] = json.loads(field['validation_rules']) field['validation_rules'] = json.loads(field['validation_rules'])
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
field['validation_rules'] = {} field['validation_rules'] = {}
return jsonify({'fields': fields}) return items_response(fields)
@app.route('/api/v2/custom_fields', methods=['POST']) @app.route('/api/v2/custom_fields', methods=['POST'])
@require_permission('manage_custom_fields') @require_permission('manage_custom_fields')
@@ -2327,7 +2351,7 @@ def api_tags():
for tag in tags: for tag in tags:
cursor.execute('SELECT COUNT(*) as device_count FROM DeviceTag WHERE tag_id = %s', (tag['id'],)) cursor.execute('SELECT COUNT(*) as device_count FROM DeviceTag WHERE tag_id = %s', (tag['id'],))
tag['device_count'] = cursor.fetchone()['device_count'] tag['device_count'] = cursor.fetchone()['device_count']
return jsonify({'tags': tags}) return items_response(tags)
@app.route('/api/v2/tags', methods=['POST']) @app.route('/api/v2/tags', methods=['POST'])
@require_permission('add_tag') @require_permission('add_tag')
@@ -2457,7 +2481,7 @@ def api_device_tags(device_id):
ORDER BY t.name ORDER BY t.name
''', (device_id,)) ''', (device_id,))
tags = cursor.fetchall() tags = cursor.fetchall()
return jsonify({'tags': tags}) return items_response(tags)
@app.route('/api/v2/devices/<int:device_id>/tags', methods=['POST']) @app.route('/api/v2/devices/<int:device_id>/tags', methods=['POST'])
@require_permission('assign_device_tag') @require_permission('assign_device_tag')
@@ -2559,7 +2583,7 @@ def api_devices_by_tag(tag_identifier):
devices = cursor.fetchall() devices = cursor.fetchall()
if not devices: if not devices:
return jsonify({'devices': [], 'tag_name': tag_name, 'count': 0}) return jsonify({'items': [], 'meta': {'tag_name': tag_name, 'count': 0}})
if simple_format: if simple_format:
# Simple format: just name and first IP as clean array # Simple format: just name and first IP as clean array
@@ -2588,7 +2612,7 @@ def api_devices_by_tag(tag_identifier):
# Full format: complete device information # Full format: complete device information
for device in devices: for device in devices:
cursor.execute(''' cursor.execute('''
SELECT ip.id, ip.ip, ip.hostname, s.id as subnet_id, s.name as subnet_name, s.cidr, s.site SELECT ip.id, ip.ip, ip.hostname, ip.notes, s.id as subnet_id, s.name as subnet_name, s.cidr, s.site
FROM DeviceIPAddress dia FROM DeviceIPAddress dia
JOIN IPAddress ip ON dia.ip_id = ip.id JOIN IPAddress ip ON dia.ip_id = ip.id
JOIN Subnet s ON ip.subnet_id = s.id JOIN Subnet s ON ip.subnet_id = s.id
@@ -2605,27 +2629,46 @@ def api_devices_by_tag(tag_identifier):
''', (device['id'],)) ''', (device['id'],))
device['tags'] = cursor.fetchall() device['tags'] = cursor.fetchall()
return jsonify({'devices': devices, 'tag_name': tag_name, 'count': len(devices)}) return jsonify({'items': devices, 'meta': {'tag_name': tag_name, 'count': len(devices)}})
# Audit Log API # Audit Log API
@app.route('/api/v2/audit/actions', methods=['GET'])
@require_permission('view_audit')
def api_audit_actions():
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT DISTINCT action FROM AuditLog ORDER BY action')
actions = [row[0] for row in cursor.fetchall()]
return items_response(actions)
@app.route('/api/v2/audit', methods=['GET']) @app.route('/api/v2/audit', methods=['GET'])
@require_permission('view_audit') @require_permission('view_audit')
def api_audit(): def api_audit():
"""Get audit log entries""" """Get audit log entries"""
from flask import current_app from flask import current_app
where_sql, filter_params = build_audit_filters()
limit = request.args.get('limit', 100, type=int)
offset = request.args.get('offset', 0, type=int)
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
limit = request.args.get('limit', 100, type=int) cursor.execute(f'''
offset = request.args.get('offset', 0, type=int) SELECT COUNT(*) as total
cursor.execute(''' FROM AuditLog al
LEFT JOIN User u ON al.user_id = u.id
{where_sql}
''', tuple(filter_params))
total = cursor.fetchone()['total']
cursor.execute(f'''
SELECT al.id, al.user_id, u.name as user_name, al.action, al.details, al.subnet_id, al.timestamp SELECT al.id, al.user_id, u.name as user_name, al.action, al.details, al.subnet_id, al.timestamp
FROM AuditLog al FROM AuditLog al
LEFT JOIN User u ON al.user_id = u.id LEFT JOIN User u ON al.user_id = u.id
{where_sql}
ORDER BY al.timestamp DESC ORDER BY al.timestamp DESC
LIMIT %s OFFSET %s LIMIT %s OFFSET %s
''', (limit, offset)) ''', tuple(filter_params) + (limit, offset))
logs = cursor.fetchall() logs = cursor.fetchall()
return jsonify({'logs': logs}) return jsonify({'items': logs, 'total': total})
# Users API (admin only) # Users API (admin only)
@app.route('/api/v2/users', methods=['GET']) @app.route('/api/v2/users', methods=['GET'])
@@ -2645,7 +2688,7 @@ def api_users():
# Don't return API keys in list # Don't return API keys in list
for user in users: for user in users:
user.pop('api_key', None) user.pop('api_key', None)
return jsonify({'users': users}) return items_response(users)
# Roles API (admin only) # Roles API (admin only)
@app.route('/api/v2/roles', methods=['GET']) @app.route('/api/v2/roles', methods=['GET'])
@@ -2665,7 +2708,7 @@ def api_roles():
WHERE rp.role_id = %s WHERE rp.role_id = %s
''', (role['id'],)) ''', (role['id'],))
role['permissions'] = cursor.fetchall() role['permissions'] = cursor.fetchall()
return jsonify({'roles': roles}) return items_response(roles)
# ── Extended v2 endpoints ───────────────────────────────────────────────────── # ── Extended v2 endpoints ─────────────────────────────────────────────────────
@@ -2832,15 +2875,17 @@ def api_reorder_custom_fields():
@app.route('/api/v2/audit/export', methods=['GET']) @app.route('/api/v2/audit/export', methods=['GET'])
@require_permission('view_audit') @require_permission('view_audit')
def api_audit_export(): def api_audit_export():
where_sql, filter_params = build_audit_filters()
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(''' cursor.execute(f'''
SELECT al.timestamp, u.name, al.action, al.details, s.name SELECT al.timestamp, u.name, al.action, al.details, s.name
FROM AuditLog al FROM AuditLog al
LEFT JOIN User u ON al.user_id = u.id LEFT JOIN User u ON al.user_id = u.id
LEFT JOIN Subnet s ON al.subnet_id = s.id LEFT JOIN Subnet s ON al.subnet_id = s.id
{where_sql}
ORDER BY al.timestamp DESC ORDER BY al.timestamp DESC
''') ''', tuple(filter_params))
rows = cursor.fetchall() rows = cursor.fetchall()
return csv_attachment(rows, ['Timestamp', 'User', 'Action', 'Details', 'Subnet'], 'audit_log.csv') return csv_attachment(rows, ['Timestamp', 'User', 'Action', 'Details', 'Subnet'], 'audit_log.csv')
+66 -15
View File
@@ -1,7 +1,16 @@
const jsonHeaders = { "Content-Type": "application/json" }; const jsonHeaders = { "Content-Type": "application/json" };
let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(fn: () => void) {
onUnauthorized = fn;
}
async function handle<T>(res: Response): Promise<T> { async function handle<T>(res: Response): Promise<T> {
if (res.status === 401) throw new Error("unauthorized"); if (res.status === 401) {
onUnauthorized?.();
throw new Error("unauthorized");
}
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data as { error?: string }).error || res.statusText); if (!res.ok) throw new Error((data as { error?: string }).error || res.statusText);
return data as T; return data as T;
@@ -36,6 +45,7 @@ export interface IpOnDevice {
subnet_name?: string; subnet_name?: string;
cidr?: string; cidr?: string;
site?: string; site?: string;
notes?: string;
} }
export interface Subnet { export interface Subnet {
@@ -121,6 +131,18 @@ export interface CustomFieldDef {
field_type: string; field_type: string;
required?: boolean; required?: boolean;
display_order?: number; display_order?: number;
default_value?: string;
help_text?: string;
validation_rules?: { select_options?: string[] };
}
export interface AuditParams {
limit?: number;
offset?: number;
user?: string;
action?: string;
from?: string;
to?: string;
} }
export const api = { export const api = {
@@ -228,8 +250,8 @@ export const api = {
return `/api/v2/subnets/${id}/export`; return `/api/v2/subnets/${id}/export`;
}, },
async tags() { async tags() {
const d = await handle<{ tags?: Tag[]; items?: Tag[] }>(await fetchApi("/api/v2/tags")); const d = await handle<{ items: Tag[] }>(await fetchApi("/api/v2/tags"));
return d.items ?? d.tags ?? []; return d.items;
}, },
async createTag(body: Partial<Tag>) { async createTag(body: Partial<Tag>) {
return handle(await fetchApi("/api/v2/tags", { method: "POST", headers: jsonHeaders, body: JSON.stringify(body) })); return handle(await fetchApi("/api/v2/tags", { method: "POST", headers: jsonHeaders, body: JSON.stringify(body) }));
@@ -249,8 +271,8 @@ export const api = {
return handle(await fetchApi(`/api/v2/devices/${deviceId}/tags/${tagId}`, { method: "DELETE" })); return handle(await fetchApi(`/api/v2/devices/${deviceId}/tags/${tagId}`, { method: "DELETE" }));
}, },
async racks() { async racks() {
const d = await handle<{ racks?: Rack[]; items?: Rack[] }>(await fetchApi("/api/v2/racks")); const d = await handle<{ items: Rack[] }>(await fetchApi("/api/v2/racks"));
return d.racks ?? d.items ?? []; return d.items;
}, },
async rack(id: number) { async rack(id: number) {
return handle<Rack>(await fetchApi(`/api/v2/racks/${id}`)); return handle<Rack>(await fetchApi(`/api/v2/racks/${id}`));
@@ -318,18 +340,37 @@ export const api = {
method: "POST", headers: jsonHeaders, body: JSON.stringify({ password }), method: "POST", headers: jsonHeaders, body: JSON.stringify({ password }),
})); }));
}, },
async audit(limit = 100) { async audit(params: AuditParams = {}) {
const d = await handle<{ logs: AuditEntry[] }>(await fetchApi(`/api/v2/audit?limit=${limit}`)); const p = new URLSearchParams();
return d.logs; if (params.limit != null) p.set("limit", String(params.limit));
if (params.offset != null) p.set("offset", String(params.offset));
if (params.user) p.set("user", params.user);
if (params.action) p.set("action", params.action);
if (params.from) p.set("from", params.from);
if (params.to) p.set("to", params.to);
const q = p.toString();
return handle<{ items: AuditEntry[]; total: number }>(await fetchApi(`/api/v2/audit${q ? `?${q}` : ""}`));
},
async auditActions() {
const d = await handle<{ items: string[] }>(await fetchApi("/api/v2/audit/actions"));
return d.items;
},
auditExportUrl(params: AuditParams = {}) {
const p = new URLSearchParams();
if (params.user) p.set("user", params.user);
if (params.action) p.set("action", params.action);
if (params.from) p.set("from", params.from);
if (params.to) p.set("to", params.to);
const q = p.toString();
return `/api/v2/audit/export${q ? `?${q}` : ""}`;
}, },
auditExportUrl: "/api/v2/audit/export",
async users() { async users() {
const d = await handle<{ users?: UserRow[]; items?: UserRow[] }>(await fetchApi("/api/v2/users")); const d = await handle<{ items: UserRow[] }>(await fetchApi("/api/v2/users"));
return d.users ?? d.items ?? []; return d.items;
}, },
async roles() { async roles() {
const d = await handle<{ roles?: RoleRow[] }>(await fetchApi("/api/v2/roles")); const d = await handle<{ items: RoleRow[] }>(await fetchApi("/api/v2/roles"));
return d.roles ?? []; return d.items;
}, },
async permissions() { async permissions() {
const d = await handle<{ items: { id: number; name: string; category?: string }[] }>( const d = await handle<{ items: { id: number; name: string; category?: string }[] }>(
@@ -338,8 +379,18 @@ export const api = {
return d.items; return d.items;
}, },
async customFields(entityType: string) { async customFields(entityType: string) {
const d = await handle<{ fields?: CustomFieldDef[] }>(await fetchApi(`/api/v2/custom_fields/${entityType}`)); const d = await handle<{ items: CustomFieldDef[] }>(await fetchApi(`/api/v2/custom_fields/${entityType}`));
return d.fields ?? []; return d.items;
},
async patchDeviceCustomFields(deviceId: number, customFields: Record<string, unknown>) {
return handle(await fetchApi(`/api/v2/devices/${deviceId}/custom-fields`, {
method: "PATCH", headers: jsonHeaders, body: JSON.stringify({ custom_fields: customFields }),
}));
},
async patchSubnetCustomFields(subnetId: number, customFields: Record<string, unknown>) {
return handle(await fetchApi(`/api/v2/subnets/${subnetId}/custom-fields`, {
method: "PATCH", headers: jsonHeaders, body: JSON.stringify({ custom_fields: customFields }),
}));
}, },
async bulkAssignIps(deviceId: number, ipIds: number[]) { async bulkAssignIps(deviceId: number, ipIds: number[]) {
return handle(await fetchApi("/api/v2/bulk/assign-ips", { return handle(await fetchApi("/api/v2/bulk/assign-ips", {
@@ -0,0 +1,129 @@
<script setup lang="ts">
import { ref, onMounted, computed, watch } from "vue";
import { api, type CustomFieldDef } from "@/api";
const props = defineProps<{
entityType: "device" | "subnet";
entityId: number;
values?: Record<string, unknown>;
canEdit: boolean;
}>();
const emit = defineEmits<{ saved: [values: Record<string, unknown>] }>();
const fields = ref<CustomFieldDef[]>([]);
const form = ref<Record<string, unknown>>({});
const loading = ref(true);
const saving = ref(false);
const err = ref("");
const msg = ref("");
const visible = computed(() => fields.value.length > 0 || Object.keys(props.values ?? {}).length > 0);
function initForm() {
const next: Record<string, unknown> = {};
for (const f of fields.value) {
const existing = props.values?.[f.field_key];
if (existing !== undefined && existing !== null) {
next[f.field_key] = existing;
} else if (f.default_value) {
next[f.field_key] = f.field_type === "checkbox" ? f.default_value === "true" : f.default_value;
} else {
next[f.field_key] = f.field_type === "checkbox" ? false : "";
}
}
form.value = next;
}
async function loadFields() {
loading.value = true;
err.value = "";
try {
fields.value = await api.customFields(props.entityType);
initForm();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed to load fields";
fields.value = [];
} finally {
loading.value = false;
}
}
onMounted(loadFields);
watch(() => props.values, () => {
if (fields.value.length) initForm();
}, { deep: true });
async function save() {
if (!props.canEdit) return;
saving.value = true;
err.value = "";
msg.value = "";
try {
const payload = { ...form.value };
if (props.entityType === "device") {
await api.patchDeviceCustomFields(props.entityId, payload);
} else {
await api.patchSubnetCustomFields(props.entityId, payload);
}
msg.value = "Saved";
emit("saved", payload);
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed to save";
} finally {
saving.value = false;
}
}
</script>
<template>
<div v-if="visible" class="card">
<h2 class="font-semibold">Custom fields</h2>
<p v-if="loading" class="mt-2 text-sm text-slate-500">Loading</p>
<form v-else class="mt-3 space-y-3" @submit.prevent="save">
<div v-for="f in fields" :key="f.id">
<label class="mb-1 block text-sm font-medium">
{{ f.name }}<span v-if="f.required" class="text-red-500"> *</span>
</label>
<p v-if="f.help_text" class="mb-1 text-xs text-slate-500">{{ f.help_text }}</p>
<template v-if="canEdit">
<textarea
v-if="f.field_type === 'textarea'"
v-model="form[f.field_key]"
class="input-field"
:required="f.required"
/>
<select
v-else-if="f.field_type === 'select'"
v-model="form[f.field_key]"
class="input-field"
:required="f.required"
>
<option value=""></option>
<option v-for="opt in f.validation_rules?.select_options ?? []" :key="opt" :value="opt">{{ opt }}</option>
</select>
<label v-else-if="f.field_type === 'checkbox'" class="flex items-center gap-2 text-sm">
<input v-model="form[f.field_key]" type="checkbox" />
<span>Enabled</span>
</label>
<input
v-else
v-model="form[f.field_key]"
class="input-field"
:type="f.field_type === 'number' ? 'number' : f.field_type === 'date' ? 'date' : 'text'"
:required="f.required"
/>
</template>
<p v-else class="text-sm text-slate-600 dark:text-slate-400">
{{ f.field_type === 'checkbox' ? (form[f.field_key] ? 'Yes' : 'No') : (form[f.field_key] || '—') }}
</p>
</div>
<div v-if="canEdit && fields.length" class="flex gap-2">
<button type="submit" class="btn-primary text-sm" :disabled="saving">Save fields</button>
</div>
<p v-if="msg" class="text-sm text-accent">{{ msg }}</p>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
</form>
</div>
</template>
+15 -1
View File
@@ -2,6 +2,20 @@ import { createApp } from "vue";
import { createPinia } from "pinia"; import { createPinia } from "pinia";
import App from "./App.vue"; import App from "./App.vue";
import router from "./router"; import router from "./router";
import { setUnauthorizedHandler } from "./api";
import { useAuthStore } from "./stores/auth";
import "./style.css"; import "./style.css";
createApp(App).use(createPinia()).use(router).mount("#app"); const pinia = createPinia();
const app = createApp(App);
app.use(pinia).use(router);
setUnauthorizedHandler(() => {
const auth = useAuthStore();
const current = router.currentRoute.value;
if (current.meta.public) return;
auth.logout();
router.push({ name: "login", query: { redirect: current.fullPath } });
});
app.mount("#app");
+1
View File
@@ -41,4 +41,5 @@ router.beforeEach(async (to) => {
return true; return true;
}); });
export { router };
export default router; export default router;
+128 -7
View File
@@ -1,30 +1,151 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from "vue"; import { ref, onMounted, computed } from "vue";
import { api, type AuditEntry } from "@/api"; import { api, type AuditEntry } from "@/api";
import { formatLocalTime } from "@/utils/datetime"; import { formatLocalTime } from "@/utils/datetime";
const logs = ref<AuditEntry[]>([]); const logs = ref<AuditEntry[]>([]);
const actions = ref<string[]>([]);
const total = ref(0);
const loading = ref(true);
const error = ref("");
const limit = 50;
const offset = ref(0);
onMounted(async () => { logs.value = await api.audit(200); }); const filters = ref({ user: "", action: "", from: "", to: "" });
const applied = ref({ user: "", action: "", from: "", to: "" });
const exportUrl = computed(() => api.auditExportUrl({
user: applied.value.user || undefined,
action: applied.value.action || undefined,
from: applied.value.from || undefined,
to: applied.value.to || undefined,
}));
const page = computed(() => Math.floor(offset.value / limit) + 1);
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / limit)));
async function load() {
loading.value = true;
error.value = "";
try {
const d = await api.audit({
limit,
offset: offset.value,
user: applied.value.user || undefined,
action: applied.value.action || undefined,
from: applied.value.from || undefined,
to: applied.value.to || undefined,
});
logs.value = d.items;
total.value = d.total;
} catch (e) {
error.value = e instanceof Error ? e.message : "Failed to load audit log";
logs.value = [];
total.value = 0;
} finally {
loading.value = false;
}
}
onMounted(async () => {
try {
actions.value = await api.auditActions();
} catch {
actions.value = [];
}
await load();
});
function applyFilters() {
applied.value = { ...filters.value };
offset.value = 0;
load();
}
function clearFilters() {
filters.value = { user: "", action: "", from: "", to: "" };
applied.value = { user: "", action: "", from: "", to: "" };
offset.value = 0;
load();
}
function prevPage() {
if (offset.value >= limit) {
offset.value -= limit;
load();
}
}
function nextPage() {
if (offset.value + limit < total.value) {
offset.value += limit;
load();
}
}
</script> </script>
<template> <template>
<div> <div>
<div class="flex items-center justify-between"> <div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-2xl font-bold">Audit log</h1> <h1 class="text-2xl font-bold">Audit log</h1>
<a href="/api/v2/audit/export" class="btn-secondary text-sm">Export CSV</a> <a :href="exportUrl" class="btn-secondary text-sm">Export CSV</a>
</div> </div>
<div class="card mt-6 overflow-x-auto">
<form class="card mt-6 flex flex-wrap items-end gap-3" @submit.prevent="applyFilters">
<div>
<label class="mb-1 block text-xs text-slate-500">User</label>
<input v-model="filters.user" class="input-field py-1.5 text-sm" placeholder="Name contains…" />
</div>
<div>
<label class="mb-1 block text-xs text-slate-500">Action</label>
<select v-model="filters.action" class="input-field py-1.5 text-sm">
<option value="">All actions</option>
<option v-for="a in actions" :key="a" :value="a">{{ a }}</option>
</select>
</div>
<div>
<label class="mb-1 block text-xs text-slate-500">From</label>
<input v-model="filters.from" type="date" class="input-field py-1.5 text-sm" />
</div>
<div>
<label class="mb-1 block text-xs text-slate-500">To</label>
<input v-model="filters.to" type="date" class="input-field py-1.5 text-sm" />
</div>
<button type="submit" class="btn-primary text-sm">Apply</button>
<button type="button" class="btn-secondary text-sm" @click="clearFilters">Clear</button>
</form>
<p v-if="loading" class="mt-6 text-slate-500">Loading</p>
<p v-else-if="error" class="mt-6 text-red-500">{{ error }}</p>
<div v-else class="card mt-6 overflow-x-auto">
<table class="w-full text-left text-sm"> <table class="w-full text-left text-sm">
<thead><tr class="border-b dark:border-slate-700"><th class="p-2">Time</th><th class="p-2">User</th><th class="p-2">Action</th><th class="p-2">Details</th></tr></thead> <thead>
<tr class="border-b dark:border-slate-700">
<th class="p-2">Time</th>
<th class="p-2">User</th>
<th class="p-2">Action</th>
<th class="p-2">Details</th>
</tr>
</thead>
<tbody> <tbody>
<tr v-for="l in logs" :key="l.id" class="border-b dark:border-slate-800"> <tr v-for="l in logs" :key="l.id" class="border-b dark:border-slate-800">
<td class="p-2 whitespace-nowrap text-xs text-slate-500">{{ formatLocalTime(l.timestamp) }}</td> <td class="p-2 whitespace-nowrap text-xs text-slate-500">{{ formatLocalTime(l.timestamp) }}</td>
<td class="p-2">{{ l.user_name }}</td> <td class="p-2">{{ l.user_name || "—" }}</td>
<td class="p-2 font-mono text-xs">{{ l.action }}</td> <td class="p-2 font-mono text-xs">{{ l.action }}</td>
<td class="p-2 max-w-md truncate">{{ l.details }}</td> <td class="p-2 max-w-md truncate">{{ l.details }}</td>
</tr> </tr>
<tr v-if="!logs.length">
<td colspan="4" class="p-4 text-center text-slate-500">No audit entries match your filters.</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div v-if="!loading && !error && total > 0" class="mt-4 flex items-center justify-between text-sm text-slate-500">
<span>{{ total }} entries · page {{ page }} of {{ totalPages }}</span>
<div class="flex gap-2">
<button type="button" class="btn-secondary text-sm" :disabled="offset === 0" @click="prevPage">Previous</button>
<button type="button" class="btn-secondary text-sm" :disabled="offset + limit >= total" @click="nextPage">Next</button>
</div>
</div>
</div> </div>
</template> </template>
+153 -92
View File
@@ -3,6 +3,7 @@ import { ref, onMounted, computed } from "vue";
import { useRoute, useRouter, RouterLink } from "vue-router"; import { useRoute, useRouter, RouterLink } from "vue-router";
import { api, type Device, type Tag, type Subnet } from "@/api"; import { api, type Device, type Tag, type Subnet } from "@/api";
import type { IpHistoryEntry } from "@/components/IpHistoryModal.vue"; import type { IpHistoryEntry } from "@/components/IpHistoryModal.vue";
import CustomFieldValues from "@/components/CustomFieldValues.vue";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { formatLocalTime } from "@/utils/datetime"; import { formatLocalTime } from "@/utils/datetime";
@@ -15,7 +16,10 @@ const subnets = ref<Subnet[]>([]);
const availableIps = ref<{ id: number; ip: string }[]>([]); const availableIps = ref<{ id: number; ip: string }[]>([]);
const history = ref<IpHistoryEntry[]>([]); const history = ref<IpHistoryEntry[]>([]);
const editName = ref(""); const editName = ref("");
const editDescription = ref("");
const saving = ref(false); const saving = ref(false);
const loading = ref(true);
const error = ref("");
const showAssignIp = ref(false); const showAssignIp = ref(false);
const assignForm = ref({ site: "", subnet_id: 0, ip_id: 0 }); const assignForm = ref({ site: "", subnet_id: 0, ip_id: 0 });
const err = ref(""); const err = ref("");
@@ -41,20 +45,32 @@ const subnetsForSite = computed(() =>
subnets.value.filter((s) => (s.site || "Unassigned") === assignForm.value.site), subnets.value.filter((s) => (s.site || "Unassigned") === assignForm.value.site),
); );
onMounted(async () => { async function loadDevice() {
const id = Number(route.params.id); loading.value = true;
const [d, tags, h, sn] = await Promise.all([ error.value = "";
api.device(id), try {
api.tags(), const id = Number(route.params.id);
api.deviceIpHistory(id).catch(() => []), const [d, tags, h, sn] = await Promise.all([
api.subnets(false), api.device(id),
]); api.tags(),
device.value = d; api.deviceIpHistory(id).catch(() => []),
editName.value = d.name; api.subnets(false),
allTags.value = tags; ]);
subnets.value = sn; device.value = d;
history.value = h as IpHistoryEntry[]; editName.value = d.name;
}); editDescription.value = d.description || "";
allTags.value = tags;
subnets.value = sn;
history.value = h as IpHistoryEntry[];
} catch (e) {
error.value = e instanceof Error ? e.message : "Failed to load device";
device.value = null;
} finally {
loading.value = false;
}
}
onMounted(loadDevice);
async function loadAvailableIps(subnetId: number) { async function loadAvailableIps(subnetId: number) {
if (!subnetId) { if (!subnetId) {
@@ -89,12 +105,19 @@ async function openAssignIpModal() {
showAssignIp.value = true; showAssignIp.value = true;
} }
async function saveName() { async function saveDevice() {
if (!device.value) return; if (!device.value) return;
saving.value = true; saving.value = true;
await api.updateDevice(device.value.id, { name: editName.value }); err.value = "";
device.value.name = editName.value; try {
saving.value = false; await api.updateDevice(device.value.id, { name: editName.value, description: editDescription.value });
device.value.name = editName.value;
device.value.description = editDescription.value;
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed to save";
} finally {
saving.value = false;
}
} }
async function assignTag(tagId: number) { async function assignTag(tagId: number) {
@@ -115,8 +138,7 @@ async function assignIp() {
try { try {
await api.assignIp(device.value.id, assignForm.value.ip_id); await api.assignIp(device.value.id, assignForm.value.ip_id);
showAssignIp.value = false; showAssignIp.value = false;
device.value = await api.device(device.value.id); await loadDevice();
history.value = (await api.deviceIpHistory(device.value.id)) as IpHistoryEntry[];
} catch (e) { } catch (e) {
err.value = e instanceof Error ? e.message : "Failed"; err.value = e instanceof Error ? e.message : "Failed";
} }
@@ -125,8 +147,7 @@ async function assignIp() {
async function removeIp(ipId: number) { async function removeIp(ipId: number) {
if (!device.value || !confirm("Remove this IP from the device?")) return; if (!device.value || !confirm("Remove this IP from the device?")) return;
await api.removeIp(device.value.id, ipId); await api.removeIp(device.value.id, ipId);
device.value = await api.device(device.value.id); await loadDevice();
history.value = (await api.deviceIpHistory(device.value.id)) as IpHistoryEntry[];
} }
async function deleteDevice() { async function deleteDevice() {
@@ -135,86 +156,126 @@ async function deleteDevice() {
router.push("/devices"); router.push("/devices");
} }
function onCustomFieldsSaved(values: Record<string, unknown>) {
if (device.value) device.value.custom_fields = values;
}
function formatTime(ts?: string) { function formatTime(ts?: string) {
return formatLocalTime(ts, "Unknown"); return formatLocalTime(ts, "Unknown");
} }
</script> </script>
<template> <template>
<div v-if="device"> <div>
<RouterLink to="/devices" class="text-sm text-accent hover:underline"> Devices</RouterLink> <RouterLink to="/devices" class="text-sm text-accent hover:underline"> Devices</RouterLink>
<div class="mt-4 flex flex-wrap items-start justify-between gap-4"> <p v-if="loading" class="mt-8 text-slate-500">Loading</p>
<div class="flex-1"> <p v-else-if="error" class="mt-8 text-red-500">{{ error }}</p>
<input v-if="auth.can('edit_device')" v-model="editName" class="input-field max-w-md text-xl font-bold" @blur="saveName" /> <template v-else-if="device">
<h1 v-else class="text-2xl font-bold">{{ device.name }}</h1> <div class="mt-4 flex flex-wrap items-start justify-between gap-4">
<p class="mt-1 text-slate-500">{{ device.description || "No description" }}</p> <div class="flex min-w-0 max-w-2xl flex-1 flex-col gap-2">
</div> <template v-if="auth.can('edit_device')">
<button <input
v-if="auth.can('delete_device')" v-model="editName"
class="text-sm text-red-500 hover:underline" class="input-field block w-full border-0 bg-transparent px-0 py-0 text-2xl font-bold shadow-none focus:ring-0"
@click="deleteDevice" aria-label="Device name"
>Delete device</button> @blur="saveDevice"
</div> />
<div class="mt-6 grid gap-4 lg:grid-cols-2"> <textarea
<div class="card"> v-model="editDescription"
<div class="flex items-center justify-between"> class="input-field block w-full resize-y text-sm"
<h2 class="font-semibold">IP addresses</h2> placeholder="Add a description…"
<button v-if="auth.can('add_device_ip')" class="text-sm text-accent hover:underline" @click="openAssignIpModal">Assign IP</button> rows="2"
@blur="saveDevice"
/>
</template>
<template v-else>
<h1 class="text-2xl font-bold">{{ device.name }}</h1>
<p v-if="device.description" class="text-slate-500">{{ device.description }}</p>
</template>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
</div> </div>
<ul class="mt-3 space-y-2"> <button
<li v-for="ip in device.ip_addresses" :key="ip.id" class="flex items-center justify-between font-mono text-sm"> v-if="auth.can('delete_device')"
<span>{{ ip.ip }} <span class="text-slate-500">({{ ip.subnet_name }})</span></span> class="shrink-0 text-sm text-red-500 hover:underline"
<button v-if="auth.can('remove_device_ip')" class="text-red-500 hover:underline" @click="removeIp(ip.id)">Remove</button> @click="deleteDevice"
</li> >Delete device</button>
<li v-if="!device.ip_addresses?.length" class="text-sm text-slate-500">None assigned</li>
</ul>
</div> </div>
<div class="card"> <div class="mt-6 grid gap-4 lg:grid-cols-2">
<h2 class="font-semibold">Tags</h2> <div class="card">
<div class="mt-2 flex flex-wrap gap-2"> <div class="flex items-center justify-between">
<span v-for="t in device.tags" :key="t.id" class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs" :style="{ backgroundColor: (t.color || '#6B7280') + '33' }"> <h2 class="font-semibold">IP addresses</h2>
{{ t.name }} <button v-if="auth.can('add_device_ip')" class="text-sm text-accent hover:underline" @click="openAssignIpModal">Assign IP</button>
<button v-if="auth.can('assign_device_tag')" class="text-red-500 hover:underline" @click="removeTag(t.id)">×</button> </div>
</span> <ul class="mt-3 space-y-2">
<li v-for="ip in device.ip_addresses" :key="ip.id" class="flex items-center justify-between gap-3 font-mono text-sm">
<span class="min-w-0">
{{ ip.ip }}
<span v-if="ip.notes || ip.subnet_name" class="font-sans text-slate-500">
{{ ip.notes || ip.subnet_name }}
</span>
</span>
<button v-if="auth.can('remove_device_ip')" class="text-red-500 hover:underline" @click="removeIp(ip.id)">Remove</button>
</li>
<li v-if="!device.ip_addresses?.length" class="text-sm text-slate-500">None assigned</li>
</ul>
</div> </div>
<select v-if="auth.can('assign_device_tag')" class="input-field mt-3" @change="assignTag(Number(($event.target as HTMLSelectElement).value)); ($event.target as HTMLSelectElement).value = ''"> <div class="card">
<option value="">Add tag</option> <h2 class="font-semibold">Tags</h2>
<option v-for="t in allTags.filter((t) => !device!.tags?.some((dt) => dt.id === t.id))" :key="t.id" :value="t.id">{{ t.name }}</option> <div class="mt-2 flex flex-wrap gap-2">
</select> <span v-for="t in device.tags" :key="t.id" class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs" :style="{ backgroundColor: (t.color || '#6B7280') + '33' }">
</div> {{ t.name }}
<div class="card lg:col-span-2"> <button v-if="auth.can('assign_device_tag')" class="text-red-500 hover:underline" @click="removeTag(t.id)">×</button>
<h2 class="font-semibold">IP history</h2>
<p v-if="!history.length" class="mt-2 text-sm text-slate-500">No assignment history.</p>
<ul v-else class="mt-3 space-y-3">
<li v-for="(entry, i) in history" :key="i" class="flex gap-3 text-sm">
<span class="shrink-0 font-semibold uppercase text-xs" :class="entry.action === 'assigned' ? 'text-emerald-600' : 'text-red-500'">
{{ entry.action === "assigned" ? "Assigned" : "Removed" }}
</span> </span>
<span class="font-mono">{{ entry.ip }}</span> <span v-if="!device.tags?.length" class="text-sm text-slate-500">No tags</span>
<span class="text-slate-500">· {{ entry.user_name }} · {{ formatTime(entry.timestamp) }}</span> </div>
</li> <select v-if="auth.can('assign_device_tag')" class="input-field mt-3" @change="assignTag(Number(($event.target as HTMLSelectElement).value)); ($event.target as HTMLSelectElement).value = ''">
</ul> <option value="">Add tag</option>
</div> <option v-for="t in allTags.filter((t) => !device!.tags?.some((dt) => dt.id === t.id))" :key="t.id" :value="t.id">{{ t.name }}</option>
</div> </select>
<div v-if="showAssignIp" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showAssignIp = false">
<form class="card w-full max-w-md space-y-3" @submit.prevent="assignIp">
<h2 class="text-lg font-semibold">Assign IP</h2>
<select v-if="!deviceSites.length" v-model="assignForm.site" class="input-field" @change="onSiteChange">
<option v-for="site in assignableSites" :key="site" :value="site">{{ site }}</option>
</select>
<select v-model="assignForm.subnet_id" class="input-field" @change="onSubnetChange">
<option v-for="s in subnetsForSite" :key="s.id" :value="s.id">{{ s.name }} ({{ s.cidr }})</option>
</select>
<select v-model="assignForm.ip_id" class="input-field" required>
<option v-for="ip in availableIps" :key="ip.id" :value="ip.id">{{ ip.ip }}</option>
</select>
<p v-if="assignForm.subnet_id && !availableIps.length" class="text-sm text-slate-500">No available IPs in this subnet</p>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<div class="flex gap-2">
<button type="submit" class="btn-primary" :disabled="!assignForm.ip_id">Assign</button>
<button type="button" class="btn-secondary" @click="showAssignIp = false">Cancel</button>
</div> </div>
</form> <CustomFieldValues
</div> v-if="auth.can('view_custom_fields')"
class="lg:col-span-2"
entity-type="device"
:entity-id="device.id"
:values="device.custom_fields"
:can-edit="auth.can('edit_device')"
@saved="onCustomFieldsSaved"
/>
<div class="card lg:col-span-2">
<h2 class="font-semibold">IP history</h2>
<p v-if="!history.length" class="mt-2 text-sm text-slate-500">No assignment history.</p>
<ul v-else class="mt-3 space-y-3">
<li v-for="(entry, i) in history" :key="i" class="flex gap-3 text-sm">
<span class="shrink-0 text-xs font-semibold uppercase" :class="entry.action === 'assigned' ? 'text-emerald-600' : 'text-red-500'">
{{ entry.action === "assigned" ? "Assigned" : "Removed" }}
</span>
<span class="font-mono">{{ entry.ip }}</span>
<span class="text-slate-500">· {{ entry.user_name }} · {{ formatTime(entry.timestamp) }}</span>
</li>
</ul>
</div>
</div>
<div v-if="showAssignIp" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showAssignIp = false">
<form class="card w-full max-w-md space-y-3" @submit.prevent="assignIp">
<h2 class="text-lg font-semibold">Assign IP</h2>
<select v-if="!deviceSites.length" v-model="assignForm.site" class="input-field" @change="onSiteChange">
<option v-for="site in assignableSites" :key="site" :value="site">{{ site }}</option>
</select>
<select v-model="assignForm.subnet_id" class="input-field" @change="onSubnetChange">
<option v-for="s in subnetsForSite" :key="s.id" :value="s.id">{{ s.name }} ({{ s.cidr }})</option>
</select>
<select v-model="assignForm.ip_id" class="input-field" required>
<option v-for="ip in availableIps" :key="ip.id" :value="ip.id">{{ ip.ip }}</option>
</select>
<p v-if="assignForm.subnet_id && !availableIps.length" class="text-sm text-slate-500">No available IPs in this subnet</p>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<div class="flex gap-2">
<button type="submit" class="btn-primary" :disabled="!assignForm.ip_id">Assign</button>
<button type="button" class="btn-secondary" @click="showAssignIp = false">Cancel</button>
</div>
</form>
</div>
</template>
</div> </div>
</template> </template>
+15 -2
View File
@@ -10,10 +10,20 @@ const showAdd = ref(false);
const showEdit = ref(false); const showEdit = ref(false);
const form = ref({ name: "", site: "", height_u: 42 }); const form = ref({ name: "", site: "", height_u: 42 });
const editId = ref(0); const editId = ref(0);
const loading = ref(true);
const err = ref(""); const err = ref("");
async function load() { async function load() {
racks.value = await api.racks(); loading.value = true;
err.value = "";
try {
racks.value = await api.racks();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed to load racks";
racks.value = [];
} finally {
loading.value = false;
}
} }
onMounted(load); onMounted(load);
@@ -60,7 +70,10 @@ async function del(id: number) {
<h1 class="text-2xl font-bold">Racks</h1> <h1 class="text-2xl font-bold">Racks</h1>
<button v-if="auth.can('add_rack')" class="btn-primary text-sm" @click="showAdd = true; err = ''">Add rack</button> <button v-if="auth.can('add_rack')" class="btn-primary text-sm" @click="showAdd = true; err = ''">Add rack</button>
</div> </div>
<div class="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <p v-if="loading" class="mt-6 text-slate-500">Loading</p>
<p v-else-if="err && !racks.length" class="mt-6 text-red-500">{{ err }}</p>
<p v-else-if="!racks.length" class="mt-6 text-slate-500">No racks yet.</p>
<div v-else class="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div v-for="r in racks" :key="r.id" class="card"> <div v-for="r in racks" :key="r.id" class="card">
<RouterLink :to="`/racks/${r.id}`" class="block transition hover:text-accent"> <RouterLink :to="`/racks/${r.id}`" class="block transition hover:text-accent">
<div class="font-medium">{{ r.name }}</div> <div class="font-medium">{{ r.name }}</div>
+111 -53
View File
@@ -5,77 +5,135 @@ import { api, type Subnet } from "@/api";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import IpHistoryModal from "@/components/IpHistoryModal.vue"; import IpHistoryModal from "@/components/IpHistoryModal.vue";
import DhcpModal from "@/components/DhcpModal.vue"; import DhcpModal from "@/components/DhcpModal.vue";
import CustomFieldValues from "@/components/CustomFieldValues.vue";
const route = useRoute(); const route = useRoute();
const auth = useAuthStore(); const auth = useAuthStore();
const subnet = ref<Subnet | null>(null); const subnet = ref<Subnet | null>(null);
const historyIp = ref<string | null>(null); const historyIp = ref<string | null>(null);
const showDhcp = ref(false); const showDhcp = ref(false);
const loading = ref(true);
const error = ref("");
const notesErr = ref("");
async function loadSubnet() { async function loadSubnet() {
subnet.value = await api.subnet(Number(route.params.id)); loading.value = true;
error.value = "";
try {
subnet.value = await api.subnet(Number(route.params.id));
} catch (e) {
error.value = e instanceof Error ? e.message : "Failed to load subnet";
subnet.value = null;
} finally {
loading.value = false;
}
} }
onMounted(loadSubnet); onMounted(loadSubnet);
async function saveNotes(ipId: number, notes: string) { async function saveNotes(ipId: number, notes: string) {
await api.patchIpNotes(ipId, notes); notesErr.value = "";
try {
await api.patchIpNotes(ipId, notes);
} catch (e) {
notesErr.value = e instanceof Error ? e.message : "Failed to save notes";
}
}
function onCustomFieldsSaved(values: Record<string, unknown>) {
if (subnet.value) subnet.value.custom_fields = values;
}
function isDhcpRow(hostname?: string) {
return hostname === "DHCP";
} }
</script> </script>
<template> <template>
<div v-if="subnet"> <div>
<RouterLink to="/" class="text-sm text-accent hover:underline"> Home</RouterLink> <RouterLink to="/" class="text-sm text-accent hover:underline"> Home</RouterLink>
<div class="mt-4 flex flex-wrap items-center justify-between gap-3"> <p v-if="loading" class="mt-8 text-slate-500">Loading</p>
<div> <p v-else-if="error" class="mt-8 text-red-500">{{ error }}</p>
<h1 class="text-2xl font-bold">{{ subnet.name }}</h1> <template v-else-if="subnet">
<p class="font-mono text-slate-500">{{ subnet.cidr }} · {{ subnet.site || "Unassigned" }}</p> <div class="mt-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-bold">{{ subnet.name }}</h1>
<div class="mt-1 flex flex-wrap items-center gap-2 font-mono text-slate-500">
<span>{{ subnet.cidr }} · {{ subnet.site || "Unassigned" }}</span>
<span
v-if="subnet.vlan_id"
class="rounded-full bg-surface-overlay px-2 py-0.5 text-xs font-semibold font-sans text-slate-600 dark:text-slate-300"
>VLAN {{ subnet.vlan_id }}</span>
</div>
<p v-if="subnet.vlan_description" class="mt-1 text-sm text-slate-500">{{ subnet.vlan_description }}</p>
<p v-if="subnet.vlan_notes" class="text-sm text-slate-400">{{ subnet.vlan_notes }}</p>
</div>
<div class="flex gap-2">
<button
v-if="auth.can('view_dhcp') || auth.can('configure_dhcp')"
type="button"
class="btn-secondary text-sm"
@click="showDhcp = true"
>
DHCP
</button>
<a v-if="auth.can('export_subnet_csv')" :href="`/api/v2/subnets/${subnet.id}/export`" class="btn-secondary text-sm">Export CSV</a>
</div>
</div> </div>
<div class="flex gap-2">
<button <CustomFieldValues
v-if="auth.can('view_dhcp') || auth.can('configure_dhcp')" v-if="auth.can('view_custom_fields')"
type="button" class="mt-6"
class="btn-secondary text-sm" entity-type="subnet"
@click="showDhcp = true" :entity-id="subnet.id"
> :values="subnet.custom_fields"
DHCP :can-edit="auth.can('edit_subnet')"
</button> @saved="onCustomFieldsSaved"
<a v-if="auth.can('export_subnet_csv')" :href="`/api/v2/subnets/${subnet.id}/export`" class="btn-secondary text-sm">Export CSV</a> />
<div class="card mt-6 overflow-x-auto">
<p v-if="notesErr" class="mb-2 text-sm text-red-500">{{ notesErr }}</p>
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b border-slate-200 dark:border-slate-700">
<th class="p-2 font-medium">IP</th>
<th class="p-2 font-medium">Hostname</th>
<th class="p-2 font-medium">Notes</th>
<th class="p-2"></th>
</tr>
</thead>
<tbody>
<tr
v-for="ip in subnet.ip_addresses"
:key="ip.id"
class="border-b border-slate-100 dark:border-slate-800"
:class="isDhcpRow(ip.hostname) ? 'bg-amber-50/80 italic dark:bg-amber-950/20' : ''"
>
<td class="p-2 font-mono">{{ ip.ip }}</td>
<td class="p-2" :class="isDhcpRow(ip.hostname) ? 'text-amber-700 dark:text-amber-400' : ''">
<RouterLink v-if="ip.device_id" :to="`/devices/${ip.device_id}`" class="text-accent hover:underline not-italic">{{ ip.device_name || ip.hostname }}</RouterLink>
<span v-else>{{ ip.hostname || "" }}</span>
</td>
<td class="p-2">
<input
v-if="auth.can('edit_subnet')"
:value="ip.notes || ''"
class="input-field py-1 text-xs"
@change="saveNotes(ip.id, ($event.target as HTMLInputElement).value)"
/>
<span v-else>{{ ip.notes || "" }}</span>
</td>
<td class="p-2">
<button type="button" class="text-xs text-accent hover:underline" @click="historyIp = ip.ip">History</button>
</td>
</tr>
<tr v-if="!subnet.ip_addresses?.length">
<td colspan="4" class="p-4 text-center text-slate-500">No IP addresses in this subnet.</td>
</tr>
</tbody>
</table>
</div> </div>
</div> <IpHistoryModal :ip="historyIp" @close="historyIp = null" />
<div class="card mt-6 overflow-x-auto"> <DhcpModal :open="showDhcp" :subnet-id="subnet.id" @close="showDhcp = false" @saved="loadSubnet" />
<table class="w-full text-left text-sm"> </template>
<thead>
<tr class="border-b border-slate-200 dark:border-slate-700">
<th class="p-2 font-medium">IP</th>
<th class="p-2 font-medium">Hostname</th>
<th class="p-2 font-medium">Notes</th>
<th class="p-2"></th>
</tr>
</thead>
<tbody>
<tr v-for="ip in subnet.ip_addresses" :key="ip.id" class="border-b border-slate-100 dark:border-slate-800">
<td class="p-2 font-mono">{{ ip.ip }}</td>
<td class="p-2">
<RouterLink v-if="ip.device_id" :to="`/devices/${ip.device_id}`" class="text-accent hover:underline">{{ ip.device_name || ip.hostname }}</RouterLink>
<span v-else>{{ ip.hostname || "" }}</span>
</td>
<td class="p-2">
<input
v-if="auth.can('edit_subnet')"
:value="ip.notes || ''"
class="input-field py-1 text-xs"
@change="saveNotes(ip.id, ($event.target as HTMLInputElement).value)"
/>
<span v-else>{{ ip.notes || "" }}</span>
</td>
<td class="p-2">
<button type="button" class="text-xs text-accent hover:underline" @click="historyIp = ip.ip">History</button>
</td>
</tr>
</tbody>
</table>
</div>
<IpHistoryModal :ip="historyIp" @close="historyIp = null" />
<DhcpModal :open="showDhcp" :subnet-id="subnet.id" @close="showDhcp = false" @saved="loadSubnet" />
</div> </div>
</template> </template>
+42 -12
View File
@@ -1,25 +1,51 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
import { api, type Tag } from "@/api"; import { api, type Tag } from "@/api";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const tags = ref<Tag[]>([]); const tags = ref<Tag[]>([]);
const form = ref({ name: "", color: "#06b6d4", description: "" }); const form = ref({ name: "", color: "#06b6d4", description: "" });
const editForm = ref({ id: 0, name: "", color: "#06b6d4", description: "" }); const editForm = ref({ id: 0, name: "", color: "#06b6d4", description: "" });
const showEdit = ref(false); const showEdit = ref(false);
const loading = ref(true);
const err = ref(""); const err = ref("");
onMounted(async () => { tags.value = await api.tags(); }); async function load() {
loading.value = true;
err.value = "";
try {
tags.value = await api.tags();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed to load tags";
tags.value = [];
} finally {
loading.value = false;
}
}
onMounted(load);
async function create() { async function create() {
await api.createTag(form.value); err.value = "";
tags.value = await api.tags(); try {
form.value = { name: "", color: "#06b6d4", description: "" }; await api.createTag(form.value);
form.value = { name: "", color: "#06b6d4", description: "" };
await load();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
} }
async function del(id: number) { async function del(id: number) {
if (!confirm("Delete tag?")) return; if (!confirm("Delete tag?")) return;
await api.deleteTag(id); err.value = "";
tags.value = await api.tags(); try {
await api.deleteTag(id);
await load();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
} }
function openEdit(t: Tag) { function openEdit(t: Tag) {
@@ -37,7 +63,7 @@ async function saveEdit() {
description: editForm.value.description, description: editForm.value.description,
}); });
showEdit.value = false; showEdit.value = false;
tags.value = await api.tags(); await load();
} catch (e) { } catch (e) {
err.value = e instanceof Error ? e.message : "Failed"; err.value = e instanceof Error ? e.message : "Failed";
} }
@@ -46,21 +72,25 @@ async function saveEdit() {
<template> <template>
<div> <div>
<h1 class="text-2xl font-bold">Tags</h1> <h1 class="text-2xl font-bold">Tags</h1>
<form class="card mt-6 flex flex-wrap gap-3" @submit.prevent="create"> <form v-if="auth.can('add_tag')" class="card mt-6 flex flex-wrap gap-3" @submit.prevent="create">
<input v-model="form.name" class="input-field max-w-xs" placeholder="Name" required /> <input v-model="form.name" class="input-field max-w-xs" placeholder="Name" required />
<input v-model="form.color" type="color" class="h-10 w-14 rounded border-0" /> <input v-model="form.color" type="color" class="h-10 w-14 rounded border-0" />
<input v-model="form.description" class="input-field max-w-xs" placeholder="Description" /> <input v-model="form.description" class="input-field max-w-xs" placeholder="Description" />
<button class="btn-primary">Add tag</button> <button class="btn-primary">Add tag</button>
</form> </form>
<ul class="mt-6 space-y-2"> <p v-if="loading" class="mt-6 text-slate-500">Loading</p>
<p v-else-if="err && !tags.length" class="mt-6 text-red-500">{{ err }}</p>
<p v-else-if="!tags.length" class="mt-6 text-slate-500">No tags yet.</p>
<ul v-else class="mt-6 space-y-2">
<li v-for="t in tags" :key="t.id" class="card flex items-center justify-between"> <li v-for="t in tags" :key="t.id" class="card flex items-center justify-between">
<span><span class="inline-block h-3 w-3 rounded-full mr-2" :style="{ backgroundColor: t.color }" />{{ t.name }}</span> <span><span class="inline-block h-3 w-3 rounded-full mr-2" :style="{ backgroundColor: t.color }" />{{ t.name }}</span>
<div class="flex gap-2"> <div v-if="auth.can('edit_tag') || auth.can('delete_tag')" class="flex gap-2">
<button class="text-sm text-accent hover:underline" @click="openEdit(t)">Edit</button> <button v-if="auth.can('edit_tag')" class="text-sm text-accent hover:underline" @click="openEdit(t)">Edit</button>
<button class="text-sm text-red-500" @click="del(t.id)">Delete</button> <button v-if="auth.can('delete_tag')" class="text-sm text-red-500" @click="del(t.id)">Delete</button>
</div> </div>
</li> </li>
</ul> </ul>
<p v-if="err && tags.length" class="mt-4 text-sm text-red-500">{{ err }}</p>
<div v-if="showEdit" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showEdit = false"> <div v-if="showEdit" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showEdit = false">
<form class="card w-full max-w-md space-y-3" @submit.prevent="saveEdit"> <form class="card w-full max-w-md space-y-3" @submit.prevent="saveEdit">
+2 -1
View File
@@ -53,7 +53,8 @@ v2 removes **all Jinja/HTML routes**. The UI is a Vue 3 SPA served from `static/
| `/api/v1/*` | **`/api/v2/*`** (v1 removed) | | `/api/v1/*` | **`/api/v2/*`** (v1 removed) |
| Session via HTML login forms | `POST /api/v2/auth/login` + session cookie | | Session via HTML login forms | `POST /api/v2/auth/login` + session cookie |
| API key only on API routes | **Same routes** accept session cookie **or** API key | | API key only on API routes | **Same routes** accept session cookie **or** API key |
| `{ "devices": [...] }` list responses | **`{ "items": [...] }`** for list endpoints (where normalized) | | `{ "devices": [...] }` list responses | **`{ "items": [...] }`** for all list endpoints |
| `{ "tags" }`, `{ "users" }`, `{ "roles" }`, `{ "racks" }`, `{ "logs" }`, `{ "fields" }` wrappers | **`{ "items" }`** (audit also includes `total`; devices-by-tag includes `meta`) |
### Version ### Version