13 Commits

Author SHA1 Message Date
jamie fc5699a04c Merge pull request 'feat: version number links to releases' (#54) from v2.0.1 into main
Reviewed-on: http://git.jdbnet.co.uk/jamie/ipam/pulls/54
2026-05-27 07:54:15 +01:00
jamie 675d477ff9 fix: 🐛 users page layout
Release / Build & Release (pull_request) Successful in 9s
Release / SonarQube (pull_request) Successful in 28s
2026-05-27 06:53:47 +00:00
jamie 34856060e8 refactor: 🎨 lock nav in place while content scrolls 2026-05-27 06:49:45 +00:00
jamie be55503e1c refactor: 🎨 remove status and alerting from dashboard 2026-05-27 06:48:26 +00:00
jamie b79763be53 fix: 🐛 searching for another device didn't work if already looking at a device 2026-05-27 06:47:14 +00:00
jamie e961afc36a feat: version number links to releases 2026-05-27 06:45:16 +00:00
jamie 616744015f Merge pull request 'refactor: 🎨 remove caching' (#48) from v2.0.0 into main
Reviewed-on: http://git.jdbnet.co.uk/jamie/ipam/pulls/48
2026-05-23 21:04:45 +01:00
jamie 87d7654606 docs: 📝 tidy docs
Release / Build & Release (pull_request) Successful in 5s
Release / SonarQube (pull_request) Successful in 27s
2026-05-23 20:03:47 +00:00
jamie 9e47cbee4e fix: 🐛 small ui fixes 2026-05-23 19:50:49 +00:00
jamie e16a667d60 feat: dashboard stats 2026-05-23 19:24:01 +00:00
jamie a8bcb9bd1c style: 🎨 subnet management layout 2026-05-23 18:56:24 +00:00
jamie 71d0b7fed6 refactor: 🎨 tidied a few bits up 2026-05-23 18:33:27 +00:00
jamie 39a8f4a49b refactor: 🎨 use a modal for dhcp config 2026-05-23 18:12:34 +00:00
25 changed files with 1570 additions and 639 deletions
+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
+229
View File
@@ -0,0 +1,229 @@
## Configuration
### Environment Variables
- `MYSQL_HOST`: MySQL/MariaDB host (default: localhost)
- `MYSQL_USER`: Database user (default: user)
- `MYSQL_PASSWORD`: Database password (default: password)
- `MYSQL_DATABASE`: Database name (default: ipam)
- `SECRET_KEY`: Flask secret key for sessions (**REQUIRED in production!**)
- `NAME`: Organisation name displayed in header (default: JDB-NET)
- `LOGO_PNG`: URL or path to organisation logo (default: JDB-NET logo)
### Database Setup
The application automatically initializes the database schema on first run. Ensure the database and user exist with appropriate
permissions:
```sql
CREATE DATABASE ipam;
CREATE USER 'ipam'@'%' IDENTIFIED BY 'your_password';
GRANT ALL PRIVILEGES ON ipam.* TO 'ipam'@'%';
FLUSH PRIVILEGES;
```
### Upgrading from v1.x
See [v1-to-v2-breaking-changes.md](v1-to-v2-breaking-changes.md) for removed features, route changes, and automatic database migrations.
Back up your database before upgrading.
## Usage
### First Login
1. Access the web interface at `http://your-server:5000`
2. Log in with the default credentials:
- Email: `admin@example.com`
- Password: `password`
3. **Change the default password immediately** via the Users page
### Managing Subnets
1. Navigate to **Subnet Management** from the main menu (`/subnets/manage`)
2. Click **Add Subnet** and fill in:
- **Name**: Friendly name for the subnet (e.g., "Office LAN")
- **CIDR**: Subnet in CIDR notation (e.g., `192.168.1.0/24`)
- **Site**: Site/location identifier
3. The system automatically generates all IP addresses in the subnet
### Adding Devices
1. Navigate to "Devices" from the main menu
2. Click "Add Device"
3. Enter device name (and optional description)
4. Click "Create Device"
### Assigning IP Addresses to Devices
1. Open a device from the Devices page
2. Select a subnet and available IP address
3. Click "Assign IP" - the hostname is automatically updated
### Configuring DHCP Pools
1. Open a subnet from the dashboard or subnet list
2. Click **DHCP** to open the DHCP pool modal
3. Set the start and end IP addresses
4. Optionally specify excluded IPs (comma-separated)
5. IPs within the pool range are automatically marked as "DHCP"
### Managing Racks
1. Navigate to "Racks" from the main menu
2. Click "Add Rack" and specify:
- **Name**: Rack identifier
- **Site**: Site location
- **Height**: Rack height in U units
3. Open a rack to assign devices to specific U positions (front or back)
### Device Tagging
1. **Managing Tags** (requires `add_tag` / `edit_tag` / `delete_tag` permissions):
- Navigate to **Tags** from the main menu
- Create tags with custom colours and descriptions
- Edit or delete existing tags as permitted by your role
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
View changes in **Audit Log**. Filter by user name, action type, and date range. Export filtered results to CSV.
### Exporting Data
- **Subnet CSV**: Click "Export CSV" on any subnet page to download IP addresses with hostnames
- **Rack CSV**: Click "Export CSV" on any rack page to download rack layout information
### Role-Based Access Control
The system uses a granular role-based access control (RBAC) system to manage user permissions:
1. **Default Roles**:
- **Admin**: Full access to all features including user and role management
- **User**: Can view and manage most features (devices, subnets, racks, etc.) but cannot manage users or roles
- **View Only**: Read-only access to view pages but cannot make any changes
2. **Custom Roles**: Administrators can create custom roles with specific permission sets from the Users page
3. **Permission Granularity**: Permissions are organized into categories:
- View permissions (access to pages)
- Device Management (add, edit, delete devices)
- Network Management (subnet operations)
- Rack Management (rack operations)
- DHCP Configuration
- Administration (user and role management)
4. **User Management**: Navigate to the Users page to:
- Create and manage users
- Assign roles to users
- Create custom roles with specific permissions
- View and regenerate API keys
### REST API
Programmatic access uses **`/api/v2`**. The browser SPA uses session cookies; automation uses API keys via:
- `X-API-Key` header
- `Authorization: Bearer <api_key>` header
- `?api_key=<api_key>` query parameter
Each user has an API key (view/regenerate on the Users page). Keys respect the same RBAC permissions as the web UI.
Full endpoint reference: [API.md](API.md)
```bash
# List devices
curl -H "X-API-Key: YOUR_KEY" https://your-server:5000/api/v2/devices
# Session login (browser-style)
curl -c cookies.txt -X POST -H "Content-Type: application/json" \
-d '{"email":"admin@example.com","password":"password"}' \
https://your-server:5000/api/v2/auth/login
```
## Kubernetes Deployment
Example deployment manifest:
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: ipam
namespace: ipam
spec:
replicas: 1
template:
spec:
containers:
- name: ipam
image: cr.jdbnet.co.uk/public/ipam:latest
ports:
- containerPort: 5000
env:
- name: SECRET_KEY
valueFrom:
secretKeyRef:
name: ipam-secrets
key: secret-key
- name: MYSQL_HOST
value: "mysql-service"
- name: MYSQL_USER
value: "ipam"
- name: MYSQL_PASSWORD
valueFrom:
secretKeyRef:
name: ipam-secrets
key: mysql-password
- name: MYSQL_DATABASE
value: "ipam"
```
## Security Notes
- **CHANGE THE DEFAULT ADMIN PASSWORD** immediately after first login
- **CHANGE THE SECRET_KEY** in production - use a strong random string (e.g., `openssl rand -hex 32`)
- Use strong passwords for database access
- Ensure database connections are secured (consider SSL/TLS for MySQL connections)
- Review audit logs regularly for unauthorized changes
- Limit database user permissions if possible (though CREATE/ALTER may be needed for schema initialization)
- **API Keys**: Keep API keys secure and never share them publicly. Regenerate keys if they may have been compromised
- **Role-Based Access**: Use the RBAC system to grant users only the permissions they need (principle of least privilege)
- **HTTPS**: Use HTTPS in production to protect API keys and session data in transit
## Troubleshooting
### Database Connection Issues
- Ensure MySQL/MariaDB is running and accessible from the container
- Check database credentials in environment variables
- Verify database and user exist with proper permissions
- Check network connectivity between container and database
- Ensure the database name matches exactly (case-sensitive on some systems)
### Application Not Starting
- Check container logs: `docker logs ipam`
- Verify all required environment variables are set
- Ensure port 5000 is not already in use
- Check that MySQL/MariaDB is reachable
### Subnet or IP Not Appearing
- Verify CIDR notation is correct (supports /24 to /32)
- Check subnet was created successfully (Subnet Management page)
- Ensure you're logged in with appropriate permissions
- Check application logs for errors
### Device IP Assignment Issues
- Verify the IP address is available (not already assigned)
- Check that the IP is not in a DHCP pool range
- Ensure the device exists and is visible in the Devices list
+13 -298
View File
@@ -4,57 +4,25 @@
# IP Address Management # IP Address Management
</div> </div>
A Flask-based web application for comprehensive IP Address Management (IPAM). Manage subnets, IP addresses, devices, DHCP pools, and physical rack infrastructure with an intuitive web interface. A Flask-based web application for IP Address Management (IPAM). Manage subnets, IP addresses, devices, DHCP pools, and rack infrastructure through a Vue 3 web interface and a JSON REST API.
## Features ## Features
- **Subnet Management**: Create and manage IP subnets with CIDR notation (supports /24 to /32) - **Subnet management** - CIDR subnets with automatic IP generation
- **IP Address Tracking**: Automatic IP address generation and tracking for each subnet - **IP assignment** - Assign addresses to devices with hostname tracking
- **Device Management**: Track devices with types (Server, VM, Switch, Firewall, WiFi AP, Printer, Other) - **Device management** - Names, descriptions, tags, and custom fields
- **Device Tagging**: Organize devices with customizable tags featuring colors and descriptions - **DHCP pools** — Configure ranges and excluded IPs per subnet
- **IP Assignment**: Assign IP addresses to devices with automatic hostname updates - **Rack management** - U positions with front/back layout
- **DHCP Pool Configuration**: Configure DHCP pools with start/end IP ranges and excluded IPs - **Site organisation** - Group subnets and devices by location
- **Rack Management**: Physical infrastructure tracking with U positions and front/back sides - **Audit logging** - Filterable change history with CSV export
- **Site Organisation**: Organize subnets and devices by site/location - **Role-based access control** - Granular permissions and custom roles
- **Audit Logging**: Complete audit trail of all changes with user, action, details, and timestamps - **REST API v2** - Session cookies for the browser, API keys for automation
- **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
- **Web Interface**: Vue 3 SPA with automatic light/dark theme and mobile-first layout
- **REST API v2**: JSON API at `/api/v2` (session cookies for browser, API keys for automation)
## Local development ## Screenshot
```bash ![IPAM Dashboard](img/screenshot.png)
# Backend
pip install -r requirements.txt
./run.sh # builds frontend if needed, starts Flask on :5000
# Frontend hot reload (optional) ## Docker Compose
cd frontend && npm install && npm run dev
# Vite proxies /api to http://127.0.0.1:5000
```
API reference: [API.md](API.md)
## Quick Start with Docker
### Docker Run
```bash
docker run -d \
--name ipam \
-p 5000:5000 \
-e MYSQL_HOST=10.10.2.27 \
-e MYSQL_USER=ipam \
-e MYSQL_PASSWORD=your_password \
-e MYSQL_DATABASE=ipam \
-e SECRET_KEY=your_secret_key \
-e NAME="Your Organisation" \
-e LOGO_PNG="https://example.com/logo.png" \
cr.jdbnet.co.uk/public/ipam:latest
```
### Docker Compose
```yaml ```yaml
services: services:
@@ -73,256 +41,3 @@ services:
- NAME=Your Organisation - NAME=Your Organisation
- LOGO_PNG=https://example.com/logo.png - LOGO_PNG=https://example.com/logo.png
``` ```
## Configuration
### Environment Variables
- `MYSQL_HOST`: MySQL/MariaDB host (default: localhost)
- `MYSQL_USER`: Database user (default: user)
- `MYSQL_PASSWORD`: Database password (default: password)
- `MYSQL_DATABASE`: Database name (default: ipam)
- `SECRET_KEY`: Flask secret key for sessions (**REQUIRED in production!**)
- `NAME`: Organisation name displayed in header (default: JDB-NET)
- `LOGO_PNG`: URL or path to organisation logo (default: JDB-NET logo)
### Database Setup
The application automatically initializes the database schema on first run. Ensure the database and user exist with appropriate permissions:
```sql
CREATE DATABASE ipam;
CREATE USER 'ipam'@'%' IDENTIFIED BY 'your_password';
GRANT ALL PRIVILEGES ON ipam.* TO 'ipam'@'%';
FLUSH PRIVILEGES;
```
### Upgrading from v1.x
See [v1-to-v2-breaking-changes.md](v1-to-v2-breaking-changes.md) for removed features, route changes, and automatic database migrations. Back up your database before upgrading.
## Usage
### First Login
1. Access the web interface at `http://your-server:5000`
2. Log in with the default credentials:
- Email: `admin@example.com`
- Password: `password`
3. **Change the default password immediately** via the Users page
### Managing Subnets
1. Navigate to "Admin" from the main menu
2. Click "Add Subnet" and fill in:
- **Name**: Friendly name for the subnet (e.g., "Office LAN")
- **CIDR**: Subnet in CIDR notation (e.g., `192.168.1.0/24`)
- **Site**: Site/location identifier
3. The system automatically generates all IP addresses in the subnet
### Adding Devices
1. Navigate to "Devices" from the main menu
2. Click "Add Device"
3. Enter device name (and optional description)
4. Click "Create Device"
### Assigning IP Addresses to Devices
1. Open a device from the Devices page
2. Select a subnet and available IP address
3. Click "Assign IP" - the hostname is automatically updated
### Configuring DHCP Pools
1. Open a subnet view
2. Click "Configure DHCP Pool"
3. Set the start and end IP addresses
4. Optionally specify excluded IPs (comma-separated)
5. IPs within the pool range are automatically marked as "DHCP"
### Managing Racks
1. Navigate to "Racks" from the main menu
2. Click "Add Rack" and specify:
- **Name**: Rack identifier
- **Site**: Site location
- **Height**: Rack height in U units
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
View all changes and actions in the "Audit Log" section, with filtering by user, subnet, action type, or device name.
### Exporting Data
- **Subnet CSV**: Click "Export CSV" on any subnet page to download IP addresses with hostnames
- **Rack CSV**: Click "Export CSV" on any rack page to download rack layout information
### Role-Based Access Control
The system uses a granular role-based access control (RBAC) system to manage user permissions:
1. **Default Roles**:
- **Admin**: Full access to all features including user and role management
- **User**: Can view and manage most features (devices, subnets, racks, etc.) but cannot manage users or roles
- **View Only**: Read-only access to view pages but cannot make any changes
2. **Custom Roles**: Administrators can create custom roles with specific permission sets from the Users page
3. **Permission Granularity**: Permissions are organized into categories:
- View permissions (access to pages)
- Device Management (add, edit, delete devices)
- Network Management (subnet operations)
- Rack Management (rack operations)
- DHCP Configuration
- Administration (user and role management)
4. **User Management**: Navigate to the Users page to:
- Create and manage users
- Assign roles to users
- Create custom roles with specific permissions
- View and regenerate API keys
### REST API
The application includes a comprehensive REST API for programmatic access:
1. **Authentication**: All API requests require an API key, which can be provided via:
- `X-API-Key` header
- `Authorization: Bearer <api_key>` header
- `?api_key=<api_key>` query parameter
2. **Base URL**: All API endpoints are prefixed with `/api/v1`
3. **Available Endpoints**:
- **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
# List all devices
curl -H "X-API-Key: your_api_key" \
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
The project includes a Kubernetes deployment manifest. See `deployment.yml` for details.
**Example Kubernetes deployment:**
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: ipam
namespace: ipam
spec:
replicas: 1
template:
spec:
containers:
- name: ipam
image: cr.jdbnet.co.uk/public/ipam:latest
ports:
- containerPort: 5000
env:
- name: SECRET_KEY
valueFrom:
secretKeyRef:
name: ipam-secrets
key: secret-key
- name: MYSQL_HOST
value: "mysql-service"
- name: MYSQL_USER
value: "ipam"
- name: MYSQL_PASSWORD
valueFrom:
secretKeyRef:
name: ipam-secrets
key: mysql-password
- name: MYSQL_DATABASE
value: "ipam"
```
## Security Notes
- **CHANGE THE DEFAULT ADMIN PASSWORD** immediately after first login
- **CHANGE THE SECRET_KEY** in production - use a strong random string (e.g., `openssl rand -hex 32`)
- Use strong passwords for database access
- Ensure database connections are secured (consider SSL/TLS for MySQL connections)
- Review audit logs regularly for unauthorized changes
- Limit database user permissions if possible (though CREATE/ALTER may be needed for schema initialization)
- **API Keys**: Keep API keys secure and never share them publicly. Regenerate keys if they may have been compromised
- **Role-Based Access**: Use the RBAC system to grant users only the permissions they need (principle of least privilege)
- **HTTPS**: Use HTTPS in production to protect API keys and session data in transit
## Troubleshooting
### Database Connection Issues
- Ensure MySQL/MariaDB is running and accessible from the container
- Check database credentials in environment variables
- Verify database and user exist with proper permissions
- Check network connectivity between container and database
- Ensure the database name matches exactly (case-sensitive on some systems)
### Application Not Starting
- Check container logs: `docker logs ipam`
- Verify all required environment variables are set
- Ensure port 5000 is not already in use
- Check that MySQL/MariaDB is reachable
### Subnet or IP Not Appearing
- Verify CIDR notation is correct (supports /24 to /32)
- Check subnet was created successfully (view in Admin page)
- Ensure you're logged in with appropriate permissions
- Check application logs for errors
### Device IP Assignment Issues
- Verify the IP address is available (not already assigned)
- Check that the IP is not in a DHCP pool range
- Ensure the device exists and is visible in the Devices list
## License
This project is provided as-is for IP Address Management.
+118 -34
View File
@@ -38,7 +38,7 @@ app.config['MYSQL_DATABASE'] = os.environ.get('MYSQL_DATABASE', 'ipam')
app.config['SESSION_COOKIE_HTTPONLY'] = True app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
app.config['NAME'] = os.environ.get('NAME', 'JDB-NET') app.config['NAME'] = os.environ.get('NAME', 'JDB-NET')
app.config['LOGO_PNG'] = os.environ.get('LOGO_PNG', 'https://assets.jdbnet.co.uk/logo/128x128.png') app.config['LOGO_PNG'] = os.environ.get('LOGO_PNG', 'https://assets.jdbnet.co.uk/projects/ipam.png')
app.config['VERSION'] = os.environ.get('VERSION', 'unknown') app.config['VERSION'] = os.environ.get('VERSION', 'unknown')
from db import init_db, hash_password, get_db_connection, verify_password, generate_api_key from db import init_db, hash_password, get_db_connection, verify_password, generate_api_key
@@ -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
with get_db_connection(current_app) as conn: where_sql, filter_params = build_audit_filters()
cursor = conn.cursor(dictionary=True)
limit = request.args.get('limit', 100, type=int) limit = request.args.get('limit', 100, type=int)
offset = request.args.get('offset', 0, type=int) offset = request.args.get('offset', 0, type=int)
cursor.execute(''' with get_db_connection(current_app) as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute(f'''
SELECT COUNT(*) as total
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 ─────────────────────────────────────────────────────
@@ -2675,18 +2718,57 @@ def api_roles():
def api_dashboard(): def api_dashboard():
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)
cursor.execute('SELECT id, name, cidr, site, vlan_id FROM Subnet ORDER BY site, name')
subnets = cursor.fetchall()
utils = get_all_subnet_utilizations(cursor) utils = get_all_subnet_utilizations(cursor)
sites = {} cursor.execute('SELECT COUNT(*) AS n FROM Device')
for s in subnets: device_count = cursor.fetchone()['n']
site = s['site'] or 'Unassigned' cursor.execute('SELECT COUNT(*) AS n FROM Subnet')
util = utils.get(s['id'], {'percent': 0}) subnet_count = cursor.fetchone()['n']
sites.setdefault(site, []).append({
'id': s['id'], 'name': s['name'], 'cidr': s['cidr'], total_ips = sum(u['total'] for u in utils.values())
'vlan_id': s['vlan_id'], 'utilization': util['percent'], used_ips = sum(u['used'] for u in utils.values())
available_ips = max(total_ips - used_ips, 0)
utilization_percent = round((used_ips / total_ips * 100) if total_ips > 0 else 0, 1)
alerting_subnets = sum(1 for u in utils.values() if u['percent'] >= 90)
cursor.execute('SELECT id, name, cidr, site, vlan_id FROM Subnet ORDER BY name')
subnet_overview = []
for s in cursor.fetchall():
u = utils.get(s['id'], {'total': 0, 'used': 0, 'percent': 0})
pct = u['percent']
subnet_overview.append({
'id': s['id'],
'name': s['name'],
'cidr': s['cidr'],
'site': s['site'] or 'Unassigned',
'vlan_id': s['vlan_id'],
'utilization': pct,
'available': u['total'] - u['used'],
'status': 'alerting' if pct >= 90 else 'active',
})
cursor.execute('''
SELECT HOUR(timestamp) AS hour, COUNT(*) AS count
FROM AuditLog
WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 24 HOUR)
GROUP BY HOUR(timestamp)
ORDER BY hour
''')
activity_by_hour = {row['hour']: row['count'] for row in cursor.fetchall()}
activity = [{'hour': h, 'count': activity_by_hour.get(h, 0)} for h in range(24)]
return jsonify({
'stats': {
'total_ips': total_ips,
'used_ips': used_ips,
'available_ips': available_ips,
'utilization_percent': utilization_percent,
'subnet_count': subnet_count,
'alerting_subnets': alerting_subnets,
'device_count': device_count,
},
'subnet_overview': subnet_overview,
'activity': activity,
}) })
return jsonify({'sites': sites})
@app.route('/api/v2/search', methods=['GET']) @app.route('/api/v2/search', methods=['GET'])
@@ -2832,15 +2914,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')
+88 -16
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 = {
@@ -156,7 +178,28 @@ export const api = {
return handle(await fetchApi("/api/v2/auth/logout", { method: "POST" })); return handle(await fetchApi("/api/v2/auth/logout", { method: "POST" }));
}, },
async dashboard() { async dashboard() {
return handle<{ sites: Record<string, Subnet[]> }>(await fetchApi("/api/v2/dashboard")); return handle<{
stats: {
total_ips: number;
used_ips: number;
available_ips: number;
utilization_percent: number;
subnet_count: number;
alerting_subnets: number;
device_count: number;
};
subnet_overview: {
id: number;
name: string;
cidr: string;
site: string;
vlan_id?: number;
utilization: number;
available: number;
status: "active" | "alerting";
}[];
activity: { hour: number; count: number }[];
}>(await fetchApi("/api/v2/dashboard"));
}, },
async search(q: string) { async search(q: string) {
return handle<Record<string, unknown[]>>(await fetchApi(`/api/v2/search?q=${encodeURIComponent(q)}`)); return handle<Record<string, unknown[]>>(await fetchApi(`/api/v2/search?q=${encodeURIComponent(q)}`));
@@ -228,8 +271,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 +292,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 +361,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 +400,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", {
+19 -11
View File
@@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue"; import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
import { RouterLink, RouterView, useRoute, useRouter } from "vue-router"; import { RouterLink, RouterView, useRoute, useRouter } from "vue-router";
import { Menu, Search, X, Home, Server, Grid3x3, Settings, Users, Tag, Layers, FileText, User } from "lucide-vue-next"; import { Menu, Search, X, Home, Server, Grid3x3, Settings, Users, Tag, Layers, FileText, User, Network } from "lucide-vue-next";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { api } from "@/api"; import { api } from "@/api";
const RELEASES_URL = "https://git.jdbnet.co.uk/jamie/ipam/releases";
const auth = useAuthStore(); const auth = useAuthStore();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -18,6 +20,7 @@ const searchLoading = ref(false);
const nav = computed(() => const nav = computed(() =>
[ [
{ to: "/", label: "Home", icon: Home, perm: "view_index" }, { to: "/", label: "Home", icon: Home, perm: "view_index" },
{ to: "/subnets", label: "Subnets", icon: Network, perm: "view_subnet", match: (path: string) => path === "/subnets" || /^\/subnets\/\d+/.test(path) },
{ to: "/devices", label: "Devices", icon: Server, perm: "view_devices" }, { to: "/devices", label: "Devices", icon: Server, perm: "view_devices" },
{ to: "/racks", label: "Racks", icon: Grid3x3, perm: "view_racks" }, { to: "/racks", label: "Racks", icon: Grid3x3, perm: "view_racks" },
{ to: "/tags", label: "Tags", icon: Tag, perm: "view_tags" }, { to: "/tags", label: "Tags", icon: Tag, perm: "view_tags" },
@@ -89,30 +92,35 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<div class="flex min-h-screen bg-surface font-sans"> <div class="flex h-screen overflow-hidden bg-surface font-sans">
<!-- Mobile overlay --> <!-- Mobile overlay -->
<div v-if="sidebarOpen" class="fixed inset-0 z-40 bg-black/50 lg:hidden" @click="sidebarOpen = false" /> <div v-if="sidebarOpen" class="fixed inset-0 z-40 bg-black/50 lg:hidden" @click="sidebarOpen = false" />
<!-- Sidebar --> <!-- Sidebar -->
<aside <aside
class="fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-slate-200 bg-surface-raised transition-transform dark:border-slate-800 lg:static lg:translate-x-0" class="fixed inset-y-0 left-0 z-50 flex h-full w-64 shrink-0 flex-col border-r border-slate-200 bg-surface-raised transition-transform dark:border-slate-800 lg:static lg:h-screen lg:translate-x-0"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'" :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
> >
<div class="flex items-center gap-3 border-b border-slate-200 p-4 dark:border-slate-800"> <div class="flex shrink-0 items-center gap-3 border-b border-slate-200 p-4 dark:border-slate-800">
<img v-if="auth.org.logo" :src="auth.org.logo" alt="" class="h-8 rounded" /> <img v-if="auth.org.logo" :src="auth.org.logo" alt="" class="h-8 rounded" />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="truncate text-sm font-semibold">{{ auth.org.name }} IPAM</div> <div class="truncate text-sm font-semibold">{{ auth.org.name }} IPAM</div>
<div class="text-xs text-slate-500">{{ auth.version }}</div> <a
:href="RELEASES_URL"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-slate-500 hover:text-accent hover:underline"
>{{ auth.version }}</a>
</div> </div>
<button class="lg:hidden" @click="sidebarOpen = false"><X class="h-5 w-5" /></button> <button class="lg:hidden" @click="sidebarOpen = false"><X class="h-5 w-5" /></button>
</div> </div>
<nav class="flex-1 overflow-y-auto p-2"> <nav class="min-h-0 flex-1 overflow-y-auto p-2">
<RouterLink <RouterLink
v-for="item in nav" v-for="item in nav"
:key="item.to" :key="item.to"
:to="item.to" :to="item.to"
class="mb-0.5 flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition" class="mb-0.5 flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition"
:class="route.path === item.to || route.path.startsWith(item.to + '/') :class="(item.match ? item.match(route.path) : route.path === item.to || route.path.startsWith(item.to + '/'))
? 'bg-accent/15 text-accent font-medium' ? 'bg-accent/15 text-accent font-medium'
: 'text-slate-600 hover:bg-surface-overlay dark:text-slate-400'" : 'text-slate-600 hover:bg-surface-overlay dark:text-slate-400'"
@click="sidebarOpen = false" @click="sidebarOpen = false"
@@ -121,15 +129,15 @@ onUnmounted(() => {
{{ item.label }} {{ item.label }}
</RouterLink> </RouterLink>
</nav> </nav>
<div class="border-t border-slate-200 p-3 dark:border-slate-800"> <div class="shrink-0 border-t border-slate-200 p-3 dark:border-slate-800">
<div class="truncate text-xs text-slate-500">{{ auth.user?.name }}</div> <div class="truncate text-xs text-slate-500">{{ auth.user?.name }}</div>
<button class="mt-2 text-xs text-accent hover:underline" @click="logout">Sign out</button> <button class="mt-2 text-xs text-accent hover:underline" @click="logout">Sign out</button>
</div> </div>
</aside> </aside>
<!-- Main --> <!-- Main -->
<div class="flex min-w-0 flex-1 flex-col"> <div class="flex min-h-0 min-w-0 flex-1 flex-col">
<header class="flex items-center gap-3 border-b border-slate-200 bg-surface-raised px-4 py-3 dark:border-slate-800"> <header class="flex shrink-0 items-center gap-3 border-b border-slate-200 bg-surface-raised px-4 py-3 dark:border-slate-800">
<button class="lg:hidden" @click="sidebarOpen = true"><Menu class="h-6 w-6" /></button> <button class="lg:hidden" @click="sidebarOpen = true"><Menu class="h-6 w-6" /></button>
<span class="font-semibold lg:hidden">{{ auth.org.name }} IPAM</span> <span class="font-semibold lg:hidden">{{ auth.org.name }} IPAM</span>
<button <button
@@ -140,7 +148,7 @@ onUnmounted(() => {
<Search class="h-5 w-5" /> <Search class="h-5 w-5" />
</button> </button>
</header> </header>
<main class="flex-1 overflow-auto p-4 md:p-6"> <main class="min-h-0 flex-1 overflow-auto p-4 md:p-6">
<RouterView /> <RouterView />
</main> </main>
</div> </div>
@@ -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>
+152
View File
@@ -0,0 +1,152 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { X } from "lucide-vue-next";
import { api } from "@/api";
import { useAuthStore } from "@/stores/auth";
const props = defineProps<{
open: boolean;
subnetId: number | null;
}>();
const emit = defineEmits<{ close: []; saved: [] }>();
const auth = useAuthStore();
const loading = ref(false);
const saving = ref(false);
const err = ref("");
const msg = ref("");
const hasPool = ref(false);
const form = ref({ start_ip: "", end_ip: "", excluded_ips: "" });
const canEdit = () => auth.can("configure_dhcp");
async function loadPool() {
if (!props.subnetId) return;
loading.value = true;
err.value = "";
msg.value = "";
hasPool.value = false;
form.value = { start_ip: "", end_ip: "", excluded_ips: "" };
try {
const d = await api.getDhcp(props.subnetId) as { pools?: { start_ip: string; end_ip: string; excluded_ips?: string }[] };
if (d.pools?.[0]) {
hasPool.value = true;
form.value.start_ip = d.pools[0].start_ip;
form.value.end_ip = d.pools[0].end_ip;
form.value.excluded_ips = d.pools[0].excluded_ips || "";
}
} catch (e) {
if (auth.can("view_dhcp")) {
err.value = e instanceof Error ? e.message : "Failed to load DHCP pool";
}
} finally {
loading.value = false;
}
}
watch(
() => [props.open, props.subnetId] as const,
([open]) => {
if (open) loadPool();
},
);
async function save() {
if (!props.subnetId || !canEdit()) return;
saving.value = true;
err.value = "";
msg.value = "";
try {
await api.setDhcp(props.subnetId, {
pools: [{
start_ip: form.value.start_ip,
end_ip: form.value.end_ip,
excluded_ips: form.value.excluded_ips.split(",").map((s) => s.trim()).filter(Boolean),
}],
});
hasPool.value = true;
msg.value = "Saved";
emit("saved");
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed to save";
} finally {
saving.value = false;
}
}
async function remove() {
if (!props.subnetId || !canEdit() || !confirm("Remove this DHCP pool?")) return;
saving.value = true;
err.value = "";
msg.value = "";
try {
await api.setDhcp(props.subnetId, { remove: true });
hasPool.value = false;
form.value = { start_ip: "", end_ip: "", excluded_ips: "" };
msg.value = "Removed";
emit("saved");
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed to remove";
} finally {
saving.value = false;
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "Escape") emit("close");
}
</script>
<template>
<Teleport to="body">
<div
v-if="open"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
@click.self="emit('close')"
@keydown="onKeydown"
>
<form class="card w-full max-w-lg space-y-4 shadow-xl" @submit.prevent="save">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">DHCP pool</h2>
<button type="button" class="rounded-lg p-1 hover:bg-surface-overlay" aria-label="Close" @click="emit('close')">
<X class="h-5 w-5" />
</button>
</div>
<p v-if="loading" class="text-sm text-slate-500">Loading</p>
<template v-else>
<input
v-model="form.start_ip"
class="input-field"
placeholder="Start IP"
required
:disabled="!canEdit()"
/>
<input
v-model="form.end_ip"
class="input-field"
placeholder="End IP"
required
:disabled="!canEdit()"
/>
<input
v-model="form.excluded_ips"
class="input-field"
placeholder="Excluded IPs (comma-separated)"
:disabled="!canEdit()"
/>
<div v-if="canEdit()" class="flex gap-2">
<button type="submit" class="btn-primary" :disabled="saving">Save</button>
<button v-if="hasPool" type="button" class="btn-secondary" :disabled="saving" @click="remove">Remove pool</button>
<button type="button" class="btn-secondary" @click="emit('close')">Cancel</button>
</div>
<div v-else class="flex justify-end">
<button type="button" class="btn-secondary" @click="emit('close')">Close</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>
</template>
</form>
</div>
</Teleport>
</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");
+3 -1
View File
@@ -12,10 +12,11 @@ const router = createRouter({
component: () => import("@/components/AppLayout.vue"), component: () => import("@/components/AppLayout.vue"),
children: [ children: [
{ path: "", name: "dashboard", component: () => import("@/views/DashboardView.vue") }, { path: "", name: "dashboard", component: () => import("@/views/DashboardView.vue") },
{ path: "subnets", name: "subnets", component: () => import("@/views/SubnetsBrowseView.vue") },
{ path: "devices", name: "devices", component: () => import("@/views/DevicesView.vue") }, { path: "devices", name: "devices", component: () => import("@/views/DevicesView.vue") },
{ path: "devices/:id", name: "device", component: () => import("@/views/DeviceDetailView.vue") }, { path: "devices/:id", name: "device", component: () => import("@/views/DeviceDetailView.vue") },
{ path: "subnets/:id", name: "subnet", component: () => import("@/views/SubnetDetailView.vue") }, { path: "subnets/:id", name: "subnet", component: () => import("@/views/SubnetDetailView.vue") },
{ path: "subnets/:id/dhcp", name: "dhcp", component: () => import("@/views/DhcpView.vue") }, { path: "subnets/:id/dhcp", redirect: (to) => `/subnets/${to.params.id}` },
{ path: "racks", name: "racks", component: () => import("@/views/RacksView.vue") }, { path: "racks", name: "racks", component: () => import("@/views/RacksView.vue") },
{ path: "racks/:id", name: "rack", component: () => import("@/views/RackDetailView.vue") }, { path: "racks/:id", name: "rack", component: () => import("@/views/RackDetailView.vue") },
{ path: "search", redirect: "/" }, { path: "search", redirect: "/" },
@@ -41,4 +42,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>
+175 -29
View File
@@ -1,52 +1,198 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from "vue"; import { ref, onMounted, computed } from "vue";
import { RouterLink } from "vue-router"; import { RouterLink } from "vue-router";
import { api, type Subnet } from "@/api"; import { Network, Wifi, Layers, Server } from "lucide-vue-next";
import { api } from "@/api";
interface DashboardStats {
total_ips: number;
used_ips: number;
available_ips: number;
utilization_percent: number;
subnet_count: number;
device_count: number;
}
interface SubnetOverviewRow {
id: number;
name: string;
cidr: string;
site: string;
vlan_id?: number;
utilization: number;
available: number;
}
interface ActivityPoint {
hour: number;
count: number;
}
const sites = ref<Record<string, Subnet[]>>({});
const loading = ref(true); const loading = ref(true);
const error = ref("");
const stats = ref<DashboardStats | null>(null);
const subnetOverview = ref<SubnetOverviewRow[]>([]);
const activity = ref<ActivityPoint[]>([]);
const donutStyle = computed(() => {
const pct = stats.value?.utilization_percent ?? 0;
return { background: `conic-gradient(rgb(6 182 212) ${pct}%, rgb(var(--surface-overlay)) ${pct}%)` };
});
const maxActivity = computed(() => Math.max(1, ...activity.value.map((a) => a.count)));
onMounted(async () => { onMounted(async () => {
try { try {
const d = await api.dashboard(); const d = await api.dashboard();
sites.value = d.sites; stats.value = d.stats;
subnetOverview.value = d.subnet_overview;
activity.value = d.activity;
} catch (e) {
error.value = e instanceof Error ? e.message : "Failed to load dashboard";
} finally { } finally {
loading.value = false; loading.value = false;
} }
}); });
function formatHour(h: number) {
if (h === 0) return "12 AM";
if (h === 12) return "12 PM";
return h < 12 ? `${h} AM` : `${h - 12} PM`;
}
</script> </script>
<template> <template>
<div> <div>
<h1 class="text-2xl font-bold">Dashboard</h1> <h1 class="text-2xl font-bold">Dashboard</h1>
<p class="mt-1 text-slate-500">Subnets grouped by site</p> <p class="mt-1 text-slate-500">Network overview</p>
<div v-if="loading" class="mt-8 text-slate-500">Loading</div>
<div v-else class="mt-6 space-y-8"> <p v-if="loading" class="mt-8 text-slate-500">Loading</p>
<section v-for="(subnets, site) in sites" :key="site"> <p v-else-if="error" class="mt-8 text-red-500">{{ error }}</p>
<h2 class="mb-3 text-lg font-semibold text-accent">{{ site }}</h2>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <template v-else-if="stats">
<RouterLink <div class="mt-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
v-for="s in subnets" <div class="card flex items-start gap-4">
<div class="rounded-lg bg-accent/15 p-3 text-accent"><Network class="h-6 w-6" /></div>
<div>
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">Total IPv4 addresses</div>
<div class="mt-1 text-2xl font-bold text-accent">{{ stats.total_ips.toLocaleString() }}</div>
<div class="text-sm text-slate-500">{{ stats.utilization_percent }}% utilised</div>
</div>
</div>
<div class="card flex items-start gap-4">
<div class="rounded-lg bg-surface-overlay p-3 text-slate-500"><Wifi class="h-6 w-6" /></div>
<div>
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">Available IPs</div>
<div class="mt-1 text-2xl font-bold">{{ stats.available_ips.toLocaleString() }}</div>
<div class="text-sm text-slate-500">{{ 100 - stats.utilization_percent }}% free</div>
</div>
</div>
<div class="card flex items-start gap-4">
<div class="rounded-lg bg-surface-overlay p-3 text-slate-500"><Layers class="h-6 w-6" /></div>
<div>
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">Subnets</div>
<div class="mt-1 text-2xl font-bold">{{ stats.subnet_count }}</div>
<div class="text-sm text-slate-500">Total</div>
</div>
</div>
<div class="card flex items-start gap-4">
<div class="rounded-lg bg-surface-overlay p-3 text-slate-500"><Server class="h-6 w-6" /></div>
<div>
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">Devices</div>
<div class="mt-1 text-2xl font-bold">{{ stats.device_count.toLocaleString() }}</div>
<div class="text-sm text-slate-500">Managed</div>
</div>
</div>
</div>
<div class="mt-6 grid gap-4 lg:grid-cols-2">
<div class="card">
<h2 class="font-semibold">IPv4 usage distribution</h2>
<div class="mt-6 flex flex-col items-center gap-6 sm:flex-row sm:justify-center">
<div class="relative h-44 w-44 shrink-0 rounded-full" :style="donutStyle">
<div class="absolute inset-5 flex flex-col items-center justify-center rounded-full bg-surface-raised text-center">
<span class="text-2xl font-bold">{{ stats.total_ips.toLocaleString() }}</span>
<span class="text-xs uppercase tracking-wide text-slate-500">Total</span>
</div>
</div>
<div class="space-y-3 text-sm">
<div class="flex items-center gap-2">
<span class="h-3 w-3 rounded-full bg-accent" />
<span>{{ stats.utilization_percent }}% Used ({{ stats.used_ips.toLocaleString() }})</span>
</div>
<div class="flex items-center gap-2">
<span class="h-3 w-3 rounded-full bg-surface-overlay ring-1 ring-slate-300 dark:ring-slate-600" />
<span>{{ 100 - stats.utilization_percent }}% Free ({{ stats.available_ips.toLocaleString() }})</span>
</div>
</div>
</div>
</div>
<div class="card">
<h2 class="font-semibold">Activity last 24 hours</h2>
<p class="mt-1 text-xs text-slate-500">Audit log entries by hour</p>
<div class="mt-4 flex h-40 items-end gap-0.5">
<div
v-for="point in activity"
:key="point.hour"
class="flex-1 rounded-t bg-accent/80 transition-all hover:bg-accent"
:style="{ height: `${Math.max(4, (point.count / maxActivity) * 100)}%` }"
:title="`${formatHour(point.hour)}: ${point.count}`"
/>
</div>
<div class="mt-2 flex justify-between text-[10px] text-slate-500">
<span>12 AM</span>
<span>6 AM</span>
<span>12 PM</span>
<span>6 PM</span>
</div>
</div>
</div>
<div class="card mt-6 overflow-x-auto">
<div class="mb-4 flex items-center justify-between gap-3">
<h2 class="font-semibold">Subnet overview</h2>
<RouterLink to="/subnets" class="text-sm text-accent hover:underline">View all subnets</RouterLink>
</div>
<table class="w-full min-w-[640px] text-left text-sm">
<thead>
<tr class="border-b border-slate-200 text-xs font-medium uppercase tracking-wide text-slate-500 dark:border-slate-700">
<th class="p-2">Subnet</th>
<th class="p-2">Name</th>
<th class="p-2">Utilised</th>
<th class="p-2">Available</th>
<th class="p-2">Site</th>
</tr>
</thead>
<tbody>
<tr
v-for="s in subnetOverview"
:key="s.id" :key="s.id"
:to="`/subnets/${s.id}`" class="border-b border-slate-100 dark:border-slate-800"
class="card block transition hover:border-accent/50"
> >
<div class="font-medium">{{ s.name }}</div> <td class="p-2">
<div class="mt-1 flex flex-wrap items-center gap-2"> <RouterLink :to="`/subnets/${s.id}`" class="font-mono text-accent hover:underline">{{ s.cidr }}</RouterLink>
<span class="font-mono text-sm text-slate-500">{{ s.cidr }}</span> </td>
<span <td class="p-2">{{ s.name }}</td>
v-if="s.vlan_id" <td class="p-2">
class="rounded-full bg-surface-overlay px-2 py-0.5 text-xs font-semibold text-slate-600 dark:text-slate-300" <div class="flex items-center gap-2">
>VLAN {{ s.vlan_id }}</span> <div class="h-1.5 w-16 overflow-hidden rounded-full bg-surface-overlay">
<div
class="h-full rounded-full bg-accent"
:style="{ width: `${s.utilization}%` }"
/>
</div> </div>
<div class="mt-3"> <span>{{ s.utilization }}%</span>
<div class="h-2 overflow-hidden rounded-full bg-surface-overlay">
<div class="h-full rounded-full bg-accent transition-all" :style="{ width: `${s.utilization ?? 0}%` }" />
</div> </div>
<div class="mt-1 text-xs text-slate-500">{{ s.utilization ?? 0 }}% used</div> </td>
</div> <td class="p-2">{{ s.available }}</td>
</RouterLink> <td class="p-2">{{ s.site }}</td>
</div> </tr>
</section> <tr v-if="!subnetOverview.length">
<td colspan="5" class="p-4 text-center text-slate-500">No subnets configured.</td>
</tr>
</tbody>
</table>
</div> </div>
</template>
</div> </div>
</template> </template>
+87 -18
View File
@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from "vue"; import { ref, computed, watch } 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,7 +45,10 @@ 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() {
loading.value = true;
error.value = "";
try {
const id = Number(route.params.id); const id = Number(route.params.id);
const [d, tags, h, sn] = await Promise.all([ const [d, tags, h, sn] = await Promise.all([
api.device(id), api.device(id),
@@ -51,10 +58,27 @@ onMounted(async () => {
]); ]);
device.value = d; device.value = d;
editName.value = d.name; editName.value = d.name;
editDescription.value = d.description || "";
allTags.value = tags; allTags.value = tags;
subnets.value = sn; subnets.value = sn;
history.value = h as IpHistoryEntry[]; 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;
}
}
watch(
() => route.params.id,
() => {
showAssignIp.value = false;
err.value = "";
loadDevice();
},
{ immediate: true },
);
async function loadAvailableIps(subnetId: number) { async function loadAvailableIps(subnetId: number) {
if (!subnetId) { if (!subnetId) {
@@ -89,12 +113,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 = "";
try {
await api.updateDevice(device.value.id, { name: editName.value, description: editDescription.value });
device.value.name = editName.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; saving.value = false;
}
} }
async function assignTag(tagId: number) { async function assignTag(tagId: number) {
@@ -115,8 +146,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 +155,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,22 +164,46 @@ 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>
<p v-if="loading" class="mt-8 text-slate-500">Loading</p>
<p v-else-if="error" class="mt-8 text-red-500">{{ error }}</p>
<template v-else-if="device">
<div class="mt-4 flex flex-wrap items-start justify-between gap-4"> <div class="mt-4 flex flex-wrap items-start justify-between gap-4">
<div class="flex-1"> <div class="flex min-w-0 max-w-2xl flex-1 flex-col gap-2">
<input v-if="auth.can('edit_device')" v-model="editName" class="input-field max-w-md text-xl font-bold" @blur="saveName" /> <template v-if="auth.can('edit_device')">
<h1 v-else class="text-2xl font-bold">{{ device.name }}</h1> <input
<p class="mt-1 text-slate-500">{{ device.description || "No description" }}</p> v-model="editName"
class="input-field block w-full border-0 bg-transparent px-0 py-0 text-2xl font-bold shadow-none focus:ring-0"
aria-label="Device name"
@blur="saveDevice"
/>
<textarea
v-model="editDescription"
class="input-field block w-full resize-y text-sm"
placeholder="Add a description…"
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>
<button <button
v-if="auth.can('delete_device')" v-if="auth.can('delete_device')"
class="text-sm text-red-500 hover:underline" class="shrink-0 text-sm text-red-500 hover:underline"
@click="deleteDevice" @click="deleteDevice"
>Delete device</button> >Delete device</button>
</div> </div>
@@ -161,8 +214,13 @@ function formatTime(ts?: string) {
<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('add_device_ip')" class="text-sm text-accent hover:underline" @click="openAssignIpModal">Assign IP</button>
</div> </div>
<ul class="mt-3 space-y-2"> <ul class="mt-3 space-y-2">
<li v-for="ip in device.ip_addresses" :key="ip.id" class="flex items-center justify-between font-mono text-sm"> <li v-for="ip in device.ip_addresses" :key="ip.id" class="flex items-center justify-between gap-3 font-mono text-sm">
<span>{{ ip.ip }} <span class="text-slate-500">({{ ip.subnet_name }})</span></span> <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> <button v-if="auth.can('remove_device_ip')" class="text-red-500 hover:underline" @click="removeIp(ip.id)">Remove</button>
</li> </li>
<li v-if="!device.ip_addresses?.length" class="text-sm text-slate-500">None assigned</li> <li v-if="!device.ip_addresses?.length" class="text-sm text-slate-500">None assigned</li>
@@ -175,18 +233,28 @@ function formatTime(ts?: string) {
{{ t.name }} {{ t.name }}
<button v-if="auth.can('assign_device_tag')" class="text-red-500 hover:underline" @click="removeTag(t.id)">×</button> <button v-if="auth.can('assign_device_tag')" class="text-red-500 hover:underline" @click="removeTag(t.id)">×</button>
</span> </span>
<span v-if="!device.tags?.length" class="text-sm text-slate-500">No tags</span>
</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 = ''"> <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 = ''">
<option value="">Add tag</option> <option value="">Add tag</option>
<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> <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>
</select> </select>
</div> </div>
<CustomFieldValues
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"> <div class="card lg:col-span-2">
<h2 class="font-semibold">IP history</h2> <h2 class="font-semibold">IP history</h2>
<p v-if="!history.length" class="mt-2 text-sm text-slate-500">No assignment history.</p> <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"> <ul v-else class="mt-3 space-y-3">
<li v-for="(entry, i) in history" :key="i" class="flex gap-3 text-sm"> <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'"> <span class="shrink-0 text-xs font-semibold uppercase" :class="entry.action === 'assigned' ? 'text-emerald-600' : 'text-red-500'">
{{ entry.action === "assigned" ? "Assigned" : "Removed" }} {{ entry.action === "assigned" ? "Assigned" : "Removed" }}
</span> </span>
<span class="font-mono">{{ entry.ip }}</span> <span class="font-mono">{{ entry.ip }}</span>
@@ -216,5 +284,6 @@ function formatTime(ts?: string) {
</div> </div>
</form> </form>
</div> </div>
</template>
</div> </div>
</template> </template>
-51
View File
@@ -1,51 +0,0 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useRoute, RouterLink } from "vue-router";
import { api } from "@/api";
const route = useRoute();
const pool = ref<{ start_ip?: string; end_ip?: string; excluded_ips?: string } | null>(null);
const form = ref({ start_ip: "", end_ip: "", excluded_ips: "" });
const msg = ref("");
onMounted(async () => {
try {
const d = await api.getDhcp(Number(route.params.id)) as { pools?: { start_ip: string; end_ip: string; excluded_ips?: string }[] };
if (d.pools?.[0]) {
pool.value = d.pools[0];
form.value.start_ip = d.pools[0].start_ip;
form.value.end_ip = d.pools[0].end_ip;
form.value.excluded_ips = d.pools[0].excluded_ips || "";
}
} catch { /* no pool */ }
});
async function save() {
await api.setDhcp(Number(route.params.id), {
pools: [{ start_ip: form.value.start_ip, end_ip: form.value.end_ip, excluded_ips: form.value.excluded_ips.split(",").map((s) => s.trim()).filter(Boolean) }],
});
msg.value = "Saved";
}
async function remove() {
await api.setDhcp(Number(route.params.id), { remove: true });
pool.value = null;
msg.value = "Removed";
}
</script>
<template>
<div>
<RouterLink :to="`/subnets/${route.params.id}`" class="text-sm text-accent hover:underline"> Subnet</RouterLink>
<h1 class="mt-4 text-2xl font-bold">DHCP pool</h1>
<form class="card mt-6 max-w-lg space-y-4" @submit.prevent="save">
<input v-model="form.start_ip" class="input-field" placeholder="Start IP" required />
<input v-model="form.end_ip" class="input-field" placeholder="End IP" required />
<input v-model="form.excluded_ips" class="input-field" placeholder="Excluded IPs (comma-separated)" />
<div class="flex gap-2">
<button type="submit" class="btn-primary">Save</button>
<button v-if="pool" type="button" class="btn-secondary" @click="remove">Remove pool</button>
</div>
<p v-if="msg" class="text-sm text-accent">{{ msg }}</p>
</form>
</div>
</template>
+7 -7
View File
@@ -7,7 +7,6 @@ import { useAuthStore } from "@/stores/auth";
const route = useRoute(); const route = useRoute();
const auth = useAuthStore(); const auth = useAuthStore();
const rack = ref<Rack | null>(null); const rack = ref<Rack | null>(null);
const side = ref("front");
const showAddDevice = ref(false); const showAddDevice = ref(false);
const showAddNonnet = ref(false); const showAddNonnet = ref(false);
const addForm = ref({ device_id: 0, position_u: 1, side: "front" }); const addForm = ref({ device_id: 0, position_u: 1, side: "front" });
@@ -24,11 +23,11 @@ onMounted(load);
const siteDevices = () => rack.value?.site_devices || []; const siteDevices = () => rack.value?.site_devices || [];
const slots = (r: Rack) => { const slotsForSide = (r: Rack, rackSide: string) => {
const h = r.height_u; const h = r.height_u;
const map: Record<number, typeof r.devices> = {}; const map: Record<number, typeof r.devices> = {};
for (const d of r.devices || []) { for (const d of r.devices || []) {
if (d.side === side.value) (map[d.position_u] ??= []).push(d); if (d.side === rackSide) (map[d.position_u] ??= []).push(d);
} }
return Array.from({ length: h }, (_, i) => ({ u: h - i, devices: map[h - i] || [] })); return Array.from({ length: h }, (_, i) => ({ u: h - i, devices: map[h - i] || [] }));
}; };
@@ -81,14 +80,14 @@ async function removeDevice(rackDeviceId: number) {
<a v-if="auth.can('export_rack_csv')" :href="`/api/v2/racks/${rack.id}/export`" class="btn-secondary text-sm">Export CSV</a> <a v-if="auth.can('export_rack_csv')" :href="`/api/v2/racks/${rack.id}/export`" class="btn-secondary text-sm">Export CSV</a>
</div> </div>
<div class="mt-4 flex flex-wrap gap-2"> <div class="mt-4 flex flex-wrap gap-2">
<button class="rounded-lg px-3 py-1 text-sm" :class="side === 'front' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="side = 'front'">Front</button>
<button class="rounded-lg px-3 py-1 text-sm" :class="side === 'back' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="side = 'back'">Back</button>
<button v-if="auth.can('add_device_to_rack')" class="btn-secondary text-sm" @click="showAddDevice = true; err = ''">Add device</button> <button v-if="auth.can('add_device_to_rack')" class="btn-secondary text-sm" @click="showAddDevice = true; err = ''">Add device</button>
<button v-if="auth.can('add_nonnet_device_to_rack')" class="btn-secondary text-sm" @click="showAddNonnet = true; err = ''">Add non-networked</button> <button v-if="auth.can('add_nonnet_device_to_rack')" class="btn-secondary text-sm" @click="showAddNonnet = true; err = ''">Add non-networked</button>
</div> </div>
<div class="card mt-6 max-w-md font-mono text-sm"> <div class="mt-6 grid gap-6 lg:grid-cols-2">
<div v-for="row in slots(rack)" :key="row.u" class="flex border-b border-slate-200 py-2 dark:border-slate-700"> <div v-for="rackSide in ['front', 'back'] as const" :key="rackSide" class="card font-mono text-sm">
<h2 class="mb-3 border-b border-slate-200 pb-2 text-base font-semibold capitalize dark:border-slate-700">{{ rackSide }}</h2>
<div v-for="row in slotsForSide(rack, rackSide)" :key="row.u" class="flex border-b border-slate-200 py-2 dark:border-slate-700">
<span class="w-10 shrink-0 text-slate-500">U{{ row.u }}</span> <span class="w-10 shrink-0 text-slate-500">U{{ row.u }}</span>
<span class="flex flex-1 flex-col gap-1"> <span class="flex flex-1 flex-col gap-1">
<span v-for="d in row.devices" :key="d.id" class="flex items-center gap-2"> <span v-for="d in row.devices" :key="d.id" class="flex items-center gap-2">
@@ -100,6 +99,7 @@ async function removeDevice(rackDeviceId: number) {
</span> </span>
</div> </div>
</div> </div>
</div>
<div v-if="showAddDevice" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showAddDevice = false"> <div v-if="showAddDevice" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showAddDevice = false">
<form class="card w-full max-w-md space-y-3" @submit.prevent="addDevice"> <form class="card w-full max-w-md space-y-3" @submit.prevent="addDevice">
+14 -1
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() {
loading.value = true;
err.value = "";
try {
racks.value = await api.racks(); 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>
+79 -9
View File
@@ -4,34 +4,94 @@ import { useRoute, RouterLink } from "vue-router";
import { api, type Subnet } from "@/api"; 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 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 loading = ref(true);
const error = ref("");
const notesErr = ref("");
onMounted(async () => { async function loadSubnet() {
loading.value = true;
error.value = "";
try {
subnet.value = await api.subnet(Number(route.params.id)); 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);
async function saveNotes(ipId: number, notes: string) { async function saveNotes(ipId: number, notes: string) {
notesErr.value = "";
try {
await api.patchIpNotes(ipId, notes); 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="/subnets" class="text-sm text-accent hover:underline"> Subnets</RouterLink>
<p v-if="loading" class="mt-8 text-slate-500">Loading</p>
<p v-else-if="error" class="mt-8 text-red-500">{{ error }}</p>
<template v-else-if="subnet">
<div class="mt-4 flex flex-wrap items-center justify-between gap-3"> <div class="mt-4 flex flex-wrap items-center justify-between gap-3">
<div> <div>
<h1 class="text-2xl font-bold">{{ subnet.name }}</h1> <h1 class="text-2xl font-bold">{{ subnet.name }}</h1>
<p class="font-mono text-slate-500">{{ subnet.cidr }} · {{ subnet.site || "Unassigned" }}</p> <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>
<div class="flex gap-2"> <div class="flex gap-2">
<RouterLink :to="`/subnets/${subnet.id}/dhcp`" class="btn-secondary text-sm">DHCP</RouterLink> <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> <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>
<CustomFieldValues
v-if="auth.can('view_custom_fields')"
class="mt-6"
entity-type="subnet"
:entity-id="subnet.id"
:values="subnet.custom_fields"
:can-edit="auth.can('edit_subnet')"
@saved="onCustomFieldsSaved"
/>
<div class="card mt-6 overflow-x-auto"> <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"> <table class="w-full text-left text-sm">
<thead> <thead>
<tr class="border-b border-slate-200 dark:border-slate-700"> <tr class="border-b border-slate-200 dark:border-slate-700">
@@ -42,10 +102,15 @@ async function saveNotes(ipId: number, notes: string) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="ip in subnet.ip_addresses" :key="ip.id" class="border-b border-slate-100 dark:border-slate-800"> <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 font-mono">{{ ip.ip }}</td>
<td class="p-2"> <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">{{ ip.device_name || ip.hostname }}</RouterLink> <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> <span v-else>{{ ip.hostname || "" }}</span>
</td> </td>
<td class="p-2"> <td class="p-2">
@@ -61,9 +126,14 @@ async function saveNotes(ipId: number, notes: string) {
<button type="button" class="text-xs text-accent hover:underline" @click="historyIp = ip.ip">History</button> <button type="button" class="text-xs text-accent hover:underline" @click="historyIp = ip.ip">History</button>
</td> </td>
</tr> </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> </tbody>
</table> </table>
</div> </div>
<IpHistoryModal :ip="historyIp" @close="historyIp = null" /> <IpHistoryModal :ip="historyIp" @close="historyIp = null" />
<DhcpModal :open="showDhcp" :subnet-id="subnet.id" @close="showDhcp = false" @saved="loadSubnet" />
</template>
</div> </div>
</template> </template>
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { RouterLink } from "vue-router";
import { api, type Subnet } from "@/api";
const subnets = ref<Subnet[]>([]);
const loading = ref(true);
const error = ref("");
const bySite = computed(() => {
const m: Record<string, Subnet[]> = {};
for (const s of subnets.value) {
const site = s.site || "Unassigned";
if (!m[site]) m[site] = [];
m[site].push(s);
}
return m;
});
const siteOrder = computed(() =>
Object.keys(bySite.value).sort((a, b) => {
if (a === "Unassigned") return -1;
if (b === "Unassigned") return 1;
return a.localeCompare(b);
}),
);
onMounted(async () => {
try {
subnets.value = await api.subnets(true);
} catch (e) {
error.value = e instanceof Error ? e.message : "Failed to load subnets";
} finally {
loading.value = false;
}
});
</script>
<template>
<div>
<h1 class="text-2xl font-bold">Subnets</h1>
<p class="mt-1 text-slate-500">Browse subnets grouped by site</p>
<p v-if="loading" class="mt-8 text-slate-500">Loading</p>
<p v-else-if="error" class="mt-8 text-red-500">{{ error }}</p>
<p v-else-if="!subnets.length" class="mt-8 text-slate-500">No subnets yet.</p>
<div v-else class="mt-6 space-y-8">
<section v-for="site in siteOrder" :key="site">
<h2 class="mb-3 text-lg font-semibold text-accent">{{ site }}</h2>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<RouterLink
v-for="s in bySite[site]"
:key="s.id"
:to="`/subnets/${s.id}`"
class="card block transition hover:border-accent/50"
>
<div class="font-medium">{{ s.name }}</div>
<div class="mt-1 flex flex-wrap items-center gap-2">
<span class="font-mono text-sm text-slate-500">{{ s.cidr }}</span>
<span
v-if="s.vlan_id"
class="rounded-full bg-surface-overlay px-2 py-0.5 text-xs font-semibold text-slate-600 dark:text-slate-300"
>VLAN {{ s.vlan_id }}</span>
</div>
<div class="mt-3">
<div class="h-2 overflow-hidden rounded-full bg-surface-overlay">
<div
class="h-full rounded-full transition-all"
:class="(s.utilization ?? 0) >= 90 ? 'bg-red-500' : 'bg-accent'"
:style="{ width: `${s.utilization ?? 0}%` }"
/>
</div>
<div class="mt-1 text-xs text-slate-500">{{ s.utilization ?? 0 }}% used</div>
</div>
</RouterLink>
</div>
</section>
</div>
</div>
</template>
+18 -3
View File
@@ -89,11 +89,26 @@ async function del(id: number) {
<p v-if="err" class="text-sm text-red-500 sm:col-span-3">{{ err }}</p> <p v-if="err" class="text-sm text-red-500 sm:col-span-3">{{ err }}</p>
</form> </form>
<ul class="mt-8 space-y-2"> <ul class="mt-8 space-y-2">
<li v-for="s in subnets" :key="s.id" class="card flex flex-wrap items-center justify-between gap-2"> <li
class="hidden px-4 text-xs font-medium text-slate-500 sm:grid sm:grid-cols-[minmax(0,1.5fr)_minmax(9rem,1fr)_5.5rem_auto] sm:items-center sm:gap-4"
>
<span>Name</span>
<span>CIDR</span>
<span>VLAN</span>
<span class="text-right">Actions</span>
</li>
<li
v-for="s in subnets"
:key="s.id"
class="card grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1.5fr)_minmax(9rem,1fr)_5.5rem_auto] sm:items-center sm:gap-4"
>
<RouterLink :to="`/subnets/${s.id}`" class="font-medium text-accent hover:underline">{{ s.name }}</RouterLink> <RouterLink :to="`/subnets/${s.id}`" class="font-medium text-accent hover:underline">{{ s.name }}</RouterLink>
<span class="font-mono text-sm text-slate-500">{{ s.cidr }}</span> <span class="font-mono text-sm text-slate-500">{{ s.cidr }}</span>
<span v-if="s.vlan_id" class="rounded-full bg-surface-overlay px-2 py-0.5 text-xs">VLAN {{ s.vlan_id }}</span> <span class="text-xs">
<div class="flex gap-2"> <span v-if="s.vlan_id" class="inline-block rounded-full bg-surface-overlay px-2 py-0.5">VLAN {{ s.vlan_id }}</span>
<span v-else class="text-slate-500"></span>
</span>
<div class="flex gap-2 sm:justify-end">
<button v-if="auth.can('edit_subnet')" class="text-sm text-accent hover:underline" @click="openEdit(s)">Edit</button> <button v-if="auth.can('edit_subnet')" class="text-sm text-accent hover:underline" @click="openEdit(s)">Edit</button>
<button v-if="auth.can('delete_subnet')" class="text-sm text-red-500" @click="del(s.id)">Delete</button> <button v-if="auth.can('delete_subnet')" class="text-sm text-red-500" @click="del(s.id)">Delete</button>
</div> </div>
+39 -9
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() {
err.value = "";
try {
await api.createTag(form.value); await api.createTag(form.value);
tags.value = await api.tags();
form.value = { name: "", color: "#06b6d4", description: "" }; 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;
err.value = "";
try {
await api.deleteTag(id); await api.deleteTag(id);
tags.value = await api.tags(); 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">
+14 -3
View File
@@ -140,10 +140,21 @@ async function delRole(id: number) {
<button v-if="auth.can('manage_users')" class="btn-primary text-sm" @click="openAddUser">Add user</button> <button v-if="auth.can('manage_users')" class="btn-primary text-sm" @click="openAddUser">Add user</button>
</div> </div>
<ul class="space-y-2"> <ul class="space-y-2">
<li v-for="u in users" :key="u.id" class="card flex flex-wrap items-center justify-between gap-2"> <li
<span>{{ u.name }} <span class="text-slate-500">&lt;{{ u.email }}&gt;</span></span> class="hidden px-4 text-xs font-medium text-slate-500 sm:grid sm:grid-cols-[minmax(0,1fr)_8rem_13rem] sm:items-center sm:gap-4"
>
<span>User</span>
<span>Role</span>
<span v-if="auth.can('manage_users')" class="text-right">Actions</span>
</li>
<li
v-for="u in users"
:key="u.id"
class="card grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_8rem_13rem] sm:items-center sm:gap-4"
>
<span class="min-w-0">{{ u.name }} <span class="text-slate-500">&lt;{{ u.email }}&gt;</span></span>
<span class="text-sm text-slate-500">{{ u.role_name }}</span> <span class="text-sm text-slate-500">{{ u.role_name }}</span>
<div v-if="auth.can('manage_users')" class="flex gap-2"> <div v-if="auth.can('manage_users')" class="flex gap-2 sm:justify-end">
<button class="text-sm text-accent hover:underline" @click="openEditUser(u)">Edit</button> <button class="text-sm text-accent hover:underline" @click="openEditUser(u)">Edit</button>
<button class="text-sm text-accent hover:underline" @click="regenKey(u.id)">API key</button> <button class="text-sm text-accent hover:underline" @click="regenKey(u.id)">API key</button>
<button class="text-sm text-red-500 hover:underline" @click="delUser(u.id)">Delete</button> <button class="text-sm text-red-500 hover:underline" @click="delUser(u.id)">Delete</button>
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

+2 -4
View File
@@ -1,8 +1,6 @@
#!/bin/bash #!/bin/bash
set -e set -e
if [ ! -f static/dist/index.html ]; then echo "Building frontend..."
echo "Building frontend..." (cd frontend && npm ci && npm run build)
(cd frontend && npm ci && npm run build)
fi
echo "Starting app..." echo "Starting app..."
python app.py python app.py
+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