Compare commits
5 Commits
71d0b7fed6
...
v2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 616744015f | |||
| 87d7654606 | |||
| 9e47cbee4e | |||
| e16a667d60 | |||
| a8bcb9bd1c |
@@ -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
|
||||||
@@ -1,60 +1,28 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="https://assets.jdbnet.co.uk/projects/ipam.png" alt="IPAM" width="200" />
|
<img src="https://assets.jdbnet.co.uk/projects/ipam.png" alt="IPAM" width="200" />
|
||||||
|
|
||||||
# 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 names, descriptions, tags, and custom fields
|
- **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,235 +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 **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
|
|
||||||
|
|
||||||
## 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
|
||||||
@@ -2718,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',
|
||||||
})
|
})
|
||||||
return jsonify({'sites': sites})
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/v2/search', methods=['GET'])
|
@app.route('/api/v2/search', methods=['GET'])
|
||||||
|
|||||||
+22
-1
@@ -178,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)}`));
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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") },
|
||||||
|
|||||||
@@ -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">
|
||||||
:key="s.id"
|
<div class="rounded-lg bg-accent/15 p-3 text-accent"><Network class="h-6 w-6" /></div>
|
||||||
:to="`/subnets/${s.id}`"
|
<div>
|
||||||
class="card block transition hover:border-accent/50"
|
<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="font-medium">{{ s.name }}</div>
|
<div class="text-sm text-slate-500">{{ stats.utilization_percent }}% utilised</div>
|
||||||
<div class="mt-1 flex flex-wrap items-center gap-2">
|
</div>
|
||||||
<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 bg-accent transition-all" :style="{ width: `${s.utilization ?? 0}%` }" />
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 text-xs text-slate-500">{{ s.utilization ?? 0 }}% used</div>
|
|
||||||
</div>
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<div class="card flex items-start gap-4">
|
||||||
</div>
|
<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"
|
||||||
|
class="border-b border-slate-100 dark:border-slate-800"
|
||||||
|
:class="s.status === 'alerting' ? 'bg-red-500/5' : ''"
|
||||||
|
>
|
||||||
|
<td class="p-2">
|
||||||
|
<RouterLink :to="`/subnets/${s.id}`" class="font-mono text-accent hover:underline">{{ s.cidr }}</RouterLink>
|
||||||
|
</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
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="s.status === 'alerting' ? 'bg-red-500/15 text-red-500' : 'bg-accent/15 text-accent'"
|
||||||
|
>
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full" :class="s.status === 'alerting' ? 'bg-red-500' : 'bg-accent'" />
|
||||||
|
{{ s.status === 'alerting' ? 'Alerting' : 'Active' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!subnetOverview.length">
|
||||||
|
<td colspan="6" class="p-4 text-center text-slate-500">No subnets configured.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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,23 +80,24 @@ 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">
|
||||||
<span class="w-10 shrink-0 text-slate-500">U{{ row.u }}</span>
|
<h2 class="mb-3 border-b border-slate-200 pb-2 text-base font-semibold capitalize dark:border-slate-700">{{ rackSide }}</h2>
|
||||||
<span class="flex flex-1 flex-col gap-1">
|
<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 v-for="d in row.devices" :key="d.id" class="flex items-center gap-2">
|
<span class="w-10 shrink-0 text-slate-500">U{{ row.u }}</span>
|
||||||
<RouterLink v-if="d.device_id" :to="`/devices/${d.device_id}`" class="text-accent hover:underline">{{ d.device_name }}</RouterLink>
|
<span class="flex flex-1 flex-col gap-1">
|
||||||
<span v-else>{{ d.nonnet_device_name }}</span>
|
<span v-for="d in row.devices" :key="d.id" class="flex items-center gap-2">
|
||||||
<button v-if="auth.can('remove_device_from_rack')" class="text-red-500 hover:underline" @click="removeDevice(d.id)">×</button>
|
<RouterLink v-if="d.device_id" :to="`/devices/${d.device_id}`" class="text-accent hover:underline">{{ d.device_name }}</RouterLink>
|
||||||
|
<span v-else>{{ d.nonnet_device_name }}</span>
|
||||||
|
<button v-if="auth.can('remove_device_from_rack')" class="text-red-500 hover:underline" @click="removeDevice(d.id)">×</button>
|
||||||
|
</span>
|
||||||
|
<span v-if="!row.devices.length" class="text-slate-500">—</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="!row.devices.length" class="text-slate-500">—</span>
|
</div>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ function isDhcpRow(hostname?: string) {
|
|||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<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-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="error" class="mt-8 text-red-500">{{ error }}</p>
|
||||||
<template v-else-if="subnet">
|
<template v-else-if="subnet">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
Reference in New Issue
Block a user