45 Commits

Author SHA1 Message Date
jamie 1346e9e5f5 docs: 📝 correct url 2026-05-30 21:43:29 +01:00
jamie af4f16aa59 docs: 📝 update readme 2026-05-30 21:42:28 +01:00
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
jamie 71d0b7fed6 refactor: 🎨 tidied a few bits up 2026-05-23 18:33:27 +00:00
jamie 39a8f4a49b refactor: 🎨 use a modal for dhcp config 2026-05-23 18:12:34 +00:00
jamie 31e417b9f5 feat: convert to api and rewrite ui 2026-05-23 18:05:51 +00:00
jamie e1dd5d1003 refactor: 🎨 more documented changes 2026-05-23 16:49:26 +00:00
jamie f01a81e558 refactor: 🎨 strip legacy features 2026-05-23 16:40:25 +00:00
jamie d334dae3d6 refactor: 🎨 remove /help route 2026-05-23 16:31:36 +00:00
jamie 22e17a8aec refactor: 🎨 remove /backup route 2026-05-23 16:30:49 +00:00
jamie 70d959f53f refactor: 🎨 move api docs out of app and into md 2026-05-23 16:27:45 +00:00
jamie dddfa347e6 refactor: 🎨 use shared helpers 2026-05-23 16:24:34 +00:00
jamie bd5f2e7e32 refactor: 🎨 consolidate to a single file 2026-05-23 16:16:51 +00:00
jamie c5406e2c7c refactor: 🎨 remove caching 2026-05-23 16:07:09 +00:00
jamie c8c483ae95 Merge pull request 'v1.9.9' (#45) from v1.9.9 into main
Reviewed-on: #45
2026-04-29 22:59:57 +01:00
jamie fd2b561308 refactor: 🎨 make whole subnet card clickable
Release / Build & Release (pull_request) Successful in 35s
Release / SonarQube (pull_request) Successful in 36s
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 21:57:54 +00:00
jamie 3e5ee0800e feat: display vlan id on main page
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 21:55:50 +00:00
jamie 5850898d5b ci: 🚀 add sonarqube 2026-04-29 21:50:10 +00:00
jamie ae28d3fb26 Merge pull request 'fix: 🐛 devices with same name return incorrect id' (#43) from v1.9.8 into main
Reviewed-on: #43
2026-04-07 11:26:38 +01:00
jamie 4d6a95e2b0 fix: 🐛 devices with same name return incorrect id
Release / release (pull_request) Successful in 28s
2026-04-07 10:26:26 +00:00
jamie d1f0e38374 Merge pull request 'feat: search modal' (#41) from v1.9.7 into main
Reviewed-on: #41
2026-02-19 20:25:29 +00:00
jamie 84d024f4c6 feat: search modal
Release / release (pull_request) Successful in 29s
2026-02-19 20:25:16 +00:00
jamie 1fa28590b4 Merge pull request 'fix: 🐛 nav bar items overlap with search bar' (#40) from v1.9.6 into main
Reviewed-on: #40
2026-02-19 19:35:01 +00:00
jamie 30a3ea66d5 fix: 🐛 nav bar items overlap with search bar
Release / release (pull_request) Successful in 29s
Release / Deploy to Kubernetes (pull_request) Has been cancelled
2026-02-19 19:34:43 +00:00
jamie 6f2cfad65f Merge pull request 'v1.9.5' (#38) from v1.9.5 into main
Reviewed-on: #38
2026-01-08 16:24:32 +00:00
jamie 2621d233f9 fix: 🐛 update version display logic to omit 'v' prefix for dev versions
Release / release (pull_request) Successful in 32s
Release / Deploy to Kubernetes (pull_request) Successful in 2s
2026-01-08 16:24:05 +00:00
jamie af4997df5a fix: 🐛 remove leading 'v' from version display in header template 2026-01-08 16:18:26 +00:00
jamie 1980fd04ba Merge pull request 'fix: 🐛 update workflow trigger to workflow_dispatch in dev.yml' (#37) from v1.9.4 into main
Reviewed-on: #37
2026-01-08 16:01:57 +00:00
jamie d06d0c76c2 fix: 🐛 update workflow trigger to workflow_dispatch in dev.yml
Release / release (pull_request) Successful in 32s
Release / Deploy to Kubernetes (pull_request) Successful in 1s
2026-01-08 16:00:55 +00:00
jamie 9244328da8 Merge pull request 'v1.9.3' (#36) from v1.9.3 into main
Dev / build (push) Has been cancelled
Dev / Deploy to Kubernetes (push) Has been cancelled
Reviewed-on: #36
2026-01-08 15:59:14 +00:00
jamie 70489c3dac fix: 🐛 update container image reference in Docker configurations
Dev / build (push) Successful in 2s
Dev / Deploy to Kubernetes (push) Successful in 1s
Release / release (pull_request) Successful in 32s
Release / Deploy to Kubernetes (pull_request) Successful in 1s
2026-01-08 15:57:58 +00:00
jamie 2a3ee1c8af fix: 🐛 update deployment configurations for dev and prod environments
Dev / build (push) Successful in 29s
Dev / Deploy to Kubernetes (push) Successful in 1s
2026-01-08 15:55:54 +00:00
134 changed files with 10395 additions and 13227 deletions
+1 -3
View File
@@ -1,7 +1,5 @@
FROM mcr.microsoft.com/devcontainers/python:3.13 FROM mcr.microsoft.com/devcontainers/python:3.14
# Set the working directory
WORKDIR /workspace WORKDIR /workspace
# Default command
CMD ["sleep", "infinity"] CMD ["sleep", "infinity"]
+1 -1
View File
@@ -13,7 +13,7 @@
] ]
} }
}, },
"postCreateCommand": "pip install -r requirements.txt; curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64; chmod +x tailwindcss-linux-x64; mv tailwindcss-linux-x64 tailwindcss", "postCreateCommand": "pip install -r requirements.txt; curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -; sudo apt-get install -y nodejs",
"forwardPorts": [5000], "forwardPorts": [5000],
"remoteUser": "vscode" "remoteUser": "vscode"
} }
+5 -13
View File
@@ -1,10 +1,10 @@
# Frontend dev
frontend/node_modules/
# Documentation # Documentation
README.md
CHANGELOG.md
*.md *.md
# Deployment files # Deployment files
deployment.yml
run.sh run.sh
Dockerfile Dockerfile
.dockerignore .dockerignore
@@ -13,6 +13,7 @@ Dockerfile
.git .git
.gitignore .gitignore
.gitattributes .gitattributes
.gitea
# Python cache # Python cache
__pycache__/ __pycache__/
@@ -43,15 +44,6 @@ ENV/
*.log *.log
logs/ logs/
# Build tools
tailwindcss
# OS files # OS files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Minified files
**/*.js
!**/*.min.js
device_types.css
devices.css
+22 -13
View File
@@ -1,9 +1,7 @@
name: Dev name: Dev
on: on:
push: workflow_dispatch:
branches:
- "**"
jobs: jobs:
build: build:
@@ -16,21 +14,32 @@ jobs:
- name: Build and push Docker image - name: Build and push Docker image
run: | run: |
docker build -t cr.jdbnet.co.uk/public/ipam:dev \ docker build -t cr.jdbnet.co.uk/public/ipam:dev --build-arg VERSION=dev .
--build-arg VERSION=dev \
.
docker push cr.jdbnet.co.uk/public/ipam:dev docker push cr.jdbnet.co.uk/public/ipam:dev
deploy: sonarqube:
name: Deploy to Kubernetes name: SonarQube
needs: release runs-on: build-htz-01
runs-on: k3s-internal-htz-01
steps: steps:
- name: Checkout - name: Checkout Code
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Deploy to Kubernetes - name: Create Valid Project Key
id: sonar_setup
run: | run: |
sudo kubectl replace -f deployment.yml --grace-period=60 --force CLEAN_KEY=$(echo "${{ gitea.repository }}" | tr '/' ':')
echo "key=$CLEAN_KEY" >> $GITHUB_OUTPUT
- name: SonarQube Scan
uses: sonarsource/sonarqube-scan-action@master
continue-on-error: true
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
with:
args: >
-Dsonar.projectKey=${{ steps.sonar_setup.outputs.key }}
-Dsonar.projectName=${{ gitea.repository }}
-Dsonar.qualitygate.wait=true
+22 -7
View File
@@ -8,6 +8,7 @@ on:
jobs: jobs:
release: release:
name: Build & Release
if: github.event.pull_request.merged == true && startsWith(github.head_ref, 'v') if: github.event.pull_request.merged == true && startsWith(github.head_ref, 'v')
runs-on: build-htz-01 runs-on: build-htz-01
steps: steps:
@@ -30,6 +31,7 @@ jobs:
run: | run: |
VERSION=${{ steps.get_version.outputs.VERSION }} VERSION=${{ steps.get_version.outputs.VERSION }}
docker build -t cr.jdbnet.co.uk/public/ipam:$VERSION \ docker build -t cr.jdbnet.co.uk/public/ipam:$VERSION \
-t cr.jdbnet.co.uk/public/ipam:v2 \
-t cr.jdbnet.co.uk/public/ipam:latest \ -t cr.jdbnet.co.uk/public/ipam:latest \
--build-arg VERSION=$VERSION \ --build-arg VERSION=$VERSION \
. .
@@ -45,16 +47,29 @@ jobs:
draft: false draft: false
prerelease: false prerelease: false
deploy: sonarqube:
name: Deploy to Kubernetes name: SonarQube
needs: release runs-on: build-htz-01
runs-on: k3s-external-htz-01
steps: steps:
- name: Checkout - name: Checkout Code
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Deploy to Kubernetes - name: Create Valid Project Key
id: sonar_setup
run: | run: |
sudo kubectl replace -f deployment.yml --grace-period=60 --force CLEAN_KEY=$(echo "${{ gitea.repository }}" | tr '/' ':')
echo "key=$CLEAN_KEY" >> $GITHUB_OUTPUT
- name: SonarQube Scan
uses: sonarsource/sonarqube-scan-action@master
continue-on-error: true
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
with:
args: >
-Dsonar.projectKey=${{ steps.sonar_setup.outputs.key }}
-Dsonar.projectName=${{ gitea.repository }}
-Dsonar.qualitygate.wait=true
+2 -3
View File
@@ -1,5 +1,4 @@
__pycache__ __pycache__
tailwindcss
static/css/output.css
.env .env
backups/ frontend/node_modules/
static/dist/
+77
View File
@@ -0,0 +1,77 @@
# IPAM API v2
All endpoints are JSON under `/api/v2`. The Vue SPA uses **session cookies** (`credentials: include`); automation uses **API keys** (`X-API-Key`, `Authorization: Bearer`, or `?api_key=`).
## Authentication
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/v2/auth/login` | `{ email, password }``{ ok }` or `{ requires_2fa }` / `{ requires_setup }` |
| POST | `/api/v2/auth/verify-2fa` | `{ code, use_backup? }` |
| POST | `/api/v2/auth/setup-2fa` | `{ action: "generate" \| "verify", code? }` |
| POST | `/api/v2/auth/logout` | Clear session |
| GET | `/api/v2/auth/me` | User, permissions, org branding |
## Account
| Method | Endpoint |
|--------|----------|
| GET | `/api/v2/account` |
| POST | `/api/v2/account/change-password` |
| POST | `/api/v2/account/disable-2fa` |
| POST | `/api/v2/account/regenerate-backup-codes` |
## List response format
List endpoints return `{ "items": [...] }`. Exceptions:
- **Audit log** — `{ "items": [...], "total": N }` (supports pagination)
- **Devices by tag** — `{ "items": [...], "meta": { "tag_name", "count" } }`
- **Single resources** (device, subnet, rack) — flat object, not wrapped in `items`
## Core resources
| Resource | Endpoints |
|----------|-----------|
| Dashboard | `GET /api/v2/dashboard` |
| Search | `GET /api/v2/search?q=` |
| Devices | CRUD + `/ips`, `/tags`, `/ip-history`, `/custom-fields` |
| Subnets | CRUD + `/available-ips`, `/next_free_ip`, `/export`, `/dhcp`, `/custom-fields` |
| IP addresses | `PATCH /api/v2/ip-addresses/{id}` (notes) |
| IP history | `GET /api/v2/ips/{ip}/history` |
| Tags | CRUD + device tag assign/remove |
| Racks | CRUD + `/devices`, `/export` |
| Custom fields | CRUD + `POST /custom-fields/reorder` |
| Audit | `GET /audit`, `GET /audit/actions`, `GET /audit/export` |
| Users & roles | CRUD + `POST /users/{id}/regenerate-api-key` |
| Permissions | `GET /permissions` |
| Bulk | `POST /bulk/assign-ips`, `/create-devices`, `/assign-tags`, `/export-subnets` |
### Subnet IP helpers
- `GET /api/v2/subnets/{id}/available-ips` — unassigned IPs outside DHCP pools
- `GET /api/v2/subnets/{id}/next_free_ip` — first available IP (also excludes DHCP pools)
### Audit query parameters
| Param | Description |
|-------|-------------|
| `limit` | Page size (default 100) |
| `offset` | Offset for pagination (default 0) |
| `user` | Filter by user name (partial match) |
| `action` | Exact action match (see `GET /audit/actions` for values) |
| `from` | Start date (`YYYY-MM-DD`) |
| `to` | End date (`YYYY-MM-DD`) |
Export (`GET /audit/export`) accepts the same filter params.
See route handlers in `app.py` for required permissions and request bodies.
## Example
```bash
curl -H "X-API-Key: YOUR_KEY" https://host/api/v2/devices
curl -c cookies.txt -X POST -H "Content-Type: application/json" \
-d '{"email":"admin@example.com","password":"password"}' \
https://host/api/v2/auth/login
```
+13 -10
View File
@@ -1,15 +1,18 @@
FROM python:3.13-slim FROM node:22-bookworm-slim AS frontend
WORKDIR /app
COPY frontend/package.json ./frontend/
RUN cd frontend && npm install
COPY frontend/ ./frontend/
RUN cd frontend && npm run build
FROM python:3.14-slim
LABEL org.opencontainers.image.vendor="JDB-NET" LABEL org.opencontainers.image.vendor="JDB-NET"
WORKDIR /app WORKDIR /app
COPY . /app COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py db.py ./
COPY --from=frontend /app/static/dist ./static/dist
ARG VERSION=unknown ARG VERSION=unknown
ENV VERSION=${VERSION} ENV VERSION=${VERSION}
RUN pip install -r requirements.txt
RUN apt-get update && apt-get install -y curl mariadb-client-compat
RUN rm -rf /var/lib/apt/lists/*
RUN curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 \
&& chmod +x tailwindcss-linux-x64 \
&& ./tailwindcss-linux-x64 -i ./static/css/input.css -o ./static/css/output.css --content "./templates/*.html,./static/js/*.min.js" --minify \
&& rm tailwindcss-linux-x64
EXPOSE 5000 EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app", "--log-level", "warning"] CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app", "--log-level", "warning"]
+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
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 JDB-NET
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+55 -287
View File
@@ -1,316 +1,84 @@
<div align="center"> <div align="center">
<img src="https://projects.jdbnet.co.uk/ipam/img/favicon.png" alt="IPAM" width="200" />
<h1>JDB-NET IPAM</h1>
# IP Address Management
<p>Open source IP address management for homelabs, small businesses, and IT teams.</p>
<p>
<a href="https://github.com/jdbnet/ipam/blob/main/LICENSE">
<img src="https://img.shields.io/github/license/jdbnet/ipam" alt="License" />
</a>
<a href="https://cr.jdbnet.co.uk">
<img src="https://img.shields.io/badge/container-cr.jdbnet.co.uk-blue" alt="Container" />
</a>
</p>
<p>
<a href="https://www.jdbnet.co.uk/product/ipam"><strong>☁️ Managed hosting from £8/month →</strong></a>
</p>
</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. ---
Manage subnets, IP assignments, DHCP pools, devices, and rack layout from
a single web interface. Built with Flask and Vue 3, deployable with a single
Docker Compose file.
![IPAM Dashboard](img/screenshot.png)
## Features ## Features
- **Subnet Management**: Create and manage IP subnets with CIDR notation (supports /24 to /32) - **Subnet management** - CIDR subnets (/24/32) 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 and assignment history
- **Device Management**: Track devices with types (Server, VM, Switch, Firewall, WiFi AP, Printer, Other) - **DHCP pools** - Configure ranges and excluded IPs per subnet; pool addresses are kept out of manual assignment
- **Device Tagging**: Organize devices with customizable tags featuring colors and descriptions - **Device management** - Names, descriptions, tags, custom fields, and bulk creation
- **IP Assignment**: Assign IP addresses to devices with automatic hostname updates - **Rack layout** - U positions with front/back face placement and non-networked entries
- **DHCP Pool Configuration**: Configure DHCP pools with start/end IP ranges and excluded IPs - **Site organisation** - Group subnets and devices by location for multi-site networks
- **Rack Management**: Physical infrastructure tracking with U positions and front/back sides - **Global search** - Press `/` to search subnets, IPs, devices, and racks from anywhere
- **Site Organisation**: Organize subnets and devices by site/location - **Audit logging** - Filterable change history with CSV export
- **Audit Logging**: Complete audit trail of all changes with user, action, details, and timestamps - **Role-based access control** - Granular permissions, custom roles, and enforced 2FA per role
- **User Management**: Multi-user support with secure password authentication - **REST API v2** - Full JSON API with session cookie and API key authentication
- **Role-Based Access Control (RBAC)**: Granular permission system with default roles (admin, user, view_only) and custom role creation - **Custom fields** - Extend devices and subnets with admin-defined fields, no schema changes required
- **REST API**: Full-featured REST API with API key authentication for programmatic access - **Organisation branding** - Set your name and logo from Settings or environment variables
- **CSV Export**: Export subnet and rack data to CSV files
- **Device Statistics**: View device counts by type
- **Web Interface**: Modern, responsive web GUI built with Tailwind CSS and dark mode support
## Quick Start with Docker ## Quick start
### Docker Run
```bash
docker run -d \
--name ipam \
-p 5000:5000 \
-v ./backups:/app/backups \
-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" \
ghcr.io/jdb-net/ipam:latest
```
### Docker Compose
```yaml ```yaml
services: services:
ipam: ipam:
image: ghcr.io/jdb-net/ipam:latest image: cr.jdbnet.co.uk/public/ipam:latest
container_name: ipam container_name: ipam
restart: unless-stopped restart: unless-stopped
ports: ports:
- "5000:5000" - "5000:5000"
environment: environment:
- MYSQL_HOST=10.10.2.27 - MYSQL_HOST=your_db_host
- MYSQL_USER=ipam - MYSQL_USER=ipam
- MYSQL_PASSWORD=your_password - MYSQL_PASSWORD=your_password
- MYSQL_DATABASE=ipam - MYSQL_DATABASE=ipam
- SECRET_KEY=your_secret_key - SECRET_KEY=your_secret_key # generate with: openssl rand -hex 32
- NAME=Your Organisation
- LOGO_PNG=https://example.com/logo.png
volumes:
- ./backups:/app/backups
``` ```
## Configuration A MySQL or MariaDB database is required. The schema is created automatically
on first run. Log in with `admin@example.com` / `password` and change the password immediately.
### Environment Variables ## Environment variables
- `MYSQL_HOST`: MySQL/MariaDB host (default: localhost) | Variable | Required | Description |
- `MYSQL_USER`: Database user (default: user) |----------|----------|-------------|
- `MYSQL_PASSWORD`: Database password (default: password) | `MYSQL_HOST` | Yes | Database host |
- `MYSQL_DATABASE`: Database name (default: ipam) | `MYSQL_USER` | Yes | Database user |
- `SECRET_KEY`: Flask secret key for sessions (**REQUIRED in production!**) | `MYSQL_PASSWORD` | Yes | Database password |
- `NAME`: Organisation name displayed in header (default: JDB-NET) | `MYSQL_DATABASE` | Yes | Database name |
- `LOGO_PNG`: URL or path to organisation logo (default: JDB-NET logo) | `SECRET_KEY` | Yes | Flask secret key - use a long random string |
### Database Setup ## Managed hosting
The application automatically initializes the database schema on first run. Ensure the database and user exist with appropriate permissions: Don't want to run it yourself? JDB-NET offers fully managed hosting from
**£8/month** - provisioned in under 10 minutes, no maintenance required.
```sql [→ jdbnet.co.uk/products/ipam](https://www.jdbnet.co.uk/product/ipam)
CREATE DATABASE ipam;
CREATE USER 'ipam'@'%' IDENTIFIED BY 'your_password';
GRANT ALL PRIVILEGES ON ipam.* TO 'ipam'@'%';
FLUSH PRIVILEGES;
```
## Usage
### First Login
1. Access the web interface at `http://your-server:5000`
2. Log in with the default credentials:
- Email: `admin@example.com`
- Password: `password`
3. **Change the default password immediately** via the Users page
### Managing Subnets
1. Navigate to "Admin" from the main menu
2. Click "Add Subnet" and fill in:
- **Name**: Friendly name for the subnet (e.g., "Office LAN")
- **CIDR**: Subnet in CIDR notation (e.g., `192.168.1.0/24`)
- **Site**: Site/location identifier
3. The system automatically generates all IP addresses in the subnet
### Adding Devices
1. Navigate to "Devices" from the main menu
2. Click "Add Device"
3. Enter device name and select device type
4. Click "Create Device"
### Assigning IP Addresses to Devices
1. Open a device from the Devices page
2. Select a subnet and available IP address
3. Click "Assign IP" - the hostname is automatically updated
### Configuring DHCP Pools
1. Open a subnet view
2. Click "Configure DHCP Pool"
3. Set the start and end IP addresses
4. Optionally specify excluded IPs (comma-separated)
5. IPs within the pool range are automatically marked as "DHCP"
### Managing Racks
1. Navigate to "Racks" from the main menu
2. Click "Add Rack" and specify:
- **Name**: Rack identifier
- **Site**: Site location
- **Height**: Rack height in U units
3. Open a rack to assign devices to specific U positions (front or back)
### Device Tagging
1. **Managing Tags** (Admin only):
- Navigate to "Admin" > "Tag Management"
- Click "Add Tag" to create new tags with custom colors and descriptions
- Edit or delete existing tags as needed
2. **Assigning Tags to Devices**:
- Open any device from the Devices page
- Use the tag assignment dropdown to add multiple tags
- Remove tags by clicking the × button next to the tag name
3. **Filtering by Tags**:
- Use the tag filter dropdown on the Devices page to view devices with specific tags
- Tags appear as colored badges throughout the interface for easy identification
### Audit Log
View all changes and actions in the "Audit Log" section, with filtering by user, subnet, action type, or device name.
### Exporting Data
- **Subnet CSV**: Click "Export CSV" on any subnet page to download IP addresses with hostnames
- **Rack CSV**: Click "Export CSV" on any rack page to download rack layout information
### Role-Based Access Control
The system uses a granular role-based access control (RBAC) system to manage user permissions:
1. **Default Roles**:
- **Admin**: Full access to all features including user and role management
- **User**: Can view and manage most features (devices, subnets, racks, etc.) but cannot manage users or roles
- **View Only**: Read-only access to view pages but cannot make any changes
2. **Custom Roles**: Administrators can create custom roles with specific permission sets from the Users page
3. **Permission Granularity**: Permissions are organized into categories:
- View permissions (access to pages)
- Device Management (add, edit, delete devices)
- Network Management (subnet operations)
- Rack Management (rack operations)
- DHCP Configuration
- Administration (user and role management)
4. **User Management**: Navigate to the Users page to:
- Create and manage users
- Assign roles to users
- Create custom roles with specific permissions
- View and regenerate API keys
### REST API
The application includes a comprehensive REST API for programmatic access:
1. **Authentication**: All API requests require an API key, which can be provided via:
- `X-API-Key` header
- `Authorization: Bearer <api_key>` header
- `?api_key=<api_key>` query parameter
2. **Base URL**: All API endpoints are prefixed with `/api/v1`
3. **Available Endpoints**:
- **Devices**: `GET`, `POST`, `PUT`, `DELETE /api/v1/devices`
- **Device Tags**: `GET /api/v1/devices/by-tag/{tag}` (filter devices by tag)
- **Tags**: `GET`, `POST`, `PUT`, `DELETE /api/v1/tags` (with `?format=simple` option)
- **Tag Assignment**: `GET`, `POST /api/v1/devices/{id}/tags`, `DELETE /api/v1/devices/{id}/tags/{tag_id}`
- **Subnets**: `GET`, `POST`, `PUT`, `DELETE /api/v1/subnets`
- **Racks**: `GET`, `POST`, `DELETE /api/v1/racks`
- **Device Types**: `GET /api/v1/device-types`
- **DHCP**: `GET`, `POST /api/v1/subnets/{id}/dhcp`
- **Audit Log**: `GET /api/v1/audit`
- **Users & Roles**: `GET /api/v1/users`, `GET /api/v1/roles` (admin only)
4. **API Keys**: Each user has a unique API key that can be viewed and regenerated from the Users page. API keys respect the same role-based permissions as the web interface.
5. **Documentation**: Full API documentation is available in the Help page of the web interface.
**Example API Requests**:
```bash
# List all devices
curl -H "X-API-Key: your_api_key" \
https://your-server:5000/api/v1/devices
# Get devices with a specific tag
curl -H "X-API-Key: your_api_key" \
https://your-server:5000/api/v1/devices/by-tag/production
# List all tags in simple format
curl -H "X-API-Key: your_api_key" \
https://your-server:5000/api/v1/tags?format=simple
```
## Kubernetes Deployment
The project includes a Kubernetes deployment manifest. See `deployment.yml` for details.
**Example Kubernetes deployment:**
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: ipam
namespace: ipam
spec:
replicas: 1
template:
spec:
containers:
- name: ipam
image: ghcr.io/jdb-net/ipam:latest
ports:
- containerPort: 5000
env:
- name: SECRET_KEY
valueFrom:
secretKeyRef:
name: ipam-secrets
key: secret-key
- name: MYSQL_HOST
value: "mysql-service"
- name: MYSQL_USER
value: "ipam"
- name: MYSQL_PASSWORD
valueFrom:
secretKeyRef:
name: ipam-secrets
key: mysql-password
- name: MYSQL_DATABASE
value: "ipam"
```
## Security Notes
- **CHANGE THE DEFAULT ADMIN PASSWORD** immediately after first login
- **CHANGE THE SECRET_KEY** in production - use a strong random string (e.g., `openssl rand -hex 32`)
- Use strong passwords for database access
- Ensure database connections are secured (consider SSL/TLS for MySQL connections)
- Review audit logs regularly for unauthorized changes
- Limit database user permissions if possible (though CREATE/ALTER may be needed for schema initialization)
- **API Keys**: Keep API keys secure and never share them publicly. Regenerate keys if they may have been compromised
- **Role-Based Access**: Use the RBAC system to grant users only the permissions they need (principle of least privilege)
- **HTTPS**: Use HTTPS in production to protect API keys and session data in transit
## Troubleshooting
### Database Connection Issues
- Ensure MySQL/MariaDB is running and accessible from the container
- Check database credentials in environment variables
- Verify database and user exist with proper permissions
- Check network connectivity between container and database
- Ensure the database name matches exactly (case-sensitive on some systems)
### Application Not Starting
- Check container logs: `docker logs ipam`
- Verify all required environment variables are set
- Ensure port 5000 is not already in use
- Check that MySQL/MariaDB is reachable
### Subnet or IP Not Appearing
- Verify CIDR notation is correct (supports /24 to /32)
- Check subnet was created successfully (view in Admin page)
- Ensure you're logged in with appropriate permissions
- Check application logs for errors
### Device IP Assignment Issues
- Verify the IP address is available (not already assigned)
- Check that the IP is not in a DHCP pool range
- Ensure the device exists and is visible in the Devices list
## License ## License
This project is provided as-is for IP Address Management. [MIT](LICENSE)
+3219 -30
View File
File diff suppressed because it is too large Load Diff
-191
View File
@@ -1,191 +0,0 @@
"""
In-memory caching module with TTL support and cache invalidation
"""
import time
import sys
from threading import Lock
from functools import wraps
class Cache:
"""Simple in-memory cache with TTL support and size limiting"""
def __init__(self, max_size_mb=50):
self._cache = {}
self._lock = Lock()
self._max_size_bytes = max_size_mb * 1024 * 1024 # Convert MB to bytes
self._access_order = [] # Track access order for LRU eviction
def _get_size(self, obj):
"""Estimate size of an object in bytes"""
size = sys.getsizeof(obj)
if isinstance(obj, dict):
size += sum(self._get_size(k) + self._get_size(v) for k, v in obj.items())
elif isinstance(obj, (list, tuple)):
size += sum(self._get_size(item) for item in obj)
elif isinstance(obj, str):
size += sys.getsizeof(obj) - sys.getsizeof('')
return size
def _get_cache_size(self):
"""Get approximate total size of cache in bytes"""
total_size = sys.getsizeof(self._cache)
for key, (value, expiry) in self._cache.items():
total_size += self._get_size(key) + self._get_size(value) + sys.getsizeof(expiry)
return total_size
def _evict_if_needed(self):
"""Evict entries if cache exceeds size limit"""
current_size = self._get_cache_size()
if current_size <= self._max_size_bytes:
return
# First, remove expired entries
current_time = time.time()
expired_keys = []
for key in list(self._cache.keys()):
_, expiry = self._cache[key]
if expiry is not None and current_time >= expiry:
expired_keys.append(key)
for key in expired_keys:
if key in self._cache:
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
# If still over limit, remove oldest entries (LRU)
current_size = self._get_cache_size()
while current_size > self._max_size_bytes and self._access_order:
oldest_key = self._access_order.pop(0)
if oldest_key in self._cache:
del self._cache[oldest_key]
current_size = self._get_cache_size()
def get(self, key):
"""Get value from cache if it exists and hasn't expired"""
with self._lock:
if key in self._cache:
value, expiry = self._cache[key]
if expiry is None or time.time() < expiry:
# Update access order (move to end for LRU)
if key in self._access_order:
self._access_order.remove(key)
self._access_order.append(key)
return value
else:
# Expired, remove it
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
return None
def set(self, key, value, ttl=None):
"""Set value in cache with optional TTL (time to live in seconds)"""
with self._lock:
# Remove old entry if it exists
if key in self._cache:
if key in self._access_order:
self._access_order.remove(key)
expiry = None if ttl is None else time.time() + ttl
self._cache[key] = (value, expiry)
self._access_order.append(key)
# Evict if needed to stay under size limit
self._evict_if_needed()
def delete(self, key):
"""Delete a key from cache"""
with self._lock:
if key in self._cache:
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
def clear(self, pattern=None):
"""Clear cache entries. If pattern is provided, only clear keys matching the pattern."""
with self._lock:
if pattern is None:
self._cache.clear()
self._access_order.clear()
else:
keys_to_delete = [key for key in self._cache.keys() if pattern in key]
for key in keys_to_delete:
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
def invalidate_subnet(self, subnet_id):
"""Invalidate all cache entries related to a specific subnet"""
patterns = [
f'subnet:{subnet_id}',
f'subnet_list',
f'index',
f'admin',
f'utilization:{subnet_id}'
]
with self._lock:
keys_to_delete = []
for key in self._cache.keys():
for pattern in patterns:
if pattern in key:
keys_to_delete.append(key)
break
for key in keys_to_delete:
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
def invalidate_device(self, device_id):
"""Invalidate all cache entries related to a specific device"""
patterns = [
f'device:{device_id}',
f'device_list',
f'devices',
f'device_types'
]
with self._lock:
keys_to_delete = []
for key in self._cache.keys():
for pattern in patterns:
if pattern in key:
keys_to_delete.append(key)
break
for key in keys_to_delete:
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
def invalidate_all(self):
"""Invalidate all cache entries"""
self.clear()
# Global cache instance
cache = Cache()
def cached(ttl=None, key_prefix=''):
"""
Decorator to cache function results
Args:
ttl: Time to live in seconds (None = no expiration)
key_prefix: Prefix for cache key
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
# Create cache key from function name, args, and kwargs
cache_key = f"{key_prefix}{func.__name__}:{str(args)}:{str(sorted(kwargs.items()))}"
# Try to get from cache
cached_value = cache.get(cache_key)
if cached_value is not None:
return cached_value
# Call function and cache result
result = func(*args, **kwargs)
cache.set(cache_key, result, ttl)
return result
return wrapper
return decorator
+148 -84
View File
@@ -6,6 +6,7 @@ import mysql.connector
import logging import logging
from flask import current_app from flask import current_app
# ── Connection, crypto, schema init ─────────────────────────────────────────
def hash_password(password, salt=None): def hash_password(password, salt=None):
if salt is None: if salt is None:
salt = base64.b64encode(os.urandom(16)).decode('utf-8') salt = base64.b64encode(os.urandom(16)).decode('utf-8')
@@ -79,19 +80,10 @@ def init_db(app=None):
) )
''') ''')
cursor.execute(''' cursor.execute('''
CREATE TABLE IF NOT EXISTS DeviceType (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL UNIQUE,
icon_class VARCHAR(255) NOT NULL
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS Device ( CREATE TABLE IF NOT EXISTS Device (
id INTEGER PRIMARY KEY AUTO_INCREMENT, id INTEGER PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
description TEXT, description TEXT
device_type_id INTEGER DEFAULT 1,
FOREIGN KEY (device_type_id) REFERENCES DeviceType(id)
) )
''') ''')
cursor.execute(''' cursor.execute('''
@@ -133,37 +125,6 @@ def init_db(app=None):
FOREIGN KEY (device_id) REFERENCES Device(id) ON DELETE CASCADE FOREIGN KEY (device_id) REFERENCES Device(id) ON DELETE CASCADE
) )
''') ''')
# Initialize default device types only if table is empty
cursor.execute('SELECT COUNT(*) FROM DeviceType')
if cursor.fetchone()[0] == 0:
cursor.executemany('INSERT INTO DeviceType (name, icon_class) VALUES (%s, %s)', [
('Server', 'fa-server'),
('Virtual Machine', 'fa-boxes-stacked'),
('Switch', 'fa-network-wired'),
('Firewall', 'fa-shield-halved'),
('WiFi AP', 'fa-wifi'),
('Printer', 'fa-print'),
('Other', 'fa-question')
])
conn.commit() # Commit the inserts before querying
# Add device_type_id column if it doesn't exist
cursor.execute("SHOW COLUMNS FROM Device LIKE 'device_type_id'")
if not cursor.fetchone():
cursor.execute('ALTER TABLE Device ADD COLUMN device_type_id INTEGER DEFAULT NULL')
# Set default device_type_id for devices that don't have one
# Use the first available device type, or leave NULL if no types exist
cursor.execute('SELECT id FROM DeviceType ORDER BY id LIMIT 1')
first_type_result = cursor.fetchone()
if first_type_result:
first_type_id = first_type_result[0]
cursor.execute('UPDATE Device SET device_type_id = %s WHERE device_type_id IS NULL', (first_type_id,))
try:
cursor.execute('ALTER TABLE Device ADD CONSTRAINT fk_device_type FOREIGN KEY (device_type_id) REFERENCES DeviceType(id)')
except mysql.connector.Error as e:
if e.errno != 1061 and e.errno != 1826 and 'Duplicate' not in str(e):
raise
# Create Role table # Create Role table
cursor.execute(''' cursor.execute('''
CREATE TABLE IF NOT EXISTS Role ( CREATE TABLE IF NOT EXISTS Role (
@@ -193,6 +154,13 @@ def init_db(app=None):
FOREIGN KEY (permission_id) REFERENCES Permission(id) ON DELETE CASCADE FOREIGN KEY (permission_id) REFERENCES Permission(id) ON DELETE CASCADE
) )
''') ''')
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'")
@@ -312,7 +280,6 @@ def init_db(app=None):
help_text TEXT, help_text TEXT,
display_order INTEGER DEFAULT 0, display_order INTEGER DEFAULT 0,
validation_rules TEXT, validation_rules TEXT,
searchable BOOLEAN DEFAULT FALSE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) )
@@ -350,32 +317,6 @@ def init_db(app=None):
if not cursor.fetchone(): if not cursor.fetchone():
cursor.execute('ALTER TABLE Subnet ADD COLUMN vlan_notes TEXT DEFAULT NULL') cursor.execute('ALTER TABLE Subnet ADD COLUMN vlan_notes TEXT DEFAULT NULL')
# Create FeatureFlags table
cursor.execute('''
CREATE TABLE IF NOT EXISTS FeatureFlags (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
feature_key VARCHAR(255) NOT NULL UNIQUE,
enabled BOOLEAN DEFAULT TRUE,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
''')
# Initialize default feature flags
default_features = [
('racks', True, 'Enable rack management functionality'),
('ip_address_notes', True, 'Enable IP address notes/descriptions editing on subnet page'),
('device_tags', True, 'Enable device tagging functionality'),
('bulk_operations', True, 'Enable bulk operations for devices and IPs')
]
for feature_key, enabled, description in default_features:
cursor.execute('SELECT id FROM FeatureFlags WHERE feature_key = %s', (feature_key,))
if not cursor.fetchone():
cursor.execute('INSERT INTO FeatureFlags (feature_key, enabled, description) VALUES (%s, %s, %s)',
(feature_key, enabled, description))
# Define all permissions with categories # Define all permissions with categories
permissions = [ permissions = [
# View permissions # View permissions
@@ -388,15 +329,11 @@ def init_db(app=None):
('view_audit', 'View Audit Log', 'View'), ('view_audit', 'View Audit Log', 'View'),
('view_admin', 'View Admin panel', 'View'), ('view_admin', 'View Admin panel', 'View'),
('view_users', 'View Users page', 'View'), ('view_users', 'View Users page', 'View'),
('view_device_types', 'View Device Types page', 'View'),
('view_device_type_stats', 'View Device Type Statistics', 'View'),
('view_devices_by_type', 'View Devices by Type', 'View'),
('view_dhcp', 'View DHCP configuration', 'View'), ('view_dhcp', 'View DHCP configuration', 'View'),
('view_help', 'View Help page', 'View'),
# Device permissions # Device permissions
('add_device', 'Add new device', 'Device'), ('add_device', 'Add new device', 'Device'),
('edit_device', 'Edit device (rename, description, type)', 'Device'), ('edit_device', 'Edit device (rename, description)', 'Device'),
('delete_device', 'Delete device', 'Device'), ('delete_device', 'Delete device', 'Device'),
('add_device_ip', 'Add IP address to device', 'Device'), ('add_device_ip', 'Add IP address to device', 'Device'),
('remove_device_ip', 'Remove IP address from device', 'Device'), ('remove_device_ip', 'Remove IP address from device', 'Device'),
@@ -418,11 +355,6 @@ def init_db(app=None):
# DHCP permissions # DHCP permissions
('configure_dhcp', 'Configure DHCP pools', 'DHCP'), ('configure_dhcp', 'Configure DHCP pools', 'DHCP'),
# Device Type permissions
('add_device_type', 'Add device type', 'Device Type'),
('edit_device_type', 'Edit device type', 'Device Type'),
('delete_device_type', 'Delete device type', 'Device Type'),
# Tag permissions # Tag permissions
('view_tags', 'View tags', 'Tag'), ('view_tags', 'View tags', 'Tag'),
('add_tag', 'Add new tag', 'Tag'), ('add_tag', 'Add new tag', 'Tag'),
@@ -438,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
@@ -488,14 +422,13 @@ def init_db(app=None):
# Assign non-admin permissions to user role # Assign non-admin permissions to user role
non_admin_permissions = [ non_admin_permissions = [
'view_index', 'view_devices', 'view_device', 'view_subnet', 'view_racks', 'view_rack', 'view_index', 'view_devices', 'view_device', 'view_subnet', 'view_racks', 'view_rack',
'view_audit', 'view_device_types', 'view_device_type_stats', 'view_devices_by_type', 'view_audit',
'view_dhcp', 'view_help', 'view_dhcp',
'add_device', 'edit_device', 'delete_device', 'add_device_ip', 'remove_device_ip', 'add_device', 'edit_device', 'delete_device', 'add_device_ip', 'remove_device_ip',
'add_subnet', 'edit_subnet', 'delete_subnet', 'export_subnet_csv', 'add_subnet', 'edit_subnet', 'delete_subnet', 'export_subnet_csv',
'add_rack', 'delete_rack', 'add_device_to_rack', 'remove_device_from_rack', 'add_rack', 'delete_rack', 'add_device_to_rack', 'remove_device_from_rack',
'add_nonnet_device_to_rack', 'export_rack_csv', 'add_nonnet_device_to_rack', 'export_rack_csv',
'configure_dhcp', 'configure_dhcp',
'add_device_type', 'edit_device_type', 'delete_device_type',
'view_tags', 'add_tag', 'edit_tag', 'delete_tag', 'assign_device_tag', 'remove_device_tag', 'view_tags', 'add_tag', 'edit_tag', 'delete_tag', 'assign_device_tag', 'remove_device_tag',
'view_custom_fields', 'manage_custom_fields' 'view_custom_fields', 'manage_custom_fields'
] ]
@@ -515,8 +448,8 @@ def init_db(app=None):
# Same view permissions as user role, but excluding admin views (view_admin, view_users) # Same view permissions as user role, but excluding admin views (view_admin, view_users)
view_only_permissions = [ view_only_permissions = [
'view_index', 'view_devices', 'view_device', 'view_subnet', 'view_racks', 'view_rack', 'view_index', 'view_devices', 'view_device', 'view_subnet', 'view_racks', 'view_rack',
'view_audit', 'view_device_types', 'view_device_type_stats', 'view_devices_by_type', 'view_audit',
'view_dhcp', 'view_help', 'view_tags', 'view_custom_fields' 'view_dhcp', 'view_tags', 'view_custom_fields'
] ]
for perm_name in view_only_permissions: for perm_name in view_only_permissions:
@@ -605,7 +538,6 @@ def init_db(app=None):
create_index_if_not_exists(cursor, 'idx_rackdevice_rack_side', 'RackDevice', 'rack_id, side') create_index_if_not_exists(cursor, 'idx_rackdevice_rack_side', 'RackDevice', 'rack_id, side')
# Device table indexes # Device table indexes
create_index_if_not_exists(cursor, 'idx_device_device_type_id', 'Device', 'device_type_id')
# User table indexes (api_key already has UNIQUE index) # User table indexes (api_key already has UNIQUE index)
create_index_if_not_exists(cursor, 'idx_user_role_id', 'User', 'role_id') create_index_if_not_exists(cursor, 'idx_user_role_id', 'User', 'role_id')
@@ -616,5 +548,137 @@ def init_db(app=None):
create_index_if_not_exists(cursor, 'idx_customfield_entity_order', 'CustomFieldDefinition', 'entity_type, display_order') create_index_if_not_exists(cursor, 'idx_customfield_entity_order', 'CustomFieldDefinition', 'entity_type, display_order')
logging.info("Database indexes created successfully") logging.info("Database indexes created successfully")
run_v2_migrations(cursor, conn)
conn.commit() conn.commit()
conn.close() conn.close()
def run_v2_migrations(cursor, conn):
"""One-time schema cleanup for v2 upgrades from v1.x."""
logging.info("Running v2 database migrations...")
cursor.execute('DROP TABLE IF EXISTS FeatureFlags')
cursor.execute("SHOW COLUMNS FROM CustomFieldDefinition LIKE 'searchable'")
if cursor.fetchone():
cursor.execute('ALTER TABLE CustomFieldDefinition DROP COLUMN searchable')
logging.info("Dropped CustomFieldDefinition.searchable column")
for perm_name in ('view_help',):
cursor.execute('SELECT id FROM Permission WHERE name = %s', (perm_name,))
row = cursor.fetchone()
if row:
perm_id = row[0]
cursor.execute('DELETE FROM RolePermission WHERE permission_id = %s', (perm_id,))
cursor.execute('DELETE FROM Permission WHERE id = %s', (perm_id,))
logging.info("Removed orphaned permission: %s", perm_name)
cursor.execute("SHOW COLUMNS FROM Device LIKE 'device_type_id'")
if cursor.fetchone():
cursor.execute("""
SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'Device'
AND COLUMN_NAME = 'device_type_id' AND REFERENCED_TABLE_NAME IS NOT NULL
""")
for (fk_name,) in cursor.fetchall():
cursor.execute(f'ALTER TABLE Device DROP FOREIGN KEY `{fk_name}`')
cursor.execute('ALTER TABLE Device DROP COLUMN device_type_id')
logging.info("Dropped Device.device_type_id column")
cursor.execute("SHOW TABLES LIKE 'DeviceType'")
if cursor.fetchone():
cursor.execute('DROP TABLE DeviceType')
logging.info("Dropped DeviceType table")
for perm_name in (
'view_device_types', 'view_device_type_stats', 'view_devices_by_type',
'add_device_type', 'edit_device_type', 'delete_device_type',
):
cursor.execute('SELECT id FROM Permission WHERE name = %s', (perm_name,))
row = cursor.fetchone()
if row:
perm_id = row[0]
cursor.execute('DELETE FROM RolePermission WHERE permission_id = %s', (perm_id,))
cursor.execute('DELETE FROM Permission WHERE id = %s', (perm_id,))
logging.info("Removed orphaned permission: %s", perm_name)
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,
}
-64
View File
@@ -1,64 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: ipam
namespace: ipam
spec:
replicas: 1
selector:
matchLabels:
app: ipam
template:
metadata:
labels:
app: ipam
spec:
containers:
- name: ipam
image: cr.jdbnet.co.uk/public/ipam:dev
imagePullPolicy: Always
ports:
- containerPort: 5000
name: "ipam"
env:
- name: SECRET_KEY
value: "41TbN7v5peFLZPrdwSCc64J3mjmiUk5fkVWsmb2m"
- name: MYSQL_HOST
value: "10.10.25.4"
- name: MYSQL_USER
value: "ipam"
- name: MYSQL_PASSWORD
value: "WXPmo05sGCfjGe"
- name: MYSQL_DATABASE
value: "ipam"
---
apiVersion: v1
kind: Service
metadata:
name: ipam-ingress-service
namespace: ipam
spec:
selector:
app: ipam
ports:
- protocol: TCP
port: 80
targetPort: 5000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ipam-ingress
namespace: ipam
spec:
rules:
- host: ipam.jdb143.uk
http:
paths:
- pathType: Prefix
path: "/"
backend:
service:
name: ipam-ingress-service
port:
number: 80
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>IPAM</title>
<link rel="icon" href="/favicon.ico" type="image/png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body class="bg-surface text-slate-900 dark:text-slate-100 antialiased">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2671
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "ipam-frontend",
"private": true,
"version": "2.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-vue-next": "^0.468.0",
"pinia": "^2.2.6",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"typescript": "~5.6.3",
"vite": "^6.0.3",
"vue-tsc": "^2.1.10"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+6
View File
@@ -0,0 +1,6 @@
<script setup lang="ts">
import { RouterView } from "vue-router";
</script>
<template>
<RouterView />
</template>
+455
View File
@@ -0,0 +1,455 @@
const jsonHeaders = { "Content-Type": "application/json" };
let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(fn: () => void) {
onUnauthorized = fn;
}
async function handle<T>(res: Response): Promise<T> {
if (res.status === 401) {
onUnauthorized?.();
throw new Error("unauthorized");
}
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data as { error?: string }).error || res.statusText);
return data as T;
}
function fetchApi(path: string, init?: RequestInit) {
return fetch(path, { credentials: "include", ...init });
}
export interface MeResponse {
logged_in: boolean;
app_version?: string;
org?: { name: string; logo: string };
user?: { id: number; name: string; email: string };
permissions?: string[];
}
export interface Device {
id: number;
name: string;
description?: string;
ip_addresses?: IpOnDevice[];
tags?: Tag[];
custom_fields?: Record<string, unknown>;
}
export interface IpOnDevice {
id: number;
ip: string;
hostname?: string;
subnet_id?: number;
subnet_name?: string;
cidr?: string;
site?: string;
notes?: string;
}
export interface Subnet {
id: number;
name: string;
cidr: string;
site?: string;
vlan_id?: number;
vlan_description?: string;
vlan_notes?: string;
utilization?: number;
total_ips?: number;
used_ips?: number;
custom_fields?: Record<string, unknown>;
ip_addresses?: SubnetIp[];
}
export interface SubnetIp {
id: number;
ip: string;
hostname?: string;
device_id?: number;
device_name?: string;
notes?: string;
}
export interface Tag {
id: number;
name: string;
color?: string;
description?: string;
}
export interface Rack {
id: number;
name: string;
site: string;
height_u: number;
used_u?: number;
percent_full?: number;
devices?: RackDevice[];
site_devices?: { id: number; name: string; description?: string }[];
}
export interface RackDevice {
id: number;
position_u: number;
side: string;
device_id?: number;
device_name?: string;
nonnet_device_name?: string;
}
export interface AuditEntry {
id: number;
user_name?: string;
action: string;
details?: string;
timestamp?: string;
}
export interface UserRow {
id: number;
name: string;
email: string;
role_id?: number;
role_name?: string;
}
export interface RoleRow {
id: number;
name: string;
description?: string;
require_2fa?: boolean;
permissions?: { id: number; name: string; category?: string }[];
}
export interface CustomFieldDef {
id: number;
entity_type: string;
name: string;
field_key: string;
field_type: string;
required?: boolean;
display_order?: number;
default_value?: string;
help_text?: string;
validation_rules?: { select_options?: string[] };
}
export interface AuditParams {
limit?: number;
offset?: number;
user?: string;
action?: string;
from?: string;
to?: string;
}
export const api = {
async me(): Promise<MeResponse> {
return handle(await fetchApi("/api/v2/auth/me"));
},
async login(email: string, password: string) {
return handle<{ ok?: boolean; requires_2fa?: boolean; requires_setup?: boolean }>(
await fetchApi("/api/v2/auth/login", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ email, password }),
}),
);
},
async verify2fa(code: string, useBackup = false) {
return handle(await fetchApi("/api/v2/auth/verify-2fa", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ code, use_backup: useBackup }),
}));
},
async setup2fa(action: "generate" | "verify", code?: string) {
return handle<{ secret?: string; qr_code?: string; backup_codes?: string[] }>(
await fetchApi("/api/v2/auth/setup-2fa", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ action, code }),
}),
);
},
async logout() {
return handle(await fetchApi("/api/v2/auth/logout", { method: "POST" }));
},
async 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) {
return handle<Record<string, unknown[]>>(await fetchApi(`/api/v2/search?q=${encodeURIComponent(q)}`));
},
async devices(params?: { tag?: string; site?: string }) {
const p = new URLSearchParams();
if (params?.tag) p.set("tag", params.tag);
if (params?.site) p.set("site", params.site);
const q = p.toString();
const d = await handle<{ items: Device[] }>(await fetchApi(`/api/v2/devices${q ? `?${q}` : ""}`));
return d.items;
},
async device(id: number) {
return handle<Device>(await fetchApi(`/api/v2/devices/${id}`));
},
async createDevice(body: Partial<Device>) {
return handle(await fetchApi("/api/v2/devices", { method: "POST", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async updateDevice(id: number, body: Partial<Device>) {
return handle(await fetchApi(`/api/v2/devices/${id}`, { method: "PUT", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async deleteDevice(id: number) {
return handle(await fetchApi(`/api/v2/devices/${id}`, { method: "DELETE" }));
},
async assignIp(deviceId: number, ipId: number) {
return handle(await fetchApi(`/api/v2/devices/${deviceId}/ips`, {
method: "POST", headers: jsonHeaders, body: JSON.stringify({ ip_id: ipId }),
}));
},
async removeIp(deviceId: number, ipId: number) {
return handle(await fetchApi(`/api/v2/devices/${deviceId}/ips/${ipId}`, { method: "DELETE" }));
},
async deviceIpHistory(deviceId: number) {
const d = await handle<{ items: unknown[] }>(await fetchApi(`/api/v2/devices/${deviceId}/ip-history`));
return d.items;
},
async subnets(includeUtil = true) {
const d = await handle<{ items: Subnet[] }>(
await fetchApi(`/api/v2/subnets${includeUtil ? "?include=utilization" : ""}`),
);
return d.items;
},
async subnet(id: number) {
return handle<Subnet>(await fetchApi(`/api/v2/subnets/${id}`));
},
async createSubnet(body: Partial<Subnet>) {
return handle(await fetchApi("/api/v2/subnets", { method: "POST", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async updateSubnet(id: number, body: Partial<Subnet>) {
return handle(await fetchApi(`/api/v2/subnets/${id}`, { method: "PUT", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async deleteSubnet(id: number) {
return handle(await fetchApi(`/api/v2/subnets/${id}`, { method: "DELETE" }));
},
async availableIps(subnetId: number) {
const d = await handle<{ items: { id: number; ip: string }[] }>(await fetchApi(`/api/v2/subnets/${subnetId}/available-ips`));
return d.items;
},
async patchIpNotes(ipId: number, notes: string) {
return handle(await fetchApi(`/api/v2/ip-addresses/${ipId}`, {
method: "PATCH", headers: jsonHeaders, body: JSON.stringify({ notes }),
}));
},
async ipHistory(ip: string) {
const d = await handle<{ items: unknown[] }>(await fetchApi(`/api/v2/ips/${encodeURIComponent(ip)}/history`));
return d.items;
},
subnetExportUrl(id: number) {
return `/api/v2/subnets/${id}/export`;
},
async tags() {
const d = await handle<{ items: Tag[] }>(await fetchApi("/api/v2/tags"));
return d.items;
},
async createTag(body: Partial<Tag>) {
return handle(await fetchApi("/api/v2/tags", { method: "POST", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async updateTag(id: number, body: Partial<Tag>) {
return handle(await fetchApi(`/api/v2/tags/${id}`, { method: "PUT", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async deleteTag(id: number) {
return handle(await fetchApi(`/api/v2/tags/${id}`, { method: "DELETE" }));
},
async assignTag(deviceId: number, tagId: number) {
return handle(await fetchApi(`/api/v2/devices/${deviceId}/tags`, {
method: "POST", headers: jsonHeaders, body: JSON.stringify({ tag_id: tagId }),
}));
},
async removeTag(deviceId: number, tagId: number) {
return handle(await fetchApi(`/api/v2/devices/${deviceId}/tags/${tagId}`, { method: "DELETE" }));
},
async racks() {
const d = await handle<{ items: Rack[] }>(await fetchApi("/api/v2/racks"));
return d.items;
},
async rack(id: number) {
return handle<Rack>(await fetchApi(`/api/v2/racks/${id}`));
},
async createRack(body: Partial<Rack>) {
return handle(await fetchApi("/api/v2/racks", { method: "POST", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async deleteRack(id: number) {
return handle(await fetchApi(`/api/v2/racks/${id}`, { method: "DELETE" }));
},
async updateRack(id: number, body: Partial<Rack>) {
return handle(await fetchApi(`/api/v2/racks/${id}`, { method: "PUT", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async addRackDevice(rackId: number, body: { position_u: number; side: string; device_id?: number; nonnet_device_name?: string }) {
return handle(await fetchApi(`/api/v2/racks/${rackId}/devices`, { method: "POST", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async removeRackDevice(rackId: number, rackDeviceId: number) {
return handle(await fetchApi(`/api/v2/racks/${rackId}/devices/${rackDeviceId}`, { method: "DELETE" }));
},
rackExportUrl(id: number) {
return `/api/v2/racks/${id}/export`;
},
async createCustomField(body: Record<string, unknown>) {
return handle(await fetchApi("/api/v2/custom_fields", { method: "POST", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async updateCustomField(id: number, body: Record<string, unknown>) {
return handle(await fetchApi(`/api/v2/custom_fields/${id}`, { method: "PUT", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async deleteCustomField(id: number) {
return handle(await fetchApi(`/api/v2/custom_fields/${id}`, { method: "DELETE" }));
},
async reorderCustomFields(entityType: string, fieldOrders: Record<number, number>) {
return handle(await fetchApi("/api/v2/custom-fields/reorder", {
method: "POST", headers: jsonHeaders, body: JSON.stringify({ entity_type: entityType, field_orders: fieldOrders }),
}));
},
async createUser(body: { name: string; email: string; password: string; role_id?: number }) {
return handle(await fetchApi("/api/v2/users", { method: "POST", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async updateUser(id: number, body: Record<string, unknown>) {
return handle(await fetchApi(`/api/v2/users/${id}`, { method: "PUT", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async deleteUser(id: number) {
return handle(await fetchApi(`/api/v2/users/${id}`, { method: "DELETE" }));
},
async regenerateApiKey(userId: number) {
return handle<{ api_key: string }>(await fetchApi(`/api/v2/users/${userId}/regenerate-api-key`, { method: "POST" }));
},
async createRole(body: { name: string; description?: string; permission_ids?: number[]; require_2fa?: boolean }) {
return handle(await fetchApi("/api/v2/roles", { method: "POST", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async updateRole(id: number, body: Record<string, unknown>) {
return handle(await fetchApi(`/api/v2/roles/${id}`, { method: "PUT", headers: jsonHeaders, body: JSON.stringify(body) }));
},
async deleteRole(id: number) {
return handle(await fetchApi(`/api/v2/roles/${id}`, { method: "DELETE" }));
},
async disable2fa(password: string) {
return handle(await fetchApi("/api/v2/account/disable-2fa", {
method: "POST", headers: jsonHeaders, body: JSON.stringify({ password }),
}));
},
async regenerateBackupCodes(password: string) {
return handle<{ backup_codes: string[] }>(await fetchApi("/api/v2/account/regenerate-backup-codes", {
method: "POST", headers: jsonHeaders, body: JSON.stringify({ password }),
}));
},
async audit(params: AuditParams = {}) {
const p = new URLSearchParams();
if (params.limit != null) p.set("limit", String(params.limit));
if (params.offset != null) p.set("offset", String(params.offset));
if (params.user) p.set("user", params.user);
if (params.action) p.set("action", params.action);
if (params.from) p.set("from", params.from);
if (params.to) p.set("to", params.to);
const q = p.toString();
return handle<{ items: AuditEntry[]; total: number }>(await fetchApi(`/api/v2/audit${q ? `?${q}` : ""}`));
},
async auditActions() {
const d = await handle<{ items: string[] }>(await fetchApi("/api/v2/audit/actions"));
return d.items;
},
auditExportUrl(params: AuditParams = {}) {
const p = new URLSearchParams();
if (params.user) p.set("user", params.user);
if (params.action) p.set("action", params.action);
if (params.from) p.set("from", params.from);
if (params.to) p.set("to", params.to);
const q = p.toString();
return `/api/v2/audit/export${q ? `?${q}` : ""}`;
},
async users() {
const d = await handle<{ items: UserRow[] }>(await fetchApi("/api/v2/users"));
return d.items;
},
async roles() {
const d = await handle<{ items: RoleRow[] }>(await fetchApi("/api/v2/roles"));
return d.items;
},
async permissions() {
const d = await handle<{ items: { id: number; name: string; category?: string }[] }>(
await fetchApi("/api/v2/permissions"),
);
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) {
const d = await handle<{ items: CustomFieldDef[] }>(await fetchApi(`/api/v2/custom_fields/${entityType}`));
return d.items;
},
async patchDeviceCustomFields(deviceId: number, customFields: Record<string, unknown>) {
return handle(await fetchApi(`/api/v2/devices/${deviceId}/custom-fields`, {
method: "PATCH", headers: jsonHeaders, body: JSON.stringify({ custom_fields: customFields }),
}));
},
async patchSubnetCustomFields(subnetId: number, customFields: Record<string, unknown>) {
return handle(await fetchApi(`/api/v2/subnets/${subnetId}/custom-fields`, {
method: "PATCH", headers: jsonHeaders, body: JSON.stringify({ custom_fields: customFields }),
}));
},
async bulkAssignIps(deviceId: number, ipIds: number[]) {
return handle(await fetchApi("/api/v2/bulk/assign-ips", {
method: "POST", headers: jsonHeaders, body: JSON.stringify({ device_id: deviceId, ip_ids: ipIds }),
}));
},
async bulkCreateDevices(names: string[]) {
return handle(await fetchApi("/api/v2/bulk/create-devices", {
method: "POST", headers: jsonHeaders, body: JSON.stringify({ names }),
}));
},
async bulkAssignTags(deviceIds: number[], tagId: number) {
return handle(await fetchApi("/api/v2/bulk/assign-tags", {
method: "POST", headers: jsonHeaders, body: JSON.stringify({ device_ids: deviceIds, tag_id: tagId }),
}));
},
async account() {
return handle(await fetchApi("/api/v2/account"));
},
async changePassword(current: string, newPw: string) {
return handle(await fetchApi("/api/v2/account/change-password", {
method: "POST", headers: jsonHeaders, body: JSON.stringify({ current_password: current, new_password: newPw }),
}));
},
async getDhcp(subnetId: number) {
return handle(await fetchApi(`/api/v2/subnets/${subnetId}/dhcp`));
},
async setDhcp(subnetId: number, body: unknown) {
return handle(await fetchApi(`/api/v2/subnets/${subnetId}/dhcp`, {
method: "POST", headers: jsonHeaders, body: JSON.stringify(body),
}));
},
};
+218
View File
@@ -0,0 +1,218 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
import { RouterLink, RouterView, useRoute, useRouter } from "vue-router";
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 { api } from "@/api";
const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const sidebarOpen = ref(false);
const searchOpen = ref(false);
const searchQ = ref("");
const searchInput = ref<HTMLInputElement | null>(null);
const searchResults = ref<Record<string, unknown[]>>({});
const searchLoading = ref(false);
const nav = computed(() =>
[
{ 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: "/racks", label: "Racks", icon: Grid3x3, perm: "view_racks" },
{ to: "/tags", label: "Tags", icon: Tag, perm: "view_tags" },
{ to: "/audit", label: "Audit", icon: FileText, perm: "view_audit" },
{ to: "/subnets/manage", label: "Subnet Management", icon: Settings, perm: "view_admin" },
{ 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: "/account", label: "Account", icon: User, perm: null },
].filter((n) => !n.perm || auth.can(n.perm)),
);
const hasResults = computed(() =>
Object.values(searchResults.value).some((items) => items.length > 0),
);
let searchTimer: ReturnType<typeof setTimeout> | null = null;
async function logout() {
await auth.logout();
router.push("/login");
}
function openSearch() {
searchOpen.value = true;
searchQ.value = "";
searchResults.value = {};
nextTick(() => searchInput.value?.focus());
}
function closeSearch() {
searchOpen.value = false;
}
async function runSearch() {
const q = searchQ.value.trim();
if (!q) {
searchResults.value = {};
return;
}
searchLoading.value = true;
try {
searchResults.value = await api.search(q);
} finally {
searchLoading.value = false;
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "/" && !["INPUT", "TEXTAREA"].includes((e.target as HTMLElement)?.tagName)) {
e.preventDefault();
openSearch();
}
if (e.key === "Escape" && searchOpen.value) {
closeSearch();
}
}
watch(searchQ, () => {
if (!searchOpen.value) return;
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(runSearch, 250);
});
onMounted(() => window.addEventListener("keydown", onKeydown));
onUnmounted(() => {
window.removeEventListener("keydown", onKeydown);
if (searchTimer) clearTimeout(searchTimer);
});
</script>
<template>
<div class="flex h-screen overflow-hidden bg-surface font-sans">
<!-- Mobile overlay -->
<div v-if="sidebarOpen" class="fixed inset-0 z-40 bg-black/50 lg:hidden" @click="sidebarOpen = false" />
<!-- Sidebar -->
<aside
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'"
>
<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-7 shrink-0 rounded" />
<div class="min-w-0 flex-1 leading-tight">
<div class="truncate text-sm font-semibold">{{ auth.org.name }} IPAM</div>
<div class="text-xs text-slate-500">{{ auth.version }}</div>
</div>
<button class="lg:hidden" @click="sidebarOpen = false"><X class="h-5 w-5" /></button>
</div>
<nav class="min-h-0 flex-1 overflow-y-auto p-2">
<RouterLink
v-for="item in nav"
:key="item.to"
:to="item.to"
class="mb-0.5 flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition"
:class="(item.match ? item.match(route.path) : route.path === item.to || route.path.startsWith(item.to + '/'))
? 'bg-accent/15 text-accent font-medium'
: 'text-slate-600 hover:bg-surface-overlay dark:text-slate-400'"
@click="sidebarOpen = false"
>
<component :is="item.icon" class="h-4 w-4 shrink-0" />
{{ item.label }}
</RouterLink>
</nav>
<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>
<button class="mt-2 text-xs text-accent hover:underline" @click="logout">Sign out</button>
</div>
</aside>
<!-- Main -->
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
<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>
<span class="font-semibold lg:hidden">{{ auth.org.name }} IPAM</span>
<button
class="ml-auto rounded-lg p-2 text-slate-600 transition hover:bg-surface-overlay hover:text-accent dark:text-slate-400"
title="Search (/)"
@click="openSearch"
>
<Search class="h-5 w-5" />
</button>
</header>
<main class="min-h-0 flex-1 overflow-auto p-4 md:p-6">
<RouterView />
</main>
</div>
<!-- Search modal -->
<div v-if="searchOpen" class="fixed inset-0 z-50 flex items-start justify-center bg-black/40 p-4 pt-[10vh]" @click.self="closeSearch">
<div class="card flex max-h-[75vh] w-full max-w-xl flex-col">
<div class="flex items-center gap-2">
<Search class="h-5 w-5 shrink-0 text-slate-400" />
<input
ref="searchInput"
v-model="searchQ"
class="input-field flex-1 border-0 bg-transparent px-0 shadow-none focus:ring-0"
placeholder="Search subnets, IPs, devices…"
autofocus
@keydown.esc="closeSearch"
/>
<button class="rounded-lg p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200" @click="closeSearch">
<X class="h-5 w-5" />
</button>
</div>
<p class="mt-1 text-xs text-slate-500">Press <kbd class="rounded bg-surface-overlay px-1">/</kbd> to open · <kbd class="rounded bg-surface-overlay px-1">Esc</kbd> to close</p>
<div v-if="searchLoading" class="mt-4 text-sm text-slate-500">Searching</div>
<div v-else-if="searchQ.trim() && !hasResults" class="mt-4 text-sm text-slate-500">No results</div>
<div v-else-if="hasResults" class="mt-4 -mx-1 flex-1 space-y-4 overflow-y-auto px-1">
<section v-if="searchResults.devices?.length">
<h2 class="text-xs font-semibold uppercase tracking-wide text-slate-500">Devices</h2>
<ul class="mt-1">
<li v-for="d in searchResults.devices as { id: number; name: string }[]" :key="d.id">
<RouterLink :to="`/devices/${d.id}`" class="block rounded-lg px-2 py-1.5 text-sm hover:bg-surface-overlay" @click="closeSearch">{{ d.name }}</RouterLink>
</li>
</ul>
</section>
<section v-if="searchResults.subnets?.length">
<h2 class="text-xs font-semibold uppercase tracking-wide text-slate-500">Subnets</h2>
<ul class="mt-1">
<li v-for="s in searchResults.subnets as { id: number; name: string; cidr: string }[]" :key="s.id">
<RouterLink :to="`/subnets/${s.id}`" class="block rounded-lg px-2 py-1.5 text-sm hover:bg-surface-overlay" @click="closeSearch">{{ s.name }} <span class="font-mono text-slate-500">({{ s.cidr }})</span></RouterLink>
</li>
</ul>
</section>
<section v-if="searchResults.ips?.length">
<h2 class="text-xs font-semibold uppercase tracking-wide text-slate-500">IPs</h2>
<ul class="mt-1">
<li v-for="ip in searchResults.ips as { ip: string; subnet_id: number; hostname?: string }[]" :key="ip.ip">
<RouterLink :to="`/subnets/${ip.subnet_id}`" class="block rounded-lg px-2 py-1.5 font-mono text-sm hover:bg-surface-overlay" @click="closeSearch">
{{ ip.ip }}<span v-if="ip.hostname" class="ml-2 font-sans text-slate-500">{{ ip.hostname }}</span>
</RouterLink>
</li>
</ul>
</section>
<section v-if="searchResults.racks?.length">
<h2 class="text-xs font-semibold uppercase tracking-wide text-slate-500">Racks</h2>
<ul class="mt-1">
<li v-for="r in searchResults.racks as { id: number; name: string; site: string }[]" :key="r.id">
<RouterLink :to="`/racks/${r.id}`" class="block rounded-lg px-2 py-1.5 text-sm hover:bg-surface-overlay" @click="closeSearch">{{ r.name }} <span class="text-slate-500">· {{ r.site }}</span></RouterLink>
</li>
</ul>
</section>
<section v-if="searchResults.tags?.length">
<h2 class="text-xs font-semibold uppercase tracking-wide text-slate-500">Tags</h2>
<ul class="mt-1">
<li v-for="t in searchResults.tags as { id: number; name: string }[]" :key="t.id">
<RouterLink to="/tags" class="block rounded-lg px-2 py-1.5 text-sm hover:bg-surface-overlay" @click="closeSearch">{{ t.name }}</RouterLink>
</li>
</ul>
</section>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,129 @@
<script setup lang="ts">
import { ref, onMounted, computed, watch } from "vue";
import { api, type CustomFieldDef } from "@/api";
const props = defineProps<{
entityType: "device" | "subnet";
entityId: number;
values?: Record<string, unknown>;
canEdit: boolean;
}>();
const emit = defineEmits<{ saved: [values: Record<string, unknown>] }>();
const fields = ref<CustomFieldDef[]>([]);
const form = ref<Record<string, unknown>>({});
const loading = ref(true);
const saving = ref(false);
const err = ref("");
const msg = ref("");
const visible = computed(() => fields.value.length > 0 || Object.keys(props.values ?? {}).length > 0);
function initForm() {
const next: Record<string, unknown> = {};
for (const f of fields.value) {
const existing = props.values?.[f.field_key];
if (existing !== undefined && existing !== null) {
next[f.field_key] = existing;
} else if (f.default_value) {
next[f.field_key] = f.field_type === "checkbox" ? f.default_value === "true" : f.default_value;
} else {
next[f.field_key] = f.field_type === "checkbox" ? false : "";
}
}
form.value = next;
}
async function loadFields() {
loading.value = true;
err.value = "";
try {
fields.value = await api.customFields(props.entityType);
initForm();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed to load fields";
fields.value = [];
} finally {
loading.value = false;
}
}
onMounted(loadFields);
watch(() => props.values, () => {
if (fields.value.length) initForm();
}, { deep: true });
async function save() {
if (!props.canEdit) return;
saving.value = true;
err.value = "";
msg.value = "";
try {
const payload = { ...form.value };
if (props.entityType === "device") {
await api.patchDeviceCustomFields(props.entityId, payload);
} else {
await api.patchSubnetCustomFields(props.entityId, payload);
}
msg.value = "Saved";
emit("saved", payload);
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed to save";
} finally {
saving.value = false;
}
}
</script>
<template>
<div v-if="visible" class="card">
<h2 class="font-semibold">Custom fields</h2>
<p v-if="loading" class="mt-2 text-sm text-slate-500">Loading</p>
<form v-else class="mt-3 space-y-3" @submit.prevent="save">
<div v-for="f in fields" :key="f.id">
<label class="mb-1 block text-sm font-medium">
{{ f.name }}<span v-if="f.required" class="text-red-500"> *</span>
</label>
<p v-if="f.help_text" class="mb-1 text-xs text-slate-500">{{ f.help_text }}</p>
<template v-if="canEdit">
<textarea
v-if="f.field_type === 'textarea'"
v-model="form[f.field_key]"
class="input-field"
:required="f.required"
/>
<select
v-else-if="f.field_type === 'select'"
v-model="form[f.field_key]"
class="input-field"
:required="f.required"
>
<option value=""></option>
<option v-for="opt in f.validation_rules?.select_options ?? []" :key="opt" :value="opt">{{ opt }}</option>
</select>
<label v-else-if="f.field_type === 'checkbox'" class="flex items-center gap-2 text-sm">
<input v-model="form[f.field_key]" type="checkbox" />
<span>Enabled</span>
</label>
<input
v-else
v-model="form[f.field_key]"
class="input-field"
:type="f.field_type === 'number' ? 'number' : f.field_type === 'date' ? 'date' : 'text'"
:required="f.required"
/>
</template>
<p v-else class="text-sm text-slate-600 dark:text-slate-400">
{{ f.field_type === 'checkbox' ? (form[f.field_key] ? 'Yes' : 'No') : (form[f.field_key] || '—') }}
</p>
</div>
<div v-if="canEdit && fields.length" class="flex gap-2">
<button type="submit" class="btn-primary text-sm" :disabled="saving">Save fields</button>
</div>
<p v-if="msg" class="text-sm text-accent">{{ msg }}</p>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
</form>
</div>
</template>
+152
View File
@@ -0,0 +1,152 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { X } from "lucide-vue-next";
import { api } from "@/api";
import { useAuthStore } from "@/stores/auth";
const props = defineProps<{
open: boolean;
subnetId: number | null;
}>();
const emit = defineEmits<{ close: []; saved: [] }>();
const auth = useAuthStore();
const loading = ref(false);
const saving = ref(false);
const err = ref("");
const msg = ref("");
const hasPool = ref(false);
const form = ref({ start_ip: "", end_ip: "", excluded_ips: "" });
const canEdit = () => auth.can("configure_dhcp");
async function loadPool() {
if (!props.subnetId) return;
loading.value = true;
err.value = "";
msg.value = "";
hasPool.value = false;
form.value = { start_ip: "", end_ip: "", excluded_ips: "" };
try {
const d = await api.getDhcp(props.subnetId) as { pools?: { start_ip: string; end_ip: string; excluded_ips?: string }[] };
if (d.pools?.[0]) {
hasPool.value = true;
form.value.start_ip = d.pools[0].start_ip;
form.value.end_ip = d.pools[0].end_ip;
form.value.excluded_ips = d.pools[0].excluded_ips || "";
}
} catch (e) {
if (auth.can("view_dhcp")) {
err.value = e instanceof Error ? e.message : "Failed to load DHCP pool";
}
} finally {
loading.value = false;
}
}
watch(
() => [props.open, props.subnetId] as const,
([open]) => {
if (open) loadPool();
},
);
async function save() {
if (!props.subnetId || !canEdit()) return;
saving.value = true;
err.value = "";
msg.value = "";
try {
await api.setDhcp(props.subnetId, {
pools: [{
start_ip: form.value.start_ip,
end_ip: form.value.end_ip,
excluded_ips: form.value.excluded_ips.split(",").map((s) => s.trim()).filter(Boolean),
}],
});
hasPool.value = true;
msg.value = "Saved";
emit("saved");
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed to save";
} finally {
saving.value = false;
}
}
async function remove() {
if (!props.subnetId || !canEdit() || !confirm("Remove this DHCP pool?")) return;
saving.value = true;
err.value = "";
msg.value = "";
try {
await api.setDhcp(props.subnetId, { remove: true });
hasPool.value = false;
form.value = { start_ip: "", end_ip: "", excluded_ips: "" };
msg.value = "Removed";
emit("saved");
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed to remove";
} finally {
saving.value = false;
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "Escape") emit("close");
}
</script>
<template>
<Teleport to="body">
<div
v-if="open"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
@click.self="emit('close')"
@keydown="onKeydown"
>
<form class="card w-full max-w-lg space-y-4 shadow-xl" @submit.prevent="save">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">DHCP pool</h2>
<button type="button" class="rounded-lg p-1 hover:bg-surface-overlay" aria-label="Close" @click="emit('close')">
<X class="h-5 w-5" />
</button>
</div>
<p v-if="loading" class="text-sm text-slate-500">Loading</p>
<template v-else>
<input
v-model="form.start_ip"
class="input-field"
placeholder="Start IP"
required
:disabled="!canEdit()"
/>
<input
v-model="form.end_ip"
class="input-field"
placeholder="End IP"
required
:disabled="!canEdit()"
/>
<input
v-model="form.excluded_ips"
class="input-field"
placeholder="Excluded IPs (comma-separated)"
:disabled="!canEdit()"
/>
<div v-if="canEdit()" class="flex gap-2">
<button type="submit" class="btn-primary" :disabled="saving">Save</button>
<button v-if="hasPool" type="button" class="btn-secondary" :disabled="saving" @click="remove">Remove pool</button>
<button type="button" class="btn-secondary" @click="emit('close')">Cancel</button>
</div>
<div v-else class="flex justify-end">
<button type="button" class="btn-secondary" @click="emit('close')">Close</button>
</div>
<p v-if="msg" class="text-sm text-accent">{{ msg }}</p>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
</template>
</form>
</div>
</Teleport>
</template>
+106
View File
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { X } from "lucide-vue-next";
import { api } from "@/api";
import { formatLocalTime } from "@/utils/datetime";
export interface IpHistoryEntry {
ip: string;
action: "assigned" | "removed";
device_name: string;
subnet_name?: string;
subnet_cidr?: string;
user_name?: string;
timestamp?: string;
}
const props = defineProps<{
ip: string | null;
}>();
const emit = defineEmits<{ close: [] }>();
const loading = ref(false);
const error = ref("");
const history = ref<IpHistoryEntry[]>([]);
watch(
() => props.ip,
async (ip) => {
if (!ip) {
history.value = [];
error.value = "";
return;
}
loading.value = true;
error.value = "";
try {
history.value = (await api.ipHistory(ip)) as IpHistoryEntry[];
} catch (e) {
error.value = e instanceof Error ? e.message : "Failed to load history";
history.value = [];
} finally {
loading.value = false;
}
},
{ immediate: true },
);
function formatTime(ts?: string) {
return formatLocalTime(ts, "Unknown");
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "Escape") emit("close");
}
</script>
<template>
<Teleport to="body">
<div
v-if="ip"
class="fixed inset-0 z-50 flex items-end justify-center bg-black/50 p-4 sm:items-center"
@click.self="emit('close')"
@keydown="onKeydown"
>
<div class="card max-h-[80vh] w-full max-w-lg overflow-hidden p-0 shadow-xl">
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-3 dark:border-slate-700">
<h2 class="font-semibold">IP history · <span class="font-mono text-accent">{{ ip }}</span></h2>
<button type="button" class="rounded-lg p-1 hover:bg-surface-overlay" aria-label="Close" @click="emit('close')">
<X class="h-5 w-5" />
</button>
</div>
<div class="max-h-[60vh] overflow-y-auto p-4">
<p v-if="loading" class="text-center text-sm text-slate-500">Loading</p>
<p v-else-if="error" class="text-center text-sm text-red-500">{{ error }}</p>
<p v-else-if="history.length === 0" class="text-center text-sm text-slate-500">No assignment history for this address.</p>
<ul v-else class="space-y-3">
<li
v-for="(entry, i) in history"
:key="i"
class="flex gap-3 border-b border-slate-100 pb-3 last:border-0 dark:border-slate-800"
>
<span
class="mt-0.5 shrink-0 text-xs font-semibold uppercase"
:class="entry.action === 'assigned' ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-500'"
>
{{ entry.action === "assigned" ? "Assigned" : "Removed" }}
</span>
<div class="min-w-0 flex-1 text-sm">
<div>
<span class="font-medium">{{ entry.device_name }}</span>
<span v-if="entry.subnet_name" class="text-slate-500">
· {{ entry.subnet_name }}<span v-if="entry.subnet_cidr"> ({{ entry.subnet_cidr }})</span>
</span>
</div>
<div class="mt-1 text-xs text-slate-500">
{{ entry.user_name || "Unknown" }} · {{ formatTime(entry.timestamp) }}
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
</Teleport>
</template>
+21
View File
@@ -0,0 +1,21 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import { setUnauthorizedHandler } from "./api";
import { useAuthStore } from "./stores/auth";
import "./style.css";
const pinia = createPinia();
const app = createApp(App);
app.use(pinia).use(router);
setUnauthorizedHandler(() => {
const auth = useAuthStore();
const current = router.currentRoute.value;
if (current.meta.public) return;
auth.logout();
router.push({ name: "login", query: { redirect: current.fullPath } });
});
app.mount("#app");
+47
View File
@@ -0,0 +1,47 @@
import { createRouter, createWebHistory } from "vue-router";
import { useAuthStore } from "@/stores/auth";
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/login", name: "login", component: () => import("@/views/LoginView.vue"), meta: { public: true } },
{ path: "/verify-2fa", name: "verify-2fa", component: () => import("@/views/Verify2faView.vue"), meta: { public: true } },
{ path: "/setup-2fa", name: "setup-2fa", component: () => import("@/views/Setup2faView.vue"), meta: { public: true } },
{
path: "/",
component: () => import("@/components/AppLayout.vue"),
children: [
{ 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/:id", name: "device", component: () => import("@/views/DeviceDetailView.vue") },
{ path: "subnets/:id", name: "subnet", component: () => import("@/views/SubnetDetailView.vue") },
{ path: "subnets/:id/dhcp", redirect: (to) => `/subnets/${to.params.id}` },
{ path: "racks", name: "racks", component: () => import("@/views/RacksView.vue") },
{ path: "racks/:id", name: "rack", component: () => import("@/views/RackDetailView.vue") },
{ path: "search", redirect: "/" },
{ path: "tags", name: "tags", component: () => import("@/views/TagsView.vue") },
{ path: "device-types", redirect: "/devices" },
{ path: "custom-fields", name: "custom-fields", component: () => import("@/views/CustomFieldsView.vue") },
{ path: "bulk", redirect: "/devices" },
{ path: "audit", name: "audit", component: () => import("@/views/AuditView.vue") },
{ path: "subnets/manage", name: "subnet-management", component: () => import("@/views/SubnetsView.vue") },
{ path: "admin", redirect: "/subnets/manage" },
{ 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") },
],
},
],
});
router.beforeEach(async (to) => {
const auth = useAuthStore();
if (!auth.loaded) await auth.fetchMe().catch(() => {});
if (to.meta.public) return true;
if (!auth.loggedIn) return { name: "login", query: { redirect: to.fullPath } };
return true;
});
export { router };
export default router;
+36
View File
@@ -0,0 +1,36 @@
import { defineStore } from "pinia";
import { api, type MeResponse } from "@/api";
export const useAuthStore = defineStore("auth", {
state: () => ({
loaded: false,
loggedIn: false,
user: null as MeResponse["user"] | null,
permissions: [] as string[],
org: { name: "IPAM", logo: "" },
version: "unknown",
}),
getters: {
can: (state) => (perm: string) => state.permissions.includes(perm),
},
actions: {
async fetchMe() {
const data = await api.me();
this.loaded = true;
this.loggedIn = data.logged_in;
this.user = data.user ?? null;
this.permissions = data.permissions ?? [];
this.org = data.org ?? this.org;
this.version = data.app_version ?? "unknown";
},
async login(email: string, password: string) {
return api.login(email, password);
},
async logout() {
await api.logout();
this.loggedIn = false;
this.user = null;
this.permissions = [];
},
},
});
+37
View File
@@ -0,0 +1,37 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--surface: 248 250 252;
--surface-raised: 255 255 255;
--surface-overlay: 241 245 249;
--accent: 6 182 212;
--accent-muted: 8 145 178;
}
@media (prefers-color-scheme: dark) {
:root {
--surface: 15 20 25;
--surface-raised: 21 28 36;
--surface-overlay: 26 35 46;
--accent: 34 211 238;
--accent-muted: 6 182 212;
}
}
}
@layer components {
.btn-primary {
@apply rounded-lg bg-accent px-4 py-2 text-sm font-medium text-slate-950 transition hover:opacity-90 disabled:opacity-50;
}
.btn-secondary {
@apply rounded-lg border border-slate-300 bg-surface-raised px-4 py-2 text-sm font-medium transition hover:bg-surface-overlay dark:border-slate-700;
}
.input-field {
@apply w-full rounded-lg border border-slate-300 bg-surface-overlay px-3 py-2 text-sm outline-none ring-accent focus:border-accent focus:ring-1 dark:border-slate-700;
}
.card {
@apply rounded-xl border border-slate-200 bg-surface-raised p-4 shadow-sm dark:border-slate-800;
}
}
+23
View File
@@ -0,0 +1,23 @@
/** Parse API timestamps (GMT strings, ISO, or naive UTC) for local display. */
export function parseApiTimestamp(ts?: string | null): Date | null {
if (!ts) return null;
const trimmed = ts.trim();
if (!trimmed) return null;
// RFC/GMT strings from Flask/MySQL — parse as-is
if (/GMT|Z|[+-]\d{2}:\d{2}$/.test(trimmed)) {
const d = new Date(trimmed);
if (!Number.isNaN(d.getTime())) return d;
}
// Naive datetime — treat as UTC
const normalized = trimmed.includes("T") ? trimmed : trimmed.replace(" ", "T");
const d = new Date(normalized.endsWith("Z") ? normalized : `${normalized}Z`);
return Number.isNaN(d.getTime()) ? null : d;
}
export function formatLocalTime(ts?: string | null, fallback = "—"): string {
const d = parseApiTimestamp(ts);
if (!d) return ts?.trim() || fallback;
return d.toLocaleString();
}
+108
View File
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { RouterLink } from "vue-router";
import { api } from "@/api";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const profile = ref<{
totp_enabled?: boolean;
role_requires_2fa?: boolean;
backup_codes?: string[];
} | null>(null);
const pw = ref({ current: "", newPw: "" });
const mfaPw = ref("");
const msg = ref("");
const err = ref("");
const newBackupCodes = ref<string[]>([]);
onMounted(async () => { profile.value = await api.account() as typeof profile.value; });
async function changePw() {
err.value = "";
try {
await api.changePassword(pw.value.current, pw.value.newPw);
msg.value = "Password updated";
pw.value = { current: "", newPw: "" };
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
async function disable2fa() {
if (!mfaPw.value || !confirm("Disable two-factor authentication?")) return;
err.value = "";
try {
await api.disable2fa(mfaPw.value);
mfaPw.value = "";
profile.value = await api.account() as typeof profile.value;
msg.value = "2FA disabled";
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
async function regenCodes() {
if (!mfaPw.value || !confirm("Regenerate backup codes? Old codes will stop working.")) return;
err.value = "";
try {
const r = await api.regenerateBackupCodes(mfaPw.value);
newBackupCodes.value = r.backup_codes;
mfaPw.value = "";
profile.value = await api.account() as typeof profile.value;
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
</script>
<template>
<div>
<h1 class="text-2xl font-bold">Account</h1>
<div class="card mt-6 max-w-md space-y-2">
<p><strong>{{ auth.user?.name }}</strong></p>
<p class="text-slate-500">{{ auth.user?.email }}</p>
<p class="text-sm">2FA: {{ profile?.totp_enabled ? "Enabled" : "Disabled" }}</p>
</div>
<div class="card mt-6 max-w-md space-y-4">
<h2 class="font-semibold">Two-factor authentication</h2>
<template v-if="profile?.totp_enabled">
<div v-if="profile.backup_codes?.length">
<p class="text-sm text-slate-500">Backup codes:</p>
<ul class="mt-2 rounded-lg bg-surface-overlay p-3 font-mono text-sm">
<li v-for="c in profile.backup_codes" :key="c">{{ c }}</li>
</ul>
</div>
<div v-if="newBackupCodes.length">
<p class="text-sm font-medium text-accent">New backup codes save these now:</p>
<ul class="mt-2 rounded-lg bg-surface-overlay p-3 font-mono text-sm">
<li v-for="c in newBackupCodes" :key="c">{{ c }}</li>
</ul>
</div>
<input v-model="mfaPw" type="password" class="input-field" placeholder="Password to confirm" />
<div class="flex flex-wrap gap-2">
<button class="btn-secondary text-sm" @click="regenCodes">Regenerate backup codes</button>
<button
v-if="!profile.role_requires_2fa"
class="text-sm text-red-500 hover:underline"
@click="disable2fa"
>Disable 2FA</button>
<p v-else class="text-sm text-slate-500">Your role requires 2FA it cannot be disabled.</p>
</div>
</template>
<template v-else>
<p class="text-sm text-slate-500">Protect your account with an authenticator app.</p>
<RouterLink to="/setup-2fa" class="btn-primary inline-block text-sm">Enable 2FA</RouterLink>
</template>
</div>
<form class="card mt-6 max-w-md space-y-3" @submit.prevent="changePw">
<h2 class="font-semibold">Change password</h2>
<input v-model="pw.current" type="password" class="input-field" placeholder="Current password" />
<input v-model="pw.newPw" type="password" class="input-field" placeholder="New password" />
<button class="btn-primary">Update</button>
<p v-if="msg" class="text-sm text-accent">{{ msg }}</p>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
</form>
</div>
</template>
+151
View File
@@ -0,0 +1,151 @@
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { api, type AuditEntry } from "@/api";
import { formatLocalTime } from "@/utils/datetime";
const logs = ref<AuditEntry[]>([]);
const actions = ref<string[]>([]);
const total = ref(0);
const loading = ref(true);
const error = ref("");
const limit = 50;
const offset = ref(0);
const filters = ref({ user: "", action: "", from: "", to: "" });
const applied = ref({ user: "", action: "", from: "", to: "" });
const exportUrl = computed(() => api.auditExportUrl({
user: applied.value.user || undefined,
action: applied.value.action || undefined,
from: applied.value.from || undefined,
to: applied.value.to || undefined,
}));
const page = computed(() => Math.floor(offset.value / limit) + 1);
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / limit)));
async function load() {
loading.value = true;
error.value = "";
try {
const d = await api.audit({
limit,
offset: offset.value,
user: applied.value.user || undefined,
action: applied.value.action || undefined,
from: applied.value.from || undefined,
to: applied.value.to || undefined,
});
logs.value = d.items;
total.value = d.total;
} catch (e) {
error.value = e instanceof Error ? e.message : "Failed to load audit log";
logs.value = [];
total.value = 0;
} finally {
loading.value = false;
}
}
onMounted(async () => {
try {
actions.value = await api.auditActions();
} catch {
actions.value = [];
}
await load();
});
function applyFilters() {
applied.value = { ...filters.value };
offset.value = 0;
load();
}
function clearFilters() {
filters.value = { user: "", action: "", from: "", to: "" };
applied.value = { user: "", action: "", from: "", to: "" };
offset.value = 0;
load();
}
function prevPage() {
if (offset.value >= limit) {
offset.value -= limit;
load();
}
}
function nextPage() {
if (offset.value + limit < total.value) {
offset.value += limit;
load();
}
}
</script>
<template>
<div>
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-2xl font-bold">Audit log</h1>
<a :href="exportUrl" class="btn-secondary text-sm">Export CSV</a>
</div>
<form class="card mt-6 flex flex-wrap items-end gap-3" @submit.prevent="applyFilters">
<div>
<label class="mb-1 block text-xs text-slate-500">User</label>
<input v-model="filters.user" class="input-field py-1.5 text-sm" placeholder="Name contains…" />
</div>
<div>
<label class="mb-1 block text-xs text-slate-500">Action</label>
<select v-model="filters.action" class="input-field py-1.5 text-sm">
<option value="">All actions</option>
<option v-for="a in actions" :key="a" :value="a">{{ a }}</option>
</select>
</div>
<div>
<label class="mb-1 block text-xs text-slate-500">From</label>
<input v-model="filters.from" type="date" class="input-field py-1.5 text-sm" />
</div>
<div>
<label class="mb-1 block text-xs text-slate-500">To</label>
<input v-model="filters.to" type="date" class="input-field py-1.5 text-sm" />
</div>
<button type="submit" class="btn-primary text-sm">Apply</button>
<button type="button" class="btn-secondary text-sm" @click="clearFilters">Clear</button>
</form>
<p v-if="loading" class="mt-6 text-slate-500">Loading</p>
<p v-else-if="error" class="mt-6 text-red-500">{{ error }}</p>
<div v-else class="card mt-6 overflow-x-auto">
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b dark:border-slate-700">
<th class="p-2">Time</th>
<th class="p-2">User</th>
<th class="p-2">Action</th>
<th class="p-2">Details</th>
</tr>
</thead>
<tbody>
<tr v-for="l in logs" :key="l.id" class="border-b dark:border-slate-800">
<td class="p-2 whitespace-nowrap text-xs text-slate-500">{{ formatLocalTime(l.timestamp) }}</td>
<td class="p-2">{{ l.user_name || "—" }}</td>
<td class="p-2 font-mono text-xs">{{ l.action }}</td>
<td class="p-2 max-w-md truncate">{{ l.details }}</td>
</tr>
<tr v-if="!logs.length">
<td colspan="4" class="p-4 text-center text-slate-500">No audit entries match your filters.</td>
</tr>
</tbody>
</table>
</div>
<div v-if="!loading && !error && total > 0" class="mt-4 flex items-center justify-between text-sm text-slate-500">
<span>{{ total }} entries · page {{ page }} of {{ totalPages }}</span>
<div class="flex gap-2">
<button type="button" class="btn-secondary text-sm" :disabled="offset === 0" @click="prevPage">Previous</button>
<button type="button" class="btn-secondary text-sm" :disabled="offset + limit >= total" @click="nextPage">Next</button>
</div>
</div>
</div>
</template>
+143
View File
@@ -0,0 +1,143 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { api, type CustomFieldDef } from "@/api";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const tab = ref<"device" | "subnet">("device");
const fields = ref<CustomFieldDef[]>([]);
const form = ref({ name: "", field_key: "", field_type: "text", required: false, default_value: "", help_text: "" });
const editForm = ref({ id: 0, name: "", field_key: "", field_type: "text", required: false, default_value: "", help_text: "" });
const showAdd = ref(false);
const showEdit = ref(false);
const err = ref("");
const fieldTypes = ["text", "textarea", "number", "select", "checkbox", "date"];
async function load() {
fields.value = await api.customFields(tab.value);
}
onMounted(load);
async function create() {
err.value = "";
try {
await api.createCustomField({ ...form.value, entity_type: tab.value });
showAdd.value = false;
form.value = { name: "", field_key: "", field_type: "text", required: false, default_value: "", help_text: "" };
await load();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
function openEdit(f: CustomFieldDef) {
editForm.value = {
id: f.id,
name: f.name,
field_key: f.field_key,
field_type: f.field_type,
required: !!f.required,
default_value: "",
help_text: "",
};
showEdit.value = true;
err.value = "";
}
async function saveEdit() {
err.value = "";
try {
await api.updateCustomField(editForm.value.id, {
name: editForm.value.name,
field_type: editForm.value.field_type,
required: editForm.value.required,
default_value: editForm.value.default_value || null,
help_text: editForm.value.help_text || null,
});
showEdit.value = false;
await load();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
async function del(id: number) {
if (!confirm("Delete this custom field?")) return;
await api.deleteCustomField(id);
await load();
}
async function moveField(index: number, dir: -1 | 1) {
const target = index + dir;
if (target < 0 || target >= fields.value.length) return;
const reordered = [...fields.value];
const [item] = reordered.splice(index, 1);
reordered.splice(target, 0, item);
const orders: Record<number, number> = {};
reordered.forEach((f, i) => { orders[f.id] = i; });
await api.reorderCustomFields(tab.value, orders);
fields.value = reordered;
}
</script>
<template>
<div>
<h1 class="text-2xl font-bold">Custom fields</h1>
<div class="mt-4 flex flex-wrap items-center gap-2">
<button class="rounded-lg px-3 py-1 text-sm" :class="tab === 'device' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="tab = 'device'; load()">Device</button>
<button class="rounded-lg px-3 py-1 text-sm" :class="tab === 'subnet' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="tab = 'subnet'; load()">Subnet</button>
<button v-if="auth.can('manage_custom_fields')" class="btn-primary ml-auto text-sm" @click="showAdd = true; err = ''">Add field</button>
</div>
<ul class="mt-6 space-y-2">
<li v-for="(f, i) in fields" :key="f.id" class="card flex flex-wrap items-center justify-between gap-2">
<span>{{ f.name }} <span class="text-slate-500">({{ f.field_type }})</span></span>
<span class="font-mono text-xs text-slate-500">{{ f.field_key }}</span>
<div v-if="auth.can('manage_custom_fields')" class="flex gap-2">
<button class="text-sm text-slate-500 hover:underline" :disabled="i === 0" @click="moveField(i, -1)"></button>
<button class="text-sm text-slate-500 hover:underline" :disabled="i === fields.length - 1" @click="moveField(i, 1)"></button>
<button class="text-sm text-accent hover:underline" @click="openEdit(f)">Edit</button>
<button class="text-sm text-red-500 hover:underline" @click="del(f.id)">Delete</button>
</div>
</li>
</ul>
<div v-if="showAdd" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showAdd = false">
<form class="card w-full max-w-md space-y-3" @submit.prevent="create">
<h2 class="text-lg font-semibold">Add custom field</h2>
<input v-model="form.name" class="input-field" placeholder="Display name" required />
<input v-model="form.field_key" class="input-field font-mono text-sm" placeholder="field_key" required />
<select v-model="form.field_type" class="input-field">
<option v-for="t in fieldTypes" :key="t" :value="t">{{ t }}</option>
</select>
<label class="flex items-center gap-2 text-sm"><input v-model="form.required" type="checkbox" /> Required</label>
<input v-model="form.default_value" class="input-field" placeholder="Default value" />
<input v-model="form.help_text" class="input-field" placeholder="Help text" />
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<div class="flex gap-2">
<button type="submit" class="btn-primary">Create</button>
<button type="button" class="btn-secondary" @click="showAdd = false">Cancel</button>
</div>
</form>
</div>
<div v-if="showEdit" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showEdit = false">
<form class="card w-full max-w-md space-y-3" @submit.prevent="saveEdit">
<h2 class="text-lg font-semibold">Edit custom field</h2>
<input v-model="editForm.name" class="input-field" required />
<input v-model="editForm.field_key" class="input-field font-mono text-sm" disabled />
<select v-model="editForm.field_type" class="input-field">
<option v-for="t in fieldTypes" :key="t" :value="t">{{ t }}</option>
</select>
<label class="flex items-center gap-2 text-sm"><input v-model="editForm.required" type="checkbox" /> Required</label>
<input v-model="editForm.default_value" class="input-field" placeholder="Default value" />
<input v-model="editForm.help_text" class="input-field" placeholder="Help text" />
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<div class="flex gap-2">
<button type="submit" class="btn-primary">Save</button>
<button type="button" class="btn-secondary" @click="showEdit = false">Cancel</button>
</div>
</form>
</div>
</div>
</template>
+198
View File
@@ -0,0 +1,198 @@
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { RouterLink } from "vue-router";
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 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 () => {
try {
const d = await api.dashboard();
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 {
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>
<template>
<div>
<h1 class="text-2xl font-bold">Dashboard</h1>
<p class="mt-1 text-slate-500">Network overview</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>
<template v-else-if="stats">
<div class="mt-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div class="card flex items-start gap-4">
<div class="rounded-lg bg-accent/15 p-3 text-accent"><Network class="h-6 w-6" /></div>
<div>
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">Total IPv4 addresses</div>
<div class="mt-1 text-2xl font-bold text-accent">{{ stats.total_ips.toLocaleString() }}</div>
<div class="text-sm text-slate-500">{{ stats.utilization_percent }}% utilised</div>
</div>
</div>
<div class="card flex items-start gap-4">
<div class="rounded-lg bg-surface-overlay p-3 text-slate-500"><Wifi class="h-6 w-6" /></div>
<div>
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">Available IPs</div>
<div class="mt-1 text-2xl font-bold">{{ stats.available_ips.toLocaleString() }}</div>
<div class="text-sm text-slate-500">{{ 100 - stats.utilization_percent }}% free</div>
</div>
</div>
<div class="card flex items-start gap-4">
<div class="rounded-lg bg-surface-overlay p-3 text-slate-500"><Layers class="h-6 w-6" /></div>
<div>
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">Subnets</div>
<div class="mt-1 text-2xl font-bold">{{ stats.subnet_count }}</div>
<div class="text-sm text-slate-500">Total</div>
</div>
</div>
<div class="card flex items-start gap-4">
<div class="rounded-lg bg-surface-overlay p-3 text-slate-500"><Server class="h-6 w-6" /></div>
<div>
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">Devices</div>
<div class="mt-1 text-2xl font-bold">{{ stats.device_count.toLocaleString() }}</div>
<div class="text-sm text-slate-500">Managed</div>
</div>
</div>
</div>
<div class="mt-6 grid gap-4 lg:grid-cols-2">
<div class="card">
<h2 class="font-semibold">IPv4 usage distribution</h2>
<div class="mt-6 flex flex-col items-center gap-6 sm:flex-row sm:justify-center">
<div class="relative h-44 w-44 shrink-0 rounded-full" :style="donutStyle">
<div class="absolute inset-5 flex flex-col items-center justify-center rounded-full bg-surface-raised text-center">
<span class="text-2xl font-bold">{{ stats.total_ips.toLocaleString() }}</span>
<span class="text-xs uppercase tracking-wide text-slate-500">Total</span>
</div>
</div>
<div class="space-y-3 text-sm">
<div class="flex items-center gap-2">
<span class="h-3 w-3 rounded-full bg-accent" />
<span>{{ stats.utilization_percent }}% Used ({{ stats.used_ips.toLocaleString() }})</span>
</div>
<div class="flex items-center gap-2">
<span class="h-3 w-3 rounded-full bg-surface-overlay ring-1 ring-slate-300 dark:ring-slate-600" />
<span>{{ 100 - stats.utilization_percent }}% Free ({{ stats.available_ips.toLocaleString() }})</span>
</div>
</div>
</div>
</div>
<div class="card">
<h2 class="font-semibold">Activity last 24 hours</h2>
<p class="mt-1 text-xs text-slate-500">Audit log entries by hour</p>
<div class="mt-4 flex h-40 items-end gap-0.5">
<div
v-for="point in activity"
:key="point.hour"
class="flex-1 rounded-t bg-accent/80 transition-all hover:bg-accent"
:style="{ height: `${Math.max(4, (point.count / maxActivity) * 100)}%` }"
:title="`${formatHour(point.hour)}: ${point.count}`"
/>
</div>
<div class="mt-2 flex justify-between text-[10px] text-slate-500">
<span>12 AM</span>
<span>6 AM</span>
<span>12 PM</span>
<span>6 PM</span>
</div>
</div>
</div>
<div class="card mt-6 overflow-x-auto">
<div class="mb-4 flex items-center justify-between gap-3">
<h2 class="font-semibold">Subnet overview</h2>
<RouterLink to="/subnets" class="text-sm text-accent hover:underline">View all subnets</RouterLink>
</div>
<table class="w-full min-w-[640px] text-left text-sm">
<thead>
<tr class="border-b border-slate-200 text-xs font-medium uppercase tracking-wide text-slate-500 dark:border-slate-700">
<th class="p-2">Subnet</th>
<th class="p-2">Name</th>
<th class="p-2">Utilised</th>
<th class="p-2">Available</th>
<th class="p-2">Site</th>
</tr>
</thead>
<tbody>
<tr
v-for="s in subnetOverview"
:key="s.id"
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>
</template>
+289
View File
@@ -0,0 +1,289 @@
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { useRoute, useRouter, RouterLink } from "vue-router";
import { api, type Device, type Tag, type Subnet } from "@/api";
import type { IpHistoryEntry } from "@/components/IpHistoryModal.vue";
import CustomFieldValues from "@/components/CustomFieldValues.vue";
import { useAuthStore } from "@/stores/auth";
import { formatLocalTime } from "@/utils/datetime";
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const device = ref<Device | null>(null);
const allTags = ref<Tag[]>([]);
const subnets = ref<Subnet[]>([]);
const availableIps = ref<{ id: number; ip: string }[]>([]);
const history = ref<IpHistoryEntry[]>([]);
const editName = ref("");
const editDescription = ref("");
const saving = ref(false);
const loading = ref(true);
const error = ref("");
const showAssignIp = ref(false);
const assignForm = ref({ site: "", subnet_id: 0, ip_id: 0 });
const err = ref("");
const sites = computed(() => {
const list = [...new Set(subnets.value.map((s) => s.site || "Unassigned"))];
return list.sort((a, b) => {
if (a === "Unassigned") return -1;
if (b === "Unassigned") return 1;
return a.localeCompare(b);
});
});
const deviceSites = computed(() =>
[...new Set((device.value?.ip_addresses ?? []).map((ip) => ip.site || "Unassigned"))],
);
const assignableSites = computed(() =>
deviceSites.value.length ? sites.value.filter((s) => deviceSites.value.includes(s)) : sites.value,
);
const subnetsForSite = computed(() =>
subnets.value.filter((s) => (s.site || "Unassigned") === assignForm.value.site),
);
async function loadDevice() {
loading.value = true;
error.value = "";
try {
const id = Number(route.params.id);
const [d, tags, h, sn] = await Promise.all([
api.device(id),
api.tags(),
api.deviceIpHistory(id).catch(() => []),
api.subnets(false),
]);
device.value = d;
editName.value = d.name;
editDescription.value = d.description || "";
allTags.value = tags;
subnets.value = sn;
history.value = h as IpHistoryEntry[];
} catch (e) {
error.value = e instanceof Error ? e.message : "Failed to load device";
device.value = null;
} finally {
loading.value = false;
}
}
watch(
() => route.params.id,
() => {
showAssignIp.value = false;
err.value = "";
loadDevice();
},
{ immediate: true },
);
async function loadAvailableIps(subnetId: number) {
if (!subnetId) {
availableIps.value = [];
assignForm.value.ip_id = 0;
return;
}
availableIps.value = await api.availableIps(subnetId);
assignForm.value.ip_id = availableIps.value[0]?.id ?? 0;
}
async function onSiteChange() {
const list = subnetsForSite.value;
assignForm.value.subnet_id = list[0]?.id ?? 0;
await loadAvailableIps(assignForm.value.subnet_id);
}
async function onSubnetChange() {
await loadAvailableIps(assignForm.value.subnet_id);
}
async function openAssignIpModal() {
err.value = "";
const defaultSite = assignableSites.value[0] ?? sites.value[0] ?? "";
const defaultSubnet = subnets.value.find((s) => (s.site || "Unassigned") === defaultSite) ?? subnets.value[0];
assignForm.value = {
site: defaultSite,
subnet_id: defaultSubnet?.id ?? 0,
ip_id: 0,
};
if (assignForm.value.subnet_id) await loadAvailableIps(assignForm.value.subnet_id);
showAssignIp.value = true;
}
async function saveDevice() {
if (!device.value) return;
saving.value = true;
err.value = "";
try {
await api.updateDevice(device.value.id, { name: editName.value, description: editDescription.value });
device.value.name = editName.value;
device.value.description = editDescription.value;
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed to save";
} finally {
saving.value = false;
}
}
async function assignTag(tagId: number) {
if (!device.value || !tagId) return;
await api.assignTag(device.value.id, tagId);
device.value = await api.device(device.value.id);
}
async function removeTag(tagId: number) {
if (!device.value || !confirm("Remove this tag?")) return;
await api.removeTag(device.value.id, tagId);
device.value = await api.device(device.value.id);
}
async function assignIp() {
if (!device.value || !assignForm.value.ip_id) return;
err.value = "";
try {
await api.assignIp(device.value.id, assignForm.value.ip_id);
showAssignIp.value = false;
await loadDevice();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
async function removeIp(ipId: number) {
if (!device.value || !confirm("Remove this IP from the device?")) return;
await api.removeIp(device.value.id, ipId);
await loadDevice();
}
async function deleteDevice() {
if (!device.value || !confirm(`Delete device "${device.value.name}"? This cannot be undone.`)) return;
await api.deleteDevice(device.value.id);
router.push("/devices");
}
function onCustomFieldsSaved(values: Record<string, unknown>) {
if (device.value) device.value.custom_fields = values;
}
function formatTime(ts?: string) {
return formatLocalTime(ts, "Unknown");
}
</script>
<template>
<div>
<RouterLink to="/devices" class="text-sm text-accent hover:underline"> Devices</RouterLink>
<p v-if="loading" class="mt-8 text-slate-500">Loading</p>
<p v-else-if="error" class="mt-8 text-red-500">{{ error }}</p>
<template v-else-if="device">
<div class="mt-4 flex flex-wrap items-start justify-between gap-4">
<div class="flex min-w-0 max-w-2xl flex-1 flex-col gap-2">
<template v-if="auth.can('edit_device')">
<input
v-model="editName"
class="input-field block w-full border-0 bg-transparent px-0 py-0 text-2xl font-bold shadow-none focus:ring-0"
aria-label="Device name"
@blur="saveDevice"
/>
<textarea
v-model="editDescription"
class="input-field block w-full resize-y text-sm"
placeholder="Add a description…"
rows="2"
@blur="saveDevice"
/>
</template>
<template v-else>
<h1 class="text-2xl font-bold">{{ device.name }}</h1>
<p v-if="device.description" class="text-slate-500">{{ device.description }}</p>
</template>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
</div>
<button
v-if="auth.can('delete_device')"
class="shrink-0 text-sm text-red-500 hover:underline"
@click="deleteDevice"
>Delete device</button>
</div>
<div class="mt-6 grid gap-4 lg:grid-cols-2">
<div class="card">
<div class="flex items-center justify-between">
<h2 class="font-semibold">IP addresses</h2>
<button v-if="auth.can('add_device_ip')" class="text-sm text-accent hover:underline" @click="openAssignIpModal">Assign IP</button>
</div>
<ul class="mt-3 space-y-2">
<li v-for="ip in device.ip_addresses" :key="ip.id" class="flex items-center justify-between gap-3 font-mono text-sm">
<span class="min-w-0">
{{ ip.ip }}
<span v-if="ip.notes || ip.subnet_name" class="font-sans text-slate-500">
{{ ip.notes || ip.subnet_name }}
</span>
</span>
<button v-if="auth.can('remove_device_ip')" class="text-red-500 hover:underline" @click="removeIp(ip.id)">Remove</button>
</li>
<li v-if="!device.ip_addresses?.length" class="text-sm text-slate-500">None assigned</li>
</ul>
</div>
<div class="card">
<h2 class="font-semibold">Tags</h2>
<div class="mt-2 flex flex-wrap gap-2">
<span v-for="t in device.tags" :key="t.id" class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs" :style="{ backgroundColor: (t.color || '#6B7280') + '33' }">
{{ t.name }}
<button v-if="auth.can('assign_device_tag')" class="text-red-500 hover:underline" @click="removeTag(t.id)">×</button>
</span>
<span v-if="!device.tags?.length" class="text-sm text-slate-500">No tags</span>
</div>
<select v-if="auth.can('assign_device_tag')" class="input-field mt-3" @change="assignTag(Number(($event.target as HTMLSelectElement).value)); ($event.target as HTMLSelectElement).value = ''">
<option value="">Add tag</option>
<option v-for="t in allTags.filter((t) => !device!.tags?.some((dt) => dt.id === t.id))" :key="t.id" :value="t.id">{{ t.name }}</option>
</select>
</div>
<CustomFieldValues
v-if="auth.can('view_custom_fields')"
class="lg:col-span-2"
entity-type="device"
:entity-id="device.id"
:values="device.custom_fields"
:can-edit="auth.can('edit_device')"
@saved="onCustomFieldsSaved"
/>
<div class="card lg:col-span-2">
<h2 class="font-semibold">IP history</h2>
<p v-if="!history.length" class="mt-2 text-sm text-slate-500">No assignment history.</p>
<ul v-else class="mt-3 space-y-3">
<li v-for="(entry, i) in history" :key="i" class="flex gap-3 text-sm">
<span class="shrink-0 text-xs font-semibold uppercase" :class="entry.action === 'assigned' ? 'text-emerald-600' : 'text-red-500'">
{{ entry.action === "assigned" ? "Assigned" : "Removed" }}
</span>
<span class="font-mono">{{ entry.ip }}</span>
<span class="text-slate-500">· {{ entry.user_name }} · {{ formatTime(entry.timestamp) }}</span>
</li>
</ul>
</div>
</div>
<div v-if="showAssignIp" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showAssignIp = false">
<form class="card w-full max-w-md space-y-3" @submit.prevent="assignIp">
<h2 class="text-lg font-semibold">Assign IP</h2>
<select v-if="!deviceSites.length" v-model="assignForm.site" class="input-field" @change="onSiteChange">
<option v-for="site in assignableSites" :key="site" :value="site">{{ site }}</option>
</select>
<select v-model="assignForm.subnet_id" class="input-field" @change="onSubnetChange">
<option v-for="s in subnetsForSite" :key="s.id" :value="s.id">{{ s.name }} ({{ s.cidr }})</option>
</select>
<select v-model="assignForm.ip_id" class="input-field" required>
<option v-for="ip in availableIps" :key="ip.id" :value="ip.id">{{ ip.ip }}</option>
</select>
<p v-if="assignForm.subnet_id && !availableIps.length" class="text-sm text-slate-500">No available IPs in this subnet</p>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<div class="flex gap-2">
<button type="submit" class="btn-primary" :disabled="!assignForm.ip_id">Assign</button>
<button type="button" class="btn-secondary" @click="showAssignIp = false">Cancel</button>
</div>
</form>
</div>
</template>
</div>
</template>
+236
View File
@@ -0,0 +1,236 @@
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { RouterLink } from "vue-router";
import { api, type Device, type Subnet } from "@/api";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const devices = ref<Device[]>([]);
const tagFilter = ref("");
const tags = ref<string[]>([]);
const subnets = ref<Subnet[]>([]);
const availableIps = ref<{ id: number; ip: string }[]>([]);
const loading = ref(true);
const showAdd = ref(false);
const showBulk = ref(false);
const assignIpOnCreate = ref(false);
const addForm = ref({ name: "", description: "", site: "", subnet_id: 0, ip_id: 0 });
const bulkForm = ref({ names: "" });
const err = ref("");
const sites = computed(() =>
[...new Set(subnets.value.map((s) => s.site || "Unassigned"))].sort(),
);
const subnetsForSite = computed(() =>
subnets.value.filter((s) => (s.site || "Unassigned") === addForm.value.site),
);
const bySite = computed(() => {
const m: Record<string, Device[]> = {};
for (const d of devices.value) {
const site = d.ip_addresses?.[0]?.site || "Unassigned";
if (!m[site]) m[site] = [];
m[site].push(d);
}
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);
}),
);
async function loadDevices() {
loading.value = true;
devices.value = await api.devices({ tag: tagFilter.value || undefined });
loading.value = false;
}
onMounted(async () => {
const [tagList, sn] = await Promise.all([api.tags(), api.subnets(false)]);
tags.value = tagList.map((t) => t.name);
subnets.value = sn;
if (sn.length) {
addForm.value.site = sn[0].site || "Unassigned";
addForm.value.subnet_id = sn[0].id;
}
await loadDevices();
});
async function loadAvailableIps(subnetId: number) {
if (!subnetId) {
availableIps.value = [];
addForm.value.ip_id = 0;
return;
}
availableIps.value = await api.availableIps(subnetId);
addForm.value.ip_id = availableIps.value[0]?.id ?? 0;
}
async function onAddSiteChange() {
const list = subnetsForSite.value;
addForm.value.subnet_id = list[0]?.id ?? 0;
await loadAvailableIps(addForm.value.subnet_id);
}
async function onAddSubnetChange() {
await loadAvailableIps(addForm.value.subnet_id);
}
async function onAssignIpToggle() {
if (assignIpOnCreate.value) {
if (!addForm.value.site) addForm.value.site = sites.value[0] ?? "";
await onAddSiteChange();
} else {
availableIps.value = [];
addForm.value.ip_id = 0;
}
}
async function openAddModal() {
err.value = "";
assignIpOnCreate.value = false;
availableIps.value = [];
const defaultSite = sites.value[0] ?? "";
const defaultSubnet = subnets.value.find((s) => (s.site || "Unassigned") === defaultSite) ?? subnets.value[0];
addForm.value = {
name: "",
description: "",
site: defaultSite,
subnet_id: defaultSubnet?.id ?? 0,
ip_id: 0,
};
showAdd.value = true;
}
async function filterTag(t: string) {
tagFilter.value = t;
await loadDevices();
}
async function createDevice() {
err.value = "";
try {
const created = await api.createDevice({
name: addForm.value.name,
description: addForm.value.description,
}) as { id: number };
if (assignIpOnCreate.value) {
if (!addForm.value.ip_id) {
err.value = "Select an IP address or uncheck “Assign an IP address”";
return;
}
if (auth.can("add_device_ip")) {
await api.assignIp(created.id, addForm.value.ip_id);
}
}
showAdd.value = false;
assignIpOnCreate.value = false;
availableIps.value = [];
addForm.value = {
name: "",
description: "",
site: sites.value[0] ?? "",
subnet_id: subnets.value[0]?.id ?? 0,
ip_id: 0,
};
await loadDevices();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
async function bulkCreate() {
err.value = "";
const names = bulkForm.value.names.split("\n").map((n) => n.trim()).filter(Boolean);
if (!names.length) {
err.value = "Enter at least one device name";
return;
}
try {
await api.bulkCreateDevices(names);
showBulk.value = false;
bulkForm.value.names = "";
await loadDevices();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
</script>
<template>
<div>
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-2xl font-bold">Devices</h1>
<div v-if="auth.can('add_device')" class="flex gap-2">
<button class="btn-primary text-sm" @click="openAddModal">Add device</button>
<button class="btn-secondary text-sm" @click="showBulk = true; err = ''">Bulk add</button>
</div>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<button class="rounded-full px-3 py-1 text-xs" :class="!tagFilter ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="filterTag('')">All</button>
<button v-for="t in tags" :key="t" class="rounded-full px-3 py-1 text-xs" :class="tagFilter === t ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="filterTag(t)">{{ t }}</button>
</div>
<div v-if="loading" class="mt-8 text-slate-500">Loading</div>
<div v-else class="mt-6 space-y-6">
<section v-for="site in siteOrder" :key="site">
<h2 class="mb-2 font-semibold text-accent">{{ site }}</h2>
<div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
<RouterLink v-for="d in bySite[site]" :key="d.id" :to="`/devices/${d.id}`" class="card flex items-center gap-3 py-3 transition hover:border-accent/50">
<div class="min-w-0 flex-1">
<div class="truncate font-medium">{{ d.name }}</div>
<div class="truncate text-xs text-slate-500">{{ d.ip_addresses?.map((i) => i.ip).join(", ") || "No IPs" }}</div>
</div>
</RouterLink>
</div>
</section>
</div>
<div v-if="showAdd" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showAdd = false">
<form class="card w-full max-w-md space-y-3" @submit.prevent="createDevice">
<h2 class="text-lg font-semibold">Add device</h2>
<input v-model="addForm.name" class="input-field" placeholder="Name" required />
<input v-model="addForm.description" class="input-field" placeholder="Description" />
<template v-if="auth.can('add_device_ip') && subnets.length">
<label class="flex items-center gap-2 text-sm">
<input v-model="assignIpOnCreate" type="checkbox" @change="onAssignIpToggle" />
Assign an IP address
</label>
<template v-if="assignIpOnCreate">
<select v-model="addForm.site" class="input-field" @change="onAddSiteChange">
<option v-for="site in sites" :key="site" :value="site">{{ site }}</option>
</select>
<select v-model="addForm.subnet_id" class="input-field" @change="onAddSubnetChange">
<option v-for="s in subnetsForSite" :key="s.id" :value="s.id">{{ s.name }} ({{ s.cidr }})</option>
</select>
<select v-model="addForm.ip_id" class="input-field" required>
<option v-for="ip in availableIps" :key="ip.id" :value="ip.id">{{ ip.ip }}</option>
</select>
<p v-if="addForm.subnet_id && !availableIps.length" class="text-xs text-slate-500">No available IPs in this subnet</p>
</template>
</template>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<div class="flex gap-2">
<button type="submit" class="btn-primary">Create</button>
<button type="button" class="btn-secondary" @click="showAdd = false">Cancel</button>
</div>
</form>
</div>
<div v-if="showBulk" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showBulk = false">
<form class="card w-full max-w-md space-y-3" @submit.prevent="bulkCreate">
<h2 class="text-lg font-semibold">Bulk add devices</h2>
<textarea v-model="bulkForm.names" class="input-field h-32" placeholder="One device name per line" required />
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<div class="flex gap-2">
<button type="submit" class="btn-primary">Create</button>
<button type="button" class="btn-secondary" @click="showBulk = false">Cancel</button>
</div>
</form>
</div>
</div>
</template>
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { ref } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useAuthStore } from "@/stores/auth";
const email = ref("");
const password = ref("");
const err = ref("");
const busy = ref(false);
const auth = useAuthStore();
const router = useRouter();
const route = useRoute();
async function submit() {
err.value = "";
busy.value = true;
try {
const r = await auth.login(email.value.trim(), password.value);
if (r.requires_setup) {
router.push("/setup-2fa");
return;
}
if (r.requires_2fa) {
router.push("/verify-2fa");
return;
}
await auth.fetchMe();
router.push((route.query.redirect as string) || "/");
} catch (e) {
err.value = e instanceof Error ? e.message : "Login failed";
} finally {
busy.value = false;
}
}
</script>
<template>
<div class="flex min-h-screen items-center justify-center bg-surface p-6">
<div class="card w-full max-w-md p-8">
<h1 class="text-2xl font-semibold">Sign in</h1>
<p class="mt-1 text-sm text-slate-500">Access your IP address management workspace.</p>
<form class="mt-8 space-y-4" @submit.prevent="submit">
<div>
<label class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Email</label>
<input v-model="email" type="email" class="input-field" required autocomplete="username" />
</div>
<div>
<label class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Password</label>
<input v-model="password" type="password" class="input-field" required autocomplete="current-password" />
</div>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<button type="submit" class="btn-primary w-full" :disabled="busy">{{ busy ? "Signing in…" : "Sign in" }}</button>
</form>
</div>
</div>
</template>
+141
View File
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useRoute, RouterLink } from "vue-router";
import { api, type Rack } from "@/api";
import { useAuthStore } from "@/stores/auth";
const route = useRoute();
const auth = useAuthStore();
const rack = ref<Rack | null>(null);
const showAddDevice = ref(false);
const showAddNonnet = ref(false);
const addForm = ref({ device_id: 0, position_u: 1, side: "front" });
const nonnetForm = ref({ nonnet_device_name: "", position_u: 1, side: "front" });
const err = ref("");
async function load() {
rack.value = await api.rack(Number(route.params.id));
const devs = rack.value?.site_devices || [];
if (devs.length) addForm.value.device_id = devs[0].id;
}
onMounted(load);
const siteDevices = () => rack.value?.site_devices || [];
const slotsForSide = (r: Rack, rackSide: string) => {
const h = r.height_u;
const map: Record<number, typeof r.devices> = {};
for (const d of r.devices || []) {
if (d.side === rackSide) (map[d.position_u] ??= []).push(d);
}
return Array.from({ length: h }, (_, i) => ({ u: h - i, devices: map[h - i] || [] }));
};
async function addDevice() {
err.value = "";
try {
await api.addRackDevice(Number(route.params.id), {
device_id: addForm.value.device_id,
position_u: addForm.value.position_u,
side: addForm.value.side,
});
showAddDevice.value = false;
await load();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
async function addNonnet() {
err.value = "";
try {
await api.addRackDevice(Number(route.params.id), {
nonnet_device_name: nonnetForm.value.nonnet_device_name,
position_u: nonnetForm.value.position_u,
side: nonnetForm.value.side,
});
showAddNonnet.value = false;
nonnetForm.value.nonnet_device_name = "";
await load();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
async function removeDevice(rackDeviceId: number) {
if (!confirm("Remove this device from the rack?")) return;
await api.removeRackDevice(Number(route.params.id), rackDeviceId);
await load();
}
</script>
<template>
<div v-if="rack">
<RouterLink to="/racks" class="text-sm text-accent hover:underline"> Racks</RouterLink>
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-bold">{{ rack.name }}</h1>
<p class="text-slate-500">{{ rack.site }} · {{ rack.height_u }}U</p>
</div>
<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 class="mt-4 flex flex-wrap gap-2">
<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>
</div>
<div class="mt-6 grid gap-6 lg:grid-cols-2">
<div v-for="rackSide in ['front', 'back'] as const" :key="rackSide" class="card font-mono text-sm">
<h2 class="mb-3 border-b border-slate-200 pb-2 text-base font-semibold capitalize dark:border-slate-700">{{ rackSide }}</h2>
<div v-for="row in slotsForSide(rack, rackSide)" :key="row.u" class="flex border-b border-slate-200 py-2 dark:border-slate-700">
<span class="w-10 shrink-0 text-slate-500">U{{ row.u }}</span>
<span class="flex flex-1 flex-col gap-1">
<span v-for="d in row.devices" :key="d.id" class="flex items-center gap-2">
<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>
</div>
</div>
</div>
<div v-if="showAddDevice" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showAddDevice = false">
<form class="card w-full max-w-md space-y-3" @submit.prevent="addDevice">
<h2 class="text-lg font-semibold">Add device to rack</h2>
<select v-model="addForm.device_id" class="input-field" required>
<option v-for="d in siteDevices()" :key="d.id" :value="d.id">{{ d.name }}</option>
</select>
<input v-model.number="addForm.position_u" type="number" :min="1" :max="rack.height_u" class="input-field" placeholder="U position" required />
<select v-model="addForm.side" class="input-field">
<option value="front">Front</option>
<option value="back">Back</option>
</select>
<p class="text-xs text-slate-500">For multi-U devices, add each U separately.</p>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<div class="flex gap-2">
<button type="submit" class="btn-primary">Add</button>
<button type="button" class="btn-secondary" @click="showAddDevice = false">Cancel</button>
</div>
</form>
</div>
<div v-if="showAddNonnet" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showAddNonnet = false">
<form class="card w-full max-w-md space-y-3" @submit.prevent="addNonnet">
<h2 class="text-lg font-semibold">Add non-networked device</h2>
<input v-model="nonnetForm.nonnet_device_name" class="input-field" placeholder="Device name" required />
<input v-model.number="nonnetForm.position_u" type="number" :min="1" :max="rack.height_u" class="input-field" placeholder="U position" required />
<select v-model="nonnetForm.side" class="input-field">
<option value="front">Front</option>
<option value="back">Back</option>
</select>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<div class="flex gap-2">
<button type="submit" class="btn-primary">Add</button>
<button type="button" class="btn-secondary" @click="showAddNonnet = false">Cancel</button>
</div>
</form>
</div>
</div>
</template>
+103
View File
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { RouterLink } from "vue-router";
import { api, type Rack } from "@/api";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const racks = ref<Rack[]>([]);
const showAdd = ref(false);
const showEdit = ref(false);
const form = ref({ name: "", site: "", height_u: 42 });
const editId = ref(0);
const loading = ref(true);
const err = ref("");
async function load() {
loading.value = true;
err.value = "";
try {
racks.value = await api.racks();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed to load racks";
racks.value = [];
} finally {
loading.value = false;
}
}
onMounted(load);
async function create() {
err.value = "";
try {
await api.createRack({ ...form.value, height_u: Number(form.value.height_u) });
showAdd.value = false;
form.value = { name: "", site: "", height_u: 42 };
await load();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
function openEdit(r: Rack) {
editId.value = r.id;
form.value = { name: r.name, site: r.site, height_u: r.height_u };
showEdit.value = true;
err.value = "";
}
async function saveEdit() {
err.value = "";
try {
await api.updateRack(editId.value, { ...form.value, height_u: Number(form.value.height_u) });
showEdit.value = false;
await load();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
async function del(id: number) {
if (!confirm("Delete this rack?")) return;
await api.deleteRack(id);
await load();
}
</script>
<template>
<div>
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-2xl font-bold">Racks</h1>
<button v-if="auth.can('add_rack')" class="btn-primary text-sm" @click="showAdd = true; err = ''">Add rack</button>
</div>
<p v-if="loading" class="mt-6 text-slate-500">Loading</p>
<p v-else-if="err && !racks.length" class="mt-6 text-red-500">{{ err }}</p>
<p v-else-if="!racks.length" class="mt-6 text-slate-500">No racks yet.</p>
<div v-else class="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div v-for="r in racks" :key="r.id" class="card">
<RouterLink :to="`/racks/${r.id}`" class="block transition hover:text-accent">
<div class="font-medium">{{ r.name }}</div>
<div class="text-sm text-slate-500">{{ r.site }} · {{ r.height_u }}U · {{ r.percent_full ?? 0 }}% full</div>
</RouterLink>
<div v-if="auth.can('add_rack') || auth.can('delete_rack')" class="mt-3 flex gap-2">
<button v-if="auth.can('add_rack')" class="text-sm text-accent hover:underline" @click="openEdit(r)">Edit</button>
<button v-if="auth.can('delete_rack')" class="text-sm text-red-500 hover:underline" @click="del(r.id)">Delete</button>
</div>
</div>
</div>
<div v-if="showAdd || showEdit" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showAdd = false; showEdit = false">
<form class="card w-full max-w-md space-y-3" @submit.prevent="showEdit ? saveEdit() : create()">
<h2 class="text-lg font-semibold">{{ showEdit ? "Edit rack" : "Add rack" }}</h2>
<input v-model="form.name" class="input-field" placeholder="Name" required />
<input v-model="form.site" class="input-field" placeholder="Site" required />
<input v-model.number="form.height_u" type="number" min="1" class="input-field" placeholder="Height (U)" required />
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<div class="flex gap-2">
<button type="submit" class="btn-primary">{{ showEdit ? "Save" : "Create" }}</button>
<button type="button" class="btn-secondary" @click="showAdd = false; showEdit = false">Cancel</button>
</div>
</form>
</div>
</div>
</template>
+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>
+65
View File
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import { api } from "@/api";
import { useAuthStore } from "@/stores/auth";
const step = ref<"generate" | "verify" | "done">("generate");
const qrCode = ref("");
const secret = ref("");
const code = ref("");
const backupCodes = ref<string[]>([]);
const err = ref("");
const router = useRouter();
const auth = useAuthStore();
onMounted(async () => {
try {
const r = await api.setup2fa("generate");
qrCode.value = r.qr_code || "";
secret.value = r.secret || "";
step.value = "verify";
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed to start setup";
}
});
async function verify() {
err.value = "";
try {
const r = await api.setup2fa("verify", code.value.trim());
backupCodes.value = r.backup_codes || [];
step.value = "done";
await auth.fetchMe();
} catch (e) {
err.value = e instanceof Error ? e.message : "Invalid code";
}
}
function finish() {
router.push("/");
}
</script>
<template>
<div class="flex min-h-screen items-center justify-center p-6">
<div class="card w-full max-w-md p-8">
<h1 class="text-xl font-semibold">Set up 2FA</h1>
<div v-if="step === 'verify'" class="mt-4 space-y-4">
<img v-if="qrCode" :src="`data:image/png;base64,${qrCode}`" alt="QR" class="mx-auto rounded-lg" />
<p class="break-all font-mono text-xs text-slate-500">{{ secret }}</p>
<input v-model="code" class="input-field text-center font-mono" placeholder="6-digit code" maxlength="6" />
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<button class="btn-primary w-full" @click="verify">Verify & enable</button>
</div>
<div v-else-if="step === 'done'" class="mt-4 space-y-4">
<p class="text-sm text-slate-500">Save these backup codes securely:</p>
<ul class="rounded-lg bg-surface-overlay p-3 font-mono text-sm">
<li v-for="c in backupCodes" :key="c">{{ c }}</li>
</ul>
<button class="btn-primary w-full" @click="finish">Continue</button>
</div>
<p v-else-if="err" class="mt-4 text-red-500">{{ err }}</p>
<p v-else class="mt-4 text-slate-500">Loading</p>
</div>
</div>
</template>
+139
View File
@@ -0,0 +1,139 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useRoute, RouterLink } from "vue-router";
import { api, type Subnet } from "@/api";
import { useAuthStore } from "@/stores/auth";
import IpHistoryModal from "@/components/IpHistoryModal.vue";
import DhcpModal from "@/components/DhcpModal.vue";
import CustomFieldValues from "@/components/CustomFieldValues.vue";
const route = useRoute();
const auth = useAuthStore();
const subnet = ref<Subnet | null>(null);
const historyIp = ref<string | null>(null);
const showDhcp = ref(false);
const loading = ref(true);
const error = ref("");
const notesErr = ref("");
async function loadSubnet() {
loading.value = true;
error.value = "";
try {
subnet.value = await api.subnet(Number(route.params.id));
} catch (e) {
error.value = e instanceof Error ? e.message : "Failed to load subnet";
subnet.value = null;
} finally {
loading.value = false;
}
}
onMounted(loadSubnet);
async function saveNotes(ipId: number, notes: string) {
notesErr.value = "";
try {
await api.patchIpNotes(ipId, notes);
} catch (e) {
notesErr.value = e instanceof Error ? e.message : "Failed to save notes";
}
}
function onCustomFieldsSaved(values: Record<string, unknown>) {
if (subnet.value) subnet.value.custom_fields = values;
}
function isDhcpRow(hostname?: string) {
return hostname === "DHCP";
}
</script>
<template>
<div>
<RouterLink to="/subnets" class="text-sm text-accent hover:underline"> Subnets</RouterLink>
<p v-if="loading" class="mt-8 text-slate-500">Loading</p>
<p v-else-if="error" class="mt-8 text-red-500">{{ error }}</p>
<template v-else-if="subnet">
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-bold">{{ subnet.name }}</h1>
<div class="mt-1 flex flex-wrap items-center gap-2 font-mono text-slate-500">
<span>{{ subnet.cidr }} · {{ subnet.site || "Unassigned" }}</span>
<span
v-if="subnet.vlan_id"
class="rounded-full bg-surface-overlay px-2 py-0.5 text-xs font-semibold font-sans text-slate-600 dark:text-slate-300"
>VLAN {{ subnet.vlan_id }}</span>
</div>
<p v-if="subnet.vlan_description" class="mt-1 text-sm text-slate-500">{{ subnet.vlan_description }}</p>
<p v-if="subnet.vlan_notes" class="text-sm text-slate-400">{{ subnet.vlan_notes }}</p>
</div>
<div class="flex gap-2">
<button
v-if="auth.can('view_dhcp') || auth.can('configure_dhcp')"
type="button"
class="btn-secondary text-sm"
@click="showDhcp = true"
>
DHCP
</button>
<a v-if="auth.can('export_subnet_csv')" :href="`/api/v2/subnets/${subnet.id}/export`" class="btn-secondary text-sm">Export CSV</a>
</div>
</div>
<CustomFieldValues
v-if="auth.can('view_custom_fields')"
class="mt-6"
entity-type="subnet"
:entity-id="subnet.id"
:values="subnet.custom_fields"
:can-edit="auth.can('edit_subnet')"
@saved="onCustomFieldsSaved"
/>
<div class="card mt-6 overflow-x-auto">
<p v-if="notesErr" class="mb-2 text-sm text-red-500">{{ notesErr }}</p>
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b border-slate-200 dark:border-slate-700">
<th class="p-2 font-medium">IP</th>
<th class="p-2 font-medium">Hostname</th>
<th class="p-2 font-medium">Notes</th>
<th class="p-2"></th>
</tr>
</thead>
<tbody>
<tr
v-for="ip in subnet.ip_addresses"
:key="ip.id"
class="border-b border-slate-100 dark:border-slate-800"
:class="isDhcpRow(ip.hostname) ? 'bg-amber-50/80 italic dark:bg-amber-950/20' : ''"
>
<td class="p-2 font-mono">{{ ip.ip }}</td>
<td class="p-2" :class="isDhcpRow(ip.hostname) ? 'text-amber-700 dark:text-amber-400' : ''">
<RouterLink v-if="ip.device_id" :to="`/devices/${ip.device_id}`" class="text-accent hover:underline not-italic">{{ ip.device_name || ip.hostname }}</RouterLink>
<span v-else>{{ ip.hostname || "" }}</span>
</td>
<td class="p-2">
<input
v-if="auth.can('edit_subnet')"
:value="ip.notes || ''"
class="input-field py-1 text-xs"
@change="saveNotes(ip.id, ($event.target as HTMLInputElement).value)"
/>
<span v-else>{{ ip.notes || "" }}</span>
</td>
<td class="p-2">
<button type="button" class="text-xs text-accent hover:underline" @click="historyIp = ip.ip">History</button>
</td>
</tr>
<tr v-if="!subnet.ip_addresses?.length">
<td colspan="4" class="p-4 text-center text-slate-500">No IP addresses in this subnet.</td>
</tr>
</tbody>
</table>
</div>
<IpHistoryModal :ip="historyIp" @close="historyIp = null" />
<DhcpModal :open="showDhcp" :subnet-id="subnet.id" @close="showDhcp = false" @saved="loadSubnet" />
</template>
</div>
</template>
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { RouterLink } from "vue-router";
import { api, type Subnet } from "@/api";
const subnets = ref<Subnet[]>([]);
const loading = ref(true);
const error = ref("");
const bySite = computed(() => {
const m: Record<string, Subnet[]> = {};
for (const s of subnets.value) {
const site = s.site || "Unassigned";
if (!m[site]) m[site] = [];
m[site].push(s);
}
return m;
});
const siteOrder = computed(() =>
Object.keys(bySite.value).sort((a, b) => {
if (a === "Unassigned") return -1;
if (b === "Unassigned") return 1;
return a.localeCompare(b);
}),
);
onMounted(async () => {
try {
subnets.value = await api.subnets(true);
} catch (e) {
error.value = e instanceof Error ? e.message : "Failed to load subnets";
} finally {
loading.value = false;
}
});
</script>
<template>
<div>
<h1 class="text-2xl font-bold">Subnets</h1>
<p class="mt-1 text-slate-500">Browse subnets grouped by site</p>
<p v-if="loading" class="mt-8 text-slate-500">Loading</p>
<p v-else-if="error" class="mt-8 text-red-500">{{ error }}</p>
<p v-else-if="!subnets.length" class="mt-8 text-slate-500">No subnets yet.</p>
<div v-else class="mt-6 space-y-8">
<section v-for="site in siteOrder" :key="site">
<h2 class="mb-3 text-lg font-semibold text-accent">{{ site }}</h2>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<RouterLink
v-for="s in bySite[site]"
:key="s.id"
:to="`/subnets/${s.id}`"
class="card block transition hover:border-accent/50"
>
<div class="font-medium">{{ s.name }}</div>
<div class="mt-1 flex flex-wrap items-center gap-2">
<span class="font-mono text-sm text-slate-500">{{ s.cidr }}</span>
<span
v-if="s.vlan_id"
class="rounded-full bg-surface-overlay px-2 py-0.5 text-xs font-semibold text-slate-600 dark:text-slate-300"
>VLAN {{ s.vlan_id }}</span>
</div>
<div class="mt-3">
<div class="h-2 overflow-hidden rounded-full bg-surface-overlay">
<div
class="h-full rounded-full transition-all"
:class="(s.utilization ?? 0) >= 90 ? 'bg-red-500' : 'bg-accent'"
:style="{ width: `${s.utilization ?? 0}%` }"
/>
</div>
<div class="mt-1 text-xs text-slate-500">{{ s.utilization ?? 0 }}% used</div>
</div>
</RouterLink>
</div>
</section>
</div>
</div>
</template>
+135
View File
@@ -0,0 +1,135 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { RouterLink } from "vue-router";
import { api, type Subnet } from "@/api";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const subnets = ref<Subnet[]>([]);
const form = ref({ name: "", cidr: "", site: "", vlan_id: "" as string | number, vlan_description: "", vlan_notes: "" });
const editForm = ref({ id: 0, name: "", cidr: "", site: "", vlan_id: "" as string | number, vlan_description: "", vlan_notes: "" });
const showEdit = ref(false);
const err = ref("");
onMounted(async () => { subnets.value = await api.subnets(); });
async function reload() {
subnets.value = await api.subnets();
}
async function add() {
err.value = "";
try {
const body: Partial<Subnet> = {
name: form.value.name,
cidr: form.value.cidr,
site: form.value.site,
vlan_description: form.value.vlan_description || undefined,
vlan_notes: form.value.vlan_notes || undefined,
};
if (form.value.vlan_id) body.vlan_id = Number(form.value.vlan_id);
await api.createSubnet(body);
form.value = { name: "", cidr: "", site: "", vlan_id: "", vlan_description: "", vlan_notes: "" };
await reload();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
function openEdit(s: Subnet) {
editForm.value = {
id: s.id,
name: s.name,
cidr: s.cidr,
site: s.site || "",
vlan_id: s.vlan_id ?? "",
vlan_description: s.vlan_description || "",
vlan_notes: s.vlan_notes || "",
};
showEdit.value = true;
err.value = "";
}
async function saveEdit() {
err.value = "";
try {
const body: Partial<Subnet> = {
name: editForm.value.name,
cidr: editForm.value.cidr,
site: editForm.value.site,
vlan_description: editForm.value.vlan_description || null,
vlan_notes: editForm.value.vlan_notes || null,
vlan_id: editForm.value.vlan_id ? Number(editForm.value.vlan_id) : null,
};
await api.updateSubnet(editForm.value.id, body);
showEdit.value = false;
await reload();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
async function del(id: number) {
if (!confirm("Delete subnet and all IPs?")) return;
await api.deleteSubnet(id);
await reload();
}
</script>
<template>
<div>
<h1 class="text-2xl font-bold">Subnet management</h1>
<form v-if="auth.can('add_subnet')" class="card mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3" @submit.prevent="add">
<input v-model="form.name" class="input-field" placeholder="Name" required />
<input v-model="form.cidr" class="input-field font-mono" placeholder="192.168.1.0/24" required />
<input v-model="form.site" class="input-field" placeholder="Site" />
<input v-model="form.vlan_id" type="number" class="input-field" placeholder="VLAN ID" min="1" max="4094" />
<input v-model="form.vlan_description" class="input-field" placeholder="VLAN description" />
<input v-model="form.vlan_notes" class="input-field" placeholder="VLAN notes" />
<button class="btn-primary sm:col-span-2 lg:col-span-3 sm:max-w-xs">Add subnet</button>
<p v-if="err" class="text-sm text-red-500 sm:col-span-3">{{ err }}</p>
</form>
<ul class="mt-8 space-y-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>
<span class="font-mono text-sm text-slate-500">{{ s.cidr }}</span>
<span class="text-xs">
<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('delete_subnet')" class="text-sm text-red-500" @click="del(s.id)">Delete</button>
</div>
</li>
</ul>
<div v-if="showEdit" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showEdit = false">
<form class="card w-full max-w-lg space-y-3" @submit.prevent="saveEdit">
<h2 class="text-lg font-semibold">Edit subnet</h2>
<input v-model="editForm.name" class="input-field" placeholder="Name" required />
<input v-model="editForm.cidr" class="input-field font-mono" placeholder="CIDR" required />
<input v-model="editForm.site" class="input-field" placeholder="Site" />
<input v-model="editForm.vlan_id" type="number" class="input-field" placeholder="VLAN ID" min="1" max="4094" />
<input v-model="editForm.vlan_description" class="input-field" placeholder="VLAN description" />
<input v-model="editForm.vlan_notes" class="input-field" placeholder="VLAN notes" />
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<div class="flex gap-2">
<button type="submit" class="btn-primary">Save</button>
<button type="button" class="btn-secondary" @click="showEdit = false">Cancel</button>
</div>
</form>
</div>
</div>
</template>
+109
View File
@@ -0,0 +1,109 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { api, type Tag } from "@/api";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const tags = ref<Tag[]>([]);
const form = ref({ name: "", color: "#06b6d4", description: "" });
const editForm = ref({ id: 0, name: "", color: "#06b6d4", description: "" });
const showEdit = ref(false);
const loading = ref(true);
const err = ref("");
async function load() {
loading.value = true;
err.value = "";
try {
tags.value = await api.tags();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed to load tags";
tags.value = [];
} finally {
loading.value = false;
}
}
onMounted(load);
async function create() {
err.value = "";
try {
await api.createTag(form.value);
form.value = { name: "", color: "#06b6d4", description: "" };
await load();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
async function del(id: number) {
if (!confirm("Delete tag?")) return;
err.value = "";
try {
await api.deleteTag(id);
await load();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
function openEdit(t: Tag) {
editForm.value = { id: t.id, name: t.name, color: t.color || "#06b6d4", description: t.description || "" };
showEdit.value = true;
err.value = "";
}
async function saveEdit() {
err.value = "";
try {
await api.updateTag(editForm.value.id, {
name: editForm.value.name,
color: editForm.value.color,
description: editForm.value.description,
});
showEdit.value = false;
await load();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
</script>
<template>
<div>
<h1 class="text-2xl font-bold">Tags</h1>
<form v-if="auth.can('add_tag')" class="card mt-6 flex flex-wrap gap-3" @submit.prevent="create">
<input v-model="form.name" class="input-field max-w-xs" placeholder="Name" required />
<input v-model="form.color" type="color" class="h-10 w-14 rounded border-0" />
<input v-model="form.description" class="input-field max-w-xs" placeholder="Description" />
<button class="btn-primary">Add tag</button>
</form>
<p v-if="loading" class="mt-6 text-slate-500">Loading</p>
<p v-else-if="err && !tags.length" class="mt-6 text-red-500">{{ err }}</p>
<p v-else-if="!tags.length" class="mt-6 text-slate-500">No tags yet.</p>
<ul v-else class="mt-6 space-y-2">
<li v-for="t in tags" :key="t.id" class="card flex items-center justify-between">
<span><span class="inline-block h-3 w-3 rounded-full mr-2" :style="{ backgroundColor: t.color }" />{{ t.name }}</span>
<div v-if="auth.can('edit_tag') || auth.can('delete_tag')" class="flex gap-2">
<button v-if="auth.can('edit_tag')" class="text-sm text-accent hover:underline" @click="openEdit(t)">Edit</button>
<button v-if="auth.can('delete_tag')" class="text-sm text-red-500" @click="del(t.id)">Delete</button>
</div>
</li>
</ul>
<p v-if="err && tags.length" class="mt-4 text-sm text-red-500">{{ err }}</p>
<div v-if="showEdit" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showEdit = false">
<form class="card w-full max-w-md space-y-3" @submit.prevent="saveEdit">
<h2 class="text-lg font-semibold">Edit tag</h2>
<input v-model="editForm.name" class="input-field" placeholder="Name" required />
<input v-model="editForm.color" type="color" class="h-10 w-14 rounded border-0" />
<input v-model="editForm.description" class="input-field" placeholder="Description" />
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<div class="flex gap-2">
<button type="submit" class="btn-primary">Save</button>
<button type="button" class="btn-secondary" @click="showEdit = false">Cancel</button>
</div>
</form>
</div>
</div>
</template>
+239
View File
@@ -0,0 +1,239 @@
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { api, type UserRow, type RoleRow } from "@/api";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const tab = ref<"users" | "roles">("users");
const users = ref<UserRow[]>([]);
const roles = ref<RoleRow[]>([]);
const permissions = ref<{ id: number; name: string; category?: string }[]>([]);
const err = ref("");
const showUserForm = ref(false);
const editUserId = ref<number | null>(null);
const userForm = ref({ name: "", email: "", password: "", role_id: 0 });
const showRoleForm = ref(false);
const editRoleId = ref<number | null>(null);
const roleForm = ref({ name: "", description: "", require_2fa: false, permission_ids: [] as number[] });
const showApiKey = ref("");
const permByCategory = computed(() => {
const m: Record<string, typeof permissions.value> = {};
for (const p of permissions.value) {
const cat = p.category || "Other";
(m[cat] ??= []).push(p);
}
return m;
});
async function load() {
[users.value, roles.value] = await Promise.all([api.users(), api.roles()]);
if (auth.can("manage_roles")) {
permissions.value = await api.permissions().catch(() => []);
}
}
onMounted(load);
function openAddUser() {
editUserId.value = null;
userForm.value = { name: "", email: "", password: "", role_id: roles.value[0]?.id ?? 0 };
showUserForm.value = true;
err.value = "";
}
function openEditUser(u: UserRow) {
editUserId.value = u.id;
userForm.value = { name: u.name, email: u.email, password: "", role_id: u.role_id ?? 0 };
showUserForm.value = true;
err.value = "";
}
async function saveUser() {
err.value = "";
try {
if (editUserId.value) {
const body: Record<string, unknown> = { name: userForm.value.name, email: userForm.value.email, role_id: userForm.value.role_id };
if (userForm.value.password) body.password = userForm.value.password;
await api.updateUser(editUserId.value, body);
} else {
await api.createUser(userForm.value);
}
showUserForm.value = false;
await load();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
async function delUser(id: number) {
if (!confirm("Delete this user?")) return;
await api.deleteUser(id);
await load();
}
async function regenKey(id: number) {
if (!confirm("Regenerate API key? The old key will stop working.")) return;
const r = await api.regenerateApiKey(id);
showApiKey.value = r.api_key;
}
function openAddRole() {
editRoleId.value = null;
roleForm.value = { name: "", description: "", require_2fa: false, permission_ids: [] };
showRoleForm.value = true;
err.value = "";
}
function openEditRole(r: RoleRow) {
editRoleId.value = r.id;
roleForm.value = {
name: r.name,
description: r.description || "",
require_2fa: !!r.require_2fa,
permission_ids: r.permissions?.map((p) => p.id) ?? [],
};
showRoleForm.value = true;
err.value = "";
}
function togglePerm(id: number) {
const idx = roleForm.value.permission_ids.indexOf(id);
if (idx >= 0) roleForm.value.permission_ids.splice(idx, 1);
else roleForm.value.permission_ids.push(id);
}
async function saveRole() {
err.value = "";
try {
if (editRoleId.value) {
await api.updateRole(editRoleId.value, roleForm.value);
} else {
await api.createRole(roleForm.value);
}
showRoleForm.value = false;
await load();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
async function delRole(id: number) {
if (!confirm("Delete this role?")) return;
await api.deleteRole(id);
await load();
}
</script>
<template>
<div>
<h1 class="text-2xl font-bold">Users & roles</h1>
<div class="mt-4 flex gap-2">
<button class="rounded-lg px-3 py-1 text-sm" :class="tab === 'users' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="tab = 'users'">Users</button>
<button class="rounded-lg px-3 py-1 text-sm" :class="tab === 'roles' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="tab = 'roles'">Roles</button>
</div>
<section v-if="tab === 'users'" class="mt-8">
<div class="mb-4 flex items-center justify-between">
<h2 class="font-semibold text-accent">Users</h2>
<button v-if="auth.can('manage_users')" class="btn-primary text-sm" @click="openAddUser">Add user</button>
</div>
<ul class="space-y-2">
<li
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>
<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="regenKey(u.id)">API key</button>
<button class="text-sm text-red-500 hover:underline" @click="delUser(u.id)">Delete</button>
</div>
</li>
</ul>
</section>
<section v-if="tab === 'roles'" class="mt-8">
<div class="mb-4 flex items-center justify-between">
<h2 class="font-semibold text-accent">Roles</h2>
<button v-if="auth.can('manage_roles')" class="btn-primary text-sm" @click="openAddRole">Add role</button>
</div>
<ul class="space-y-2">
<li v-for="r in roles" :key="r.id" class="card">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<div class="font-medium">{{ r.name }} <span v-if="r.require_2fa" class="text-xs text-slate-500">(2FA required)</span></div>
<div class="text-sm text-slate-500">{{ r.description }}</div>
</div>
<div v-if="auth.can('manage_roles')" class="flex gap-2">
<button class="text-sm text-accent hover:underline" @click="openEditRole(r)">Edit</button>
<button class="text-sm text-red-500 hover:underline" @click="delRole(r.id)">Delete</button>
</div>
</div>
</li>
</ul>
</section>
<div v-if="showUserForm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showUserForm = false">
<form class="card w-full max-w-md space-y-3" @submit.prevent="saveUser">
<h2 class="text-lg font-semibold">{{ editUserId ? "Edit user" : "Add user" }}</h2>
<input v-model="userForm.name" class="input-field" placeholder="Name" required />
<input v-model="userForm.email" type="email" class="input-field" placeholder="Email" required />
<input v-model="userForm.password" type="password" class="input-field" :placeholder="editUserId ? 'New password (optional)' : 'Password'" :required="!editUserId" />
<select v-model="userForm.role_id" class="input-field">
<option v-for="r in roles" :key="r.id" :value="r.id">{{ r.name }}</option>
</select>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<div class="flex gap-2">
<button type="submit" class="btn-primary">Save</button>
<button type="button" class="btn-secondary" @click="showUserForm = false">Cancel</button>
</div>
</form>
</div>
<div v-if="showRoleForm" class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 p-4 pt-[10vh]" @click.self="showRoleForm = false">
<form class="card w-full max-w-lg space-y-3" @submit.prevent="saveRole">
<h2 class="text-lg font-semibold">{{ editRoleId ? "Edit role" : "Add role" }}</h2>
<input v-model="roleForm.name" class="input-field" placeholder="Name" required />
<input v-model="roleForm.description" class="input-field" placeholder="Description" />
<label class="flex items-center gap-2 text-sm">
<input v-model="roleForm.require_2fa" type="checkbox" />
Require 2FA
</label>
<div v-if="permissions.length" class="max-h-48 overflow-y-auto rounded-lg border border-slate-200 p-3 dark:border-slate-700">
<div v-for="(perms, cat) in permByCategory" :key="cat" class="mb-3">
<div class="text-xs font-semibold uppercase text-slate-500">{{ cat }}</div>
<label v-for="p in perms" :key="p.id" class="mt-1 flex items-center gap-2 text-sm">
<input type="checkbox" :checked="roleForm.permission_ids.includes(p.id)" @change="togglePerm(p.id)" />
{{ p.name }}
</label>
</div>
</div>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<div class="flex gap-2">
<button type="submit" class="btn-primary">Save</button>
<button type="button" class="btn-secondary" @click="showRoleForm = false">Cancel</button>
</div>
</form>
</div>
<div v-if="showApiKey" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showApiKey = ''">
<div class="card w-full max-w-md space-y-3">
<h2 class="text-lg font-semibold">New API key</h2>
<p class="text-sm text-slate-500">Copy this key now it won't be shown again.</p>
<code class="block break-all rounded-lg bg-surface-overlay p-3 text-sm">{{ showApiKey }}</code>
<button class="btn-primary" @click="showApiKey = ''">Done</button>
</div>
</div>
</div>
</template>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import { api } from "@/api";
import { useAuthStore } from "@/stores/auth";
const code = ref("");
const useBackup = ref(false);
const err = ref("");
const busy = ref(false);
const router = useRouter();
const auth = useAuthStore();
async function submit() {
err.value = "";
busy.value = true;
try {
await api.verify2fa(code.value.trim(), useBackup.value);
await auth.fetchMe();
router.push("/");
} catch (e) {
err.value = e instanceof Error ? e.message : "Verification failed";
} finally {
busy.value = false;
}
}
</script>
<template>
<div class="flex min-h-screen items-center justify-center p-6">
<div class="card w-full max-w-md p-8">
<h1 class="text-xl font-semibold">Two-factor authentication</h1>
<form class="mt-6 space-y-4" @submit.prevent="submit">
<input v-model="code" class="input-field text-center font-mono text-lg tracking-widest" :placeholder="useBackup ? 'Backup code' : '000000'" />
<label class="flex items-center gap-2 text-sm">
<input v-model="useBackup" type="checkbox" /> Use backup code
</label>
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
<button class="btn-primary w-full" :disabled="busy">Verify</button>
</form>
</div>
</div>
</template>
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+25
View File
@@ -0,0 +1,25 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
darkMode: "media",
theme: {
extend: {
colors: {
surface: {
DEFAULT: "rgb(var(--surface) / <alpha-value>)",
raised: "rgb(var(--surface-raised) / <alpha-value>)",
overlay: "rgb(var(--surface-overlay) / <alpha-value>)",
},
accent: {
DEFAULT: "rgb(var(--accent) / <alpha-value>)",
muted: "rgb(var(--accent-muted) / <alpha-value>)",
},
},
fontFamily: {
sans: ["IBM Plex Sans", "system-ui", "sans-serif"],
mono: ["IBM Plex Mono", "ui-monospace", "monospace"],
},
},
},
plugins: [],
};
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"paths": { "@/*": ["./src/*"] },
"baseUrl": "."
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+56
View File
@@ -0,0 +1,56 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig, type Plugin } from "vite";
import vue from "@vitejs/plugin-vue";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const staticRoot = path.resolve(__dirname, "../static");
function servePwaFromStatic(): Plugin {
return {
name: "serve-pwa-from-static",
configureServer(server) {
server.middlewares.use((req, res, next) => {
const url = req.url?.split("?")[0] ?? "";
if (url !== "/manifest.webmanifest" && url !== "/sw.js") {
next();
return;
}
const name = url.slice(1);
const filePath = path.join(staticRoot, name);
if (!fs.existsSync(filePath)) {
next();
return;
}
const body = fs.readFileSync(filePath);
const type = name.endsWith(".webmanifest")
? "application/manifest+json"
: "application/javascript";
res.setHeader("Content-Type", type);
res.end(body);
});
},
};
}
export default defineConfig({
plugins: [vue(), servePwaFromStatic()],
resolve: {
alias: { "@": path.resolve(__dirname, "src") },
},
build: {
outDir: "../static/dist",
emptyOutDir: true,
},
server: {
port: 5173,
proxy: {
"/api": "http://127.0.0.1:5000",
"/ws": {
target: "ws://127.0.0.1:5000",
ws: true,
},
},
},
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

+1 -3
View File
@@ -2,7 +2,5 @@ Flask
mysql-connector-python mysql-connector-python
dotenv dotenv
gunicorn gunicorn
requests
pyotp pyotp
qrcode[pil] qrcode[pil]
Flask-Limiter
+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()
-5647
View File
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -1,7 +1,6 @@
#!/bin/bash #!/bin/bash
set -e
echo "Generating CSS..." echo "Building frontend..."
./tailwindcss -i ./static/css/input.css -o ./static/css/output.css --content "./templates/*.html,./static/js/*.min.js" --minify (cd frontend && npm ci && npm run build)
echo "Starting app..." echo "Starting app..."
python app.py python app.py
-94
View File
@@ -1,94 +0,0 @@
/* Icon search suggestions styling */
.icon-suggestions {
max-height: 240px;
overflow-y: auto;
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.icon-suggestion-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
cursor: pointer;
transition: background-color 0.15s ease-in-out;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.icon-suggestion-item:last-child {
border-bottom: none;
}
.icon-suggestion-item:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.dark .icon-suggestion-item {
border-bottom-color: rgba(255, 255, 255, 0.1);
}
.dark .icon-suggestion-item:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.icon-suggestion-item i {
width: 20px;
text-align: center;
font-size: 1.125rem;
color: #4b5563;
}
.dark .icon-suggestion-item i {
color: #d1d5db;
}
.icon-suggestion-item span {
font-family: 'Courier New', monospace;
font-size: 0.875rem;
color: #374151;
}
.dark .icon-suggestion-item span {
color: #e5e7eb;
}
/* Icon preview styling */
.icon-preview {
display: flex;
align-items: center;
justify-content: center;
min-width: 2rem;
}
/* Scrollbar styling for suggestions */
.icon-suggestions::-webkit-scrollbar {
width: 8px;
}
.icon-suggestions::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.05);
border-radius: 4px;
}
.dark .icon-suggestions::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
}
.icon-suggestions::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
.dark .icon-suggestions::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
}
.icon-suggestions::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
.dark .icon-suggestions::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
-1
View File
@@ -1 +0,0 @@
.icon-suggestions{max-height:240px;overflow-y:auto;border-radius:.5rem;box-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -2px rgba(0,0,0,.05)}.icon-suggestion-item{display:flex;align-items:center;gap:.75rem;padding:.75rem 1rem;cursor:pointer;transition:background-color .15s ease-in-out;border-bottom:1px solid rgba(0,0,0,.1)}.icon-suggestion-item:last-child{border-bottom:none}.icon-suggestion-item:hover{background-color:rgba(0,0,0,.05)}.dark .icon-suggestion-item{border-bottom-color:rgba(255,255,255,.1)}.dark .icon-suggestion-item:hover{background-color:rgba(255,255,255,.1)}.icon-suggestion-item i{width:20px;text-align:center;font-size:1.125rem;color:#4b5563}.dark .icon-suggestion-item i{color:#d1d5db}.icon-suggestion-item span{font-family:'Courier New',monospace;font-size:.875rem;color:#374151}.dark .icon-suggestion-item span{color:#e5e7eb}.icon-preview{display:flex;align-items:center;justify-content:center;min-width:2rem}.icon-suggestions::-webkit-scrollbar{width:8px}.icon-suggestions::-webkit-scrollbar-track{background:rgba(0,0,0,.05);border-radius:4px}.dark .icon-suggestions::-webkit-scrollbar-track{background:rgba(255,255,255,.05)}.icon-suggestions::-webkit-scrollbar-thumb{background:rgba(0,0,0,.2);border-radius:4px}.dark .icon-suggestions::-webkit-scrollbar-thumb{background:rgba(255,255,255,.2)}.icon-suggestions::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.3)}.dark .icon-suggestions::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.3)}
-16
View File
@@ -1,16 +0,0 @@
h2 {
cursor: pointer;
}
.container form:not(.mb-6), .mt-4 {
display: none;
}
.allocated-ips {
display: block;
margin-top: 1rem;
}
.button-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
justify-items: center;
}
-1
View File
@@ -1 +0,0 @@
h2{cursor:pointer}.container form:not(.mb-6),.mt-4{display:none}.allocated-ips{display:block;margin-top:1rem}.button-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:1rem;justify-items:center}
-1
View File
@@ -1 +0,0 @@
@import "tailwindcss"
-15
View File
@@ -1,15 +0,0 @@
function validateSubnetForm() {
const cidrInput = document.getElementById('cidr-input');
const errorSpan = document.getElementById('cidr-error');
const cidrPattern = /^(?:\d{1,3}\.){3}\d{1,3}\/([0-9]|[1-2][0-9]|3[0-2])$/;
if (!cidrPattern.test(cidrInput.value.trim())) {
errorSpan.textContent = 'Please enter a valid CIDR (e.g., 192.168.1.0/24)';
errorSpan.classList.remove('hidden');
cidrInput.classList.add('border-red-500');
return false;
}
errorSpan.textContent = '';
errorSpan.classList.add('hidden');
cidrInput.classList.remove('border-red-500');
return true;
}
-1
View File
@@ -1 +0,0 @@
function validateSubnetForm(){let e=document.getElementById("cidr-input"),t=document.getElementById("cidr-error");return/^(?:\d{1,3}\.){3}\d{1,3}\/([0-9]|[1-2][0-9]|3[0-2])$/.test(e.value.trim())?(t.textContent="",t.classList.add("hidden"),e.classList.remove("border-red-500"),!0):(t.textContent="Please enter a valid CIDR (e.g., 192.168.1.0/24)",t.classList.remove("hidden"),e.classList.add("border-red-500"),!1)}
-147
View File
@@ -1,147 +0,0 @@
function showAddSubnetModal() {
document.getElementById('add-subnet-modal').classList.remove('hidden');
document.getElementById('add-subnet-name').value = '';
document.getElementById('add-subnet-cidr').value = '';
document.getElementById('add-subnet-site').value = '';
document.getElementById('add-subnet-vlan-id').value = '';
document.getElementById('add-subnet-vlan-description').value = '';
document.getElementById('add-subnet-vlan-notes').value = '';
document.getElementById('vlan-id-error').classList.add('hidden');
}
function closeAddSubnetModal() {
document.getElementById('add-subnet-modal').classList.add('hidden');
document.getElementById('cidr-error').classList.add('hidden');
document.getElementById('vlan-id-error').classList.add('hidden');
}
function editSubnet(subnetId, name, cidr, site, vlanId, vlanDescription, vlanNotes) {
document.getElementById('edit-subnet-id').value = subnetId;
document.getElementById('edit-subnet-name').value = name;
document.getElementById('edit-subnet-cidr').value = cidr;
document.getElementById('edit-subnet-site').value = site;
document.getElementById('edit-subnet-vlan-id').value = vlanId || '';
document.getElementById('edit-subnet-vlan-description').value = vlanDescription || '';
document.getElementById('edit-subnet-vlan-notes').value = vlanNotes || '';
document.getElementById('edit-subnet-modal').classList.remove('hidden');
}
function closeEditSubnetModal() {
document.getElementById('edit-subnet-modal').classList.add('hidden');
document.getElementById('edit-cidr-error').classList.add('hidden');
document.getElementById('edit-vlan-id-error').classList.add('hidden');
}
function validateVlanId(vlanIdValue, errorElementId) {
if (!vlanIdValue || vlanIdValue.trim() === '') {
return true; // VLAN ID is optional
}
const vlanId = parseInt(vlanIdValue.trim());
if (isNaN(vlanId)) {
const errorElement = document.getElementById(errorElementId);
if (errorElement) {
errorElement.textContent = 'VLAN ID must be a valid integer';
errorElement.classList.remove('hidden');
}
return false;
}
if (vlanId < 1 || vlanId > 4094) {
const errorElement = document.getElementById(errorElementId);
if (errorElement) {
errorElement.textContent = 'VLAN ID must be between 1 and 4094';
errorElement.classList.remove('hidden');
}
return false;
}
const errorElement = document.getElementById(errorElementId);
if (errorElement) {
errorElement.classList.add('hidden');
}
return true;
}
function validateSubnetForm() {
const cidrInput = document.getElementById('add-subnet-cidr');
const cidrError = document.getElementById('cidr-error');
const cidr = cidrInput.value.trim();
// Basic CIDR validation
const cidrPattern = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/;
if (!cidrPattern.test(cidr)) {
cidrError.textContent = 'Invalid CIDR format. Use format like 192.168.1.0/24';
cidrError.classList.remove('hidden');
return false;
}
// Check prefix length
const parts = cidr.split('/');
if (parts.length === 2) {
const prefixLen = parseInt(parts[1]);
if (prefixLen < 24 || prefixLen > 32) {
cidrError.textContent = 'Subnet must be /24 or smaller (e.g., /24, /25, ... /32)';
cidrError.classList.remove('hidden');
return false;
}
}
cidrError.classList.add('hidden');
// Validate VLAN ID
const vlanIdInput = document.getElementById('add-subnet-vlan-id');
if (!validateVlanId(vlanIdInput.value, 'vlan-id-error')) {
return false;
}
return true;
}
function validateEditSubnetForm() {
const cidrInput = document.getElementById('edit-subnet-cidr');
const cidrError = document.getElementById('edit-cidr-error');
const cidr = cidrInput.value.trim();
// Basic CIDR validation
const cidrPattern = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/;
if (!cidrPattern.test(cidr)) {
cidrError.textContent = 'Invalid CIDR format. Use format like 192.168.1.0/24';
cidrError.classList.remove('hidden');
return false;
}
// Check prefix length
const parts = cidr.split('/');
if (parts.length === 2) {
const prefixLen = parseInt(parts[1]);
if (prefixLen < 24 || prefixLen > 32) {
cidrError.textContent = 'Subnet must be /24 or smaller (e.g., /24, /25, ... /32)';
cidrError.classList.remove('hidden');
return false;
}
}
cidrError.classList.add('hidden');
// Validate VLAN ID
const vlanIdInput = document.getElementById('edit-subnet-vlan-id');
if (!validateVlanId(vlanIdInput.value, 'edit-vlan-id-error')) {
return false;
}
return true;
}
// Close modals when clicking outside
window.onclick = function(event) {
const addModal = document.getElementById('add-subnet-modal');
const editModal = document.getElementById('edit-subnet-modal');
if (event.target === addModal) {
closeAddSubnetModal();
}
if (event.target === editModal) {
closeEditSubnetModal();
}
}
-1
View File
@@ -1 +0,0 @@
function showAddSubnetModal(){document.getElementById("add-subnet-modal").classList.remove("hidden"),document.getElementById("add-subnet-name").value="",document.getElementById("add-subnet-cidr").value="",document.getElementById("add-subnet-site").value="",document.getElementById("add-subnet-vlan-id").value="",document.getElementById("add-subnet-vlan-description").value="",document.getElementById("add-subnet-vlan-notes").value="",document.getElementById("vlan-id-error").classList.add("hidden")}function closeAddSubnetModal(){document.getElementById("add-subnet-modal").classList.add("hidden"),document.getElementById("cidr-error").classList.add("hidden"),document.getElementById("vlan-id-error").classList.add("hidden")}function editSubnet(e,t,d,n,l,i,a){document.getElementById("edit-subnet-id").value=e,document.getElementById("edit-subnet-name").value=t,document.getElementById("edit-subnet-cidr").value=d,document.getElementById("edit-subnet-site").value=n,document.getElementById("edit-subnet-vlan-id").value=l||"",document.getElementById("edit-subnet-vlan-description").value=i||"",document.getElementById("edit-subnet-vlan-notes").value=a||"",document.getElementById("edit-subnet-modal").classList.remove("hidden")}function closeEditSubnetModal(){document.getElementById("edit-subnet-modal").classList.add("hidden"),document.getElementById("edit-cidr-error").classList.add("hidden"),document.getElementById("edit-vlan-id-error").classList.add("hidden")}function validateVlanId(e,t){if(!e||""===e.trim())return!0;let d=parseInt(e.trim());if(isNaN(d)){let n=document.getElementById(t);return n&&(n.textContent="VLAN ID must be a valid integer",n.classList.remove("hidden")),!1}if(d<1||d>4094){let l=document.getElementById(t);return l&&(l.textContent="VLAN ID must be between 1 and 4094",l.classList.remove("hidden")),!1}let i=document.getElementById(t);return i&&i.classList.add("hidden"),!0}function validateSubnetForm(){let e=document.getElementById("add-subnet-cidr"),t=document.getElementById("cidr-error"),d=e.value.trim();if(!/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(d))return t.textContent="Invalid CIDR format. Use format like 192.168.1.0/24",t.classList.remove("hidden"),!1;let n=d.split("/");if(2===n.length){let l=parseInt(n[1]);if(l<24||l>32)return t.textContent="Subnet must be /24 or smaller (e.g., /24, /25, ... /32)",t.classList.remove("hidden"),!1}t.classList.add("hidden");let i=document.getElementById("add-subnet-vlan-id");return!!validateVlanId(i.value,"vlan-id-error")}function validateEditSubnetForm(){let e=document.getElementById("edit-subnet-cidr"),t=document.getElementById("edit-cidr-error"),d=e.value.trim();if(!/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(d))return t.textContent="Invalid CIDR format. Use format like 192.168.1.0/24",t.classList.remove("hidden"),!1;let n=d.split("/");if(2===n.length){let l=parseInt(n[1]);if(l<24||l>32)return t.textContent="Subnet must be /24 or smaller (e.g., /24, /25, ... /32)",t.classList.remove("hidden"),!1}t.classList.add("hidden");let i=document.getElementById("edit-subnet-vlan-id");return!!validateVlanId(i.value,"edit-vlan-id-error")}window.onclick=function(e){let t=document.getElementById("add-subnet-modal"),d=document.getElementById("edit-subnet-modal");e.target===t&&closeAddSubnetModal(),e.target===d&&closeEditSubnetModal()};
-79
View File
@@ -1,79 +0,0 @@
// API Documentation Interactive Functions
function getApiKey() {
return document.getElementById('apiKey').value;
}
function showStatus(message, isError = false) {
const status = document.getElementById('connectionStatus');
status.textContent = message;
status.className = `mt-2 text-sm ${isError ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}`;
}
async function testConnection() {
const apiKey = getApiKey();
if (!apiKey) {
showStatus('Please enter your API key', true);
return;
}
try {
const response = await axios.get('/api/v1/devices', {
headers: { 'X-API-Key': apiKey }
});
showStatus('✓ Connection successful');
} catch (error) {
if (error.response?.status === 401) {
showStatus('✗ Invalid API key', true);
} else if (error.response?.status === 403) {
showStatus('✗ Insufficient permissions', true);
} else {
showStatus('✗ Connection failed', true);
}
}
}
async function tryEndpoint(method, url, data, responseId) {
const apiKey = getApiKey();
if (!apiKey) {
showStatus('Please enter your API key first', true);
return;
}
try {
const config = {
method: method,
url: url,
headers: { 'X-API-Key': apiKey }
};
if (data) {
config.data = data;
}
const response = await axios(config);
document.getElementById(responseId + '-response').classList.remove('hidden');
document.getElementById(responseId).textContent = JSON.stringify(response.data, null, 2);
} catch (error) {
document.getElementById(responseId + '-response').classList.remove('hidden');
const errorMessage = error.response?.data?.error || error.message;
document.getElementById(responseId).textContent = `Error (${error.response?.status || 'Network'}): ${errorMessage}`;
}
}
async function tryEndpointWithId(method, baseUrl, inputId, responseId) {
const id = document.getElementById(inputId).value;
if (!id) {
alert('Please enter an ID');
return;
}
await tryEndpoint(method, baseUrl + encodeURIComponent(id), null, responseId);
}
// Auto-populate API key if user is logged in
document.addEventListener('DOMContentLoaded', function() {
const apiKeyInput = document.getElementById('apiKey');
if (apiKeyInput && apiKeyInput.value) {
testConnection();
}
});
-1
View File
@@ -1 +0,0 @@
function getApiKey(){return document.getElementById("apiKey").value}function showStatus(e,t=!1){let n=document.getElementById("connectionStatus");n.textContent=e,n.className=`mt-2 text-sm ${t?"text-red-600 dark:text-red-400":"text-green-600 dark:text-green-400"}`}async function testConnection(){let e=getApiKey();if(!e){showStatus("Please enter your API key",!0);return}try{await axios.get("/api/v1/devices",{headers:{"X-API-Key":e}}),showStatus("✓ Connection successful")}catch(t){t.response?.status===401?showStatus("✗ Invalid API key",!0):t.response?.status===403?showStatus("✗ Insufficient permissions",!0):showStatus("✗ Connection failed",!0)}}async function tryEndpoint(e,t,n,s){let a=getApiKey();if(!a){showStatus("Please enter your API key first",!0);return}try{let o={method:e,url:t,headers:{"X-API-Key":a}};n&&(o.data=n);let r=await axios(o);document.getElementById(s+"-response").classList.remove("hidden"),document.getElementById(s).textContent=JSON.stringify(r.data,null,2)}catch(i){document.getElementById(s+"-response").classList.remove("hidden");let d=i.response?.data?.error||i.message;document.getElementById(s).textContent=`Error (${i.response?.status||"Network"}): ${d}`}}async function tryEndpointWithId(e,t,n,s){let a=document.getElementById(n).value;if(!a){alert("Please enter an ID");return}await tryEndpoint(e,t+encodeURIComponent(a),null,s)}document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("apiKey");e&&e.value&&testConnection()});
-122
View File
@@ -1,122 +0,0 @@
document.addEventListener('DOMContentLoaded', function() {
// Filter toggle functionality
const filterToggle = document.getElementById('filter-toggle');
const filterForm = document.getElementById('audit-filter-form');
const filterArrow = document.getElementById('filter-arrow');
if (filterToggle && filterForm && filterArrow) {
filterToggle.addEventListener('click', function() {
filterForm.classList.toggle('hidden');
// Toggle rotation using inline style for better compatibility
if (filterForm.classList.contains('hidden')) {
filterArrow.style.transform = 'rotate(0deg)';
} else {
filterArrow.style.transform = 'rotate(180deg)';
}
});
// Set initial arrow rotation if form is visible (has active filters or expand_filters param)
if (!filterForm.classList.contains('hidden')) {
filterArrow.style.transform = 'rotate(180deg)';
}
}
// Format timestamps
document.querySelectorAll('td[data-utc]').forEach(function(td) {
const utc = td.getAttribute('data-utc');
if (utc) {
const date = new Date(utc + 'Z');
td.textContent = date.toLocaleString();
}
});
// Parse and display visual diffs
document.querySelectorAll('.diff-container').forEach(function(container) {
const details = container.getAttribute('data-details');
if (!details) return;
// Try to parse common change patterns
let html = details;
// Pattern 1: "Changed X from 'old' to 'new'"
html = html.replace(/Changed (.+?) from ['"](.+?)['"] to ['"](.+?)['"]/gi, function(match, field, oldVal, newVal) {
return `Changed ${field} from <span class="diff-removed">${oldVal}</span> to <span class="diff-added">${newVal}</span>`;
});
// Pattern 2: "Renamed X to Y"
html = html.replace(/Renamed (.+?) to ['"](.+?)['"]/gi, function(match, oldVal, newVal) {
return `Renamed <span class="diff-removed">${oldVal}</span> to <span class="diff-added">${newVal}</span>`;
});
// Pattern 3: "Updated X: old -> new"
html = html.replace(/Updated (.+?):\s*(.+?)\s*->\s*(.+?)(?:\s|$)/gi, function(match, field, oldVal, newVal) {
return `Updated ${field}: <span class="diff-removed">${oldVal}</span> → <span class="diff-added">${newVal}</span>`;
});
// Pattern 4: "Set X to Y" (when it was previously something else, look for context)
html = html.replace(/Set (.+?) to ['"](.+?)['"]/gi, function(match, field, newVal) {
return `Set ${field} to <span class="diff-added">${newVal}</span>`;
});
// Pattern 5: "Removed X" or "Deleted X"
html = html.replace(/(Removed|Deleted) ['"](.+?)['"]/gi, function(match, action, val) {
return `${action} <span class="diff-removed">${val}</span>`;
});
// Pattern 6: "Added X"
html = html.replace(/Added ['"](.+?)['"]/gi, function(match, val) {
return `Added <span class="diff-added">${val}</span>`;
});
// Pattern 7: "Assigned X to Y" or "Unassigned X from Y"
// Capture everything after "to " or "from " to preserve all spaces in target
html = html.replace(/(Assigned|Unassigned) (.+?) (to|from) (.+)$/gi, function(match, action, item, prep, target) {
const actionClass = action === 'Assigned' ? 'diff-added' : 'diff-removed';
// Preserve the space between prep and target
return `${action} <span class="${actionClass}">${item}</span> ${prep} ${target}`;
});
// Pattern 8: Generic "from X to Y" pattern
html = html.replace(/from ['"](.+?)['"] to ['"](.+?)['"]/gi, function(match, oldVal, newVal) {
return `from <span class="diff-removed">${oldVal}</span> to <span class="diff-added">${newVal}</span>`;
});
container.innerHTML = html || details;
});
// Export button handler
const exportBtn = document.getElementById('export-btn');
if (exportBtn) {
exportBtn.addEventListener('click', function() {
const form = document.getElementById('audit-filter-form');
const formData = new FormData(form);
const params = new URLSearchParams();
// Add all form fields to params
for (const [key, value] of formData.entries()) {
if (value) {
if (key === 'user_ids') {
// Handle multiple user_ids
params.append('user_ids', value);
} else {
params.append(key, value);
}
}
}
// Handle multiple user_ids separately
const userSelect = form.querySelector('select[name="user_ids"]');
if (userSelect) {
const selectedUsers = Array.from(userSelect.selectedOptions).map(opt => opt.value);
params.delete('user_ids');
selectedUsers.forEach(userId => {
params.append('user_ids', userId);
});
}
// Redirect to export endpoint
window.location.href = '/audit/export_csv?' + params.toString();
});
}
});
-1
View File
@@ -1 +0,0 @@
document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("filter-toggle"),t=document.getElementById("audit-filter-form"),n=document.getElementById("filter-arrow");e&&t&&n&&(e.addEventListener("click",function(){t.classList.toggle("hidden"),t.classList.contains("hidden")?n.style.transform="rotate(0deg)":n.style.transform="rotate(180deg)"}),t.classList.contains("hidden")||(n.style.transform="rotate(180deg)")),document.querySelectorAll("td[data-utc]").forEach(function(e){let t=e.getAttribute("data-utc");if(t){let n=new Date(t+"Z");e.textContent=n.toLocaleString()}}),document.querySelectorAll(".diff-container").forEach(function(e){let t=e.getAttribute("data-details");if(!t)return;let n=t;n=(n=(n=(n=(n=(n=(n=(n=n.replace(/Changed (.+?) from ['"](.+?)['"] to ['"](.+?)['"]/gi,function(e,t,n,d){return`Changed ${t} from <span class="diff-removed">${n}</span> to <span class="diff-added">${d}</span>`})).replace(/Renamed (.+?) to ['"](.+?)['"]/gi,function(e,t,n){return`Renamed <span class="diff-removed">${t}</span> to <span class="diff-added">${n}</span>`})).replace(/Updated (.+?):\s*(.+?)\s*->\s*(.+?)(?:\s|$)/gi,function(e,t,n,d){return`Updated ${t}: <span class="diff-removed">${n}</span> → <span class="diff-added">${d}</span>`})).replace(/Set (.+?) to ['"](.+?)['"]/gi,function(e,t,n){return`Set ${t} to <span class="diff-added">${n}</span>`})).replace(/(Removed|Deleted) ['"](.+?)['"]/gi,function(e,t,n){return`${t} <span class="diff-removed">${n}</span>`})).replace(/Added ['"](.+?)['"]/gi,function(e,t){return`Added <span class="diff-added">${t}</span>`})).replace(/(Assigned|Unassigned) (.+?) (to|from) (.+)$/gi,function(e,t,n,d,a){return`${t} <span class="${"Assigned"===t?"diff-added":"diff-removed"}">${n}</span> ${d} ${a}`})).replace(/from ['"](.+?)['"] to ['"](.+?)['"]/gi,function(e,t,n){return`from <span class="diff-removed">${t}</span> to <span class="diff-added">${n}</span>`}),e.innerHTML=n||t});let d=document.getElementById("export-btn");d&&d.addEventListener("click",function(){let e=document.getElementById("audit-filter-form"),t=new FormData(e),n=new URLSearchParams;for(let[d,a]of t.entries())a&&("user_ids"===d?n.append("user_ids",a):n.append(d,a));let s=e.querySelector('select[name="user_ids"]');if(s){let r=Array.from(s.selectedOptions).map(e=>e.value);n.delete("user_ids"),r.forEach(e=>{n.append("user_ids",e)})}window.location.href="/audit/export_csv?"+n.toString()})});
-143
View File
@@ -1,143 +0,0 @@
document.addEventListener('DOMContentLoaded', function() {
const messageDiv = document.getElementById('message');
function showMessage(text, isError = false) {
messageDiv.textContent = text;
messageDiv.className = isError
? 'mb-4 p-4 rounded-lg bg-red-200 dark:bg-red-800 text-red-800 dark:text-red-200'
: 'mb-4 p-4 rounded-lg bg-green-200 dark:bg-green-800 text-green-800 dark:text-green-200';
messageDiv.classList.remove('hidden');
setTimeout(() => {
messageDiv.classList.add('hidden');
}, 5000);
}
// Create backup button
const createBackupBtn = document.getElementById('create-backup-btn');
if (createBackupBtn) {
createBackupBtn.addEventListener('click', function() {
createBackupBtn.disabled = true;
createBackupBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Creating...';
fetch('/backup/create', {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage(`Backup created successfully: ${data.filename}`);
setTimeout(() => window.location.reload(), 1500);
} else {
showMessage(data.error || 'Failed to create backup', true);
createBackupBtn.disabled = false;
createBackupBtn.innerHTML = '<i class="fas fa-database"></i> <span>Create Backup</span>';
}
})
.catch(error => {
showMessage('Error creating backup: ' + error.message, true);
createBackupBtn.disabled = false;
createBackupBtn.innerHTML = '<i class="fas fa-database"></i> <span>Create Backup</span>';
});
});
}
// Upload and restore form
const uploadRestoreForm = document.getElementById('upload-restore-form');
if (uploadRestoreForm) {
uploadRestoreForm.addEventListener('submit', function(e) {
e.preventDefault();
if (!confirm('WARNING: This will replace all current database data with the backup. Are you sure you want to continue?')) {
return;
}
const formData = new FormData(this);
const submitBtn = this.querySelector('button[type="submit"]');
const originalText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Restoring...';
fetch('/backup/restore', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage('Database restored successfully. Page will reload...');
setTimeout(() => window.location.reload(), 2000);
} else {
showMessage(data.error || 'Failed to restore backup', true);
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
})
.catch(error => {
showMessage('Error restoring backup: ' + error.message, true);
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
});
});
}
// Existing backup restore form
const existingRestoreForm = document.getElementById('existing-restore-form');
if (existingRestoreForm) {
existingRestoreForm.addEventListener('submit', function(e) {
e.preventDefault();
if (!confirm('WARNING: This will replace all current database data with the backup. Are you sure you want to continue?')) {
return;
}
const formData = new FormData(this);
const submitBtn = this.querySelector('button[type="submit"]');
const originalText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Restoring...';
fetch('/backup/restore', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage('Database restored successfully. Page will reload...');
setTimeout(() => window.location.reload(), 2000);
} else {
showMessage(data.error || 'Failed to restore backup', true);
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
})
.catch(error => {
showMessage('Error restoring backup: ' + error.message, true);
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
});
});
}
});
function deleteBackup(filename) {
if (!confirm(`Are you sure you want to delete backup "${filename}"?`)) {
return;
}
fetch(`/backup/delete/${filename}`, {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
window.location.reload();
} else {
alert('Error: ' + (data.error || 'Failed to delete backup'));
}
})
.catch(error => {
alert('Error: ' + error.message);
});
}
-1
View File
@@ -1 +0,0 @@
document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("message");function t(t,a=!1){e.textContent=t,e.className=a?"mb-4 p-4 rounded-lg bg-red-200 dark:bg-red-800 text-red-800 dark:text-red-200":"mb-4 p-4 rounded-lg bg-green-200 dark:bg-green-800 text-green-800 dark:text-green-200",e.classList.remove("hidden"),setTimeout(()=>{e.classList.add("hidden")},5e3)}let a=document.getElementById("create-backup-btn");a&&a.addEventListener("click",function(){a.disabled=!0,a.innerHTML='<i class="fas fa-spinner fa-spin"></i> Creating...',fetch("/backup/create",{method:"POST"}).then(e=>e.json()).then(e=>{e.success?(t(`Backup created successfully: ${e.filename}`),setTimeout(()=>window.location.reload(),1500)):(t(e.error||"Failed to create backup",!0),a.disabled=!1,a.innerHTML='<i class="fas fa-database"></i> <span>Create Backup</span>')}).catch(e=>{t("Error creating backup: "+e.message,!0),a.disabled=!1,a.innerHTML='<i class="fas fa-database"></i> <span>Create Backup</span>'})});let r=document.getElementById("upload-restore-form");r&&r.addEventListener("submit",function(e){if(e.preventDefault(),!confirm("WARNING: This will replace all current database data with the backup. Are you sure you want to continue?"))return;let a=new FormData(this),r=this.querySelector('button[type="submit"]'),n=r.innerHTML;r.disabled=!0,r.innerHTML='<i class="fas fa-spinner fa-spin"></i> Restoring...',fetch("/backup/restore",{method:"POST",body:a}).then(e=>e.json()).then(e=>{e.success?(t("Database restored successfully. Page will reload..."),setTimeout(()=>window.location.reload(),2e3)):(t(e.error||"Failed to restore backup",!0),r.disabled=!1,r.innerHTML=n)}).catch(e=>{t("Error restoring backup: "+e.message,!0),r.disabled=!1,r.innerHTML=n})});let n=document.getElementById("existing-restore-form");n&&n.addEventListener("submit",function(e){if(e.preventDefault(),!confirm("WARNING: This will replace all current database data with the backup. Are you sure you want to continue?"))return;let a=new FormData(this),r=this.querySelector('button[type="submit"]'),n=r.innerHTML;r.disabled=!0,r.innerHTML='<i class="fas fa-spinner fa-spin"></i> Restoring...',fetch("/backup/restore",{method:"POST",body:a}).then(e=>e.json()).then(e=>{e.success?(t("Database restored successfully. Page will reload..."),setTimeout(()=>window.location.reload(),2e3)):(t(e.error||"Failed to restore backup",!0),r.disabled=!1,r.innerHTML=n)}).catch(e=>{t("Error restoring backup: "+e.message,!0),r.disabled=!1,r.innerHTML=n})})});function deleteBackup(e){confirm(`Are you sure you want to delete backup "${e}"?`)&&fetch(`/backup/delete/${e}`,{method:"POST"}).then(e=>e.json()).then(e=>{e.success?window.location.reload():alert("Error: "+(e.error||"Failed to delete backup"))}).catch(e=>{alert("Error: "+e.message)})}
-183
View File
@@ -1,183 +0,0 @@
function showTab(tabName) {
// Hide all panels
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.add('hidden'));
// Update all tab buttons to inactive state
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
btn.classList.add('border-transparent', 'text-gray-500');
});
// Show selected panel
document.getElementById('panel-' + tabName).classList.remove('hidden');
// Update selected tab to active state
const activeTab = document.getElementById('tab-' + tabName);
activeTab.classList.remove('border-transparent', 'text-gray-500');
activeTab.classList.add('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
}
document.addEventListener('DOMContentLoaded', function() {
// Update selected IP count
document.getElementById('bulk-ip-select')?.addEventListener('change', function() {
document.getElementById('selected-ip-count').textContent = this.selectedOptions.length;
});
document.getElementById('bulk-tag-device-select')?.addEventListener('change', function() {
document.getElementById('selected-tag-device-count').textContent = this.selectedOptions.length;
});
// Load available IPs when subnet changes
document.getElementById('bulk-subnet-select')?.addEventListener('change', function() {
const subnetId = this.value;
const ipSelect = document.getElementById('bulk-ip-select');
if (!subnetId) {
ipSelect.innerHTML = '<option value="" disabled>Select a subnet first...</option>';
document.getElementById('selected-ip-count').textContent = '0';
return;
}
ipSelect.innerHTML = '<option value="" disabled>Loading...</option>';
fetch(`/get_available_ips?subnet_id=${subnetId}`)
.then(response => response.json())
.then(data => {
ipSelect.innerHTML = '';
if (data.available_ips.length === 0) {
ipSelect.innerHTML = '<option value="" disabled>No available IPs in this subnet</option>';
} else {
data.available_ips.forEach(ip => {
const option = document.createElement('option');
option.value = ip.id;
option.textContent = ip.ip;
ipSelect.appendChild(option);
});
}
document.getElementById('selected-ip-count').textContent = '0';
})
.catch(() => {
ipSelect.innerHTML = '<option value="" disabled>Error loading IPs</option>';
});
});
// Bulk IP Assignment
document.getElementById('bulk-assign-ips-form')?.addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const resultDiv = document.getElementById('assign-ips-result');
resultDiv.classList.remove('hidden');
resultDiv.innerHTML = '<p class="text-blue-500">Processing...</p>';
fetch('/bulk/assign_ips', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
let html = '<div class="space-y-2">';
if (data.success.length > 0) {
html += `<div class="text-green-600 dark:text-green-400"><strong>Successfully assigned ${data.success.length} IP(s):</strong><ul class="list-disc list-inside mt-2">`;
data.success.forEach(item => {
html += `<li>${item.ip}</li>`;
});
html += '</ul></div>';
}
if (data.failed.length > 0) {
html += `<div class="text-red-600 dark:text-red-400"><strong>Failed ${data.failed.length} assignment(s):</strong><ul class="list-disc list-inside mt-2">`;
data.failed.forEach(item => {
const ipDisplay = item.ip ? ` (${item.ip})` : '';
html += `<li>IP ID ${item.ip_id}${ipDisplay}: ${item.reason}</li>`;
});
html += '</ul></div>';
}
html += '</div>';
resultDiv.innerHTML = html;
// Reload IP list if successful
if (data.success.length > 0) {
const subnetSelect = document.getElementById('bulk-subnet-select');
if (subnetSelect.value) {
subnetSelect.dispatchEvent(new Event('change'));
}
}
})
.catch(error => {
resultDiv.innerHTML = `<p class="text-red-600">Error: ${error.message}</p>`;
});
});
// Bulk Device Creation
document.getElementById('bulk-create-devices-form')?.addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const resultDiv = document.getElementById('create-devices-result');
resultDiv.classList.remove('hidden');
resultDiv.innerHTML = '<p class="text-blue-500">Processing...</p>';
fetch('/bulk/create_devices', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
let html = '<div class="space-y-2">';
if (data.success.length > 0) {
html += `<div class="text-green-600 dark:text-green-400"><strong>Successfully created ${data.success.length} device(s):</strong><ul class="list-disc list-inside mt-2">`;
data.success.forEach(item => {
html += `<li>${item.name}</li>`;
});
html += '</ul></div>';
}
if (data.failed.length > 0) {
html += `<div class="text-red-600 dark:text-red-400"><strong>Failed ${data.failed.length} creation(s):</strong><ul class="list-disc list-inside mt-2">`;
data.failed.forEach(item => {
html += `<li>${item.name}: ${item.reason}</li>`;
});
html += '</ul></div>';
}
html += '</div>';
resultDiv.innerHTML = html;
if (data.success.length > 0) {
setTimeout(() => window.location.reload(), 2000);
}
})
.catch(error => {
resultDiv.innerHTML = `<p class="text-red-600">Error: ${error.message}</p>`;
});
});
// Bulk Tag Assignment
document.getElementById('bulk-assign-tags-form')?.addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const resultDiv = document.getElementById('assign-tags-result');
resultDiv.classList.remove('hidden');
resultDiv.innerHTML = '<p class="text-blue-500">Processing...</p>';
fetch('/bulk/assign_tags', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
let html = '<div class="space-y-2">';
if (data.success.length > 0) {
html += `<div class="text-green-600 dark:text-green-400"><strong>Successfully assigned ${data.success.length} tag(s):</strong><ul class="list-disc list-inside mt-2">`;
data.success.forEach(item => {
html += `<li>${item.device_name}: ${item.tag_name}</li>`;
});
html += '</ul></div>';
}
if (data.failed.length > 0) {
html += `<div class="text-red-600 dark:text-red-400"><strong>Failed ${data.failed.length} assignment(s):</strong><ul class="list-disc list-inside mt-2">`;
data.failed.forEach(item => {
html += `<li>Device ID ${item.device_id}, Tag ID ${item.tag_id}: ${item.reason}</li>`;
});
html += '</ul></div>';
}
html += '</div>';
resultDiv.innerHTML = html;
})
.catch(error => {
resultDiv.innerHTML = `<p class="text-red-600">Error: ${error.message}</p>`;
});
});
});
-1
View File
@@ -1 +0,0 @@
function showTab(e){document.querySelectorAll(".tab-panel").forEach(e=>e.classList.add("hidden")),document.querySelectorAll(".tab-btn").forEach(e=>{e.classList.remove("border-gray-600","text-gray-900","dark:text-gray-100"),e.classList.add("border-transparent","text-gray-500")}),document.getElementById("panel-"+e).classList.remove("hidden");let t=document.getElementById("tab-"+e);t.classList.remove("border-transparent","text-gray-500"),t.classList.add("border-gray-600","text-gray-900","dark:text-gray-100")}document.addEventListener("DOMContentLoaded",function(){document.getElementById("bulk-ip-select")?.addEventListener("change",function(){document.getElementById("selected-ip-count").textContent=this.selectedOptions.length}),document.getElementById("bulk-tag-device-select")?.addEventListener("change",function(){document.getElementById("selected-tag-device-count").textContent=this.selectedOptions.length}),document.getElementById("bulk-subnet-select")?.addEventListener("change",function(){let e=this.value,t=document.getElementById("bulk-ip-select");if(!e){t.innerHTML='<option value="" disabled>Select a subnet first...</option>',document.getElementById("selected-ip-count").textContent="0";return}t.innerHTML='<option value="" disabled>Loading...</option>',fetch(`/get_available_ips?subnet_id=${e}`).then(e=>e.json()).then(e=>{t.innerHTML="",0===e.available_ips.length?t.innerHTML='<option value="" disabled>No available IPs in this subnet</option>':e.available_ips.forEach(e=>{let s=document.createElement("option");s.value=e.id,s.textContent=e.ip,t.appendChild(s)}),document.getElementById("selected-ip-count").textContent="0"}).catch(()=>{t.innerHTML='<option value="" disabled>Error loading IPs</option>'})}),document.getElementById("bulk-assign-ips-form")?.addEventListener("submit",function(e){e.preventDefault();let t=new FormData(this),s=document.getElementById("assign-ips-result");s.classList.remove("hidden"),s.innerHTML='<p class="text-blue-500">Processing...</p>',fetch("/bulk/assign_ips",{method:"POST",body:t}).then(e=>e.json()).then(e=>{let t='<div class="space-y-2">';if(e.success.length>0&&(t+=`<div class="text-green-600 dark:text-green-400"><strong>Successfully assigned ${e.success.length} IP(s):</strong><ul class="list-disc list-inside mt-2">`,e.success.forEach(e=>{t+=`<li>${e.ip}</li>`}),t+="</ul></div>"),e.failed.length>0&&(t+=`<div class="text-red-600 dark:text-red-400"><strong>Failed ${e.failed.length} assignment(s):</strong><ul class="list-disc list-inside mt-2">`,e.failed.forEach(e=>{let s=e.ip?` (${e.ip})`:"";t+=`<li>IP ID ${e.ip_id}${s}: ${e.reason}</li>`}),t+="</ul></div>"),t+="</div>",s.innerHTML=t,e.success.length>0){let n=document.getElementById("bulk-subnet-select");n.value&&n.dispatchEvent(new Event("change"))}}).catch(e=>{s.innerHTML=`<p class="text-red-600">Error: ${e.message}</p>`})}),document.getElementById("bulk-create-devices-form")?.addEventListener("submit",function(e){e.preventDefault();let t=new FormData(this),s=document.getElementById("create-devices-result");s.classList.remove("hidden"),s.innerHTML='<p class="text-blue-500">Processing...</p>',fetch("/bulk/create_devices",{method:"POST",body:t}).then(e=>e.json()).then(e=>{let t='<div class="space-y-2">';e.success.length>0&&(t+=`<div class="text-green-600 dark:text-green-400"><strong>Successfully created ${e.success.length} device(s):</strong><ul class="list-disc list-inside mt-2">`,e.success.forEach(e=>{t+=`<li>${e.name}</li>`}),t+="</ul></div>"),e.failed.length>0&&(t+=`<div class="text-red-600 dark:text-red-400"><strong>Failed ${e.failed.length} creation(s):</strong><ul class="list-disc list-inside mt-2">`,e.failed.forEach(e=>{t+=`<li>${e.name}: ${e.reason}</li>`}),t+="</ul></div>"),t+="</div>",s.innerHTML=t,e.success.length>0&&setTimeout(()=>window.location.reload(),2e3)}).catch(e=>{s.innerHTML=`<p class="text-red-600">Error: ${e.message}</p>`})}),document.getElementById("bulk-assign-tags-form")?.addEventListener("submit",function(e){e.preventDefault();let t=new FormData(this),s=document.getElementById("assign-tags-result");s.classList.remove("hidden"),s.innerHTML='<p class="text-blue-500">Processing...</p>',fetch("/bulk/assign_tags",{method:"POST",body:t}).then(e=>e.json()).then(e=>{let t='<div class="space-y-2">';e.success.length>0&&(t+=`<div class="text-green-600 dark:text-green-400"><strong>Successfully assigned ${e.success.length} tag(s):</strong><ul class="list-disc list-inside mt-2">`,e.success.forEach(e=>{t+=`<li>${e.device_name}: ${e.tag_name}</li>`}),t+="</ul></div>"),e.failed.length>0&&(t+=`<div class="text-red-600 dark:text-red-400"><strong>Failed ${e.failed.length} assignment(s):</strong><ul class="list-disc list-inside mt-2">`,e.failed.forEach(e=>{t+=`<li>Device ID ${e.device_id}, Tag ID ${e.tag_id}: ${e.reason}</li>`}),t+="</ul></div>"),t+="</div>",s.innerHTML=t}).catch(e=>{s.innerHTML=`<p class="text-red-600">Error: ${e.message}</p>`})})});
-328
View File
@@ -1,328 +0,0 @@
// Custom Fields Management JavaScript
// Get initial tab from URL parameter or default to 'device'
const urlParams = new URLSearchParams(window.location.search);
let currentTab = urlParams.get('tab') || 'device';
if (currentTab !== 'device' && currentTab !== 'subnet') {
currentTab = 'device';
}
// Switch to the correct tab on page load
if (currentTab === 'subnet') {
switchTab('subnet');
} else {
// Ensure device tab is active on load
switchTab('device');
}
// Function to get current active tab
function getCurrentTab() {
return currentTab;
}
let fieldData = {};
// Tab switching
function switchTab(entityType) {
currentTab = entityType;
// Update tab buttons
document.getElementById('tab-device').classList.remove('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
document.getElementById('tab-device').classList.add('border-transparent', 'text-gray-500');
document.getElementById('tab-subnet').classList.remove('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
document.getElementById('tab-subnet').classList.add('border-transparent', 'text-gray-500');
if (entityType === 'device') {
document.getElementById('tab-device').classList.remove('border-transparent', 'text-gray-500');
document.getElementById('tab-device').classList.add('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
document.getElementById('device-fields-tab').classList.remove('hidden');
document.getElementById('subnet-fields-tab').classList.add('hidden');
} else {
document.getElementById('tab-subnet').classList.remove('border-transparent', 'text-gray-500');
document.getElementById('tab-subnet').classList.add('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
document.getElementById('device-fields-tab').classList.add('hidden');
document.getElementById('subnet-fields-tab').classList.remove('hidden');
}
// Update URL without reloading page
const newUrl = new URL(window.location);
newUrl.searchParams.set('tab', entityType);
window.history.pushState({}, '', newUrl);
}
// Show add field modal
function showAddFieldModal(entityType) {
// Determine the target entity type - prioritize explicit parameter, then read from DOM
let targetEntityType = entityType;
if (!targetEntityType) {
// Read from active tab button - check which tab has the active styling
const deviceTab = document.getElementById('tab-device');
const subnetTab = document.getElementById('tab-subnet');
if (deviceTab && deviceTab.classList.contains('border-gray-600')) {
targetEntityType = 'device';
} else if (subnetTab && subnetTab.classList.contains('border-gray-600')) {
targetEntityType = 'subnet';
} else {
// Fallback to currentTab variable
targetEntityType = currentTab || 'device';
}
}
// Ensure targetEntityType is valid
if (targetEntityType !== 'device' && targetEntityType !== 'subnet') {
targetEntityType = 'device';
}
// Ensure we're on the correct tab
if (targetEntityType !== currentTab) {
switchTab(targetEntityType);
}
document.getElementById('modal-title').textContent = 'Add Custom Field';
document.getElementById('form-action').value = 'add_field';
document.getElementById('form-field-id').value = '';
// Always set entity_type explicitly - double check it's set
const entityTypeInput = document.getElementById('form-entity-type');
entityTypeInput.value = targetEntityType;
// Debug: log to verify
console.log('Opening modal for entity type:', targetEntityType, 'currentTab:', currentTab, 'input value:', entityTypeInput.value);
// Reset form
document.getElementById('field-name').value = '';
document.getElementById('field-key').value = '';
document.getElementById('field-type').value = 'text';
document.getElementById('field-required').checked = false;
document.getElementById('field-default-value').value = '';
document.getElementById('field-help-text').value = '';
document.getElementById('field-display-order').value = '0';
document.getElementById('field-searchable').checked = false;
// Reset validation fields
document.getElementById('field-min-length').value = '';
document.getElementById('field-max-length').value = '';
document.getElementById('field-regex-pattern').value = '';
document.getElementById('field-min-value').value = '';
document.getElementById('field-max-value').value = '';
document.getElementById('field-select-options').value = '';
updateFieldTypeOptions();
document.getElementById('field-modal').classList.remove('hidden');
}
// Close field modal
function closeFieldModal() {
document.getElementById('field-modal').classList.add('hidden');
}
// Update field type options visibility
function updateFieldTypeOptions() {
const fieldType = document.getElementById('field-type').value;
// Hide all validation sections
document.getElementById('text-validation').classList.add('hidden');
document.getElementById('number-validation').classList.add('hidden');
document.getElementById('select-validation').classList.add('hidden');
// Show relevant validation section
if (fieldType === 'text' || fieldType === 'textarea') {
document.getElementById('text-validation').classList.remove('hidden');
} else if (fieldType === 'number' || fieldType === 'decimal') {
document.getElementById('number-validation').classList.remove('hidden');
} else if (fieldType === 'select') {
document.getElementById('select-validation').classList.remove('hidden');
}
}
// Auto-generate field key from name
function generateFieldKey(name) {
return name.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
// Edit field
function editField(fieldId, entityType) {
// Get field data from embedded JSON
const fieldsDataElement = document.getElementById('fields-data');
if (!fieldsDataElement) {
console.error('Fields data not found');
return;
}
try {
const fieldsData = JSON.parse(fieldsDataElement.textContent);
const fields = fieldsData[entityType] || [];
const field = fields.find(f => f.id === fieldId);
if (field) {
populateEditForm(field, entityType);
} else {
console.error('Field not found:', fieldId, entityType);
}
} catch (error) {
console.error('Error parsing fields data:', error);
}
}
function populateEditForm(field, entityType) {
document.getElementById('modal-title').textContent = 'Edit Custom Field';
document.getElementById('form-action').value = 'edit_field';
document.getElementById('form-field-id').value = field.id;
document.getElementById('form-entity-type').value = entityType;
document.getElementById('field-name').value = field.name || '';
document.getElementById('field-key').value = field.field_key || '';
document.getElementById('field-type').value = field.field_type || 'text';
document.getElementById('field-required').checked = field.required || false;
document.getElementById('field-default-value').value = field.default_value || '';
document.getElementById('field-help-text').value = field.help_text || '';
document.getElementById('field-display-order').value = field.display_order || 0;
document.getElementById('field-searchable').checked = field.searchable || false;
// Parse validation rules
let validationRules = {};
if (field.validation_rules) {
if (typeof field.validation_rules === 'string') {
try {
validationRules = JSON.parse(field.validation_rules);
} catch (e) {
validationRules = {};
}
} else {
validationRules = field.validation_rules;
}
}
// Populate validation fields
document.getElementById('field-min-length').value = validationRules.min_length || '';
document.getElementById('field-max-length').value = validationRules.max_length || '';
document.getElementById('field-regex-pattern').value = validationRules.regex_pattern || '';
document.getElementById('field-min-value').value = validationRules.min_value || '';
document.getElementById('field-max-value').value = validationRules.max_value || '';
if (validationRules.select_options) {
document.getElementById('field-select-options').value = validationRules.select_options.join(', ');
} else {
document.getElementById('field-select-options').value = '';
}
updateFieldTypeOptions();
document.getElementById('field-modal').classList.remove('hidden');
}
// Move field up/down
function moveField(entityType, fieldId, direction) {
// Get all fields for this entity type
const tbody = document.getElementById(`${entityType}-fields-tbody`);
const rows = Array.from(tbody.querySelectorAll('tr'));
const currentIndex = rows.findIndex(row => row.dataset.fieldId == fieldId);
if (currentIndex === -1) return;
let targetIndex;
if (direction === 'up' && currentIndex > 0) {
targetIndex = currentIndex - 1;
} else if (direction === 'down' && currentIndex < rows.length - 1) {
targetIndex = currentIndex + 1;
} else {
return;
}
// Swap rows
const currentRow = rows[currentIndex];
const targetRow = rows[targetIndex];
tbody.insertBefore(currentRow, direction === 'up' ? targetRow : targetRow.nextSibling);
// Update display orders and submit
const fieldOrders = {};
Array.from(tbody.querySelectorAll('tr')).forEach((row, index) => {
fieldOrders[row.dataset.fieldId] = index;
});
// Submit reorder
const form = document.createElement('form');
form.method = 'POST';
form.action = '/custom_fields';
const actionInput = document.createElement('input');
actionInput.type = 'hidden';
actionInput.name = 'action';
actionInput.value = 'reorder';
form.appendChild(actionInput);
const entityTypeInput = document.createElement('input');
entityTypeInput.type = 'hidden';
entityTypeInput.name = 'entity_type';
entityTypeInput.value = entityType;
form.appendChild(entityTypeInput);
const ordersInput = document.createElement('input');
ordersInput.type = 'hidden';
ordersInput.name = 'field_orders';
ordersInput.value = JSON.stringify(fieldOrders);
form.appendChild(ordersInput);
document.body.appendChild(form);
form.submit();
}
// Event listeners
document.addEventListener('DOMContentLoaded', function() {
// Auto-generate field key from name
const nameInput = document.getElementById('field-name');
const keyInput = document.getElementById('field-key');
if (nameInput && keyInput) {
nameInput.addEventListener('input', function() {
// Only auto-generate if key is empty or matches previous generated value
if (!keyInput.value || keyInput.dataset.autoGenerated === 'true') {
keyInput.value = generateFieldKey(this.value);
keyInput.dataset.autoGenerated = 'true';
}
});
keyInput.addEventListener('input', function() {
// Mark as manually edited
this.dataset.autoGenerated = 'false';
});
}
// Update field type options when type changes
const fieldTypeSelect = document.getElementById('field-type');
if (fieldTypeSelect) {
fieldTypeSelect.addEventListener('change', updateFieldTypeOptions);
}
// Ensure entity_type is set correctly before form submission
const fieldForm = document.getElementById('field-form');
if (fieldForm) {
fieldForm.addEventListener('submit', function(e) {
const entityTypeInput = document.getElementById('form-entity-type');
// Always ensure entity_type is set to currentTab
// This handles cases where the modal was opened without explicitly setting it
if (!entityTypeInput.value || entityTypeInput.value.trim() === '') {
entityTypeInput.value = currentTab;
console.log('Entity type was empty, setting to:', currentTab);
}
// Double-check it's a valid value
if (entityTypeInput.value !== 'device' && entityTypeInput.value !== 'subnet') {
entityTypeInput.value = currentTab;
console.log('Entity type was invalid, setting to currentTab:', currentTab);
}
console.log('Submitting form with entity_type:', entityTypeInput.value, 'currentTab:', currentTab);
});
}
});
// Close modal when clicking outside
window.onclick = function(event) {
const modal = document.getElementById('field-modal');
if (event.target === modal) {
closeFieldModal();
}
}
File diff suppressed because one or more lines are too long
-61
View File
@@ -1,61 +0,0 @@
document.addEventListener('DOMContentLoaded', function() {
const siteSelect = document.getElementById('site-select');
const subnetSelect = document.getElementById('subnet-select');
const ipSelect = document.getElementById('ip-select');
const renameBtn = document.querySelector('.rename-btn');
const saveBtn = document.querySelector('.save-btn');
const cancelBtn = document.querySelector('.cancel-btn');
const nameInput = document.querySelector('input[name="new_name"]');
const h1 = document.querySelector('h1');
siteSelect.addEventListener('change', function() {
const selectedSite = this.value;
let firstSubnet = null;
Array.from(subnetSelect.options).forEach(option => {
if (!option.value) return;
if (option.getAttribute('data-site') === selectedSite) {
option.style.display = '';
if (!firstSubnet) firstSubnet = option.value;
} else {
option.style.display = 'none';
}
});
subnetSelect.value = firstSubnet || '';
const event = new Event('change', { bubbles: true });
subnetSelect.dispatchEvent(event);
});
subnetSelect.addEventListener('change', function() {
const subnetId = this.value;
if (!subnetId) {
ipSelect.innerHTML = '<option value="" disabled selected>Select IP</option>';
return;
}
fetch(`/get_available_ips?subnet_id=${subnetId}`)
.then(response => response.json())
.then(data => {
ipSelect.innerHTML = '<option value="" disabled selected>Select IP</option>';
data.available_ips.forEach(ip => {
const option = document.createElement('option');
option.value = ip.id;
option.textContent = ip.ip;
ipSelect.appendChild(option);
});
});
});
if (renameBtn && saveBtn && cancelBtn && nameInput && h1) {
renameBtn.addEventListener('click', function(e) {
e.preventDefault();
nameInput.classList.remove('hidden');
saveBtn.classList.remove('hidden');
cancelBtn.classList.remove('hidden');
h1.classList.add('hidden');
nameInput.focus();
});
cancelBtn.addEventListener('click', function(e) {
e.preventDefault();
nameInput.classList.add('hidden');
saveBtn.classList.add('hidden');
cancelBtn.classList.add('hidden');
h1.classList.remove('hidden');
});
}
});
-1
View File
@@ -1 +0,0 @@
document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("site-select"),t=document.getElementById("subnet-select"),n=document.getElementById("ip-select"),i=document.querySelector(".rename-btn"),l=document.querySelector(".save-btn"),s=document.querySelector(".cancel-btn"),a=document.querySelector('input[name="new_name"]'),d=document.querySelector("h1");e.addEventListener("change",function(){let e=this.value,n=null;Array.from(t.options).forEach(t=>{t.value&&(t.getAttribute("data-site")===e?(t.style.display="",n||(n=t.value)):t.style.display="none")}),t.value=n||"";let i=new Event("change",{bubbles:!0});t.dispatchEvent(i)}),t.addEventListener("change",function(){let e=this.value;if(!e){n.innerHTML='<option value="" disabled selected>Select IP</option>';return}fetch(`/get_available_ips?subnet_id=${e}`).then(e=>e.json()).then(e=>{n.innerHTML='<option value="" disabled selected>Select IP</option>',e.available_ips.forEach(e=>{let t=document.createElement("option");t.value=e.id,t.textContent=e.ip,n.appendChild(t)})})}),i&&l&&s&&a&&d&&(i.addEventListener("click",function(e){e.preventDefault(),a.classList.remove("hidden"),l.classList.remove("hidden"),s.classList.remove("hidden"),d.classList.add("hidden"),a.focus()}),s.addEventListener("click",function(e){e.preventDefault(),a.classList.add("hidden"),l.classList.add("hidden"),s.classList.add("hidden"),d.classList.remove("hidden")}))});
-157
View File
@@ -1,157 +0,0 @@
// Font Awesome icon search functionality
// Common Font Awesome icons for device types
const fontAwesomeIcons = [
// Network & Server
'fa-server', 'fa-router', 'fa-network-wired', 'fa-switch', 'fa-hub', 'fa-ethernet',
'fa-satellite-dish', 'fa-broadcast-tower', 'fa-tower-cell', 'fa-wifi', 'fa-network',
'fa-project-diagram', 'fa-sitemap', 'fa-diagram-project', 'fa-cloud',
// Security
'fa-shield-halved', 'fa-shield', 'fa-shield-alt', 'fa-firewall', 'fa-lock', 'fa-unlock',
'fa-key', 'fa-fingerprint', 'fa-user-shield', 'fa-user-lock',
// Hardware
'fa-print', 'fa-boxes-stacked', 'fa-database', 'fa-hard-drive', 'fa-memory', 'fa-microchip',
'fa-cpu', 'fa-usb', 'fa-fan', 'fa-battery-full', 'fa-power-off', 'fa-plug', 'fa-bolt',
'fa-lightbulb', 'fa-monitor', 'fa-display', 'fa-tv', 'fa-camera', 'fa-video',
// Computing
'fa-laptop', 'fa-desktop', 'fa-tablet', 'fa-mobile-alt', 'fa-phone', 'fa-keyboard',
'fa-mouse', 'fa-microphone', 'fa-headphones', 'fa-speaker',
// Storage & Files
'fa-box', 'fa-package', 'fa-archive', 'fa-folder', 'fa-file', 'fa-hdd', 'fa-ssd',
'fa-floppy-disk', 'fa-disk', 'fa-save', 'fa-folder-open', 'fa-folder-plus',
// Data & Analytics
'fa-chart-line', 'fa-chart-bar', 'fa-chart-pie', 'fa-graph', 'fa-analytics',
'fa-database', 'fa-file-database', 'fa-file-chart-line', 'fa-file-chart-pie',
// Location & Infrastructure
'fa-globe', 'fa-earth', 'fa-map', 'fa-location', 'fa-map-marker', 'fa-building',
'fa-warehouse', 'fa-home', 'fa-office', 'fa-industry',
// Tools & Utilities
'fa-robot', 'fa-cog', 'fa-gear', 'fa-wrench', 'fa-tools', 'fa-question',
'fa-code', 'fa-terminal', 'fa-console', 'fa-bug', 'fa-bug-slash',
// Identification
'fa-id-card', 'fa-credit-card', 'fa-qrcode', 'fa-barcode', 'fa-rfid',
// Transport & Logistics
'fa-truck', 'fa-shipping-fast', 'fa-conveyor-belt', 'fa-pallet', 'fa-dolly',
'fa-cube', 'fa-cubes', 'fa-layer-group', 'fa-stack',
// UI & Display
'fa-th', 'fa-th-large', 'fa-th-list', 'fa-list', 'fa-list-ul', 'fa-list-ol',
'fa-table', 'fa-columns', 'fa-grid', 'fa-window-maximize', 'fa-window-restore',
'fa-window-minimize', 'fa-window-close', 'fa-expand', 'fa-compress',
// Actions
'fa-sync', 'fa-sync-alt', 'fa-redo', 'fa-undo', 'fa-refresh', 'fa-download',
'fa-upload', 'fa-exchange-alt', 'fa-share', 'fa-link', 'fa-unlink', 'fa-chain',
'fa-chain-broken', 'fa-arrows-alt', 'fa-arrows', 'fa-move',
// Time & Calendar
'fa-clock', 'fa-hourglass', 'fa-stopwatch', 'fa-timer', 'fa-calendar',
'fa-calendar-alt', 'fa-calendar-check', 'fa-calendar-times', 'fa-history',
// Media
'fa-play', 'fa-pause', 'fa-stop', 'fa-step-backward', 'fa-step-forward',
'fa-fast-backward', 'fa-fast-forward', 'fa-eject', 'fa-record-vinyl',
'fa-compact-disc', 'fa-cd', 'fa-dvd',
// Users
'fa-user-shield', 'fa-user-lock', 'fa-user-secret', 'fa-user-cog', 'fa-user-gear',
'fa-user-tie', 'fa-user-ninja', 'fa-users', 'fa-users-cog', 'fa-user-group',
'fa-user-friends', 'fa-user-plus', 'fa-user-minus', 'fa-user-times', 'fa-user-check',
'fa-user-xmark', 'fa-user-slash'
];
function initIconSearch() {
const iconInputs = document.querySelectorAll('.icon-search-input');
iconInputs.forEach(input => {
const container = input.closest('.icon-search-container');
const preview = container.querySelector('.icon-preview');
const suggestions = container.querySelector('.icon-suggestions');
if (!preview || !suggestions) return;
// Initialize preview if input already has a value
if (input.value && input.value.trim()) {
const iconClass = input.value.trim().startsWith('fa-') ? input.value.trim() : `fa-${input.value.trim()}`;
preview.innerHTML = `<i class="fas ${iconClass}"></i>`;
preview.classList.remove('hidden');
}
input.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase().trim();
// Update preview
if (query) {
const iconClass = query.startsWith('fa-') ? query : `fa-${query}`;
preview.innerHTML = `<i class="fas ${iconClass}"></i>`;
preview.classList.remove('hidden');
} else {
preview.classList.add('hidden');
}
// Filter and display suggestions
if (query.length > 0) {
const filtered = fontAwesomeIcons.filter(icon =>
icon.includes(query) || icon.replace('fa-', '').includes(query)
).slice(0, 10); // Show top 10 matches
if (filtered.length > 0) {
suggestions.innerHTML = filtered.map(icon => `
<div class="icon-suggestion-item" data-icon="${icon}">
<i class="fas ${icon}"></i>
<span>${icon}</span>
</div>
`).join('');
suggestions.classList.remove('hidden');
// Add click handlers
suggestions.querySelectorAll('.icon-suggestion-item').forEach(item => {
item.addEventListener('click', () => {
input.value = item.dataset.icon;
preview.innerHTML = `<i class="fas ${item.dataset.icon}"></i>`;
preview.classList.remove('hidden');
suggestions.classList.add('hidden');
});
});
} else {
suggestions.classList.add('hidden');
}
} else {
suggestions.classList.add('hidden');
}
});
// Hide suggestions when clicking outside
document.addEventListener('click', (e) => {
if (!container.contains(e.target)) {
suggestions.classList.add('hidden');
}
});
// Update preview on blur if value exists
input.addEventListener('blur', () => {
const value = input.value.trim();
if (value && preview) {
const iconClass = value.startsWith('fa-') ? value : `fa-${value}`;
preview.innerHTML = `<i class="fas ${iconClass}"></i>`;
preview.classList.remove('hidden');
}
});
});
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initIconSearch);
} else {
initIconSearch();
}
-6
View File
@@ -1,6 +0,0 @@
const fontAwesomeIcons=["fa-server","fa-router","fa-network-wired","fa-switch","fa-hub","fa-ethernet","fa-satellite-dish","fa-broadcast-tower","fa-tower-cell","fa-wifi","fa-network","fa-project-diagram","fa-sitemap","fa-diagram-project","fa-cloud","fa-shield-halved","fa-shield","fa-shield-alt","fa-firewall","fa-lock","fa-unlock","fa-key","fa-fingerprint","fa-user-shield","fa-user-lock","fa-print","fa-boxes-stacked","fa-database","fa-hard-drive","fa-memory","fa-microchip","fa-cpu","fa-usb","fa-fan","fa-battery-full","fa-power-off","fa-plug","fa-bolt","fa-lightbulb","fa-monitor","fa-display","fa-tv","fa-camera","fa-video","fa-laptop","fa-desktop","fa-tablet","fa-mobile-alt","fa-phone","fa-keyboard","fa-mouse","fa-microphone","fa-headphones","fa-speaker","fa-box","fa-package","fa-archive","fa-folder","fa-file","fa-hdd","fa-ssd","fa-floppy-disk","fa-disk","fa-save","fa-folder-open","fa-folder-plus","fa-chart-line","fa-chart-bar","fa-chart-pie","fa-graph","fa-analytics","fa-database","fa-file-database","fa-file-chart-line","fa-file-chart-pie","fa-globe","fa-earth","fa-map","fa-location","fa-map-marker","fa-building","fa-warehouse","fa-home","fa-office","fa-industry","fa-robot","fa-cog","fa-gear","fa-wrench","fa-tools","fa-question","fa-code","fa-terminal","fa-console","fa-bug","fa-bug-slash","fa-id-card","fa-credit-card","fa-qrcode","fa-barcode","fa-rfid","fa-truck","fa-shipping-fast","fa-conveyor-belt","fa-pallet","fa-dolly","fa-cube","fa-cubes","fa-layer-group","fa-stack","fa-th","fa-th-large","fa-th-list","fa-list","fa-list-ul","fa-list-ol","fa-table","fa-columns","fa-grid","fa-window-maximize","fa-window-restore","fa-window-minimize","fa-window-close","fa-expand","fa-compress","fa-sync","fa-sync-alt","fa-redo","fa-undo","fa-refresh","fa-download","fa-upload","fa-exchange-alt","fa-share","fa-link","fa-unlink","fa-chain","fa-chain-broken","fa-arrows-alt","fa-arrows","fa-move","fa-clock","fa-hourglass","fa-stopwatch","fa-timer","fa-calendar","fa-calendar-alt","fa-calendar-check","fa-calendar-times","fa-history","fa-play","fa-pause","fa-stop","fa-step-backward","fa-step-forward","fa-fast-backward","fa-fast-forward","fa-eject","fa-record-vinyl","fa-compact-disc","fa-cd","fa-dvd","fa-user-shield","fa-user-lock","fa-user-secret","fa-user-cog","fa-user-gear","fa-user-tie","fa-user-ninja","fa-users","fa-users-cog","fa-user-group","fa-user-friends","fa-user-plus","fa-user-minus","fa-user-times","fa-user-check","fa-user-xmark","fa-user-slash"];function initIconSearch(){let a=document.querySelectorAll(".icon-search-input");a.forEach(a=>{let e=a.closest(".icon-search-container"),f=e.querySelector(".icon-preview"),s=e.querySelector(".icon-suggestions");if(f&&s){if(a.value&&a.value.trim()){let i=a.value.trim().startsWith("fa-")?a.value.trim():`fa-${a.value.trim()}`;f.innerHTML=`<i class="fas ${i}"></i>`,f.classList.remove("hidden")}a.addEventListener("input",e=>{let i=e.target.value.toLowerCase().trim();if(i){let r=i.startsWith("fa-")?i:`fa-${i}`;f.innerHTML=`<i class="fas ${r}"></i>`,f.classList.remove("hidden")}else f.classList.add("hidden");if(i.length>0){let t=fontAwesomeIcons.filter(a=>a.includes(i)||a.replace("fa-","").includes(i)).slice(0,10);t.length>0?(s.innerHTML=t.map(a=>`
<div class="icon-suggestion-item" data-icon="${a}">
<i class="fas ${a}"></i>
<span>${a}</span>
</div>
`).join(""),s.classList.remove("hidden"),s.querySelectorAll(".icon-suggestion-item").forEach(e=>{e.addEventListener("click",()=>{a.value=e.dataset.icon,f.innerHTML=`<i class="fas ${e.dataset.icon}"></i>`,f.classList.remove("hidden"),s.classList.add("hidden")})})):s.classList.add("hidden")}else s.classList.add("hidden")}),document.addEventListener("click",a=>{e.contains(a.target)||s.classList.add("hidden")}),a.addEventListener("blur",()=>{let e=a.value.trim();if(e&&f){let s=e.startsWith("fa-")?e:`fa-${e}`;f.innerHTML=`<i class="fas ${s}"></i>`,f.classList.remove("hidden")}})}})}"loading"===document.readyState?document.addEventListener("DOMContentLoaded",initIconSearch):initIconSearch();
-73
View File
@@ -1,73 +0,0 @@
document.addEventListener('DOMContentLoaded', function() {
// Tag filter functionality
const tagFilter = document.getElementById('tag-filter');
if (tagFilter) {
tagFilter.addEventListener('change', function() {
const selectedTag = this.value;
if (selectedTag) {
window.location.href = '/devices?tag=' + encodeURIComponent(selectedTag);
} else {
window.location.href = '/devices';
}
});
}
// Expand/collapse site groups
document.querySelectorAll('.site-header').forEach(header => {
header.addEventListener('click', function(e) {
const deviceList = this.closest('.site-group').querySelector('.device-list');
const icon = this.querySelector('.expand-btn i');
if (deviceList.classList.contains('hidden')) {
deviceList.classList.remove('hidden');
icon.classList.remove('fa-chevron-down');
icon.classList.add('fa-chevron-up');
} else {
deviceList.classList.add('hidden');
icon.classList.remove('fa-chevron-up');
icon.classList.add('fa-chevron-down');
}
});
});
// Scroll to Top Button
const scrollToTopButton = document.createElement('button');
scrollToTopButton.innerHTML = '<i class="fas fa-arrow-up"></i>';
scrollToTopButton.style.fontSize = '26px';
scrollToTopButton.className = 'fixed bottom-5 right-5 bg-gray-200 dark:bg-zinc-800 text-black dark:text-white p-3 rounded-full shadow-lg hidden';
scrollToTopButton.style.width = '60px';
scrollToTopButton.style.height = '60px';
scrollToTopButton.style.borderRadius = '50%';
document.body.appendChild(scrollToTopButton);
const style = document.createElement('style');
style.textContent = `
@keyframes bob {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
.bobbing {
animation: bob 1.5s infinite;
}
`;
document.head.appendChild(style);
scrollToTopButton.classList.add('bobbing');
window.addEventListener('scroll', () => {
if (window.scrollY > 200) {
scrollToTopButton.classList.remove('hidden');
} else {
scrollToTopButton.classList.add('hidden');
}
});
scrollToTopButton.addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
});
-14
View File
@@ -1,14 +0,0 @@
document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("tag-filter");e&&e.addEventListener("change",function(){let e=this.value;e?window.location.href="/devices?tag="+encodeURIComponent(e):window.location.href="/devices"}),document.querySelectorAll(".site-header").forEach(e=>{e.addEventListener("click",function(e){let t=this.closest(".site-group").querySelector(".device-list"),s=this.querySelector(".expand-btn i");t.classList.contains("hidden")?(t.classList.remove("hidden"),s.classList.remove("fa-chevron-down"),s.classList.add("fa-chevron-up")):(t.classList.add("hidden"),s.classList.remove("fa-chevron-up"),s.classList.add("fa-chevron-down"))})});let t=document.createElement("button");t.innerHTML='<i class="fas fa-arrow-up"></i>',t.style.fontSize="26px",t.className="fixed bottom-5 right-5 bg-gray-200 dark:bg-zinc-800 text-black dark:text-white p-3 rounded-full shadow-lg hidden",t.style.width="60px",t.style.height="60px",t.style.borderRadius="50%",document.body.appendChild(t);let s=document.createElement("style");s.textContent=`
@keyframes bob {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
.bobbing {
animation: bob 1.5s infinite;
}
`,document.head.appendChild(s),t.classList.add("bobbing"),window.addEventListener("scroll",()=>{window.scrollY>200?t.classList.remove("hidden"):t.classList.add("hidden")}),t.addEventListener("click",()=>{window.scrollTo({top:0,behavior:"smooth"})})});
-7
View File
@@ -1,7 +0,0 @@
document.querySelectorAll('.export-csv-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
e.stopPropagation();
const subnetId = this.getAttribute('data-subnet-id');
window.location.href = `/subnet/${subnetId}/export_csv`;
});
});
-1
View File
@@ -1 +0,0 @@
document.querySelectorAll(".export-csv-btn").forEach(t=>{t.addEventListener("click",function(t){t.stopPropagation();let e=this.getAttribute("data-subnet-id");window.location.href=`/subnet/${e}/export_csv`})});
-12
View File
@@ -1,12 +0,0 @@
document.addEventListener('DOMContentLoaded', function() {
const navToggle = document.getElementById('nav-toggle');
const mobileNav = document.getElementById('mobile-nav');
navToggle.addEventListener('click', function() {
mobileNav.classList.toggle('hidden');
});
document.addEventListener('click', function(e) {
if (!mobileNav.contains(e.target) && !navToggle.contains(e.target)) {
mobileNav.classList.add('hidden');
}
});
});
-1
View File
@@ -1 +0,0 @@
document.addEventListener("DOMContentLoaded",function(){let t=document.getElementById("nav-toggle"),e=document.getElementById("mobile-nav");t.addEventListener("click",function(){e.classList.toggle("hidden")}),document.addEventListener("click",function(n){e.contains(n.target)||t.contains(n.target)||e.classList.add("hidden")})});
-104
View File
@@ -1,104 +0,0 @@
// IP History Modal functionality
document.addEventListener('DOMContentLoaded', function() {
const modal = document.getElementById('ip-history-modal');
const closeBtn = document.getElementById('close-ip-history-modal');
const content = document.getElementById('ip-history-content');
const ipAddressSpan = document.getElementById('modal-ip-address');
// Open modal when IP is clicked
document.querySelectorAll('.ip-history-btn').forEach(btn => {
btn.addEventListener('click', function() {
const ip = this.getAttribute('data-ip');
ipAddressSpan.textContent = ip;
modal.classList.remove('hidden');
modal.classList.add('flex');
loadIPHistory(ip);
});
});
// Close modal
closeBtn.addEventListener('click', function() {
modal.classList.add('hidden');
modal.classList.remove('flex');
});
// Close modal when clicking outside
modal.addEventListener('click', function(e) {
if (e.target === modal) {
modal.classList.add('hidden');
modal.classList.remove('flex');
}
});
// Close modal with Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && !modal.classList.contains('hidden')) {
modal.classList.add('hidden');
modal.classList.remove('flex');
}
});
function loadIPHistory(ip) {
content.innerHTML = '<div class="text-center text-gray-600 dark:text-gray-400">Loading...</div>';
fetch(`/api/ip/${encodeURIComponent(ip)}/history`)
.then(response => response.json())
.then(data => {
if (data.history && data.history.length > 0) {
displayHistory(data.history);
} else {
content.innerHTML = '<div class="text-center text-gray-600 dark:text-gray-400">No history found for this IP address.</div>';
}
})
.catch(error => {
console.error('Error loading IP history:', error);
content.innerHTML = '<div class="text-center text-red-500">Error loading IP history. Please try again.</div>';
});
}
function displayHistory(history) {
let html = '<div class="space-y-3">';
history.forEach((entry, index) => {
const isAssigned = entry.action === 'assigned';
const icon = isAssigned ? 'fa-plus-circle text-green-500' : 'fa-minus-circle text-red-500';
const actionText = isAssigned ? 'Assigned' : 'Removed';
// Format timestamp
let timestamp = 'Unknown';
if (entry.timestamp) {
try {
const date = new Date(entry.timestamp);
timestamp = date.toLocaleString();
} catch (e) {
timestamp = entry.timestamp;
}
}
html += `
<div class="flex items-start gap-3 pb-3 ${index < history.length - 1 ? 'border-b border-gray-400 dark:border-zinc-600' : ''}">
<div class="flex-shrink-0 mt-1">
<i class="fas ${icon}"></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-semibold">${actionText}</span>
<span class="text-gray-600 dark:text-gray-400">to</span>
<span class="font-semibold">${entry.device_name || 'Unknown'}</span>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400 mt-1">
${entry.subnet_name || 'Unknown'} (${entry.subnet_cidr || 'N/A'})
</div>
<div class="text-xs text-gray-500 dark:text-gray-500 mt-1">
by ${entry.user_name || 'Unknown'} ${timestamp}
</div>
</div>
</div>
`;
});
html += '</div>';
content.innerHTML = html;
}
});
-20
View File
@@ -1,20 +0,0 @@
document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("ip-history-modal"),t=document.getElementById("close-ip-history-modal"),s=document.getElementById("ip-history-content"),i=document.getElementById("modal-ip-address");document.querySelectorAll(".ip-history-btn").forEach(t=>{t.addEventListener("click",function(){var t;let a=this.getAttribute("data-ip");i.textContent=a,e.classList.remove("hidden"),e.classList.add("flex"),t=a,s.innerHTML='<div class="text-center text-gray-600 dark:text-gray-400">Loading...</div>',fetch(`/api/ip/${encodeURIComponent(t)}/history`).then(e=>e.json()).then(e=>{var t;let i;e.history&&e.history.length>0?(t=e.history,i='<div class="space-y-3">',t.forEach((e,s)=>{let a="assigned"===e.action,n="Unknown";if(e.timestamp)try{let d=new Date(e.timestamp);n=d.toLocaleString()}catch(r){n=e.timestamp}i+=`
<div class="flex items-start gap-3 pb-3 ${s<t.length-1?"border-b border-gray-400 dark:border-zinc-600":""}">
<div class="flex-shrink-0 mt-1">
<i class="fas ${a?"fa-plus-circle text-green-500":"fa-minus-circle text-red-500"}"></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-semibold">${a?"Assigned":"Removed"}</span>
<span class="text-gray-600 dark:text-gray-400">to</span>
<span class="font-semibold">${e.device_name||"Unknown"}</span>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400 mt-1">
${e.subnet_name||"Unknown"} (${e.subnet_cidr||"N/A"})
</div>
<div class="text-xs text-gray-500 dark:text-gray-500 mt-1">
by ${e.user_name||"Unknown"} ${n}
</div>
</div>
</div>
`}),i+="</div>",s.innerHTML=i):s.innerHTML='<div class="text-center text-gray-600 dark:text-gray-400">No history found for this IP address.</div>'}).catch(e=>{console.error("Error loading IP history:",e),s.innerHTML='<div class="text-center text-red-500">Error loading IP history. Please try again.</div>'})})}),t.addEventListener("click",function(){e.classList.add("hidden"),e.classList.remove("flex")}),e.addEventListener("click",function(t){t.target===e&&(e.classList.add("hidden"),e.classList.remove("flex"))}),document.addEventListener("keydown",function(t){"Escape"!==t.key||e.classList.contains("hidden")||(e.classList.add("hidden"),e.classList.remove("flex"))})});
-41
View File
@@ -1,41 +0,0 @@
document.addEventListener('DOMContentLoaded', function() {
// Export CSV button
const exportBtn = document.getElementById('export-csv');
if (exportBtn) {
exportBtn.addEventListener('click', function() {
const rackId = exportBtn.getAttribute('data-rack-id');
if (rackId) {
window.location = '/rack/' + rackId + '/export_csv';
}
});
}
// Form toggle functionality
function showBothAddButtons() {
document.getElementById('show-add-device-form').classList.remove('hidden');
document.getElementById('show-nonnet-form').classList.remove('hidden');
}
showBothAddButtons();
document.getElementById('show-nonnet-form').onclick = function() {
document.getElementById('nonnet-form').classList.remove('hidden');
this.classList.add('hidden');
};
document.getElementById('hide-nonnet-form').onclick = function() {
document.getElementById('nonnet-form').classList.add('hidden');
showBothAddButtons();
};
document.getElementById('show-add-device-form').onclick = function() {
document.getElementById('add-device-form').classList.remove('hidden');
this.classList.add('hidden');
};
document.getElementById('hide-add-device-form').onclick = function() {
document.getElementById('add-device-form').classList.add('hidden');
showBothAddButtons();
};
});
-1
View File
@@ -1 +0,0 @@
document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("export-csv");function d(){document.getElementById("show-add-device-form").classList.remove("hidden"),document.getElementById("show-nonnet-form").classList.remove("hidden")}e&&e.addEventListener("click",function(){let d=e.getAttribute("data-rack-id");d&&(window.location="/rack/"+d+"/export_csv")}),d(),document.getElementById("show-nonnet-form").onclick=function(){document.getElementById("nonnet-form").classList.remove("hidden"),this.classList.add("hidden")},document.getElementById("hide-nonnet-form").onclick=function(){document.getElementById("nonnet-form").classList.add("hidden"),d()},document.getElementById("show-add-device-form").onclick=function(){document.getElementById("add-device-form").classList.remove("hidden"),this.classList.add("hidden")},document.getElementById("hide-add-device-form").onclick=function(){document.getElementById("add-device-form").classList.add("hidden"),d()}});
-34
View File
@@ -1,34 +0,0 @@
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.site-header').forEach(header => {
header.addEventListener('click', function(e) {
if (e.target.closest('button')) return;
const subnetList = this.closest('.site-group').querySelector('.subnet-list');
const icon = this.querySelector('.expand-btn i');
if (subnetList.classList.contains('hidden')) {
subnetList.classList.remove('hidden');
icon.classList.remove('fa-chevron-down');
icon.classList.add('fa-chevron-up');
} else {
subnetList.classList.add('hidden');
icon.classList.remove('fa-chevron-up');
icon.classList.add('fa-chevron-down');
}
});
});
document.querySelectorAll('.expand-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
e.stopPropagation();
const subnetList = this.closest('.site-group').querySelector('.subnet-list');
const icon = this.querySelector('i');
if (subnetList.classList.contains('hidden')) {
subnetList.classList.remove('hidden');
icon.classList.remove('fa-chevron-down');
icon.classList.add('fa-chevron-up');
} else {
subnetList.classList.add('hidden');
icon.classList.remove('fa-chevron-up');
icon.classList.add('fa-chevron-down');
}
});
});
});
-1
View File
@@ -1 +0,0 @@
document.addEventListener("DOMContentLoaded",function(){document.querySelectorAll(".site-header").forEach(e=>{e.addEventListener("click",function(e){if(e.target.closest("button"))return;let s=this.closest(".site-group").querySelector(".subnet-list"),t=this.querySelector(".expand-btn i");s.classList.contains("hidden")?(s.classList.remove("hidden"),t.classList.remove("fa-chevron-down"),t.classList.add("fa-chevron-up")):(s.classList.add("hidden"),t.classList.remove("fa-chevron-up"),t.classList.add("fa-chevron-down"))})}),document.querySelectorAll(".expand-btn").forEach(e=>{e.addEventListener("click",function(e){e.stopPropagation();let s=this.closest(".site-group").querySelector(".subnet-list"),t=this.querySelector("i");s.classList.contains("hidden")?(s.classList.remove("hidden"),t.classList.remove("fa-chevron-down"),t.classList.add("fa-chevron-up")):(s.classList.add("hidden"),t.classList.remove("fa-chevron-up"),t.classList.add("fa-chevron-down"))})})});
-282
View File
@@ -1,282 +0,0 @@
document.addEventListener('DOMContentLoaded', () => {
// Only target the form on the subnet page, not the header search form
// Look for a form that's not in the header (header forms have action="/search")
const allForms = document.querySelectorAll('form');
let form = null;
for (let f of allForms) {
if (f.action !== '/search' && f.method === 'POST') {
form = f;
break;
}
}
if (form) {
// Check if search input already exists to prevent duplicates
if (!document.querySelector('input[placeholder="Search by IP or Hostname"]')) {
form.addEventListener('submit', (event) => {
event.preventDefault();
});
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.placeholder = 'Search by IP or Hostname';
searchInput.className = 'p-2 w-full rounded-lg bg-gray-200 dark:bg-zinc-800 border border-gray-600 focus:outline-none focus:border-blue-400 mb-4 text-center';
form.insertAdjacentElement('beforebegin', searchInput);
searchInput.addEventListener('keypress', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
const searchTerm = searchInput.value.toLowerCase();
const rows = document.querySelectorAll('tbody tr');
rows.forEach(row => {
const ipCell = row.querySelector('td:nth-child(1)').textContent.toLowerCase();
const hostnameCell = row.querySelector('td:nth-child(2)').textContent.toLowerCase();
const descCell = row.querySelector('td:nth-child(3)');
const descText = descCell ? descCell.textContent.toLowerCase() : '';
if (ipCell.includes(searchTerm) || hostnameCell.includes(searchTerm) || descText.includes(searchTerm)) {
row.style.backgroundColor = 'rgba(59, 130, 246, 0.5)';
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
setTimeout(() => {
row.style.backgroundColor = '';
}, 3000);
} else {
row.style.backgroundColor = '';
}
});
}
});
}
}
// Description toggle functionality
const toggleBtn = document.getElementById('toggle-desc');
const descCols = document.querySelectorAll('.desc-col');
const descHeader = document.getElementById('desc-col-header');
let shown = false;
if (toggleBtn) {
toggleBtn.addEventListener('click', function() {
shown = !shown;
descCols.forEach(col => col.classList.toggle('hidden', !shown));
if (descHeader) descHeader.classList.toggle('hidden', !shown);
toggleBtn.textContent = shown ? 'Hide Descriptions' : 'Show Descriptions';
});
}
// Scroll to Top Button
const scrollToTopButton = document.createElement('button');
scrollToTopButton.innerHTML = '<i class="fas fa-arrow-up"></i>';
scrollToTopButton.style.fontSize = '26px';
scrollToTopButton.className = 'fixed bottom-5 right-5 bg-gray-200 dark:bg-zinc-800 text-black dark:text-white p-3 rounded-full shadow-lg hidden';
scrollToTopButton.style.width = '60px';
scrollToTopButton.style.height = '60px';
scrollToTopButton.style.borderRadius = '50%';
document.body.appendChild(scrollToTopButton);
const style = document.createElement('style');
style.textContent = `
@keyframes bob {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
.bobbing {
animation: bob 1.5s infinite;
}
`;
document.head.appendChild(style);
scrollToTopButton.classList.add('bobbing');
window.addEventListener('scroll', () => {
if (window.scrollY > 200) {
scrollToTopButton.classList.remove('hidden');
} else {
scrollToTopButton.classList.add('hidden');
}
});
scrollToTopButton.addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
// Force scrollbar thumb to render on page load
// This fixes the issue where scrollbar thumb is missing on initial page load
// The scrollbar only renders its thumb after a scroll event has occurred
requestAnimationFrame(() => {
const isScrollable = document.documentElement.scrollHeight > document.documentElement.clientHeight;
if (isScrollable && window.scrollY === 0) {
// Trigger a minimal scroll to force scrollbar rendering, then scroll back
window.scrollBy(0, 1);
requestAnimationFrame(() => {
window.scrollBy(0, -1);
});
}
});
// Scroll to IP anchor if present in URL hash
if (window.location.hash) {
const hash = window.location.hash.substring(1);
const element = document.getElementById(hash);
if (element) {
setTimeout(() => {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Highlight the row briefly
element.style.backgroundColor = 'rgba(59, 130, 246, 0.5)';
setTimeout(() => {
element.style.backgroundColor = '';
}, 3000);
}, 100);
}
}
// Auto-resize all description textareas (both editable and readonly)
const allDescTextareas = document.querySelectorAll('.desc-col textarea');
allDescTextareas.forEach(textarea => {
textarea.style.overflow = 'hidden';
textarea.style.resize = 'none';
function autoResize() {
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
}
autoResize();
});
// IP Notes inline editing functionality
const ipNotesTextareas = document.querySelectorAll('.ip-notes-textarea');
const originalValues = new Map();
// Helper function to show toast notification
function showToast(message, type = 'success') {
const toast = document.createElement('div');
toast.className = `fixed top-20 right-4 px-4 py-3 rounded-lg shadow-lg z-50 ${
type === 'success'
? 'bg-green-500 text-white'
: 'bg-red-500 text-white'
}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.transition = 'opacity 0.3s';
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
ipNotesTextareas.forEach(textarea => {
// Store original value
originalValues.set(textarea, textarea.value);
// Ensure overflow is hidden and resize is disabled
textarea.style.overflow = 'hidden';
textarea.style.resize = 'none';
// Auto-resize textarea
function autoResize() {
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
}
autoResize();
// Handle input to auto-resize
textarea.addEventListener('input', autoResize);
// Handle blur event to save notes
textarea.addEventListener('blur', async function() {
const ipId = this.getAttribute('data-ip-id');
const deviceDesc = this.getAttribute('data-device-desc') || '';
const fullValue = this.value;
const originalValue = originalValues.get(this);
// Extract IP notes: everything after the device description
let ipNotes = '';
if (deviceDesc) {
// If device description exists, check if textarea starts with it
const deviceDescTrimmed = deviceDesc.trim();
const fullValueTrimmed = fullValue.trim();
if (fullValueTrimmed.startsWith(deviceDescTrimmed)) {
// Remove device description from the beginning
ipNotes = fullValueTrimmed.substring(deviceDescTrimmed.length).trim();
// Also handle case where there's a newline separator
if (ipNotes.startsWith('\n')) {
ipNotes = ipNotes.substring(1).trim();
}
} else {
// Device description was modified or removed - extract everything as IP notes
// This shouldn't normally happen, but handle gracefully
ipNotes = fullValueTrimmed;
}
} else {
// No device description, so entire value is IP notes
ipNotes = fullValue.trim();
}
// Only save if value changed
if (fullValue !== originalValue) {
// Show loading indicator
const originalBg = this.style.backgroundColor;
this.style.backgroundColor = 'rgba(59, 130, 246, 0.2)';
this.disabled = true;
try {
const response = await fetch(`/ip/${ipId}/update_notes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ notes: ipNotes })
});
const data = await response.json();
if (data.success) {
// Update the displayed value to reflect what was saved
let newDisplayValue = '';
if (deviceDesc) {
newDisplayValue = deviceDesc;
if (ipNotes) {
newDisplayValue += '\n' + ipNotes;
}
} else {
newDisplayValue = ipNotes;
}
this.value = newDisplayValue;
originalValues.set(this, newDisplayValue);
autoResize();
showToast('Notes saved successfully', 'success');
} else {
// Restore original value on error
this.value = originalValue;
autoResize();
showToast(data.error || 'Failed to save notes', 'error');
}
} catch (error) {
// Restore original value on error
this.value = originalValue;
autoResize();
showToast('Error saving notes. Please try again.', 'error');
console.error('Error saving IP notes:', error);
} finally {
this.style.backgroundColor = originalBg;
this.disabled = false;
}
}
});
// Handle Escape key to cancel editing
textarea.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
this.value = originalValues.get(this);
autoResize();
this.blur();
}
});
});
});
-14
View File
@@ -1,14 +0,0 @@
document.addEventListener("DOMContentLoaded",()=>{let e=document.querySelectorAll("form"),t=null;for(let l of e)if("/search"!==l.action&&"POST"===l.method){t=l;break}if(t&&!document.querySelector('input[placeholder="Search by IP or Hostname"]')){t.addEventListener("submit",e=>{e.preventDefault()});let o=document.createElement("input");o.type="text",o.placeholder="Search by IP or Hostname",o.className="p-2 w-full rounded-lg bg-gray-200 dark:bg-zinc-800 border border-gray-600 focus:outline-none focus:border-blue-400 mb-4 text-center",t.insertAdjacentElement("beforebegin",o),o.addEventListener("keypress",e=>{if("Enter"===e.key){e.preventDefault();let t=o.value.toLowerCase(),l=document.querySelectorAll("tbody tr");l.forEach(e=>{let l=e.querySelector("td:nth-child(1)").textContent.toLowerCase(),o=e.querySelector("td:nth-child(2)").textContent.toLowerCase(),s=e.querySelector("td:nth-child(3)"),r=s?s.textContent.toLowerCase():"";l.includes(t)||o.includes(t)||r.includes(t)?(e.style.backgroundColor="rgba(59, 130, 246, 0.5)",e.scrollIntoView({behavior:"smooth",block:"center"}),setTimeout(()=>{e.style.backgroundColor=""},3e3)):e.style.backgroundColor=""})}})}let s=document.getElementById("toggle-desc"),r=document.querySelectorAll(".desc-col"),n=document.getElementById("desc-col-header"),i=!1;s&&s.addEventListener("click",function(){i=!i,r.forEach(e=>e.classList.toggle("hidden",!i)),n&&n.classList.toggle("hidden",!i),s.textContent=i?"Hide Descriptions":"Show Descriptions"});let a=document.createElement("button");a.innerHTML='<i class="fas fa-arrow-up"></i>',a.style.fontSize="26px",a.className="fixed bottom-5 right-5 bg-gray-200 dark:bg-zinc-800 text-black dark:text-white p-3 rounded-full shadow-lg hidden",a.style.width="60px",a.style.height="60px",a.style.borderRadius="50%",document.body.appendChild(a);let d=document.createElement("style");if(d.textContent=`
@keyframes bob {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
.bobbing {
animation: bob 1.5s infinite;
}
`,document.head.appendChild(d),a.classList.add("bobbing"),window.addEventListener("scroll",()=>{window.scrollY>200?a.classList.remove("hidden"):a.classList.add("hidden")}),a.addEventListener("click",()=>{window.scrollTo({top:0,behavior:"smooth"})}),requestAnimationFrame(()=>{let e=document.documentElement.scrollHeight>document.documentElement.clientHeight;e&&0===window.scrollY&&(window.scrollBy(0,1),requestAnimationFrame(()=>{window.scrollBy(0,-1)}))}),window.location.hash){let c=window.location.hash.substring(1),h=document.getElementById(c);h&&setTimeout(()=>{h.scrollIntoView({behavior:"smooth",block:"center"}),h.style.backgroundColor="rgba(59, 130, 246, 0.5)",setTimeout(()=>{h.style.backgroundColor=""},3e3)},100)}let u=document.querySelectorAll(".desc-col textarea");u.forEach(e=>{function t(){e.style.height="auto",e.style.height=e.scrollHeight+"px"}e.style.overflow="hidden",e.style.resize="none",t()});let y=document.querySelectorAll(".ip-notes-textarea"),b=new Map;function g(e,t="success"){let l=document.createElement("div");l.className=`fixed top-20 right-4 px-4 py-3 rounded-lg shadow-lg z-50 ${"success"===t?"bg-green-500 text-white":"bg-red-500 text-white"}`,l.textContent=e,document.body.appendChild(l),setTimeout(()=>{l.style.transition="opacity 0.3s",l.style.opacity="0",setTimeout(()=>l.remove(),300)},3e3)}y.forEach(e=>{function t(){e.style.height="auto",e.style.height=e.scrollHeight+"px"}b.set(e,e.value),e.style.overflow="hidden",e.style.resize="none",t(),e.addEventListener("input",t),e.addEventListener("blur",async function(){let e=this.getAttribute("data-ip-id"),l=this.getAttribute("data-device-desc")||"",o=this.value,s=b.get(this),r="";if(l){let n=l.trim(),i=o.trim();i.startsWith(n)?(r=i.substring(n.length).trim()).startsWith("\n")&&(r=r.substring(1).trim()):r=i}else r=o.trim();if(o!==s){let a=this.style.backgroundColor;this.style.backgroundColor="rgba(59, 130, 246, 0.2)",this.disabled=!0;try{let d=await fetch(`/ip/${e}/update_notes`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({notes:r})}),c=await d.json();if(c.success){let h="";l?(h=l,r&&(h+="\n"+r)):h=r,this.value=h,b.set(this,h),t(),g("Notes saved successfully","success")}else this.value=s,t(),g(c.error||"Failed to save notes","error")}catch(u){this.value=s,t(),g("Error saving notes. Please try again.","error"),console.error("Error saving IP notes:",u)}finally{this.style.backgroundColor=a,this.disabled=!1}}}),e.addEventListener("keydown",function(e){"Escape"===e.key&&(this.value=b.get(this),t(),this.blur())})})});
-164
View File
@@ -1,164 +0,0 @@
// Auto-save custom fields on blur (subnet page)
document.addEventListener('DOMContentLoaded', () => {
const customFieldsForm = document.getElementById('custom-fields-form');
if (!customFieldsForm) {
return; // No custom fields form on this page
}
const subnetId = customFieldsForm.action.match(/\/subnet\/(\d+)\/update_custom_fields/)?.[1];
if (!subnetId) {
return;
}
// Get all form fields
const formFields = customFieldsForm.querySelectorAll('input, textarea, select');
const originalValues = new Map();
// Store original values
formFields.forEach(field => {
if (field.type === 'checkbox') {
originalValues.set(field, field.checked);
} else {
originalValues.set(field, field.value);
}
});
// Helper function to show toast notification (reuse from subnet.js if available)
function showToast(message, type = 'success') {
const toast = document.createElement('div');
toast.className = `fixed top-20 right-4 px-4 py-3 rounded-lg shadow-lg z-50 ${
type === 'success'
? 'bg-green-500 text-white'
: 'bg-red-500 text-white'
}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.transition = 'opacity 0.3s';
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// Auto-resize textareas
const textareas = customFieldsForm.querySelectorAll('textarea');
textareas.forEach(textarea => {
textarea.style.overflow = 'hidden';
textarea.style.resize = 'none';
function autoResize() {
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
}
autoResize();
textarea.addEventListener('input', autoResize);
});
// Check if form has changes
function hasChanges() {
for (const field of formFields) {
let currentValue;
if (field.type === 'checkbox') {
currentValue = field.checked;
} else {
currentValue = field.value;
}
const originalValue = originalValues.get(field);
if (currentValue !== originalValue) {
return true;
}
}
return false;
}
// Save all custom fields
let saveInProgress = false;
async function saveCustomFields() {
if (saveInProgress) {
return; // Prevent multiple simultaneous saves
}
if (!hasChanges()) {
return; // No changes to save
}
saveInProgress = true;
// Show loading indicator on form
const originalOpacity = customFieldsForm.style.opacity;
customFieldsForm.style.opacity = '0.6';
customFieldsForm.style.pointerEvents = 'none';
try {
// Create FormData from form and convert to JSON
const formData = new FormData(customFieldsForm);
const data = {};
// Process all fields
for (const [key, value] of formData.entries()) {
data[key] = value;
}
// Handle checkboxes that weren't checked (they don't appear in FormData)
formFields.forEach(field => {
if (field.type === 'checkbox' && !field.checked) {
data[field.name] = '';
}
});
const response = await fetch(customFieldsForm.action, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
if (response.ok) {
// Update original values
formFields.forEach(field => {
if (field.type === 'checkbox') {
originalValues.set(field, field.checked);
} else {
originalValues.set(field, field.value);
}
});
showToast('Custom fields saved successfully', 'success');
} else {
const data = await response.json().catch(() => ({}));
const errorMsg = data.errors ? data.errors.join(', ') : (data.error || 'Failed to save custom fields');
showToast(errorMsg, 'error');
}
} catch (error) {
showToast('Error saving custom fields. Please try again.', 'error');
console.error('Error saving custom fields:', error);
} finally {
customFieldsForm.style.opacity = originalOpacity;
customFieldsForm.style.pointerEvents = '';
saveInProgress = false;
}
}
// Add blur event listeners to all fields
formFields.forEach(field => {
// Skip if it's a checkbox (we'll handle change event instead)
if (field.type === 'checkbox') {
field.addEventListener('change', () => {
// Small delay to ensure value is updated
setTimeout(saveCustomFields, 100);
});
} else {
field.addEventListener('blur', saveCustomFields);
}
});
// Prevent form submission (since we're using auto-save)
customFieldsForm.addEventListener('submit', (e) => {
e.preventDefault();
saveCustomFields();
});
});
-1
View File
@@ -1 +0,0 @@
document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("custom-fields-form");if(!e)return;let t=e.action.match(/\/subnet\/(\d+)\/update_custom_fields/)?.[1];if(!t)return;let r=e.querySelectorAll("input, textarea, select"),s=new Map;function o(e,t="success"){let r=document.createElement("div");r.className=`fixed top-20 right-4 px-4 py-3 rounded-lg shadow-lg z-50 ${"success"===t?"bg-green-500 text-white":"bg-red-500 text-white"}`,r.textContent=e,document.body.appendChild(r),setTimeout(()=>{r.style.transition="opacity 0.3s",r.style.opacity="0",setTimeout(()=>r.remove(),300)},3e3)}r.forEach(e=>{"checkbox"===e.type?s.set(e,e.checked):s.set(e,e.value)});let n=e.querySelectorAll("textarea");function c(){for(let e of r){let t;t="checkbox"===e.type?e.checked:e.value;let o=s.get(e);if(t!==o)return!0}return!1}n.forEach(e=>{function t(){e.style.height="auto",e.style.height=e.scrollHeight+"px"}e.style.overflow="hidden",e.style.resize="none",t(),e.addEventListener("input",t)});let l=!1;async function i(){if(l||!c())return;l=!0;let t=e.style.opacity;e.style.opacity="0.6",e.style.pointerEvents="none";try{let n=new FormData(e),i={};for(let[a,d]of n.entries())i[a]=d;r.forEach(e=>{"checkbox"!==e.type||e.checked||(i[e.name]="")});let u=await fetch(e.action,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)});if(u.ok)r.forEach(e=>{"checkbox"===e.type?s.set(e,e.checked):s.set(e,e.value)}),o("Custom fields saved successfully","success");else{let y=await u.json().catch(()=>({})),f=y.errors?y.errors.join(", "):y.error||"Failed to save custom fields";o(f,"error")}}catch(h){o("Error saving custom fields. Please try again.","error"),console.error("Error saving custom fields:",h)}finally{e.style.opacity=t,e.style.pointerEvents="",l=!1}}r.forEach(e=>{"checkbox"===e.type?e.addEventListener("change",()=>{setTimeout(i,100)}):e.addEventListener("blur",i)}),e.addEventListener("submit",e=>{e.preventDefault(),i()})});
-69
View File
@@ -1,69 +0,0 @@
// Tag Management JavaScript
function showAddTagModal() {
document.getElementById('add-tag-modal').classList.remove('hidden');
document.getElementById('add-tag-name').value = '';
document.getElementById('add-tag-color').value = '#6B7280';
document.getElementById('add-tag-description').value = '';
updateColorPreview('add');
}
function closeAddTagModal() {
document.getElementById('add-tag-modal').classList.add('hidden');
}
function editTag(tagId, name, color, description) {
document.getElementById('edit-tag-id').value = tagId;
document.getElementById('edit-tag-name').value = name;
document.getElementById('edit-tag-color').value = color;
document.getElementById('edit-tag-description').value = description || '';
updateColorPreview('edit');
document.getElementById('edit-tag-modal').classList.remove('hidden');
}
function closeEditTagModal() {
document.getElementById('edit-tag-modal').classList.add('hidden');
}
function updateColorPreview(mode) {
const colorInput = document.getElementById(`${mode}-tag-color`);
const preview = document.getElementById(`${mode}-color-preview`);
preview.textContent = colorInput.value.toUpperCase();
}
// Event listeners
document.addEventListener('DOMContentLoaded', function() {
const addColorInput = document.getElementById('add-tag-color');
const editColorInput = document.getElementById('edit-tag-color');
if (addColorInput) {
addColorInput.addEventListener('input', () => updateColorPreview('add'));
}
if (editColorInput) {
editColorInput.addEventListener('input', () => updateColorPreview('edit'));
}
// Handle edit tag button clicks
document.querySelectorAll('.edit-tag-btn').forEach(button => {
button.addEventListener('click', function() {
const tagId = this.dataset.tagId;
const tagName = this.dataset.tagName;
const tagColor = this.dataset.tagColor;
const tagDescription = this.dataset.tagDescription;
editTag(tagId, tagName, tagColor, tagDescription);
});
});
});
// Close modals when clicking outside
window.onclick = function(event) {
const addModal = document.getElementById('add-tag-modal');
const editModal = document.getElementById('edit-tag-modal');
if (event.target === addModal) {
closeAddTagModal();
}
if (event.target === editModal) {
closeEditTagModal();
}
}
-1
View File
@@ -1 +0,0 @@
function showAddTagModal(){document.getElementById("add-tag-modal").classList.remove("hidden"),document.getElementById("add-tag-name").value="",document.getElementById("add-tag-color").value="#6B7280",document.getElementById("add-tag-description").value="",updateColorPreview("add")}function closeAddTagModal(){document.getElementById("add-tag-modal").classList.add("hidden")}function editTag(e,t,d,a){document.getElementById("edit-tag-id").value=e,document.getElementById("edit-tag-name").value=t,document.getElementById("edit-tag-color").value=d,document.getElementById("edit-tag-description").value=a||"",updateColorPreview("edit"),document.getElementById("edit-tag-modal").classList.remove("hidden")}function closeEditTagModal(){document.getElementById("edit-tag-modal").classList.add("hidden")}function updateColorPreview(e){let t=document.getElementById(`${e}-tag-color`),d=document.getElementById(`${e}-color-preview`);d.textContent=t.value.toUpperCase()}document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("add-tag-color"),t=document.getElementById("edit-tag-color");e&&e.addEventListener("input",()=>updateColorPreview("add")),t&&t.addEventListener("input",()=>updateColorPreview("edit")),document.querySelectorAll(".edit-tag-btn").forEach(e=>{e.addEventListener("click",function(){let e=this.dataset.tagId,t=this.dataset.tagName,d=this.dataset.tagColor,a=this.dataset.tagDescription;editTag(e,t,d,a)})})}),window.onclick=function(e){let t=document.getElementById("add-tag-modal"),d=document.getElementById("edit-tag-modal");e.target===t&&closeAddTagModal(),e.target===d&&closeEditTagModal()};
-40
View File
@@ -1,40 +0,0 @@
document.addEventListener('DOMContentLoaded', function() {
// Check if toast was dismissed in this session
const toastDismissed = sessionStorage.getItem('update-toast-dismissed');
if (toastDismissed) {
return;
}
// Check for updates
fetch('/check_update')
.then(response => response.json())
.then(data => {
if (data.update_available) {
const toast = document.getElementById('update-toast');
const currentVersionEl = document.getElementById('toast-current-version');
const latestVersionEl = document.getElementById('toast-latest-version');
const compareLink = document.getElementById('toast-compare-link');
const closeBtn = document.getElementById('toast-close');
// Set versions
currentVersionEl.textContent = 'v' + data.current_version;
latestVersionEl.textContent = 'v' + data.latest_version;
// Set compare link (current version to latest version)
compareLink.href = `https://git.jdbnet.co.uk/jamie/ipam/compare/v${data.current_version}...v${data.latest_version}`;
// Show toast
toast.classList.remove('hidden');
// Close button handler
closeBtn.addEventListener('click', function() {
toast.classList.add('hidden');
sessionStorage.setItem('update-toast-dismissed', 'true');
});
}
})
.catch(error => {
console.error('Error checking for updates:', error);
});
});
-1
View File
@@ -1 +0,0 @@
document.addEventListener("DOMContentLoaded",function(){let t=sessionStorage.getItem("update-toast-dismissed");!t&&fetch("/check_update").then(t=>t.json()).then(t=>{if(t.update_available){let e=document.getElementById("update-toast"),n=document.getElementById("toast-current-version"),s=document.getElementById("toast-latest-version"),a=document.getElementById("toast-compare-link"),d=document.getElementById("toast-close");n.textContent="v"+t.current_version,s.textContent="v"+t.latest_version,a.href=`https://git.jdbnet.co.uk/jamie/ipam/compare/v${t.current_version}...v${t.latest_version}`,e.classList.remove("hidden"),d.addEventListener("click",function(){e.classList.add("hidden"),sessionStorage.setItem("update-toast-dismissed","true")})}}).catch(t=>{console.error("Error checking for updates:",t)})});

Some files were not shown because too many files have changed in this diff Show More