14 Commits

Author SHA1 Message Date
jamie e6ccba0e0a Merge pull request 'feat: move org name and logo to db' (#56) from v2.0.2 into main
Reviewed-on: http://git.jdbnet.co.uk/jamie/ipam/pulls/56
2026-05-30 15:33:54 +01:00
jamie 1a3d47a72e fix: 🐛 layout issue
Release / Build & Release (pull_request) Successful in 28s
Release / SonarQube (pull_request) Successful in 28s
2026-05-30 14:33:41 +00:00
jamie 6012566b22 feat: move org name and logo to db 2026-05-30 14:31:01 +00:00
jamie fc5699a04c Merge pull request 'feat: version number links to releases' (#54) from v2.0.1 into main
Reviewed-on: http://git.jdbnet.co.uk/jamie/ipam/pulls/54
2026-05-27 07:54:15 +01:00
jamie 675d477ff9 fix: 🐛 users page layout
Release / Build & Release (pull_request) Successful in 9s
Release / SonarQube (pull_request) Successful in 28s
2026-05-27 06:53:47 +00:00
jamie 34856060e8 refactor: 🎨 lock nav in place while content scrolls 2026-05-27 06:49:45 +00:00
jamie be55503e1c refactor: 🎨 remove status and alerting from dashboard 2026-05-27 06:48:26 +00:00
jamie b79763be53 fix: 🐛 searching for another device didn't work if already looking at a device 2026-05-27 06:47:14 +00:00
jamie e961afc36a feat: version number links to releases 2026-05-27 06:45:16 +00:00
jamie 616744015f Merge pull request 'refactor: 🎨 remove caching' (#48) from v2.0.0 into main
Reviewed-on: http://git.jdbnet.co.uk/jamie/ipam/pulls/48
2026-05-23 21:04:45 +01:00
jamie 87d7654606 docs: 📝 tidy docs
Release / Build & Release (pull_request) Successful in 5s
Release / SonarQube (pull_request) Successful in 27s
2026-05-23 20:03:47 +00:00
jamie 9e47cbee4e fix: 🐛 small ui fixes 2026-05-23 19:50:49 +00:00
jamie e16a667d60 feat: dashboard stats 2026-05-23 19:24:01 +00:00
jamie a8bcb9bd1c style: 🎨 subnet management layout 2026-05-23 18:56:24 +00:00
18 changed files with 938 additions and 368 deletions
+229
View File
@@ -0,0 +1,229 @@
## Configuration
### Environment Variables
- `MYSQL_HOST`: MySQL/MariaDB host (default: localhost)
- `MYSQL_USER`: Database user (default: user)
- `MYSQL_PASSWORD`: Database password (default: password)
- `MYSQL_DATABASE`: Database name (default: ipam)
- `SECRET_KEY`: Flask secret key for sessions (**REQUIRED in production!**)
- `NAME`: Organisation name displayed in header (default: JDB-NET)
- `LOGO_PNG`: URL or path to organisation logo (default: JDB-NET logo)
### Database Setup
The application automatically initializes the database schema on first run. Ensure the database and user exist with appropriate
permissions:
```sql
CREATE DATABASE ipam;
CREATE USER 'ipam'@'%' IDENTIFIED BY 'your_password';
GRANT ALL PRIVILEGES ON ipam.* TO 'ipam'@'%';
FLUSH PRIVILEGES;
```
### Upgrading from v1.x
See [v1-to-v2-breaking-changes.md](v1-to-v2-breaking-changes.md) for removed features, route changes, and automatic database migrations.
Back up your database before upgrading.
## Usage
### First Login
1. Access the web interface at `http://your-server:5000`
2. Log in with the default credentials:
- Email: `admin@example.com`
- Password: `password`
3. **Change the default password immediately** via the Users page
### Managing Subnets
1. Navigate to **Subnet Management** from the main menu (`/subnets/manage`)
2. Click **Add Subnet** and fill in:
- **Name**: Friendly name for the subnet (e.g., "Office LAN")
- **CIDR**: Subnet in CIDR notation (e.g., `192.168.1.0/24`)
- **Site**: Site/location identifier
3. The system automatically generates all IP addresses in the subnet
### Adding Devices
1. Navigate to "Devices" from the main menu
2. Click "Add Device"
3. Enter device name (and optional description)
4. Click "Create Device"
### Assigning IP Addresses to Devices
1. Open a device from the Devices page
2. Select a subnet and available IP address
3. Click "Assign IP" - the hostname is automatically updated
### Configuring DHCP Pools
1. Open a subnet from the dashboard or subnet list
2. Click **DHCP** to open the DHCP pool modal
3. Set the start and end IP addresses
4. Optionally specify excluded IPs (comma-separated)
5. IPs within the pool range are automatically marked as "DHCP"
### Managing Racks
1. Navigate to "Racks" from the main menu
2. Click "Add Rack" and specify:
- **Name**: Rack identifier
- **Site**: Site location
- **Height**: Rack height in U units
3. Open a rack to assign devices to specific U positions (front or back)
### Device Tagging
1. **Managing Tags** (requires `add_tag` / `edit_tag` / `delete_tag` permissions):
- Navigate to **Tags** from the main menu
- Create tags with custom colours and descriptions
- Edit or delete existing tags as permitted by your role
2. **Assigning Tags to Devices**:
- Open any device from the Devices page
- Use the tag assignment dropdown to add multiple tags
- Remove tags by clicking the × button next to the tag name
3. **Filtering by Tags**:
- Use the tag filter dropdown on the Devices page to view devices with specific tags
- Tags appear as colored badges throughout the interface for easy identification
### Audit Log
View changes in **Audit Log**. Filter by user name, action type, and date range. Export filtered results to CSV.
### Exporting Data
- **Subnet CSV**: Click "Export CSV" on any subnet page to download IP addresses with hostnames
- **Rack CSV**: Click "Export CSV" on any rack page to download rack layout information
### Role-Based Access Control
The system uses a granular role-based access control (RBAC) system to manage user permissions:
1. **Default Roles**:
- **Admin**: Full access to all features including user and role management
- **User**: Can view and manage most features (devices, subnets, racks, etc.) but cannot manage users or roles
- **View Only**: Read-only access to view pages but cannot make any changes
2. **Custom Roles**: Administrators can create custom roles with specific permission sets from the Users page
3. **Permission Granularity**: Permissions are organized into categories:
- View permissions (access to pages)
- Device Management (add, edit, delete devices)
- Network Management (subnet operations)
- Rack Management (rack operations)
- DHCP Configuration
- Administration (user and role management)
4. **User Management**: Navigate to the Users page to:
- Create and manage users
- Assign roles to users
- Create custom roles with specific permissions
- View and regenerate API keys
### REST API
Programmatic access uses **`/api/v2`**. The browser SPA uses session cookies; automation uses API keys via:
- `X-API-Key` header
- `Authorization: Bearer <api_key>` header
- `?api_key=<api_key>` query parameter
Each user has an API key (view/regenerate on the Users page). Keys respect the same RBAC permissions as the web UI.
Full endpoint reference: [API.md](API.md)
```bash
# List devices
curl -H "X-API-Key: YOUR_KEY" https://your-server:5000/api/v2/devices
# Session login (browser-style)
curl -c cookies.txt -X POST -H "Content-Type: application/json" \
-d '{"email":"admin@example.com","password":"password"}' \
https://your-server:5000/api/v2/auth/login
```
## Kubernetes Deployment
Example deployment manifest:
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: ipam
namespace: ipam
spec:
replicas: 1
template:
spec:
containers:
- name: ipam
image: cr.jdbnet.co.uk/public/ipam:latest
ports:
- containerPort: 5000
env:
- name: SECRET_KEY
valueFrom:
secretKeyRef:
name: ipam-secrets
key: secret-key
- name: MYSQL_HOST
value: "mysql-service"
- name: MYSQL_USER
value: "ipam"
- name: MYSQL_PASSWORD
valueFrom:
secretKeyRef:
name: ipam-secrets
key: mysql-password
- name: MYSQL_DATABASE
value: "ipam"
```
## Security Notes
- **CHANGE THE DEFAULT ADMIN PASSWORD** immediately after first login
- **CHANGE THE SECRET_KEY** in production - use a strong random string (e.g., `openssl rand -hex 32`)
- Use strong passwords for database access
- Ensure database connections are secured (consider SSL/TLS for MySQL connections)
- Review audit logs regularly for unauthorized changes
- Limit database user permissions if possible (though CREATE/ALTER may be needed for schema initialization)
- **API Keys**: Keep API keys secure and never share them publicly. Regenerate keys if they may have been compromised
- **Role-Based Access**: Use the RBAC system to grant users only the permissions they need (principle of least privilege)
- **HTTPS**: Use HTTPS in production to protect API keys and session data in transit
## Troubleshooting
### Database Connection Issues
- Ensure MySQL/MariaDB is running and accessible from the container
- Check database credentials in environment variables
- Verify database and user exist with proper permissions
- Check network connectivity between container and database
- Ensure the database name matches exactly (case-sensitive on some systems)
### Application Not Starting
- Check container logs: `docker logs ipam`
- Verify all required environment variables are set
- Ensure port 5000 is not already in use
- Check that MySQL/MariaDB is reachable
### Subnet or IP Not Appearing
- Verify CIDR notation is correct (supports /24 to /32)
- Check subnet was created successfully (Subnet Management page)
- Ensure you're logged in with appropriate permissions
- Check application logs for errors
### Device IP Assignment Issues
- Verify the IP address is available (not already assigned)
- Check that the IP is not in a DHCP pool range
- Ensure the device exists and is visible in the Devices list
+13 -277
View File
@@ -4,57 +4,25 @@
# IP Address Management # IP Address Management
</div> </div>
A Flask-based web application for comprehensive IP Address Management (IPAM). Manage subnets, IP addresses, devices, DHCP pools, and physical rack infrastructure with an intuitive web interface. A Flask-based web application for IP Address Management (IPAM). Manage subnets, IP addresses, devices, DHCP pools, and rack infrastructure through a Vue 3 web interface and a JSON REST API.
## Features ## Features
- **Subnet Management**: Create and manage IP subnets with CIDR notation (supports /24 to /32) - **Subnet management** - CIDR subnets with automatic IP generation
- **IP Address Tracking**: Automatic IP address generation and tracking for each subnet - **IP assignment** - Assign addresses to devices with hostname tracking
- **Device Management**: Track devices with 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 ![IPAM Dashboard](img/screenshot.png)
# Backend
pip install -r requirements.txt
./run.sh # builds frontend if needed, starts Flask on :5000
# Frontend hot reload (optional) ## Docker Compose
cd frontend && npm install && npm run dev
# Vite proxies /api to http://127.0.0.1:5000
```
API reference: [API.md](API.md)
## Quick Start with Docker
### Docker Run
```bash
docker run -d \
--name ipam \
-p 5000:5000 \
-e MYSQL_HOST=10.10.2.27 \
-e MYSQL_USER=ipam \
-e MYSQL_PASSWORD=your_password \
-e MYSQL_DATABASE=ipam \
-e SECRET_KEY=your_secret_key \
-e NAME="Your Organisation" \
-e LOGO_PNG="https://example.com/logo.png" \
cr.jdbnet.co.uk/public/ipam:latest
```
### Docker Compose
```yaml ```yaml
services: services:
@@ -73,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.
+90 -16
View File
@@ -37,11 +37,14 @@ app.config['MYSQL_PASSWORD'] = os.environ.get('MYSQL_PASSWORD', 'password')
app.config['MYSQL_DATABASE'] = os.environ.get('MYSQL_DATABASE', 'ipam') 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'] = ''
app.config['LOGO_PNG'] = os.environ.get('LOGO_PNG', 'https://assets.jdbnet.co.uk/logo/128x128.png') app.config['LOGO_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,
load_org_settings, save_org_settings, org_branding,
)
# ── TOTP / 2FA helpers ─────────────────────────────────────────────────────── # ── TOTP / 2FA helpers ───────────────────────────────────────────────────────
def generate_totp_secret(): def generate_totp_secret():
@@ -1260,17 +1263,18 @@ def api_auth_logout():
@app.route('/api/v2/auth/me', methods=['GET']) @app.route('/api/v2/auth/me', methods=['GET'])
def api_auth_me(): def api_auth_me():
branding = org_branding()
user = resolve_auth() user = resolve_auth()
if not user: if not user:
return jsonify({ return jsonify({
'logged_in': False, 'logged_in': False,
'app_version': app.config['VERSION'], 'app_version': app.config['VERSION'],
'org': {'name': app.config['NAME'], 'logo': app.config['LOGO_PNG']}, 'org': branding,
}) })
return jsonify({ return jsonify({
'logged_in': True, 'logged_in': True,
'app_version': app.config['VERSION'], 'app_version': app.config['VERSION'],
'org': {'name': app.config['NAME'], 'logo': app.config['LOGO_PNG']}, 'org': branding,
'user': {'id': user['id'], 'name': user['name'], 'email': user.get('email', '')}, 'user': {'id': user['id'], 'name': user['name'], 'email': user.get('email', '')},
'permissions': sorted(user.get('permissions') or []), 'permissions': sorted(user.get('permissions') or []),
}) })
@@ -2718,18 +2722,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'])
@@ -3009,6 +3052,36 @@ def api_delete_role(role_id):
return jsonify({'ok': True}) return jsonify({'ok': True})
@app.route('/api/v2/settings', methods=['GET'])
@require_permission('view_settings')
def api_get_settings():
return jsonify({
'org_name': app.config['NAME'],
'org_logo': app.config['LOGO_PNG'],
})
@app.route('/api/v2/settings', methods=['PUT'])
@require_permission('manage_settings')
def api_update_settings():
data = json_body()
name = (data.get('org_name') or '').strip()
logo = (data.get('org_logo') or '').strip()
save_org_settings(current_app, name, logo)
with get_db_connection(current_app) as conn:
add_audit_log(
get_current_user_id(),
'update_settings',
f"Updated organisation settings (name: {name or '(default)'})",
conn=conn,
)
return jsonify({
'org_name': name,
'org_logo': logo,
'org': org_branding(),
})
@app.route('/api/v2/permissions', methods=['GET']) @app.route('/api/v2/permissions', methods=['GET'])
@require_permission('manage_roles') @require_permission('manage_roles')
def api_permissions(): def api_permissions():
@@ -3127,7 +3200,7 @@ DIST = os.path.join(STATIC_ROOT, 'dist')
@app.route('/favicon.ico') @app.route('/favicon.ico')
def favicon(): def favicon():
logo = app.config['LOGO_PNG'] logo = org_branding()['logo']
if logo.startswith(('http://', 'https://')): if logo.startswith(('http://', 'https://')):
return redirect(logo) return redirect(logo)
path = logo if os.path.isabs(logo) else os.path.join(os.path.dirname(os.path.abspath(__file__)), logo) path = logo if os.path.isabs(logo) else os.path.join(os.path.dirname(os.path.abspath(__file__)), logo)
@@ -3161,6 +3234,7 @@ def spa(path):
# ── App startup ─────────────────────────────────────────────────────────────── # ── App startup ───────────────────────────────────────────────────────────────
init_db(app) init_db(app)
load_org_settings(app)
if __name__ == '__main__': if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True) app.run(host='0.0.0.0', port=5000, debug=True)
+86
View File
@@ -155,6 +155,13 @@ def init_db(app=None):
) )
''') ''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS Setting (
setting_key VARCHAR(255) PRIMARY KEY,
value TEXT
)
''')
# Add role_id column to User table if it doesn't exist # Add role_id column to User table if it doesn't exist
cursor.execute("SHOW COLUMNS FROM User LIKE 'role_id'") cursor.execute("SHOW COLUMNS FROM User LIKE 'role_id'")
if not cursor.fetchone(): if not cursor.fetchone():
@@ -363,6 +370,8 @@ def init_db(app=None):
# Admin permissions # Admin permissions
('manage_users', 'Manage users (add, edit, delete)', 'Admin'), ('manage_users', 'Manage users (add, edit, delete)', 'Admin'),
('manage_roles', 'Manage roles and permissions', 'Admin'), ('manage_roles', 'Manage roles and permissions', 'Admin'),
('view_settings', 'View Settings page', 'Admin'),
('manage_settings', 'Manage organisation settings', 'Admin'),
] ]
# Insert permissions # Insert permissions
@@ -596,3 +605,80 @@ def run_v2_migrations(cursor, conn):
logging.info("Removed orphaned permission: %s", perm_name) logging.info("Removed orphaned permission: %s", perm_name)
logging.info("v2 database migrations complete") logging.info("v2 database migrations complete")
DEFAULT_ORG_NAME = 'JDB-NET'
DEFAULT_ORG_LOGO = 'https://assets.jdbnet.co.uk/projects/ipam.png'
ORG_NAME_KEY = 'org_name'
ORG_LOGO_KEY = 'org_logo'
def get_setting(cursor, key):
cursor.execute('SELECT value FROM Setting WHERE setting_key = %s', (key,))
row = cursor.fetchone()
if not row or row[0] is None:
return ''
return row[0]
def set_setting(cursor, key, value):
cursor.execute(
'INSERT INTO Setting (setting_key, value) VALUES (%s, %s) '
'ON DUPLICATE KEY UPDATE value = %s',
(key, value, value),
)
def load_org_settings(app):
"""Load org name/logo from DB; migrate from env vars when DB values are blank."""
env_name = (os.environ.get('NAME') or '').strip()
env_logo = (os.environ.get('LOGO_PNG') or '').strip()
conn = get_db_connection(app)
cursor = conn.cursor()
try:
name = get_setting(cursor, ORG_NAME_KEY).strip()
logo = get_setting(cursor, ORG_LOGO_KEY).strip()
if not name and env_name:
name = env_name
set_setting(cursor, ORG_NAME_KEY, name)
logging.info("Migrated organisation name from NAME env var to database")
if not logo and env_logo:
logo = env_logo
set_setting(cursor, ORG_LOGO_KEY, logo)
logging.info("Migrated organisation logo from LOGO_PNG env var to database")
app.config['NAME'] = name
app.config['LOGO_PNG'] = logo
conn.commit()
finally:
cursor.close()
conn.close()
def save_org_settings(app, name, logo):
conn = get_db_connection(app)
cursor = conn.cursor()
try:
set_setting(cursor, ORG_NAME_KEY, name)
set_setting(cursor, ORG_LOGO_KEY, logo)
conn.commit()
finally:
cursor.close()
conn.close()
app.config['NAME'] = name
app.config['LOGO_PNG'] = logo
def org_branding(app=None):
"""Return stored org branding with defaults applied for display."""
if app is None:
app = current_app
name = (app.config.get('NAME') or '').strip()
logo = (app.config.get('LOGO_PNG') or '').strip()
return {
'name': name or DEFAULT_ORG_NAME,
'logo': logo or DEFAULT_ORG_LOGO,
}
+30 -1
View File
@@ -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)}`));
@@ -378,6 +399,14 @@ export const api = {
); );
return d.items; return d.items;
}, },
async settings() {
return handle<{ org_name: string; org_logo: string }>(await fetchApi("/api/v2/settings"));
},
async updateSettings(body: { org_name: string; org_logo: string }) {
return handle<{ org_name: string; org_logo: string; org?: { name: string; logo: string } }>(
await fetchApi("/api/v2/settings", { method: "PUT", headers: jsonHeaders, body: JSON.stringify(body) }),
);
},
async customFields(entityType: string) { async customFields(entityType: string) {
const d = await handle<{ items: CustomFieldDef[] }>(await fetchApi(`/api/v2/custom_fields/${entityType}`)); const d = await handle<{ items: CustomFieldDef[] }>(await fetchApi(`/api/v2/custom_fields/${entityType}`));
return d.items; return d.items;
+14 -12
View File
@@ -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, SlidersHorizontal, 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,12 +18,14 @@ 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" },
{ to: "/audit", label: "Audit", icon: FileText, perm: "view_audit" }, { to: "/audit", label: "Audit", icon: FileText, perm: "view_audit" },
{ to: "/subnets/manage", label: "Subnet Management", icon: Settings, perm: "view_admin" }, { to: "/subnets/manage", label: "Subnet Management", icon: Settings, perm: "view_admin" },
{ to: "/users", label: "Users", icon: Users, perm: "view_users" }, { to: "/users", label: "Users", icon: Users, perm: "view_users" },
{ to: "/settings", label: "Settings", icon: SlidersHorizontal, perm: "view_settings" },
{ to: "/custom-fields", label: "Fields", icon: Layers, perm: "view_custom_fields" }, { to: "/custom-fields", label: "Fields", icon: Layers, perm: "view_custom_fields" },
{ to: "/account", label: "Account", icon: User, perm: null }, { to: "/account", label: "Account", icon: User, perm: null },
].filter((n) => !n.perm || auth.can(n.perm)), ].filter((n) => !n.perm || auth.can(n.perm)),
@@ -89,30 +91,30 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<div class="flex min-h-screen bg-surface font-sans"> <div class="flex h-screen overflow-hidden bg-surface font-sans">
<!-- Mobile overlay --> <!-- Mobile overlay -->
<div v-if="sidebarOpen" class="fixed inset-0 z-40 bg-black/50 lg:hidden" @click="sidebarOpen = false" /> <div v-if="sidebarOpen" class="fixed inset-0 z-40 bg-black/50 lg:hidden" @click="sidebarOpen = false" />
<!-- Sidebar --> <!-- Sidebar -->
<aside <aside
class="fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-slate-200 bg-surface-raised transition-transform dark:border-slate-800 lg:static lg:translate-x-0" class="fixed inset-y-0 left-0 z-50 flex h-full w-64 shrink-0 flex-col border-r border-slate-200 bg-surface-raised transition-transform dark:border-slate-800 lg:static lg:h-screen lg:translate-x-0"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'" :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
> >
<div class="flex items-center gap-3 border-b border-slate-200 p-4 dark:border-slate-800"> <div class="flex h-14 shrink-0 items-center gap-2.5 border-b border-slate-200 px-4 dark:border-slate-800">
<img v-if="auth.org.logo" :src="auth.org.logo" alt="" class="h-8 rounded" /> <img v-if="auth.org.logo" :src="auth.org.logo" alt="" class="h-7 shrink-0 rounded" />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1 leading-tight">
<div class="truncate text-sm font-semibold">{{ auth.org.name }} IPAM</div> <div class="truncate text-sm font-semibold">{{ auth.org.name }} IPAM</div>
<div class="text-xs text-slate-500">{{ auth.version }}</div> <div class="text-xs text-slate-500">{{ auth.version }}</div>
</div> </div>
<button class="lg:hidden" @click="sidebarOpen = false"><X class="h-5 w-5" /></button> <button class="lg:hidden" @click="sidebarOpen = false"><X class="h-5 w-5" /></button>
</div> </div>
<nav class="flex-1 overflow-y-auto p-2"> <nav class="min-h-0 flex-1 overflow-y-auto p-2">
<RouterLink <RouterLink
v-for="item in nav" v-for="item in nav"
:key="item.to" :key="item.to"
:to="item.to" :to="item.to"
class="mb-0.5 flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition" class="mb-0.5 flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition"
:class="route.path === item.to || route.path.startsWith(item.to + '/') :class="(item.match ? item.match(route.path) : route.path === item.to || route.path.startsWith(item.to + '/'))
? 'bg-accent/15 text-accent font-medium' ? 'bg-accent/15 text-accent font-medium'
: 'text-slate-600 hover:bg-surface-overlay dark:text-slate-400'" : 'text-slate-600 hover:bg-surface-overlay dark:text-slate-400'"
@click="sidebarOpen = false" @click="sidebarOpen = false"
@@ -121,15 +123,15 @@ onUnmounted(() => {
{{ item.label }} {{ item.label }}
</RouterLink> </RouterLink>
</nav> </nav>
<div class="border-t border-slate-200 p-3 dark:border-slate-800"> <div class="shrink-0 border-t border-slate-200 p-3 dark:border-slate-800">
<div class="truncate text-xs text-slate-500">{{ auth.user?.name }}</div> <div class="truncate text-xs text-slate-500">{{ auth.user?.name }}</div>
<button class="mt-2 text-xs text-accent hover:underline" @click="logout">Sign out</button> <button class="mt-2 text-xs text-accent hover:underline" @click="logout">Sign out</button>
</div> </div>
</aside> </aside>
<!-- Main --> <!-- Main -->
<div class="flex min-w-0 flex-1 flex-col"> <div class="flex min-h-0 min-w-0 flex-1 flex-col">
<header class="flex items-center gap-3 border-b border-slate-200 bg-surface-raised px-4 py-3 dark:border-slate-800"> <header class="flex h-14 shrink-0 items-center gap-3 border-b border-slate-200 bg-surface-raised px-4 dark:border-slate-800">
<button class="lg:hidden" @click="sidebarOpen = true"><Menu class="h-6 w-6" /></button> <button class="lg:hidden" @click="sidebarOpen = true"><Menu class="h-6 w-6" /></button>
<span class="font-semibold lg:hidden">{{ auth.org.name }} IPAM</span> <span class="font-semibold lg:hidden">{{ auth.org.name }} IPAM</span>
<button <button
@@ -140,7 +142,7 @@ onUnmounted(() => {
<Search class="h-5 w-5" /> <Search class="h-5 w-5" />
</button> </button>
</header> </header>
<main class="flex-1 overflow-auto p-4 md:p-6"> <main class="min-h-0 flex-1 overflow-auto p-4 md:p-6">
<RouterView /> <RouterView />
</main> </main>
</div> </div>
+2
View File
@@ -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") },
@@ -27,6 +28,7 @@ const router = createRouter({
{ path: "subnets/manage", name: "subnet-management", component: () => import("@/views/SubnetsView.vue") }, { path: "subnets/manage", name: "subnet-management", component: () => import("@/views/SubnetsView.vue") },
{ path: "admin", redirect: "/subnets/manage" }, { path: "admin", redirect: "/subnets/manage" },
{ path: "users", name: "users", component: () => import("@/views/UsersView.vue") }, { path: "users", name: "users", component: () => import("@/views/UsersView.vue") },
{ path: "settings", name: "settings", component: () => import("@/views/SettingsView.vue") },
{ path: "account", name: "account", component: () => import("@/views/AccountView.vue") }, { path: "account", name: "account", component: () => import("@/views/AccountView.vue") },
], ],
}, },
+179 -33
View File
@@ -1,52 +1,198 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from "vue"; import { ref, onMounted, computed } from "vue";
import { RouterLink } from "vue-router"; import { RouterLink } from "vue-router";
import { api, type Subnet } from "@/api"; import { Network, Wifi, Layers, Server } from "lucide-vue-next";
import { api } from "@/api";
interface DashboardStats {
total_ips: number;
used_ips: number;
available_ips: number;
utilization_percent: number;
subnet_count: number;
device_count: number;
}
interface SubnetOverviewRow {
id: number;
name: string;
cidr: string;
site: string;
vlan_id?: number;
utilization: number;
available: number;
}
interface ActivityPoint {
hour: number;
count: number;
}
const sites = ref<Record<string, Subnet[]>>({});
const loading = ref(true); const loading = ref(true);
const error = ref("");
const stats = ref<DashboardStats | null>(null);
const subnetOverview = ref<SubnetOverviewRow[]>([]);
const activity = ref<ActivityPoint[]>([]);
const donutStyle = computed(() => {
const pct = stats.value?.utilization_percent ?? 0;
return { background: `conic-gradient(rgb(6 182 212) ${pct}%, rgb(var(--surface-overlay)) ${pct}%)` };
});
const maxActivity = computed(() => Math.max(1, ...activity.value.map((a) => a.count)));
onMounted(async () => { onMounted(async () => {
try { try {
const d = await api.dashboard(); const d = await api.dashboard();
sites.value = d.sites; stats.value = d.stats;
subnetOverview.value = d.subnet_overview;
activity.value = d.activity;
} catch (e) {
error.value = e instanceof Error ? e.message : "Failed to load dashboard";
} finally { } finally {
loading.value = false; loading.value = false;
} }
}); });
function formatHour(h: number) {
if (h === 0) return "12 AM";
if (h === 12) return "12 PM";
return h < 12 ? `${h} AM` : `${h - 12} PM`;
}
</script> </script>
<template> <template>
<div> <div>
<h1 class="text-2xl font-bold">Dashboard</h1> <h1 class="text-2xl font-bold">Dashboard</h1>
<p class="mt-1 text-slate-500">Subnets grouped by site</p> <p class="mt-1 text-slate-500">Network overview</p>
<div v-if="loading" class="mt-8 text-slate-500">Loading</div>
<div v-else class="mt-6 space-y-8"> <p v-if="loading" class="mt-8 text-slate-500">Loading</p>
<section v-for="(subnets, site) in sites" :key="site"> <p v-else-if="error" class="mt-8 text-red-500">{{ error }}</p>
<h2 class="mb-3 text-lg font-semibold text-accent">{{ site }}</h2>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <template v-else-if="stats">
<RouterLink <div class="mt-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
v-for="s in subnets" <div class="card flex items-start gap-4">
: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</div>
</div>
</div>
<div class="card flex items-start gap-4">
<div class="rounded-lg bg-surface-overlay p-3 text-slate-500"><Server class="h-6 w-6" /></div>
<div>
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">Devices</div>
<div class="mt-1 text-2xl font-bold">{{ stats.device_count.toLocaleString() }}</div>
<div class="text-sm text-slate-500">Managed</div>
</div>
</div>
</div>
<div class="mt-6 grid gap-4 lg:grid-cols-2">
<div class="card">
<h2 class="font-semibold">IPv4 usage distribution</h2>
<div class="mt-6 flex flex-col items-center gap-6 sm:flex-row sm:justify-center">
<div class="relative h-44 w-44 shrink-0 rounded-full" :style="donutStyle">
<div class="absolute inset-5 flex flex-col items-center justify-center rounded-full bg-surface-raised text-center">
<span class="text-2xl font-bold">{{ stats.total_ips.toLocaleString() }}</span>
<span class="text-xs uppercase tracking-wide text-slate-500">Total</span>
</div>
</div>
<div class="space-y-3 text-sm">
<div class="flex items-center gap-2">
<span class="h-3 w-3 rounded-full bg-accent" />
<span>{{ stats.utilization_percent }}% Used ({{ stats.used_ips.toLocaleString() }})</span>
</div>
<div class="flex items-center gap-2">
<span class="h-3 w-3 rounded-full bg-surface-overlay ring-1 ring-slate-300 dark:ring-slate-600" />
<span>{{ 100 - stats.utilization_percent }}% Free ({{ stats.available_ips.toLocaleString() }})</span>
</div>
</div>
</div>
</div>
<div class="card">
<h2 class="font-semibold">Activity last 24 hours</h2>
<p class="mt-1 text-xs text-slate-500">Audit log entries by hour</p>
<div class="mt-4 flex h-40 items-end gap-0.5">
<div
v-for="point in activity"
:key="point.hour"
class="flex-1 rounded-t bg-accent/80 transition-all hover:bg-accent"
:style="{ height: `${Math.max(4, (point.count / maxActivity) * 100)}%` }"
:title="`${formatHour(point.hour)}: ${point.count}`"
/>
</div>
<div class="mt-2 flex justify-between text-[10px] text-slate-500">
<span>12 AM</span>
<span>6 AM</span>
<span>12 PM</span>
<span>6 PM</span>
</div>
</div>
</div>
<div class="card mt-6 overflow-x-auto">
<div class="mb-4 flex items-center justify-between gap-3">
<h2 class="font-semibold">Subnet overview</h2>
<RouterLink to="/subnets" class="text-sm text-accent hover:underline">View all subnets</RouterLink>
</div>
<table class="w-full min-w-[640px] text-left text-sm">
<thead>
<tr class="border-b border-slate-200 text-xs font-medium uppercase tracking-wide text-slate-500 dark:border-slate-700">
<th class="p-2">Subnet</th>
<th class="p-2">Name</th>
<th class="p-2">Utilised</th>
<th class="p-2">Available</th>
<th class="p-2">Site</th>
</tr>
</thead>
<tbody>
<tr
v-for="s in subnetOverview"
:key="s.id"
class="border-b border-slate-100 dark:border-slate-800"
>
<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 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>
</tr>
<tr v-if="!subnetOverview.length">
<td colspan="5" class="p-4 text-center text-slate-500">No subnets configured.</td>
</tr>
</tbody>
</table>
</div>
</template>
</div> </div>
</template> </template>
+10 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from "vue"; import { ref, computed, watch } from "vue";
import { useRoute, useRouter, RouterLink } from "vue-router"; import { useRoute, useRouter, RouterLink } from "vue-router";
import { api, type Device, type Tag, type Subnet } from "@/api"; import { api, type Device, type Tag, type Subnet } from "@/api";
import type { IpHistoryEntry } from "@/components/IpHistoryModal.vue"; import type { IpHistoryEntry } from "@/components/IpHistoryModal.vue";
@@ -70,7 +70,15 @@ async function loadDevice() {
} }
} }
onMounted(loadDevice); watch(
() => route.params.id,
() => {
showAssignIp.value = false;
err.value = "";
loadDevice();
},
{ immediate: true },
);
async function loadAvailableIps(subnetId: number) { async function loadAvailableIps(subnetId: number) {
if (!subnetId) { if (!subnetId) {
+15 -15
View File
@@ -7,7 +7,6 @@ import { useAuthStore } from "@/stores/auth";
const route = useRoute(); const route = useRoute();
const auth = useAuthStore(); const auth = useAuthStore();
const rack = ref<Rack | null>(null); const rack = ref<Rack | null>(null);
const side = ref("front");
const showAddDevice = ref(false); const showAddDevice = ref(false);
const showAddNonnet = ref(false); const showAddNonnet = ref(false);
const addForm = ref({ device_id: 0, position_u: 1, side: "front" }); const addForm = ref({ device_id: 0, position_u: 1, side: "front" });
@@ -24,11 +23,11 @@ onMounted(load);
const siteDevices = () => rack.value?.site_devices || []; const siteDevices = () => rack.value?.site_devices || [];
const slots = (r: Rack) => { const slotsForSide = (r: Rack, rackSide: string) => {
const h = r.height_u; const h = r.height_u;
const map: Record<number, typeof r.devices> = {}; const map: Record<number, typeof r.devices> = {};
for (const d of r.devices || []) { for (const d of r.devices || []) {
if (d.side === side.value) (map[d.position_u] ??= []).push(d); if (d.side === rackSide) (map[d.position_u] ??= []).push(d);
} }
return Array.from({ length: h }, (_, i) => ({ u: h - i, devices: map[h - i] || [] })); return Array.from({ length: h }, (_, i) => ({ u: h - i, devices: map[h - i] || [] }));
}; };
@@ -81,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>
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { api } from "@/api";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const form = ref({ org_name: "", org_logo: "" });
const msg = ref("");
const err = ref("");
const busy = ref(false);
async function load() {
const data = await api.settings();
form.value = { org_name: data.org_name, org_logo: data.org_logo };
}
onMounted(load);
async function save() {
err.value = "";
msg.value = "";
busy.value = true;
try {
const data = await api.updateSettings(form.value);
form.value = { org_name: data.org_name, org_logo: data.org_logo };
if (data.org) auth.org = data.org;
else await auth.fetchMe();
msg.value = "Settings saved";
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
} finally {
busy.value = false;
}
}
</script>
<template>
<div>
<h1 class="text-2xl font-bold">Settings</h1>
<p class="mt-1 text-sm text-slate-500">Configure organisation branding shown in the header and browser tab.</p>
<form class="card mt-6 max-w-lg space-y-4" @submit.prevent="save">
<div>
<label class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Organisation name</label>
<input v-model="form.org_name" class="input-field" placeholder="Your Organisation" />
<p class="mt-1 text-xs text-slate-500">Shown as {{ form.org_name || "Organisation" }} IPAM in the sidebar.</p>
</div>
<div>
<label class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Logo URL</label>
<input v-model="form.org_logo" class="input-field font-mono text-sm" placeholder="https://example.com/logo.png" />
<p class="mt-1 text-xs text-slate-500">URL or path to a PNG logo. Also used as the favicon.</p>
</div>
<div v-if="form.org_logo" class="rounded-lg border border-slate-200 p-4 dark:border-slate-700">
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-slate-500">Preview</p>
<img :src="form.org_logo" alt="" class="h-10 rounded" @error="($event.target as HTMLImageElement).style.display = 'none'" />
</div>
<div v-if="auth.can('manage_settings')" class="flex flex-wrap items-center gap-3">
<button type="submit" class="btn-primary" :disabled="busy">{{ busy ? "Saving…" : "Save" }}</button>
<p v-if="msg" class="text-sm text-accent">{{ msg }}</p>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
</div>
<p v-else class="text-sm text-slate-500">You can view settings but do not have permission to change them.</p>
</form>
</div>
</template>
+1 -1
View File
@@ -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">
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { RouterLink } from "vue-router";
import { api, type Subnet } from "@/api";
const subnets = ref<Subnet[]>([]);
const loading = ref(true);
const error = ref("");
const bySite = computed(() => {
const m: Record<string, Subnet[]> = {};
for (const s of subnets.value) {
const site = s.site || "Unassigned";
if (!m[site]) m[site] = [];
m[site].push(s);
}
return m;
});
const siteOrder = computed(() =>
Object.keys(bySite.value).sort((a, b) => {
if (a === "Unassigned") return -1;
if (b === "Unassigned") return 1;
return a.localeCompare(b);
}),
);
onMounted(async () => {
try {
subnets.value = await api.subnets(true);
} catch (e) {
error.value = e instanceof Error ? e.message : "Failed to load subnets";
} finally {
loading.value = false;
}
});
</script>
<template>
<div>
<h1 class="text-2xl font-bold">Subnets</h1>
<p class="mt-1 text-slate-500">Browse subnets grouped by site</p>
<p v-if="loading" class="mt-8 text-slate-500">Loading</p>
<p v-else-if="error" class="mt-8 text-red-500">{{ error }}</p>
<p v-else-if="!subnets.length" class="mt-8 text-slate-500">No subnets yet.</p>
<div v-else class="mt-6 space-y-8">
<section v-for="site in siteOrder" :key="site">
<h2 class="mb-3 text-lg font-semibold text-accent">{{ site }}</h2>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<RouterLink
v-for="s in bySite[site]"
:key="s.id"
:to="`/subnets/${s.id}`"
class="card block transition hover:border-accent/50"
>
<div class="font-medium">{{ s.name }}</div>
<div class="mt-1 flex flex-wrap items-center gap-2">
<span class="font-mono text-sm text-slate-500">{{ s.cidr }}</span>
<span
v-if="s.vlan_id"
class="rounded-full bg-surface-overlay px-2 py-0.5 text-xs font-semibold text-slate-600 dark:text-slate-300"
>VLAN {{ s.vlan_id }}</span>
</div>
<div class="mt-3">
<div class="h-2 overflow-hidden rounded-full bg-surface-overlay">
<div
class="h-full rounded-full transition-all"
:class="(s.utilization ?? 0) >= 90 ? 'bg-red-500' : 'bg-accent'"
:style="{ width: `${s.utilization ?? 0}%` }"
/>
</div>
<div class="mt-1 text-xs text-slate-500">{{ s.utilization ?? 0 }}% used</div>
</div>
</RouterLink>
</div>
</section>
</div>
</div>
</template>
+18 -3
View File
@@ -89,11 +89,26 @@ async function del(id: number) {
<p v-if="err" class="text-sm text-red-500 sm:col-span-3">{{ err }}</p> <p v-if="err" class="text-sm text-red-500 sm:col-span-3">{{ err }}</p>
</form> </form>
<ul class="mt-8 space-y-2"> <ul class="mt-8 space-y-2">
<li v-for="s in subnets" :key="s.id" class="card flex flex-wrap items-center justify-between gap-2"> <li
class="hidden px-4 text-xs font-medium text-slate-500 sm:grid sm:grid-cols-[minmax(0,1.5fr)_minmax(9rem,1fr)_5.5rem_auto] sm:items-center sm:gap-4"
>
<span>Name</span>
<span>CIDR</span>
<span>VLAN</span>
<span class="text-right">Actions</span>
</li>
<li
v-for="s in subnets"
:key="s.id"
class="card grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1.5fr)_minmax(9rem,1fr)_5.5rem_auto] sm:items-center sm:gap-4"
>
<RouterLink :to="`/subnets/${s.id}`" class="font-medium text-accent hover:underline">{{ s.name }}</RouterLink> <RouterLink :to="`/subnets/${s.id}`" class="font-medium text-accent hover:underline">{{ s.name }}</RouterLink>
<span class="font-mono text-sm text-slate-500">{{ s.cidr }}</span> <span class="font-mono text-sm text-slate-500">{{ s.cidr }}</span>
<span v-if="s.vlan_id" class="rounded-full bg-surface-overlay px-2 py-0.5 text-xs">VLAN {{ s.vlan_id }}</span> <span class="text-xs">
<div class="flex gap-2"> <span v-if="s.vlan_id" class="inline-block rounded-full bg-surface-overlay px-2 py-0.5">VLAN {{ s.vlan_id }}</span>
<span v-else class="text-slate-500"></span>
</span>
<div class="flex gap-2 sm:justify-end">
<button v-if="auth.can('edit_subnet')" class="text-sm text-accent hover:underline" @click="openEdit(s)">Edit</button> <button v-if="auth.can('edit_subnet')" class="text-sm text-accent hover:underline" @click="openEdit(s)">Edit</button>
<button v-if="auth.can('delete_subnet')" class="text-sm text-red-500" @click="del(s.id)">Delete</button> <button v-if="auth.can('delete_subnet')" class="text-sm text-red-500" @click="del(s.id)">Delete</button>
</div> </div>
+14 -3
View File
@@ -140,10 +140,21 @@ async function delRole(id: number) {
<button v-if="auth.can('manage_users')" class="btn-primary text-sm" @click="openAddUser">Add user</button> <button v-if="auth.can('manage_users')" class="btn-primary text-sm" @click="openAddUser">Add user</button>
</div> </div>
<ul class="space-y-2"> <ul class="space-y-2">
<li v-for="u in users" :key="u.id" class="card flex flex-wrap items-center justify-between gap-2"> <li
<span>{{ u.name }} <span class="text-slate-500">&lt;{{ u.email }}&gt;</span></span> class="hidden px-4 text-xs font-medium text-slate-500 sm:grid sm:grid-cols-[minmax(0,1fr)_8rem_13rem] sm:items-center sm:gap-4"
>
<span>User</span>
<span>Role</span>
<span v-if="auth.can('manage_users')" class="text-right">Actions</span>
</li>
<li
v-for="u in users"
:key="u.id"
class="card grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_8rem_13rem] sm:items-center sm:gap-4"
>
<span class="min-w-0">{{ u.name }} <span class="text-slate-500">&lt;{{ u.email }}&gt;</span></span>
<span class="text-sm text-slate-500">{{ u.role_name }}</span> <span class="text-sm text-slate-500">{{ u.role_name }}</span>
<div v-if="auth.can('manage_users')" class="flex gap-2"> <div v-if="auth.can('manage_users')" class="flex gap-2 sm:justify-end">
<button class="text-sm text-accent hover:underline" @click="openEditUser(u)">Edit</button> <button class="text-sm text-accent hover:underline" @click="openEditUser(u)">Edit</button>
<button class="text-sm text-accent hover:underline" @click="regenKey(u.id)">API key</button> <button class="text-sm text-accent hover:underline" @click="regenKey(u.id)">API key</button>
<button class="text-sm text-red-500 hover:underline" @click="delUser(u.id)">Delete</button> <button class="text-sm text-red-500 hover:underline" @click="delUser(u.id)">Delete</button>
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Reset a user's password (admin CLI). Uses MYSQL_* env vars from .env."""
import argparse
import getpass
import os
import secrets
import sys
from dotenv import load_dotenv
from flask import Flask
os.chdir(os.path.dirname(os.path.abspath(__file__)))
load_dotenv()
app = Flask(__name__)
app.config['MYSQL_HOST'] = os.environ.get('MYSQL_HOST', 'localhost')
app.config['MYSQL_USER'] = os.environ.get('MYSQL_USER', 'user')
app.config['MYSQL_PASSWORD'] = os.environ.get('MYSQL_PASSWORD', 'password')
app.config['MYSQL_DATABASE'] = os.environ.get('MYSQL_DATABASE', 'ipam')
from db import get_db_connection, hash_password
def reset_password(email, password):
email = email.strip()
if not email:
raise SystemExit('Email is required.')
conn = get_db_connection(app)
try:
cursor = conn.cursor()
cursor.execute('SELECT id, name FROM User WHERE email = %s', (email,))
row = cursor.fetchone()
if not row:
raise SystemExit(f'No user found with email: {email}')
user_id, name = row
cursor.execute(
'UPDATE User SET password = %s WHERE id = %s',
(hash_password(password), user_id),
)
finally:
conn.close()
return name
def main():
parser = argparse.ArgumentParser(
description='Reset an IPAM user password.',
)
parser.add_argument('email', help='User email address')
parser.add_argument(
'--password', '-p',
help='New password (prompted securely if omitted)',
)
parser.add_argument(
'--generate', '-g',
action='store_true',
help='Generate a random password and print it',
)
args = parser.parse_args()
if args.generate and args.password:
raise SystemExit('Use either --password or --generate, not both.')
if args.generate:
password = secrets.token_urlsafe(16)
elif args.password:
password = args.password
else:
password = getpass.getpass('New password: ')
confirm = getpass.getpass('Confirm password: ')
if password != confirm:
raise SystemExit('Passwords do not match.')
if not password:
raise SystemExit('Password cannot be empty.')
name = reset_password(args.email, password)
print(f'Password reset for {name} ({args.email}).')
if args.generate:
print(f'Generated password: {password}')
if __name__ == '__main__':
main()
+2 -4
View File
@@ -1,8 +1,6 @@
#!/bin/bash #!/bin/bash
set -e set -e
if [ ! -f static/dist/index.html ]; then echo "Building frontend..."
echo "Building frontend..." (cd frontend && npm ci && npm run build)
(cd frontend && npm ci && npm run build)
fi
echo "Starting app..." echo "Starting app..."
python app.py python app.py