Compare commits
7 Commits
31e417b9f5
..
v2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 616744015f | |||
| 87d7654606 | |||
| 9e47cbee4e | |||
| e16a667d60 | |||
| a8bcb9bd1c | |||
| 71d0b7fed6 | |||
| 39a8f4a49b |
@@ -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 \
|
||||||
.
|
.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|

|
||||||
# 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.
|
|
||||||
@@ -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
@@ -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", {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<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";
|
||||||
|
|
||||||
@@ -18,6 +18,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" },
|
||||||
@@ -112,7 +113,7 @@ onUnmounted(() => {
|
|||||||
: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"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,52 +1,215 @@
|
|||||||
<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;
|
||||||
|
alerting_subnets: number;
|
||||||
|
device_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubnetOverviewRow {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
cidr: string;
|
||||||
|
site: string;
|
||||||
|
vlan_id?: number;
|
||||||
|
utilization: number;
|
||||||
|
available: number;
|
||||||
|
status: "active" | "alerting";
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
<span v-if="stats.alerting_subnets" class="text-red-500">/ {{ stats.alerting_subnets }} alerting</span>
|
||||||
|
</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>
|
||||||
|
<th class="p-2">Status</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"
|
:class="s.status === 'alerting' ? 'bg-red-500/5' : ''"
|
||||||
>
|
>
|
||||||
<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>
|
||||||
|
<td class="p-2">{{ s.name }}</td>
|
||||||
|
<td class="p-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-surface-overlay">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full"
|
||||||
|
:class="s.status === 'alerting' ? 'bg-red-500' : 'bg-accent'"
|
||||||
|
:style="{ width: `${s.utilization}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span>{{ s.utilization }}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="p-2">{{ s.available }}</td>
|
||||||
|
<td class="p-2">{{ s.site }}</td>
|
||||||
|
<td class="p-2">
|
||||||
<span
|
<span
|
||||||
v-if="s.vlan_id"
|
class="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium"
|
||||||
class="rounded-full bg-surface-overlay px-2 py-0.5 text-xs font-semibold text-slate-600 dark:text-slate-300"
|
:class="s.status === 'alerting' ? 'bg-red-500/15 text-red-500' : 'bg-accent/15 text-accent'"
|
||||||
>VLAN {{ s.vlan_id }}</span>
|
>
|
||||||
</div>
|
<span class="h-1.5 w-1.5 rounded-full" :class="s.status === 'alerting' ? 'bg-red-500' : 'bg-accent'" />
|
||||||
<div class="mt-3">
|
{{ s.status === 'alerting' ? 'Alerting' : 'Active' }}
|
||||||
<div class="h-2 overflow-hidden rounded-full bg-surface-overlay">
|
</span>
|
||||||
<div class="h-full rounded-full bg-accent transition-all" :style="{ width: `${s.utilization ?? 0}%` }" />
|
</td>
|
||||||
</div>
|
</tr>
|
||||||
<div class="mt-1 text-xs text-slate-500">{{ s.utilization ?? 0 }}% used</div>
|
<tr v-if="!subnetOverview.length">
|
||||||
</div>
|
<td colspan="6" class="p-4 text-center text-slate-500">No subnets configured.</td>
|
||||||
</RouterLink>
|
</tr>
|
||||||
</div>
|
</tbody>
|
||||||
</section>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref, onMounted, computed } from "vue";
|
|||||||
import { useRoute, useRouter, RouterLink } from "vue-router";
|
import { useRoute, useRouter, RouterLink } from "vue-router";
|
||||||
import { api, type Device, type Tag, type Subnet } from "@/api";
|
import { api, type Device, type Tag, type Subnet } from "@/api";
|
||||||
import type { IpHistoryEntry } from "@/components/IpHistoryModal.vue";
|
import type { IpHistoryEntry } from "@/components/IpHistoryModal.vue";
|
||||||
|
import CustomFieldValues from "@/components/CustomFieldValues.vue";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { formatLocalTime } from "@/utils/datetime";
|
import { formatLocalTime } from "@/utils/datetime";
|
||||||
|
|
||||||
@@ -15,7 +16,10 @@ const subnets = ref<Subnet[]>([]);
|
|||||||
const availableIps = ref<{ id: number; ip: string }[]>([]);
|
const availableIps = ref<{ id: number; ip: string }[]>([]);
|
||||||
const history = ref<IpHistoryEntry[]>([]);
|
const history = ref<IpHistoryEntry[]>([]);
|
||||||
const editName = ref("");
|
const editName = ref("");
|
||||||
|
const editDescription = ref("");
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref("");
|
||||||
const showAssignIp = ref(false);
|
const showAssignIp = ref(false);
|
||||||
const assignForm = ref({ site: "", subnet_id: 0, ip_id: 0 });
|
const assignForm = ref({ site: "", subnet_id: 0, ip_id: 0 });
|
||||||
const err = ref("");
|
const err = ref("");
|
||||||
@@ -41,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,19 @@ 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadDevice);
|
||||||
|
|
||||||
async function loadAvailableIps(subnetId: number) {
|
async function loadAvailableIps(subnetId: number) {
|
||||||
if (!subnetId) {
|
if (!subnetId) {
|
||||||
@@ -89,13 +105,20 @@ 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) {
|
||||||
if (!device.value || !tagId) return;
|
if (!device.value || !tagId) return;
|
||||||
@@ -115,8 +138,7 @@ async function assignIp() {
|
|||||||
try {
|
try {
|
||||||
await api.assignIp(device.value.id, assignForm.value.ip_id);
|
await api.assignIp(device.value.id, assignForm.value.ip_id);
|
||||||
showAssignIp.value = false;
|
showAssignIp.value = false;
|
||||||
device.value = await api.device(device.value.id);
|
await loadDevice();
|
||||||
history.value = (await api.deviceIpHistory(device.value.id)) as IpHistoryEntry[];
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
err.value = e instanceof Error ? e.message : "Failed";
|
err.value = e instanceof Error ? e.message : "Failed";
|
||||||
}
|
}
|
||||||
@@ -125,8 +147,7 @@ async function assignIp() {
|
|||||||
async function removeIp(ipId: number) {
|
async function removeIp(ipId: number) {
|
||||||
if (!device.value || !confirm("Remove this IP from the device?")) return;
|
if (!device.value || !confirm("Remove this IP from the device?")) return;
|
||||||
await api.removeIp(device.value.id, ipId);
|
await api.removeIp(device.value.id, ipId);
|
||||||
device.value = await api.device(device.value.id);
|
await loadDevice();
|
||||||
history.value = (await api.deviceIpHistory(device.value.id)) as IpHistoryEntry[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteDevice() {
|
async function deleteDevice() {
|
||||||
@@ -135,22 +156,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 +206,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 +225,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 +276,6 @@ function formatTime(ts?: string) {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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 +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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user