67 Commits

Author SHA1 Message Date
jamie e6ccba0e0a Merge pull request 'feat: move org name and logo to db' (#56) from v2.0.2 into main
Reviewed-on: http://git.jdbnet.co.uk/jamie/ipam/pulls/56
2026-05-30 15:33:54 +01:00
jamie 1a3d47a72e fix: 🐛 layout issue
Release / Build & Release (pull_request) Successful in 28s
Release / SonarQube (pull_request) Successful in 28s
2026-05-30 14:33:41 +00:00
jamie 6012566b22 feat: move org name and logo to db 2026-05-30 14:31:01 +00:00
jamie fc5699a04c Merge pull request 'feat: version number links to releases' (#54) from v2.0.1 into main
Reviewed-on: http://git.jdbnet.co.uk/jamie/ipam/pulls/54
2026-05-27 07:54:15 +01:00
jamie 675d477ff9 fix: 🐛 users page layout
Release / Build & Release (pull_request) Successful in 9s
Release / SonarQube (pull_request) Successful in 28s
2026-05-27 06:53:47 +00:00
jamie 34856060e8 refactor: 🎨 lock nav in place while content scrolls 2026-05-27 06:49:45 +00:00
jamie be55503e1c refactor: 🎨 remove status and alerting from dashboard 2026-05-27 06:48:26 +00:00
jamie b79763be53 fix: 🐛 searching for another device didn't work if already looking at a device 2026-05-27 06:47:14 +00:00
jamie e961afc36a feat: version number links to releases 2026-05-27 06:45:16 +00:00
jamie 616744015f Merge pull request 'refactor: 🎨 remove caching' (#48) from v2.0.0 into main
Reviewed-on: http://git.jdbnet.co.uk/jamie/ipam/pulls/48
2026-05-23 21:04:45 +01:00
jamie 87d7654606 docs: 📝 tidy docs
Release / Build & Release (pull_request) Successful in 5s
Release / SonarQube (pull_request) Successful in 27s
2026-05-23 20:03:47 +00:00
jamie 9e47cbee4e fix: 🐛 small ui fixes 2026-05-23 19:50:49 +00:00
jamie e16a667d60 feat: dashboard stats 2026-05-23 19:24:01 +00:00
jamie a8bcb9bd1c style: 🎨 subnet management layout 2026-05-23 18:56:24 +00:00
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
jamie 8a01cb4755 Merge pull request 'ci: 🚀 switch to gitea' (#35) from v1.9.2 into main
Dev / build (push) Successful in 1s
Dev / Deploy to Kubernetes (push) Successful in 2s
Reviewed-on: #35
2026-01-08 15:53:11 +00:00
jamie d85b409662 chore: remove release-please configuration and version files
Release Please / release-please (push) Has been cancelled
Staging / Build and Push (push) Has been cancelled
Staging / Deploy to Kubernetes (push) Has been cancelled
2026-01-08 15:52:13 +00:00
jamie 9dfea6c795 fix: 🐛 update container image registry in deployment configuration
Dev / build (push) Successful in 2s
Dev / Deploy to Kubernetes (push) Successful in 1s
Release / release (pull_request) Successful in 31s
Release / Deploy to Kubernetes (pull_request) Failing after 2s
2026-01-08 15:48:30 +00:00
jamie 29cb46963c fix: 🐛 update workflow trigger from pull_request to push
Dev / build (push) Successful in 29s
Dev / Deploy to Kubernetes (push) Successful in 6s
2026-01-08 15:44:52 +00:00
jamie ca7c5f77a4 ci: 🚀 switch to gitea
Dev / release (pull_request) Has been skipped
Dev / Deploy to Kubernetes (pull_request) Has been skipped
2026-01-08 15:42:13 +00:00
jamie 9f28113573 Merge pull request 'chore(main): release 1.10.0' (#34) from release-please--branches--main into main
Staging / Build and Push (push) Has been cancelled
Staging / Deploy to Kubernetes (push) Has been cancelled
Release Please / release-please (push) Has been cancelled
Reviewed-on: #34
2026-01-08 15:38:38 +00:00
github-actions[bot] f4920cbee6 chore(main): release 1.10.0 2025-12-31 01:08:53 +00:00
jamie c1b0a7084b feat: feature flags 2025-12-31 01:08:30 +00:00
Jamie 9558baf84e Merge pull request #33 from JDB-NET/release-please--branches--main
chore(main): release 1.9.1
2025-12-29 18:24:31 +00:00
github-actions[bot] 5912bc6367 chore(main): release 1.9.1 2025-12-29 18:24:13 +00:00
jamie 83c1b21c04 fix: 🐛 device page dictionary 2025-12-29 18:23:50 +00:00
Jamie a73ce91a2f Merge pull request #31 from JDB-NET/release-please--branches--main
chore(main): release 1.9.0
2025-12-27 23:03:53 +00:00
github-actions[bot] 71bce2989c chore(main): release 1.9.0 2025-12-27 23:03:35 +00:00
jamie c7350aeb1f feat: vlan management 2025-12-27 23:03:06 +00:00
jamie 7e1c4b126e refactor: 🎨 auto save custom fields 2025-12-27 22:34:18 +00:00
jamie 8b001a047b feat: ip address notes/descriptions 2025-12-27 22:30:54 +00:00
jamie b23cda48af feat: custom fields by device or subnet 2025-12-27 22:07:49 +00:00
jamie 53dc19a549 fix: 🐛 2fa verification 2025-12-27 02:23:28 +00:00
jamie 91067994ba refactor: 🎨 minify 2025-12-27 02:08:52 +00:00
jamie 21042b7fd7 feat: ip address history 2025-12-27 01:45:37 +00:00
jamie e028f9610c feat: log api usage to audit log 2025-12-27 01:31:35 +00:00
jamie e316a16386 feat: api rate limiting 2025-12-27 01:26:43 +00:00
jamie 181e2b2ca5 style: 💄 backup code button 2025-12-27 01:19:36 +00:00
jamie 5037c1b578 feat: two factor authentication 2025-12-24 12:17:44 +00:00
106 changed files with 10498 additions and 9284 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"
} }
+4 -6
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,9 +44,6 @@ ENV/
*.log *.log
logs/ logs/
# Build tools
tailwindcss
# OS files # OS files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
+45
View File
@@ -0,0 +1,45 @@
name: Dev
on:
workflow_dispatch:
jobs:
build:
runs-on: build-htz-01
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build and push Docker image
run: |
docker build -t cr.jdbnet.co.uk/public/ipam:dev --build-arg VERSION=dev .
docker push cr.jdbnet.co.uk/public/ipam:dev
sonarqube:
name: SonarQube
runs-on: build-htz-01
steps:
- name: Checkout Code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Create Valid Project Key
id: sonar_setup
run: |
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
+75
View File
@@ -0,0 +1,75 @@
name: Release
on:
pull_request:
branches:
- main
types: [closed]
jobs:
release:
name: Build & Release
if: github.event.pull_request.merged == true && startsWith(github.head_ref, 'v')
runs-on: build-htz-01
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract Version
id: get_version
run: echo "VERSION=${{ github.head_ref }}" >> $GITHUB_OUTPUT
- name: Generate Changelog
id: changelog
uses: https://github.com/metcalfc/changelog-generator@v4.6.2
with:
myToken: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
run: |
VERSION=${{ steps.get_version.outputs.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 \
--build-arg VERSION=$VERSION \
.
docker push cr.jdbnet.co.uk/public/ipam:$VERSION
docker push cr.jdbnet.co.uk/public/ipam:latest
- name: Create Gitea Release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
tag_name: ${{ steps.get_version.outputs.VERSION }}
name: ${{ steps.get_version.outputs.VERSION }}
body: ${{ steps.changelog.outputs.changelog }}
draft: false
prerelease: false
sonarqube:
name: SonarQube
runs-on: build-htz-01
steps:
- name: Checkout Code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Create Valid Project Key
id: sonar_setup
run: |
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
-72
View File
@@ -1,72 +0,0 @@
name: Release Please
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: write
packages: write
jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
steps:
- uses: googleapis/release-please-action@v4
id: release
with:
manifest-file: .release-please-manifest.json
config-file: .release-please-config.json
- name: Checkout
if: ${{ steps.release.outputs.release_created }}
uses: actions/checkout@v4
- name: Set up Docker Buildx
if: ${{ steps.release.outputs.release_created }}
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
if: ${{ steps.release.outputs.release_created }}
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Read version
if: ${{ steps.release.outputs.release_created }}
id: version
run: |
VERSION=$(cat VERSION)
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "VERSION=$VERSION" >> $GITHUB_ENV
- name: Build and push Docker image
if: ${{ steps.release.outputs.release_created }}
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/jdb-net/ipam:${{ env.VERSION }}
ghcr.io/jdb-net/ipam:latest
build-args: |
VERSION=${{ env.VERSION }}
deploy:
name: Deploy to Kubernetes
needs: release-please
if: ${{ needs.release-please.outputs.release_created }}
runs-on: [ k3s-internal-htz-01 ]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Apply manifests
run: |
sudo kubectl replace -f deployment.yml --grace-period=60 --force
+2 -3
View File
@@ -1,5 +1,4 @@
__pycache__ __pycache__
tailwindcss
static/css/output.css
.env .env
backups/ frontend/node_modules/
static/dist/
-54
View File
@@ -1,54 +0,0 @@
{
"packages": {
".": {
"release-type": "simple",
"version-file": "VERSION",
"extra-files": [
"CHANGELOG.md"
],
"changelog-sections": [
{
"type": "feat",
"section": "Features"
},
{
"type": "fix",
"section": "Bug Fixes"
},
{
"type": "refactor",
"section": "Refactoring"
},
{
"type": "style",
"section": "Style Changes"
},
{
"type": "perf",
"section": "Performance Improvements"
},
{
"type": "docs",
"section": "Documentation"
},
{
"type": "test",
"section": "Tests"
},
{
"type": "build",
"section": "Build System"
},
{
"type": "ci",
"section": "CI/CD"
},
{
"type": "chore",
"section": "Chores"
}
]
}
},
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
}
-3
View File
@@ -1,3 +0,0 @@
{
".": "1.8.0"
}
+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
```
-143
View File
@@ -1,143 +0,0 @@
# Changelog
## [1.8.0](https://github.com/JDB-NET/ipam/compare/v1.7.0...v1.8.0) (2025-12-23)
### Features
* :sparkles: get next available ip by api ([64ae4be](https://github.com/JDB-NET/ipam/commit/64ae4be6d5997ff0b16ff5232237d38f2fec5b64))
### Bug Fixes
* :bug: global search missing from devices ([283c445](https://github.com/JDB-NET/ipam/commit/283c445263b7dc992448d907e682e53b7720b610))
### Build System
* :rocket: redeploy ([d7fcffd](https://github.com/JDB-NET/ipam/commit/d7fcffd4b5598b682dede864ba526b1257584f6a))
## [1.7.0](https://github.com/JDB-NET/ipam/compare/v1.6.1...v1.7.0) (2025-12-05)
### Features
* :sparkles: add devices by tag page ([9c0e6d0](https://github.com/JDB-NET/ipam/commit/9c0e6d035c8dda68281b2bfe2b7a61802353f7a7))
### Bug Fixes
* :bug: invalidate cache when device type is added ([47208b3](https://github.com/JDB-NET/ipam/commit/47208b31eed51f0cf0d7c8c411093bda1c84cf1b))
* :bug: invalidate linked cache ([8242e9d](https://github.com/JDB-NET/ipam/commit/8242e9d758ef19030b516e4a51f0cfb556f4e5ba))
## [1.6.1](https://github.com/JDB-NET/ipam/compare/v1.6.0...v1.6.1) (2025-12-05)
### Bug Fixes
* :bug: invalidate subnet cache when device is deleted ([286bf4b](https://github.com/JDB-NET/ipam/commit/286bf4b665e6352dea7b14753f080fa5cabb7926))
## [1.6.0](https://github.com/JDB-NET/ipam/compare/v1.5.1...v1.6.0) (2025-12-05)
### Features
* :sparkles: backup and restore ([707846b](https://github.com/JDB-NET/ipam/commit/707846bb3c717df9223ea7103e29efc6e671e16d))
* :sparkles: bulk operations ([2163be8](https://github.com/JDB-NET/ipam/commit/2163be8f79b579e38944a689915a18d5c35f8d3a))
* :sparkles: global search ([3e8965d](https://github.com/JDB-NET/ipam/commit/3e8965de6f19b3b382e236b08df685401205f356))
* :sparkles: in memory cache ([3a9250f](https://github.com/JDB-NET/ipam/commit/3a9250f5b0c14bfc6a807fe2948bbc852a652047))
* :sparkles: subnet utilisation stats ([f98e92d](https://github.com/JDB-NET/ipam/commit/f98e92da062640d47bec3516def0efde3aebd058))
* :sparkles: update available notification ([730b870](https://github.com/JDB-NET/ipam/commit/730b8701db81f5e03760a25209baeab2f81116fa))
### Refactoring
* :art: database indexing and optimisation ([47f68fd](https://github.com/JDB-NET/ipam/commit/47f68fd27cf62d0e0d2af55089bc0556043c12ff))
* :art: header link to github releases ([61e3200](https://github.com/JDB-NET/ipam/commit/61e320020724e437d8a607e7341b12b2fe6f794d))
* :art: improved audit log filtering ([f016598](https://github.com/JDB-NET/ipam/commit/f0165985fc194fd3a3e460b52447a5511908ed91))
* :art: js ([1d9209a](https://github.com/JDB-NET/ipam/commit/1d9209a714a6d0b7d1901b6e3470f5265e0171a6))
* :art: tidy nav bar ([69588d6](https://github.com/JDB-NET/ipam/commit/69588d6518571d8de55c718c14176bb78cb19ee1))
### CI/CD
* :rocket: include all commit types ([f6795f5](https://github.com/JDB-NET/ipam/commit/f6795f52815a2d599840c8ed83c99ad690a046c8))
## [1.5.1](https://github.com/JDB-NET/ipam/compare/v1.5.0...v1.5.1) (2025-12-04)
### Bug Fixes
* :bug: audit log on mobile ([6f01c99](https://github.com/JDB-NET/ipam/commit/6f01c9956f4a31414a082a779eb493735df0b8e6))
## [1.5.0](https://github.com/JDB-NET/ipam/compare/v1.4.2...v1.5.0) (2025-11-21)
### Features
* :sparkles: device tags ([ad1e576](https://github.com/JDB-NET/ipam/commit/ad1e576da42bf90c59347f7f7a4cce13c6842204))
## [1.4.2](https://github.com/JDB-NET/ipam/compare/v1.4.1...v1.4.2) (2025-11-08)
### Bug Fixes
* :bug: ensure all fields are updated by api ([5c1ad03](https://github.com/JDB-NET/ipam/commit/5c1ad039904b2c8c8629242b5558b03da5ad782c))
## [1.4.1](https://github.com/JDB-NET/ipam/compare/v1.4.0...v1.4.1) (2025-11-06)
### Bug Fixes
* :bug: pagination no longer gets out of control ([80b6de3](https://github.com/JDB-NET/ipam/commit/80b6de395fc4ddb4e7cd3ece89b423af2667d298))
* :bug: styling of admin and users pages ([d56e064](https://github.com/JDB-NET/ipam/commit/d56e0647f74fba1db1f504e02364406691ede9f3))
## [1.4.0](https://github.com/JDB-NET/ipam/compare/v1.3.0...v1.4.0) (2025-11-06)
### Features
* :sparkles: full api integration ([c53472c](https://github.com/JDB-NET/ipam/commit/c53472c5d760e28e53a737cb0546e85c9a422d15))
## [1.3.0](https://github.com/JDB-NET/ipam/compare/v1.2.0...v1.3.0) (2025-11-06)
### Features
* :sparkles: role based access control ([3bf2697](https://github.com/JDB-NET/ipam/commit/3bf269701030bc1f14a48c5af488286c424dbfa7))
## [1.2.0](https://github.com/JDB-NET/ipam/compare/v1.1.1...v1.2.0) (2025-11-06)
### Features
* :sparkles: added the ability to create/edit/remove device types ([d68eefc](https://github.com/JDB-NET/ipam/commit/d68eefcf0cc4a59cda9cedb3e126d974ee45d2ad))
### Bug Fixes
* :bug: missing button classes ([f93fa15](https://github.com/JDB-NET/ipam/commit/f93fa155eb5d6c9ff4ed19f332c3ad6fff328d31))
## [1.1.1](https://github.com/JDB-NET/ipam/compare/v1.1.0...v1.1.1) (2025-11-01)
### Bug Fixes
* :bug: image name ([de123fa](https://github.com/JDB-NET/ipam/commit/de123fafd40d97ea6e545bd8dd1d3a812e2a709f))
## [1.1.0](https://github.com/JDB-NET/ipam/compare/v1.0.0...v1.1.0) (2025-11-01)
### Features
* Added icon on login button. Closes [#1](https://github.com/JDB-NET/ipam/issues/1) ([6e068b6](https://github.com/JDB-NET/ipam/commit/6e068b672592f7d23ca66a0a6189b5763d89a698))
* Added light mode up to admin ([38c8402](https://github.com/JDB-NET/ipam/commit/38c840251f03c8f1e1a2c407efa77621df70ce2f))
* Rack stuff now complete ([5d220d3](https://github.com/JDB-NET/ipam/commit/5d220d354df83db8b2bfbf8e2c87bd78ba91f6e5))
### Bug Fixes
* Back buttons now hidden on mobile ([40a7a2f](https://github.com/JDB-NET/ipam/commit/40a7a2f2d58f6c89a7e7e74908c088e7eddf966a))
* Corrected image in deployment ([9ecd492](https://github.com/JDB-NET/ipam/commit/9ecd492065fcd226d274f8e343d401437e1c8de8))
* Fixed back button on device page ([9734e4d](https://github.com/JDB-NET/ipam/commit/9734e4df0b27461867393c132991f9e2ec907de4))
* Fixed database initialisation and dropped to 1 worker ([7cd6a0f](https://github.com/JDB-NET/ipam/commit/7cd6a0f96d8dc20743603d55498d8c1af8069690))
+15 -9
View File
@@ -1,12 +1,18 @@
FROM python:3.13-slim FROM node:22-bookworm-slim AS frontend
WORKDIR /app WORKDIR /app
COPY . /app COPY frontend/package.json ./frontend/
RUN pip install -r requirements.txt RUN cd frontend && npm install
RUN apt-get update && apt-get install -y curl mariadb-client-compat COPY frontend/ ./frontend/
RUN rm -rf /var/lib/apt/lists/* RUN cd frontend && npm run build
RUN curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 \
&& chmod +x tailwindcss-linux-x64 \ FROM python:3.14-slim
&& ./tailwindcss-linux-x64 -i ./static/css/input.css -o ./static/css/output.css --content "./templates/*.html,./static/js/*.js" --minify \ LABEL org.opencontainers.image.vendor="JDB-NET"
&& rm tailwindcss-linux-x64 WORKDIR /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
ENV VERSION=${VERSION}
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
+15 -288
View File
@@ -1,54 +1,33 @@
<div align="center"> <div align="center">
<img src="https://projects.jdbnet.co.uk/ipam/img/favicon.png" alt="IPAM" width="200" /> <img src="https://assets.jdbnet.co.uk/projects/ipam.png" alt="IPAM" width="200" />
# IP Address Management # IP Address Management
</div> </div>
A Flask-based web application for comprehensive IP Address Management (IPAM). Manage subnets, IP addresses, devices, DHCP pools, and physical rack infrastructure with an intuitive web interface. A Flask-based web application for IP Address Management (IPAM). Manage subnets, IP addresses, devices, DHCP pools, and rack infrastructure through a Vue 3 web interface and a JSON REST API.
## Features ## Features
- **Subnet Management**: Create and manage IP subnets with CIDR notation (supports /24 to /32) - **Subnet management** - CIDR subnets with automatic IP generation
- **IP Address Tracking**: Automatic IP address generation and tracking for each subnet - **IP assignment** - Assign addresses to devices with hostname tracking
- **Device Management**: Track devices with types (Server, VM, Switch, Firewall, WiFi AP, Printer, Other) - **Device management** - Names, descriptions, tags, and custom fields
- **Device Tagging**: Organize devices with customizable tags featuring colors and descriptions - **DHCP pools** — Configure ranges and excluded IPs per subnet
- **IP Assignment**: Assign IP addresses to devices with automatic hostname updates - **Rack management** - U positions with front/back layout
- **DHCP Pool Configuration**: Configure DHCP pools with start/end IP ranges and excluded IPs - **Site organisation** - Group subnets and devices by location
- **Rack Management**: Physical infrastructure tracking with U positions and front/back sides - **Audit logging** - Filterable change history with CSV export
- **Site Organisation**: Organize subnets and devices by site/location - **Role-based access control** - Granular permissions and custom roles
- **Audit Logging**: Complete audit trail of all changes with user, action, details, and timestamps - **REST API v2** - Session cookies for the browser, API keys for automation
- **User Management**: Multi-user support with secure password authentication
- **Role-Based Access Control (RBAC)**: Granular permission system with default roles (admin, user, view_only) and custom role creation
- **REST API**: Full-featured REST API with API key authentication for programmatic access
- **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 ## Screenshot
### Docker Run ![IPAM Dashboard](img/screenshot.png)
```bash ## Docker Compose
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:
@@ -61,256 +40,4 @@ services:
- SECRET_KEY=your_secret_key - SECRET_KEY=your_secret_key
- NAME=Your Organisation - NAME=Your Organisation
- LOGO_PNG=https://example.com/logo.png - LOGO_PNG=https://example.com/logo.png
volumes:
- ./backups:/app/backups
``` ```
## 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;
```
## 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
This project is provided as-is for IP Address Management.
-1
View File
@@ -1 +0,0 @@
1.8.0
+3217 -24
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
+232 -58
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 (
@@ -194,6 +155,13 @@ def init_db(app=None):
) )
''') ''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS Setting (
setting_key VARCHAR(255) PRIMARY KEY,
value TEXT
)
''')
# Add role_id column to User table if it doesn't exist # Add role_id column to User table if it doesn't exist
cursor.execute("SHOW COLUMNS FROM User LIKE 'role_id'") cursor.execute("SHOW COLUMNS FROM User LIKE 'role_id'")
if not cursor.fetchone(): if not cursor.fetchone():
@@ -209,6 +177,28 @@ def init_db(app=None):
if not cursor.fetchone(): if not cursor.fetchone():
cursor.execute('ALTER TABLE User ADD COLUMN api_key VARCHAR(255) DEFAULT NULL UNIQUE') cursor.execute('ALTER TABLE User ADD COLUMN api_key VARCHAR(255) DEFAULT NULL UNIQUE')
# Add 2FA columns to User table if they don't exist
cursor.execute("SHOW COLUMNS FROM User LIKE 'totp_secret'")
if not cursor.fetchone():
cursor.execute('ALTER TABLE User ADD COLUMN totp_secret VARCHAR(255) DEFAULT NULL')
cursor.execute("SHOW COLUMNS FROM User LIKE 'totp_enabled'")
if not cursor.fetchone():
cursor.execute('ALTER TABLE User ADD COLUMN totp_enabled BOOLEAN DEFAULT FALSE')
cursor.execute("SHOW COLUMNS FROM User LIKE 'backup_codes'")
if not cursor.fetchone():
cursor.execute('ALTER TABLE User ADD COLUMN backup_codes TEXT DEFAULT NULL')
cursor.execute("SHOW COLUMNS FROM User LIKE 'two_fa_setup_complete'")
if not cursor.fetchone():
cursor.execute('ALTER TABLE User ADD COLUMN two_fa_setup_complete BOOLEAN DEFAULT FALSE')
# Add require_2fa column to Role table if it doesn't exist
cursor.execute("SHOW COLUMNS FROM Role LIKE 'require_2fa'")
if not cursor.fetchone():
cursor.execute('ALTER TABLE Role ADD COLUMN require_2fa BOOLEAN DEFAULT FALSE')
# Ensure AuditLog foreign keys have ON DELETE SET NULL to preserve audit logs # Ensure AuditLog foreign keys have ON DELETE SET NULL to preserve audit logs
# This is critical - audit logs should NEVER be deleted, even when referenced entities are deleted # This is critical - audit logs should NEVER be deleted, even when referenced entities are deleted
try: try:
@@ -277,6 +267,56 @@ def init_db(app=None):
) )
''') ''')
# Create CustomFieldDefinition table
cursor.execute('''
CREATE TABLE IF NOT EXISTS CustomFieldDefinition (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
entity_type ENUM('device', 'subnet') NOT NULL,
name VARCHAR(255) NOT NULL,
field_key VARCHAR(255) NOT NULL UNIQUE,
field_type VARCHAR(50) NOT NULL,
required BOOLEAN DEFAULT FALSE,
default_value TEXT,
help_text TEXT,
display_order INTEGER DEFAULT 0,
validation_rules TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
''')
# Add custom_fields column to Device table if it doesn't exist
cursor.execute("SHOW COLUMNS FROM Device LIKE 'custom_fields'")
if not cursor.fetchone():
cursor.execute('ALTER TABLE Device ADD COLUMN custom_fields TEXT DEFAULT NULL')
# Initialize existing records with empty JSON object
cursor.execute("UPDATE Device SET custom_fields = '{}' WHERE custom_fields IS NULL")
# Add custom_fields column to Subnet table if it doesn't exist
cursor.execute("SHOW COLUMNS FROM Subnet LIKE 'custom_fields'")
if not cursor.fetchone():
cursor.execute('ALTER TABLE Subnet ADD COLUMN custom_fields TEXT DEFAULT NULL')
# Initialize existing records with empty JSON object
cursor.execute("UPDATE Subnet SET custom_fields = '{}' WHERE custom_fields IS NULL")
# Add notes column to IPAddress table if it doesn't exist
cursor.execute("SHOW COLUMNS FROM IPAddress LIKE 'notes'")
if not cursor.fetchone():
cursor.execute('ALTER TABLE IPAddress ADD COLUMN notes TEXT DEFAULT NULL')
# Add VLAN columns to Subnet table if they don't exist
cursor.execute("SHOW COLUMNS FROM Subnet LIKE 'vlan_id'")
if not cursor.fetchone():
cursor.execute('ALTER TABLE Subnet ADD COLUMN vlan_id INTEGER DEFAULT NULL')
cursor.execute("SHOW COLUMNS FROM Subnet LIKE 'vlan_description'")
if not cursor.fetchone():
cursor.execute('ALTER TABLE Subnet ADD COLUMN vlan_description VARCHAR(255) DEFAULT NULL')
cursor.execute("SHOW COLUMNS FROM Subnet LIKE 'vlan_notes'")
if not cursor.fetchone():
cursor.execute('ALTER TABLE Subnet ADD COLUMN vlan_notes TEXT DEFAULT NULL')
# Define all permissions with categories # Define all permissions with categories
permissions = [ permissions = [
# View permissions # View permissions
@@ -289,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'),
@@ -319,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'),
@@ -332,9 +363,15 @@ def init_db(app=None):
('assign_device_tag', 'Assign tag to device', 'Tag'), ('assign_device_tag', 'Assign tag to device', 'Tag'),
('remove_device_tag', 'Remove tag from device', 'Tag'), ('remove_device_tag', 'Remove tag from device', 'Tag'),
# Custom Fields permissions
('view_custom_fields', 'View custom fields', 'Custom Fields'),
('manage_custom_fields', 'Manage custom fields (add, edit, delete)', 'Custom Fields'),
# 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
@@ -385,15 +422,15 @@ 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'
] ]
for perm_name in non_admin_permissions: for perm_name in non_admin_permissions:
@@ -411,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_dhcp', 'view_tags', 'view_custom_fields'
] ]
for perm_name in view_only_permissions: for perm_name in view_only_permissions:
@@ -469,6 +506,7 @@ def init_db(app=None):
create_index_if_not_exists(cursor, 'idx_ipaddress_hostname', 'IPAddress', 'hostname') create_index_if_not_exists(cursor, 'idx_ipaddress_hostname', 'IPAddress', 'hostname')
create_index_if_not_exists(cursor, 'idx_ipaddress_ip', 'IPAddress', 'ip') create_index_if_not_exists(cursor, 'idx_ipaddress_ip', 'IPAddress', 'ip')
create_index_if_not_exists(cursor, 'idx_ipaddress_subnet_hostname', 'IPAddress', 'subnet_id, hostname') create_index_if_not_exists(cursor, 'idx_ipaddress_subnet_hostname', 'IPAddress', 'subnet_id, hostname')
create_index_if_not_exists(cursor, 'idx_ipaddress_notes', 'IPAddress', 'notes(255)')
# DeviceIPAddress table indexes # DeviceIPAddress table indexes
create_index_if_not_exists(cursor, 'idx_deviceipaddress_device_id', 'DeviceIPAddress', 'device_id') create_index_if_not_exists(cursor, 'idx_deviceipaddress_device_id', 'DeviceIPAddress', 'device_id')
@@ -500,11 +538,147 @@ 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')
# CustomFieldDefinition table indexes
create_index_if_not_exists(cursor, 'idx_customfield_entity_type', 'CustomFieldDefinition', 'entity_type')
create_index_if_not_exists(cursor, 'idx_customfield_field_key', 'CustomFieldDefinition', 'field_key')
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: ghcr.io/jdb-net/ipam:latest
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

+2 -1
View File
@@ -2,4 +2,5 @@ Flask
mysql-connector-python mysql-connector-python
dotenv dotenv
gunicorn gunicorn
requests pyotp
qrcode[pil]
+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()
-3947
View File
File diff suppressed because it is too large Load Diff
+3 -4
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/*.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);
}
-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 @@
@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;
}
-65
View File
@@ -1,65 +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 = '';
}
function closeAddSubnetModal() {
document.getElementById('add-subnet-modal').classList.add('hidden');
document.getElementById('cidr-error').classList.add('hidden');
}
function editSubnet(subnetId, name, cidr, site) {
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-modal').classList.remove('hidden');
}
function closeEditSubnetModal() {
document.getElementById('edit-subnet-modal').classList.add('hidden');
document.getElementById('edit-cidr-error').classList.add('hidden');
}
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');
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();
}
}
-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();
}
});
-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();
});
}
});
-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);
});
}
-175
View File
@@ -1,175 +0,0 @@
function showTab(tabName) {
// Hide all panels
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.add('hidden'));
// Remove active class from all tabs
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
// Show selected panel
document.getElementById('panel-' + tabName).classList.remove('hidden');
// Add active class to selected tab
document.getElementById('tab-' + tabName).classList.add('active');
}
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>`;
});
});
});
-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');
});
}
});
-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();
}
-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' });
});
});
-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`;
});
});
-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');
}
});
});
-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();
};
});
-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');
}
});
});
});
-135
View File
@@ -1,135 +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();
if (ipCell.includes(searchTerm) || hostnameCell.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);
}
}
});
-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();
}
}
-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://github.com/JDB-NET/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);
});
});
-199
View File
@@ -1,199 +0,0 @@
// These variables are set inline in the template from server data
// permissions and rolePermissions are passed from the template
function showTab(tab) {
document.getElementById('users-tab').classList.add('hidden');
document.getElementById('roles-tab').classList.add('hidden');
document.getElementById('tab-users').classList.remove('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
document.getElementById('tab-users').classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
document.getElementById('tab-roles').classList.remove('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
document.getElementById('tab-roles').classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
if (tab === 'users') {
document.getElementById('users-tab').classList.remove('hidden');
document.getElementById('tab-users').classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
document.getElementById('tab-users').classList.add('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
} else {
document.getElementById('roles-tab').classList.remove('hidden');
document.getElementById('tab-roles').classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
document.getElementById('tab-roles').classList.add('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
}
}
function editUser(userId, name, email, roleId, apiKey) {
document.getElementById('edit-user-id').value = userId;
document.getElementById('edit-user-name').value = name;
document.getElementById('edit-user-email').value = email;
document.getElementById('edit-user-password').value = '';
document.getElementById('edit-user-role').value = (roleId === null || roleId === 'null') ? '' : roleId;
document.getElementById('edit-user-api-key').textContent = apiKey || 'No API Key';
document.getElementById('edit-user-modal').classList.remove('hidden');
}
function closeEditUserModal() {
document.getElementById('edit-user-modal').classList.add('hidden');
}
function showAddRoleModal() {
// Make sure edit modal is closed first
document.getElementById('edit-role-modal').classList.add('hidden');
// Clear any form data
const addForm = document.querySelector('#add-role-modal form');
if (addForm) {
addForm.reset();
}
// Show add modal
document.getElementById('add-role-modal').classList.remove('hidden');
}
function closeAddRoleModal() {
document.getElementById('add-role-modal').classList.add('hidden');
}
function editRole(roleId, roleName, roleDescription) {
// Make sure add modal is closed first
document.getElementById('add-role-modal').classList.add('hidden');
document.getElementById('edit-role-id').value = roleId;
document.getElementById('edit-role-name').value = roleName;
document.getElementById('edit-role-description').value = roleDescription || '';
const permissionsDiv = document.getElementById('edit-role-permissions');
permissionsDiv.innerHTML = '';
const rolePerms = rolePermissions[roleId] || [];
// Group permissions by merged categories
const viewPerms = permissions.filter(p => p[3] === 'View');
const devicePerms = permissions.filter(p => p[3] === 'Device');
const deviceTypePerms = permissions.filter(p => p[3] === 'Device Type');
const subnetPerms = permissions.filter(p => p[3] === 'Subnet');
const dhcpPerms = permissions.filter(p => p[3] === 'DHCP');
const rackPerms = permissions.filter(p => p[3] === 'Rack');
const adminPerms = permissions.filter(p => p[3] === 'Admin');
let html = '';
// View Permissions
html += ' <!-- View Permissions -->\n';
html += ' <div class="col-span-full">\n';
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">View Permissions</h4>\n';
html += ' <div class="grid grid-cols-1 md:grid-cols-2 gap-2">\n';
viewPerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
html += ' </div>\n';
html += ' </div>\n';
html += ' \n';
// Device Management
html += ' <!-- Device Management -->\n';
html += ' <div>\n';
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Device Management</h4>\n';
devicePerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
deviceTypePerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
html += ' </div>\n';
html += ' \n';
// Network Management
html += ' <!-- Network Management -->\n';
html += ' <div>\n';
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Network Management</h4>\n';
subnetPerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
dhcpPerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
html += ' </div>\n';
html += ' \n';
// Rack Management
html += ' <!-- Rack Management -->\n';
html += ' <div>\n';
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Rack Management</h4>\n';
rackPerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
html += ' </div>\n';
html += ' \n';
// Admin
html += ' <!-- Admin -->\n';
html += ' <div>\n';
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Administration</h4>\n';
adminPerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
html += ' </div>\n';
permissionsDiv.innerHTML = html;
document.getElementById('edit-role-modal').classList.remove('hidden');
}
function closeEditRoleModal() {
document.getElementById('edit-role-modal').classList.add('hidden');
}
function deleteRole(roleId, roleName) {
if (confirm(`Are you sure you want to delete the role "${roleName}"?`)) {
const form = document.createElement('form');
form.method = 'POST';
form.action = '/users';
form.innerHTML = `
<input type="hidden" name="action" value="delete_role">
<input type="hidden" name="role_id" value="${roleId}">
`;
document.body.appendChild(form);
form.submit();
}
}
// Close modals when clicking outside
window.onclick = function(event) {
const editUserModal = document.getElementById('edit-user-modal');
const editRoleModal = document.getElementById('edit-role-modal');
const addRoleModal = document.getElementById('add-role-modal');
if (event.target === editUserModal) {
closeEditUserModal();
}
if (event.target === editRoleModal) {
closeEditRoleModal();
}
if (event.target === addRoleModal) {
closeAddRoleModal();
}
}
-32
View File
@@ -1,32 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Add Device</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-md pt-20">
<div class="flex items-center mb-6 relative">
<a href="/devices" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
<h1 class="text-3xl font-bold text-center w-full">Add Device</h1>
</div>
<form action="/add_device" method="POST" class="flex flex-col space-y-4">
<input type="text" name="device_name" placeholder="Device Name" class="p-3 rounded-lg bg-gray-200 dark:bg-zinc-800 border border-gray-600" required>
<label for="device_type" class="block mb-2">Device Type</label>
<select id="device_type" name="device_type" class="p-2 rounded w-full mb-4 bg-gray-200 dark:bg-zinc-800 border border-gray-600" required>
{% for dtype in device_types %}
<option value="{{ dtype[0] }}">{{ dtype[1] }}</option>
{% endfor %}
</select>
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">Add Device</button>
</form>
</div>
</div>
</body>
</html>
-37
View File
@@ -1,37 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Add Rack</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-md pt-20">
<div class="flex items-center mb-6 relative">
<a href="/racks" class="absolute left-0 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 flex items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
<h1 class="text-3xl font-bold text-center w-full">Add Rack</h1>
</div>
<form action="/rack/add" method="POST" class="space-y-6 bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
<div>
<label for="name" class="block font-medium mb-1">Rack Name</label>
<input type="text" id="name" name="name" class="p-3 rounded-lg bg-gray-200 dark:bg-zinc-800 border border-gray-600 w-full" required>
</div>
<div>
<label for="site" class="block font-medium mb-1">Site</label>
<input type="text" id="site" name="site" class="p-3 rounded-lg bg-gray-200 dark:bg-zinc-800 border border-gray-600 w-full" required>
</div>
<div>
<label for="height_u" class="block font-medium mb-1">Height (U)</label>
<input type="number" id="height_u" name="height_u" min="1" max="60" class="p-3 rounded-lg bg-gray-200 dark:bg-zinc-800 border border-gray-600 w-full" required>
</div>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg w-full">Add Rack</button>
</form>
</div>
</div>
</body>
</html>
-205
View File
@@ -1,205 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Panel</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 mx-4 py-8 pt-20">
<div class="container max-w-7xl mx-auto">
<h1 class="text-3xl font-bold mb-6 text-center">Admin Panel</h1>
{% if error %}
<div class="bg-red-200 dark:bg-red-800 text-red-800 dark:text-red-200 p-4 rounded-lg mb-6">
{{ error }}
</div>
{% endif %}
<!-- Quick Links -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
<a href="/audit" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
<div class="flex items-center space-x-4">
<i class="fas fa-clipboard-list text-3xl text-gray-600 dark:text-gray-400"></i>
<div>
<h3 class="text-lg font-bold">Audit Log</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">View system activity</p>
</div>
</div>
<i class="fas fa-chevron-right text-gray-400"></i>
</a>
<a href="/users" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
<div class="flex items-center space-x-4">
<i class="fas fa-users text-3xl text-gray-600 dark:text-gray-400"></i>
<div>
<h3 class="text-lg font-bold">User Management</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Manage users & roles</p>
</div>
</div>
<i class="fas fa-chevron-right text-gray-400"></i>
</a>
{% if has_permission('view_tags') %}
<a href="/tags" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
<div class="flex items-center space-x-4">
<i class="fas fa-tags text-3xl text-gray-600 dark:text-gray-400"></i>
<div>
<h3 class="text-lg font-bold">Tag Management</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Manage device tags</p>
</div>
</div>
<i class="fas fa-chevron-right text-gray-400"></i>
</a>
{% endif %}
<a href="/api-docs" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
<div class="flex items-center space-x-4">
<i class="fas fa-code text-3xl text-gray-600 dark:text-gray-400"></i>
<div>
<h3 class="text-lg font-bold">API Documentation</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Interactive API reference</p>
</div>
</div>
<i class="fas fa-chevron-right text-gray-400"></i>
</a>
<a href="/backup" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
<div class="flex items-center space-x-4">
<i class="fas fa-database text-3xl text-gray-600 dark:text-gray-400"></i>
<div>
<h3 class="text-lg font-bold">Backup & Restore</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Database backup and restore</p>
</div>
</div>
<i class="fas fa-chevron-right text-gray-400"></i>
</a>
</div>
<!-- Subnet Management Section -->
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold">Subnet Management</h2>
{% if can_add_subnet %}
<button onclick="showAddSubnetModal()" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg font-medium">
<i class="fas fa-plus mr-2"></i>Add Subnet
</button>
{% endif %}
</div>
{% if subnets %}
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-gray-600">
<th class="text-center p-3">Name</th>
<th class="text-center p-3">CIDR</th>
<th class="text-center p-3">Site</th>
<th class="text-center p-3">Utilisation</th>
<th class="text-center p-3">Actions</th>
</tr>
</thead>
<tbody>
{% for subnet in subnets %}
<tr class="border-b border-gray-600 hover:bg-gray-300 dark:hover:bg-zinc-700">
<td class="p-3 font-medium text-center">{{ subnet.name }}</td>
<td class="p-3 font-mono text-sm text-center">{{ subnet.cidr }}</td>
<td class="p-3 text-center">
<span class="px-2 py-1 bg-gray-300 dark:bg-zinc-700 rounded text-sm">{{ subnet.site }}</span>
</td>
<td class="p-3 text-center">
{% if subnet.utilization %}
<span class="text-sm text-gray-600 dark:text-gray-400">{{ subnet.utilization.percent }}%</span>
<span class="text-xs text-gray-500 dark:text-gray-500 ml-1">({{ subnet.utilization.used }}/{{ subnet.utilization.total }})</span>
{% else %}
<span class="text-sm text-gray-500"></span>
{% endif %}
</td>
<td class="p-3 text-center">
<div class="flex items-center justify-center space-x-2">
<a href="/subnet/{{ subnet.id }}" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300" title="View Subnet">
<i class="fas fa-eye"></i>
</a>
{% if can_edit_subnet %}
<button onclick="editSubnet({{ subnet.id }}, '{{ subnet.name|replace("'", "\\'") }}', '{{ subnet.cidr|replace("'", "\\'") }}', '{{ subnet.site|replace("'", "\\'") }}')" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Edit Subnet">
<i class="fas fa-edit"></i>
</button>
{% endif %}
{% if can_delete_subnet %}
<form action="/delete_subnet" method="POST" onsubmit="return confirm('Are you sure you want to delete this subnet and all its IPs? This action is irreversible.');" class="inline">
<input type="hidden" name="subnet_id" value="{{ subnet.id }}">
<button type="submit" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Delete Subnet">
<i class="fas fa-trash"></i>
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-8 text-gray-500">
<i class="fas fa-network-wired text-4xl mb-4"></i>
<p>No subnets found. Add your first subnet to get started.</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Add Subnet Modal -->
<div id="add-subnet-modal" class="hidden fixed inset-0 bg-black/30 backdrop-blur-md flex items-center justify-center z-50">
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-lg max-w-md w-full mx-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">Add New Subnet</h2>
<button onclick="closeAddSubnetModal()" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-times"></i>
</button>
</div>
<form action="/add_subnet" method="POST" onsubmit="return validateSubnetForm();">
<div class="space-y-4">
<input type="text" name="name" id="add-subnet-name" placeholder="Subnet Name" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
<input type="text" name="cidr" id="add-subnet-cidr" placeholder="CIDR (e.g., 192.168.1.0/24)" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
<input type="text" name="site" id="add-subnet-site" placeholder="Site/Location" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
<span id="cidr-error" class="text-red-500 text-sm hidden"></span>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button type="button" onclick="closeAddSubnetModal()" class="px-4 py-2 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
<button type="submit" class="px-4 py-2 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Add Subnet</button>
</div>
</form>
</div>
</div>
<!-- Edit Subnet Modal -->
<div id="edit-subnet-modal" class="hidden fixed inset-0 bg-black/30 backdrop-blur-md flex items-center justify-center z-50">
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-lg max-w-md w-full mx-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">Edit Subnet</h2>
<button onclick="closeEditSubnetModal()" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-times"></i>
</button>
</div>
<form action="/edit_subnet" method="POST" onsubmit="return validateEditSubnetForm();">
<input type="hidden" name="subnet_id" id="edit-subnet-id">
<div class="space-y-4">
<input type="text" name="name" id="edit-subnet-name" placeholder="Subnet Name" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
<input type="text" name="cidr" id="edit-subnet-cidr" placeholder="CIDR (e.g., 192.168.1.0/24)" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
<input type="text" name="site" id="edit-subnet-site" placeholder="Site/Location" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
<span id="edit-cidr-error" class="text-red-500 text-sm hidden"></span>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button type="button" onclick="closeEditSubnetModal()" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
<button type="submit" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Save Changes</button>
</div>
</form>
</div>
</div>
<script src="/static/js/add_subnet.js"></script>
<script src="/static/js/admin.js"></script>
</body>
</html>
-331
View File
@@ -1,331 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Documentation - IPAM</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 mx-4 py-8 pt-20">
<div class="container max-w-7xl mx-auto">
<h1 class="text-3xl font-bold mb-8 text-center">API Documentation</h1>
<!-- Authentication Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8 mb-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">Authentication</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<p class="mb-4">All API requests require authentication using an API key. You can provide the API key in one of three ways:</p>
<ul class="list-disc list-inside space-y-2 ml-4">
<li><strong>Header:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">X-API-Key: your_api_key</code></li>
<li><strong>Authorization Header:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">Authorization: Bearer your_api_key</code></li>
<li><strong>Query Parameter:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">?api_key=your_api_key</code></li>
</ul>
<p class="mt-4"><strong>Base URL:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">/api/v1</code></p>
</div>
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h3 class="font-semibold mb-2">Your API Key</h3>
<div class="flex items-center space-x-2">
<input type="text" id="apiKey" value="{{ api_key or '' }}" readonly
class="flex-1 px-3 py-2 bg-gray-100 dark:bg-zinc-600 border border-gray-400 dark:border-zinc-500 rounded text-sm font-mono"
placeholder="API key not found">
<button onclick="testConnection()" class="px-4 py-2 bg-gray-500 hover:bg-gray-600 dark:bg-zinc-600 dark:hover:bg-zinc-500 text-white rounded text-sm transition-colors">
<i class="fas fa-plug mr-2"></i>Test
</button>
</div>
<div id="connectionStatus" class="mt-2 text-sm"></div>
</div>
</div>
</div>
<!-- Interactive Endpoints -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8 mb-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
<i class="fas fa-play-circle mr-2"></i>Interactive Testing
</h2>
<p class="text-gray-600 dark:text-gray-300 mb-6">Test GET endpoints directly in your browser. Other methods are documented below.</p>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- GET /devices -->
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-2">
<span class="px-2 py-1 bg-green-600 text-white rounded text-xs font-mono">GET</span>
<code class="text-sm">/api/v1/devices</code>
</div>
<button onclick="tryEndpoint('GET', '/api/v1/devices', null, 'devices-list')"
class="px-3 py-1 bg-gray-500 hover:bg-gray-600 dark:bg-zinc-600 dark:hover:bg-zinc-500 text-white rounded text-xs transition-colors">
<i class="fas fa-play mr-1"></i>Try
</button>
</div>
<p class="text-xs text-gray-600 dark:text-gray-300 mb-2">List all devices</p>
<div id="devices-list-response" class="hidden">
<pre class="bg-gray-100 dark:bg-zinc-600 p-2 rounded text-xs overflow-x-auto max-h-32" id="devices-list"></pre>
</div>
</div>
<!-- GET /devices/{id} -->
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-2">
<span class="px-2 py-1 bg-green-600 text-white rounded text-xs font-mono">GET</span>
<code class="text-sm">/api/v1/devices/{id}</code>
</div>
<div class="flex items-center space-x-1">
<input type="number" id="device-id" placeholder="ID" class="px-2 py-1 border rounded text-xs w-16">
<button onclick="tryEndpointWithId('GET', '/api/v1/devices/', 'device-id', 'device-detail')"
class="px-3 py-1 bg-gray-500 hover:bg-gray-600 dark:bg-zinc-600 dark:hover:bg-zinc-500 text-white rounded text-xs transition-colors">
<i class="fas fa-play mr-1"></i>Try
</button>
</div>
</div>
<p class="text-xs text-gray-600 dark:text-gray-300 mb-2">Get device by ID</p>
<div id="device-detail-response" class="hidden">
<pre class="bg-gray-100 dark:bg-zinc-600 p-2 rounded text-xs overflow-x-auto max-h-32" id="device-detail"></pre>
</div>
</div>
<!-- GET /devices/by-tag/{tag} -->
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-2">
<span class="px-2 py-1 bg-green-600 text-white rounded text-xs font-mono">GET</span>
<code class="text-sm">/api/v1/devices/by-tag/{tag}</code>
</div>
<div class="flex items-center space-x-1">
<input type="text" id="tag-name" placeholder="Tag" class="px-2 py-1 border rounded text-xs w-20">
<button onclick="tryEndpointWithId('GET', '/api/v1/devices/by-tag/', 'tag-name', 'devices-by-tag')"
class="px-3 py-1 bg-gray-500 hover:bg-gray-600 dark:bg-zinc-600 dark:hover:bg-zinc-500 text-white rounded text-xs transition-colors">
<i class="fas fa-play mr-1"></i>Try
</button>
</div>
</div>
<p class="text-xs text-gray-600 dark:text-gray-300 mb-2">Filter devices by tag</p>
<div id="devices-by-tag-response" class="hidden">
<pre class="bg-gray-100 dark:bg-zinc-600 p-2 rounded text-xs overflow-x-auto max-h-32" id="devices-by-tag"></pre>
</div>
</div>
<!-- GET /tags -->
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-2">
<span class="px-2 py-1 bg-green-600 text-white rounded text-xs font-mono">GET</span>
<code class="text-sm">/api/v1/tags</code>
</div>
<button onclick="tryEndpoint('GET', '/api/v1/tags', null, 'tags-list')"
class="px-3 py-1 bg-gray-500 hover:bg-gray-600 dark:bg-zinc-600 dark:hover:bg-zinc-500 text-white rounded text-xs transition-colors">
<i class="fas fa-play mr-1"></i>Try
</button>
</div>
<p class="text-xs text-gray-600 dark:text-gray-300 mb-2">List all tags</p>
<div id="tags-list-response" class="hidden">
<pre class="bg-gray-100 dark:bg-zinc-600 p-2 rounded text-xs overflow-x-auto max-h-32" id="tags-list"></pre>
</div>
</div>
</div>
</div>
<!-- Complete API Documentation -->
<div class="space-y-6">
<!-- Devices Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
<i class="fas fa-server mr-2"></i>Devices
</h2>
<div class="space-y-4">
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<h4 class="font-semibold mb-2">Read Operations</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/devices</code> - List all devices</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/devices/{id}</code> - Get device details</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/devices/by-tag/{tag}</code> - Get devices by tag</li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-2">Write Operations</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/devices</code> - Create device</li>
<li><code class="bg-blue-200 dark:bg-blue-800 px-2 py-1 rounded text-xs">PUT /api/v1/devices/{id}</code> - Update device</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/devices/{id}</code> - Delete device</li>
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/devices/{id}/ips</code> - Add IP to device</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/devices/{id}/ips/{ip_id}</code> - Remove IP</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Subnets Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
<i class="fas fa-network-wired mr-2"></i>Subnets
</h2>
<div class="space-y-4">
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<h4 class="font-semibold mb-2">Read Operations</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/subnets</code> - List all subnets</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/subnets/{id}</code> - Get subnet details</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/subnets/{id}/next_free_ip</code> - Get next free IP address</li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-2">Write Operations</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/subnets</code> - Create subnet</li>
<li><code class="bg-blue-200 dark:bg-blue-800 px-2 py-1 rounded text-xs">PUT /api/v1/subnets/{id}</code> - Update subnet</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/subnets/{id}</code> - Delete subnet</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Racks Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
<i class="fas fa-building mr-2"></i>Racks
</h2>
<div class="space-y-4">
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<h4 class="font-semibold mb-2">Read Operations</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/racks</code> - List all racks</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/racks/{id}</code> - Get rack details</li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-2">Write Operations</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/racks</code> - Create rack</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/racks/{id}</code> - Delete rack</li>
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/racks/{id}/devices</code> - Add device to rack</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/racks/{id}/devices/{device_id}</code> - Remove device</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Tags Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
<i class="fas fa-tags mr-2"></i>Tags
</h2>
<div class="space-y-4">
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<h4 class="font-semibold mb-2">Read Operations</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/tags</code> - List all tags</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/tags?format=simple</code> - List tags in simple format</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/tags/{id}</code> - Get tag details</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/devices/{id}/tags</code> - Get device tags</li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-2">Write Operations</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/tags</code> - Create tag</li>
<li><code class="bg-blue-200 dark:bg-blue-800 px-2 py-1 rounded text-xs">PUT /api/v1/tags/{id}</code> - Update tag</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/tags/{id}</code> - Delete tag</li>
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/devices/{id}/tags</code> - Assign tag to device</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/devices/{id}/tags/{tag_id}</code> - Remove tag</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Additional Endpoints -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
<i class="fas fa-cogs mr-2"></i>Additional Endpoints
</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h4 class="font-semibold mb-3"><i class="fas fa-info-circle mr-2"></i>System Information</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/info</code> - System information</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/device-types</code> - List device types</li>
</ul>
</div>
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h4 class="font-semibold mb-3"><i class="fas fa-dharmachakra mr-2"></i>DHCP Management</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/subnets/{id}/dhcp</code> - Get DHCP config</li>
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/subnets/{id}/dhcp</code> - Generate DHCP config</li>
</ul>
</div>
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h4 class="font-semibold mb-3"><i class="fas fa-users mr-2"></i>User & Role Management</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/users</code> - List users</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/roles</code> - List roles</li>
</ul>
</div>
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h4 class="font-semibold mb-3"><i class="fas fa-clipboard-list mr-2"></i>Audit Log</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/audit</code> - List audit entries</li>
</ul>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-2">Supports filtering with query parameters</p>
</div>
</div>
</div>
<!-- Response Format & Permissions -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
<i class="fas fa-info-circle mr-2"></i>Response Format & Permissions
</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-3">Success Responses</h3>
<p class="mb-3 text-sm">All API responses are in JSON format. Successful requests return:</p>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-green-200 dark:bg-green-800 px-2 py-1 rounded text-xs">200 OK</code> - Request successful</li>
<li><code class="bg-green-200 dark:bg-green-800 px-2 py-1 rounded text-xs">201 Created</code> - Resource created</li>
<li><code class="bg-green-200 dark:bg-green-800 px-2 py-1 rounded text-xs">204 No Content</code> - Success with no response body</li>
</ul>
</div>
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-3">Error Responses</h3>
<p class="mb-3 text-sm">Error responses include descriptive messages:</p>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">400 Bad Request</code> - Invalid request data</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">401 Unauthorized</code> - Missing or invalid API key</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">403 Forbidden</code> - Insufficient permissions</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">404 Not Found</code> - Resource not found</li>
</ul>
</div>
</div>
<div class="mt-6 bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-3"><i class="fas fa-shield-alt mr-2"></i>Permissions</h3>
<p class="text-sm">API endpoints respect the same role-based permissions as the web interface. Users can only perform actions that their role allows. If a user lacks the required permission, the API will return a <code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">403 Forbidden</code> error with details about the missing permission.</p>
</div>
</div>
</div>
</div>
</div>
<script src="/static/js/api_docs.js"></script>
</body>
</html>
-192
View File
@@ -1,192 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Audit Log</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-8xl pt-20">
<h1 class="text-3xl font-bold mb-6 text-center">Audit Log</h1>
<!-- Collapsible Filter Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg mb-6">
<button type="button" id="filter-toggle" class="w-full flex items-center justify-between p-4 hover:bg-gray-300 dark:hover:bg-zinc-700 rounded-lg transition-colors hover:cursor-pointer">
<h2 class="text-lg font-semibold">Filters</h2>
<i class="fas fa-chevron-down transition-transform duration-200 transform" id="filter-arrow"></i>
</button>
<!-- Advanced Filter Form -->
<form method="GET" id="audit-filter-form" class="px-4 pb-4 {% if not (search_query or selected_user_ids or request.args.get('subnet_id') or request.args.get('action') or request.args.get('device_name') or date_from or date_to or request.args.get('expand_filters')) %}hidden{% endif %}">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4">
<!-- Search -->
<div class="lg:col-span-3">
<label class="block text-sm font-medium mb-1">Search</label>
<input type="text" name="search" value="{{ search_query or '' }}" placeholder="Search in details, user, action, subnet..." class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
</div>
<!-- Multiple Users -->
<div>
<label class="block text-sm font-medium mb-1">Users</label>
<select name="user_ids" multiple size="5" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
{% for user in users %}
<option value="{{ user[0] }}" {% if selected_user_ids and user[0]|string in selected_user_ids %}selected{% endif %}>{{ user[1] }}</option>
{% endfor %}
</select>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Hold Ctrl/Cmd to select multiple</p>
</div>
<!-- Subnet -->
<div>
<label class="block text-sm font-medium mb-1">Subnet</label>
<select name="subnet_id" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
<option value="">All Subnets</option>
{% for subnet in subnets %}
<option value="{{ subnet[0] }}" {% if request.args.get('subnet_id') == subnet[0]|string %}selected{% endif %}>{{ subnet[1] }}</option>
{% endfor %}
</select>
</div>
<!-- Action -->
<div>
<label class="block text-sm font-medium mb-1">Action</label>
<select name="action" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
<option value="">All Actions</option>
{% for a in actions %}
<option value="{{ a }}" {% if request.args.get('action') == a %}selected{% endif %}>{{ a }}</option>
{% endfor %}
</select>
</div>
<!-- Device Name -->
<div>
<label class="block text-sm font-medium mb-1">Device</label>
<select name="device_name" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
<option value="">All Devices</option>
{% for device in devices %}
<option value="{{ device[0] }}" {% if request.args.get('device_name') == device[0] %}selected{% endif %}>{{ device[0] }}</option>
{% endfor %}
</select>
</div>
<!-- Date From -->
<div>
<label class="block text-sm font-medium mb-1">Date From</label>
<input type="date" name="date_from" value="{{ date_from or '' }}" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
</div>
<!-- Date To -->
<div>
<label class="block text-sm font-medium mb-1">Date To</label>
<input type="date" name="date_to" value="{{ date_to or '' }}" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
</div>
</div>
<div class="flex gap-2 justify-center">
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
<i class="fas fa-search"></i>
<span>Filter</span>
</button>
<a href="/audit?expand_filters=1" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
<i class="fas fa-times"></i>
<span>Clear</span>
</a>
<button type="button" id="export-btn" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
<i class="fas fa-file-csv"></i>
<span>Export CSV</span>
</button>
</div>
</form>
</div>
<!-- Audit Log Table -->
<div class="overflow-x-auto">
<table class="w-full table-auto bg-gray-200 dark:bg-zinc-800 rounded-lg overflow-hidden">
<thead>
<tr class="bg-gray-400 dark:bg-zinc-700">
<th class="px-4 py-2 text-center">User</th>
<th class="px-4 py-2 text-center">Action</th>
<th class="px-4 py-2 text-center details-cell">Details</th>
<th class="px-4 py-2 text-center">Subnet</th>
<th class="px-4 py-2 text-center">Timestamp</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr class="border-b border-gray-700">
<td class="px-4 py-2 text-center">{{ log[1] or 'Unknown' }}</td>
<td class="px-4 py-2 text-center">{{ log[2] }}</td>
<td class="px-4 py-2 text-center details-cell">
<div class="diff-container" data-details="{{ log[3]|e }}"></div>
</td>
<td class="px-4 py-2 text-center">{{ log[4] or 'N/A' }}</td>
<td class="px-4 py-2 text-center" data-utc="{{ log[5] }}">{{ log[5] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if total_pages > 1 %}
<div class="flex justify-center mt-6 space-x-2">
{% if page > 1 %}
{% set prev_args = query_args.copy() %}
{% set _ = prev_args.update({'page': page-1}) %}
<a href="{{ url_for('audit', **prev_args) }}" class="px-3 py-1 rounded bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer flex items-center gap-2">
<i class="fa fa-angle-left"></i>
<span class="hidden sm:inline">Prev</span>
</a>
{% endif %}
{# Smart pagination logic #}
{% set delta = 2 %}
{% set start_page = [1, page - delta]|max %}
{% set end_page = [total_pages, page + delta]|min %}
{# Show first page if we're not near the start #}
{% if start_page > 1 %}
{% set page_args = query_args.copy() %}
{% set _ = page_args.update({'page': 1}) %}
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded hover:cursor-pointer {{ 'bg-gray-200 dark:bg-gray-500' if 1 == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">1</a>
{% if start_page > 2 %}
<span class="px-3 py-1 text-gray-600 dark:text-gray-400"></span>
{% endif %}
{% endif %}
{# Show pages around current page #}
{% for p in range(start_page, end_page + 1) %}
{% set page_args = query_args.copy() %}
{% set _ = page_args.update({'page': p}) %}
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded hover:cursor-pointer {{ 'bg-gray-200 dark:bg-gray-500' if p == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">{{ p }}</a>
{% endfor %}
{# Show last page if we're not near the end #}
{% if end_page < total_pages %}
{% if end_page < total_pages - 1 %}
<span class="px-3 py-1 text-gray-600 dark:text-gray-400"></span>
{% endif %}
{% set page_args = query_args.copy() %}
{% set _ = page_args.update({'page': total_pages}) %}
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded hover:cursor-pointer {{ 'bg-gray-200 dark:bg-gray-500' if total_pages == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">{{ total_pages }}</a>
{% endif %}
{% if page < total_pages %}
{% set next_args = query_args.copy() %}
{% set _ = next_args.update({'page': page+1}) %}
<a href="{{ url_for('audit', **next_args) }}" class="px-3 py-1 rounded bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer flex items-center gap-2">
<span class="hidden sm:inline">Next</span>
<i class="fa fa-angle-right"></i>
</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
<script src="/static/js/audit.js"></script>
</body>
</html>
-126
View File
@@ -1,126 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Backup & Restore</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-4xl pt-20">
<div class="flex items-center mb-6 relative">
<a href="/admin" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-full w-11 h-11 hover:cursor-pointer"><i class="fas fa-arrow-left"></i></a>
<h1 class="text-3xl font-bold text-center w-full">Backup & Restore</h1>
</div>
<div id="message" class="hidden mb-4 p-4 rounded-lg"></div>
<!-- Create Backup Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg p-6 mb-6">
<h2 class="text-xl font-bold mb-4">Create Backup</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Create a new database backup. This will export the entire database to a SQL file.</p>
<button id="create-backup-btn" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
<i class="fas fa-database"></i>
<span>Create Backup</span>
</button>
</div>
<!-- Restore Backup Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg p-6 mb-6">
<h2 class="text-xl font-bold mb-4">Restore Backup</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Restore the database from a backup file. <strong class="text-red-600 dark:text-red-400">Warning: This will replace all current data!</strong></p>
<div class="space-y-4">
<!-- Upload Backup File -->
<div>
<label class="block text-sm font-medium mb-2">Upload Backup File</label>
<form id="upload-restore-form" enctype="multipart/form-data" class="flex gap-2">
<label class="flex-1 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 border border-gray-600 rounded-lg px-4 py-2 cursor-pointer flex items-center justify-center hover:cursor-pointer">
<input type="file" name="backup_file" accept=".sql" required class="hidden" onchange="updateFileLabel(this)">
<span id="file-label" class="text-sm">Choose File</span>
</label>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">
<i class="fas fa-upload"></i> Upload & Restore
</button>
</form>
</div>
<!-- Or Select Existing Backup -->
{% if backups %}
<div>
<label class="block text-sm font-medium mb-2">Or Restore from Existing Backup</label>
<form id="existing-restore-form" class="flex gap-2">
<select name="backup_filename" required class="flex-1 border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
<option value="">Select a backup...</option>
{% for backup in backups %}
<option value="{{ backup.filename }}">{{ backup.filename }} ({{ (backup.size / 1024 / 1024)|round(2) }} MB, {{ backup.created }})</option>
{% endfor %}
</select>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">
<i class="fas fa-undo"></i> Restore
</button>
</form>
</div>
{% endif %}
</div>
</div>
<!-- Available Backups Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg p-6">
<h2 class="text-xl font-bold mb-4">Available Backups</h2>
{% if backups %}
<div class="overflow-x-auto">
<table class="w-full table-auto">
<thead>
<tr class="bg-gray-400 dark:bg-zinc-700">
<th class="px-4 py-2 text-left">Filename</th>
<th class="px-4 py-2 text-left">Size</th>
<th class="px-4 py-2 text-left">Created</th>
<th class="px-4 py-2 text-center">Actions</th>
</tr>
</thead>
<tbody>
{% for backup in backups %}
<tr class="border-b border-gray-700">
<td class="px-4 py-2">{{ backup.filename }}</td>
<td class="px-4 py-2">{{ (backup.size / 1024 / 1024)|round(2) }} MB</td>
<td class="px-4 py-2">{{ backup.created }}</td>
<td class="px-4 py-2 text-center">
<div class="flex gap-2 justify-center">
<a href="/backup/download/{{ backup.filename }}" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-3 py-1 rounded text-sm" title="Download">
<i class="fas fa-download"></i>
</a>
<button onclick="deleteBackup('{{ backup.filename }}')" class="bg-red-300 hover:bg-red-400 dark:bg-red-700 dark:hover:bg-red-600 hover:cursor-pointer px-3 py-1 rounded text-sm" title="Delete">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-gray-600 dark:text-gray-400">No backups available. Create your first backup above.</p>
{% endif %}
</div>
</div>
</div>
<script src="/static/js/backup.js"></script>
<script>
function updateFileLabel(input) {
const label = document.getElementById('file-label');
if (input.files && input.files[0]) {
label.textContent = input.files[0].name;
} else {
label.textContent = 'Choose File';
}
}
</script>
</body>
</html>
-160
View File
@@ -1,160 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bulk Operations</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 mx-4 py-8 pt-20">
<div class="container max-w-6xl mx-auto">
<div class="flex items-center mb-6 relative">
<a href="/devices" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
<h1 class="text-3xl font-bold text-center w-full">Bulk Operations</h1>
</div>
<!-- Tabs -->
<div class="flex flex-wrap gap-2 mb-6 justify-center border-b border-gray-600">
<button onclick="showTab('assign-ips')" id="tab-assign-ips" class="tab-btn px-4 py-2 rounded-t-lg bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 hover:cursor-pointer active">Bulk IP Assignment</button>
<button onclick="showTab('create-devices')" id="tab-create-devices" class="tab-btn px-4 py-2 rounded-t-lg bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 hover:cursor-pointer">Bulk Device Creation</button>
<button onclick="showTab('assign-tags')" id="tab-assign-tags" class="tab-btn px-4 py-2 rounded-t-lg bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 hover:cursor-pointer">Bulk Tag Assignment</button>
<button onclick="showTab('export')" id="tab-export" class="tab-btn px-4 py-2 rounded-t-lg bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 hover:cursor-pointer">Bulk Export</button>
</div>
<!-- Bulk IP Assignment -->
<div id="panel-assign-ips" class="tab-panel bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
{% if can_add_device_ip %}
<h2 class="text-2xl font-bold mb-4">Bulk IP Assignment</h2>
<p class="text-gray-600 dark:text-gray-400 mb-4">Select a device and assign multiple IPs from a subnet. Hold Ctrl/Cmd to select multiple IPs.</p>
<form id="bulk-assign-ips-form" class="space-y-4">
<div>
<label class="block mb-2 font-medium">Select Device:</label>
<select id="bulk-device-select" name="device_id" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
<option value="">Select a device...</option>
{% for device in devices %}
<option value="{{ device[0] }}">{{ device[1] }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block mb-2 font-medium">Select Subnet:</label>
<select id="bulk-subnet-select" name="subnet_id" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
<option value="">Select a subnet...</option>
{% for subnet in subnets %}
<option value="{{ subnet[0] }}">{{ subnet[1] }} ({{ subnet[2] }}) - {{ subnet[3] or 'Unassigned' }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block mb-2 font-medium">Select IPs (hold Ctrl/Cmd to select multiple):</label>
<select id="bulk-ip-select" name="ip_ids[]" multiple size="15" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
<option value="" disabled>Select a subnet first...</option>
</select>
<p class="text-sm text-gray-500 mt-1">Selected: <span id="selected-ip-count">0</span> IPs</p>
</div>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg">Assign IPs</button>
</form>
<div id="assign-ips-result" class="mt-4 hidden"></div>
{% else %}
<p class="text-gray-500">You don't have permission to assign IPs to devices.</p>
{% endif %}
</div>
<!-- Bulk Device Creation -->
<div id="panel-create-devices" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
{% if can_add_device %}
<h2 class="text-2xl font-bold mb-4">Bulk Device Creation</h2>
<p class="text-gray-600 dark:text-gray-400 mb-4">Create multiple devices at once. Enter one device name per line.</p>
<form id="bulk-create-devices-form" class="space-y-4">
<div>
<label class="block mb-2 font-medium">Device Names (one per line):</label>
<textarea id="device-names" name="device_names" rows="10" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" placeholder="Device 1&#10;Device 2&#10;Device 3" required></textarea>
<p class="text-sm text-gray-500 mt-1">Enter device names, one per line</p>
</div>
<div>
<label class="block mb-2 font-medium">Device Type:</label>
<select name="device_type" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
{% for dtype in device_types %}
<option value="{{ dtype[0] }}">{{ dtype[1] }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg">Create Devices</button>
</form>
<div id="create-devices-result" class="mt-4 hidden"></div>
{% else %}
<p class="text-gray-500">You don't have permission to create devices.</p>
{% endif %}
</div>
<!-- Bulk Tag Assignment -->
<div id="panel-assign-tags" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
{% if can_assign_device_tag %}
<h2 class="text-2xl font-bold mb-4">Bulk Tag Assignment</h2>
<p class="text-gray-600 dark:text-gray-400 mb-4">Select multiple devices and assign one or more tags to them.</p>
<form id="bulk-assign-tags-form" class="space-y-4">
<div>
<label class="block mb-2 font-medium">Select Devices (hold Ctrl/Cmd to select multiple):</label>
<select id="bulk-tag-device-select" name="device_ids[]" multiple size="10" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full">
{% for device in devices %}
<option value="{{ device[0] }}">{{ device[1] }}</option>
{% endfor %}
</select>
<p class="text-sm text-gray-500 mt-1">Selected: <span id="selected-tag-device-count">0</span> devices</p>
</div>
<div>
<label class="block mb-2 font-medium">Select Tags (hold Ctrl/Cmd to select multiple):</label>
<select name="tag_ids[]" multiple size="5" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
{% for tag in tags %}
<option value="{{ tag[0] }}">{{ tag[1] }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg">Assign Tags</button>
</form>
<div id="assign-tags-result" class="mt-4 hidden"></div>
{% else %}
<p class="text-gray-500">You don't have permission to assign tags to devices.</p>
{% endif %}
</div>
<!-- Bulk Export -->
<div id="panel-export" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
{% if can_export_subnet_csv %}
<h2 class="text-2xl font-bold mb-4">Bulk Subnet Export</h2>
<p class="text-gray-600 dark:text-gray-400 mb-4">Select multiple subnets and export them to a single CSV file.</p>
<form id="bulk-export-form" method="POST" action="/bulk/export_subnets" class="space-y-4">
<div>
<label class="block mb-2 font-medium">Select Subnets (hold Ctrl/Cmd to select multiple):</label>
<select name="subnet_ids[]" multiple size="10" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
{% for subnet in subnets %}
<option value="{{ subnet[0] }}">{{ subnet[1] }} ({{ subnet[2] }}) - {{ subnet[3] or 'Unassigned' }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg">Export to CSV</button>
</form>
{% else %}
<p class="text-gray-500">You don't have permission to export subnets.</p>
{% endif %}
</div>
</div>
</div>
<script src="/static/js/bulk_operations.js"></script>
<style>
.tab-btn.active {
background-color: rgb(156 163 175);
color: white;
}
.dark .tab-btn.active {
background-color: rgb(63 63 70);
}
</style>
</body>
</html>
-130
View File
@@ -1,130 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ device.name }} - Device Details</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-2xl pt-20">
<div class="flex items-center mb-8 relative justify-between gap-4">
<a href="/devices" class="hidden sm:flex bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 flex items-center justify-center rounded-full w-11 h-11 shrink-0"><i class="fas fa-arrow-left"></i></a>
<h1 class="text-3xl font-bold text-center flex-1 min-w-0 truncate">{{ device.name }}</h1>
<form action="/update_device_type" method="POST" class="hidden md:inline ml-2">
<input type="hidden" name="device_id" value="{{ device.id }}">
<select name="device_type_id" class="border p-2 rounded bg-gray-200 dark:bg-zinc-800 border-gray-600" onchange="this.form.submit()">
{% for dtype in device_types %}
<option value="{{ dtype[0] }}" {% if device.device_type_id == dtype[0] %}selected{% endif %}>{{ dtype[1] }}</option>
{% endfor %}
</select>
</form>
<div class="flex items-center shrink-0">
<form action="/rename_device" method="POST" class="inline">
<input type="hidden" name="device_id" value="{{ device.id }}">
<input type="text" name="new_name" value="{{ device.name }}" class="hidden border p-1 rounded bg-gray-200 dark:bg-zinc-800 border-gray-600 w-32 mr-2" style="vertical-align: middle;" required>
<button type="button" class="text-blue-400 hover:text-blue-600 hover:cursor-pointer ml-2 rename-btn" title="Rename Device"><i class="fas fa-pencil-alt"></i></button>
<button type="submit" class="text-green-400 hover:text-green-600 hover:cursor-pointer ml-2 save-btn hidden" title="Save Name"><i class="fas fa-check"></i></button>
<button type="button" class="text-gray-400 hover:text-gray-600 hover:cursor-pointer ml-2 cancel-btn hidden" title="Cancel"><i class="fas fa-times"></i></button>
</form>
<form action="/delete_device" method="POST" onsubmit="return confirm('Are you sure you want to delete this device?');">
<input type="hidden" name="device_id" value="{{ device.id }}">
<button type="submit" class="ml-4 text-red-500 hover:text-red-700 hover:cursor-pointer" title="Delete Device">
<i class="fas fa-trash fa-lg"></i>
</button>
</form>
</div>
</div>
<form action="/device/{{ device.id }}/add_ip" method="POST" class="mb-6">
<div class="flex flex-col space-y-4">
<select name="site" id="site-select" class="border p-3 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full" required>
<option value="" disabled selected>Select Site...</option>
{% set sites = subnets | map(attribute='site') | unique | list %}
{% for site in sites %}
<option value="{{ site }}">{{ site }}</option>
{% endfor %}
</select>
<select name="subnet_id" id="subnet-select" class="border p-3 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full" required>
<option value="" disabled selected>Select Subnet...</option>
{% for subnet in subnets %}
<option value="{{ subnet.id }}" data-site="{{ subnet.site }}">{{ subnet.name }} ({{ subnet.cidr }})</option>
{% endfor %}
</select>
<select name="ip_id" id="ip-select" class="border p-3 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full" required>
<option value="" disabled selected>Select IP...</option>
</select>
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg w-full">Add IP</button>
</div>
</form>
<div class="allocated-ips mb-6">
<h3 class="text-lg font-bold mb-2">Allocated IPs:</h3>
<ul class="space-y-2">
{% for ip in device_ips %}
<li class="flex justify-between items-center bg-gray-200 dark:bg-zinc-700 p-2 rounded-lg">
<span class="allocated-ip">{{ ip.ip }}</span>
<form action="/device/{{ device.id }}/delete_ip" method="POST" class="inline">
<input type="hidden" name="device_ip_id" value="{{ ip.device_ip_id }}">
<button type="submit" class="text-red-500 hover:text-red-600 hover:cursor-pointer py-1 mr-2 text-lg"><i class="fas fa-trash"></i></button>
</form>
</li>
{% endfor %}
</ul>
</div>
<!-- Tags Section -->
<div class="tags-section mb-6">
<h3 class="text-lg font-bold mb-2">Tags:</h3>
<div class="flex flex-wrap gap-2 mb-4">
{% if device_tags %}
{% for tag in device_tags %}
<div class="flex items-center space-x-1 px-3 py-1 rounded-full text-sm" style="background-color: {{ tag.color }}20; border: 1px solid {{ tag.color }}">
<div class="w-2 h-2 rounded-full" style="background-color: {{ tag.color }}"></div>
<span>{{ tag.name }}</span>
{% if can_remove_device_tag %}
<form action="/device/{{ device.id }}/remove_tag" method="POST" class="inline">
<input type="hidden" name="tag_id" value="{{ tag.id }}">
<button type="submit" class="ml-1 text-red-500 hover:text-red-700 text-xs" title="Remove tag">
<i class="fas fa-times"></i>
</button>
</form>
{% endif %}
</div>
{% endfor %}
{% else %}
<span class="text-gray-500">No tags assigned</span>
{% endif %}
</div>
{% if can_assign_device_tag and all_tags %}
<form action="/device/{{ device.id }}/assign_tag" method="POST" class="flex gap-2">
<select name="tag_id" class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 flex-1" required>
<option value="" disabled selected>Select a tag to assign...</option>
{% for tag in all_tags %}
{% set already_assigned = device_tags|selectattr('id', 'equalto', tag.id)|list|length > 0 %}
{% if not already_assigned %}
<option value="{{ tag.id }}">{{ tag.name }}</option>
{% endif %}
{% endfor %}
</select>
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">
<i class="fas fa-plus mr-1"></i>Assign Tag
</button>
</form>
{% endif %}
</div>
<form action="/update_device_description" method="POST" class="mb-6 mt-4">
<input type="hidden" name="device_id" value="{{ device.id }}">
<label for="description" class="block mb-2 text-lg font-bold">Description</label>
<textarea id="description" name="description" rows="3" class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full resize-y" placeholder="Enter device description...">{{ device.description or '' }}</textarea>
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg w-full mt-2">Save Description</button>
</form>
</div>
</div>
<script src="/static/js/device.js"></script>
</body>
</html>
-45
View File
@@ -1,45 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Device Type Statistics</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-2xl pt-20">
<div class="flex items-center mb-6 relative">
<a href="/devices" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
<h1 class="text-3xl font-bold text-center w-full">Device Stats</h1>
<a href="/device_types" class="hidden sm:flex absolute right-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-lg px-4 py-2 text-sm"><i class="fas fa-cog mr-2"></i>Manage Types</a>
</div>
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
<table class="w-full table-auto">
<thead>
<tr class="dark:bg-zinc-700">
<th class="px-4 py-2 text-left">Type</th>
<th class="px-4 py-2 text-center">Count</th>
</tr>
</thead>
<tbody>
{% for name, icon_class, count in stats %}
<tr class="border-b border-gray-700">
<td class="px-4 py-3 flex items-center gap-2">
<a href="{{ url_for('devices_by_type', device_type=name) }}" class="hover:underline flex items-center gap-2">
<i class="fas {{ icon_class }} dark:text-white"></i> {{ name }}
</a>
</td>
<td class="px-4 py-3 text-center font-bold">{{ count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>
-101
View File
@@ -1,101 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Device Type Management</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="/static/css/device_types.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-6xl pt-20">
<div class="flex items-center mb-6 relative">
<a href="/device_type_stats" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
<h1 class="text-3xl font-bold text-center w-full">Device Type Management</h1>
</div>
{% if error %}
<div class="mb-4 bg-red-200 dark:bg-red-800 text-red-900 dark:text-red-100 p-4 rounded-lg">
{{ error }}
</div>
{% endif %}
<form action="/device_types" method="POST" class="mb-8 bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
<input type="hidden" name="action" value="add">
<h2 class="text-xl font-bold mb-4">Add New Device Type</h2>
<div class="flex flex-col space-y-4">
<div class="flex flex-col md:flex-row gap-4">
<input type="text" name="name" placeholder="Device Type Name (e.g., Router, Load Balancer)" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 flex-1" required>
<div class="icon-search-container relative flex-1">
<div class="flex items-center gap-2">
<div class="icon-preview hidden text-2xl text-gray-600 dark:text-gray-400 flex-shrink-0"></div>
<input type="text" name="icon_class" placeholder="Icon Class (e.g., fa-server, fa-router)" class="icon-search-input border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 flex-1" required>
</div>
<div class="icon-suggestions hidden absolute z-10 w-full mt-1 bg-gray-300 dark:bg-zinc-900 border border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto">
</div>
</div>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg whitespace-nowrap">Add Device Type</button>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">
<p><strong>Icon Class Format:</strong> Start typing to see icon suggestions. Use Font Awesome icon classes (e.g., <code>fa-server</code>, <code>fa-router</code>, <code>fa-database</code>).</p>
<p class="mt-1">Common icons: <code>fa-server</code>, <code>fa-network-wired</code>, <code>fa-shield-halved</code>, <code>fa-wifi</code>, <code>fa-print</code>, <code>fa-boxes-stacked</code>, <code>fa-question</code></p>
</div>
</div>
</form>
<h2 class="text-xl font-bold mb-4">Existing Device Types</h2>
{% if device_types %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for device_type in device_types %}
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md hover:shadow-lg transition-shadow">
<form id="edit-form-{{ device_type[0] }}" action="/device_types" method="POST" class="space-y-4">
<input type="hidden" name="action" value="edit">
<input type="hidden" name="device_type_id" value="{{ device_type[0] }}">
<div class="flex flex-col space-y-3">
<div class="flex items-center justify-center mb-2">
<i class="fas {{ device_type[2] }} text-4xl text-gray-700 dark:text-gray-300"></i>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
<input type="text" name="name" value="{{ device_type[1] }}" class="border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
</div>
<div class="icon-search-container relative">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Icon</label>
<div class="flex items-center gap-2">
<div class="icon-preview text-xl text-gray-600 dark:text-gray-400 flex-shrink-0">
<i class="fas {{ device_type[2] }}"></i>
</div>
<input type="text" name="icon_class" value="{{ device_type[2] }}" class="icon-search-input border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 flex-1" required>
</div>
<div class="icon-suggestions hidden absolute z-10 w-full mt-1 bg-gray-300 dark:bg-zinc-900 border border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto">
</div>
</div>
</div>
</form>
<div class="flex gap-2 pt-2">
<button type="submit" form="edit-form-{{ device_type[0] }}" class="flex-1 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-3 py-2 rounded-lg text-sm font-medium transition-colors">
<i class="fas fa-save mr-1"></i> Save
</button>
<form action="/device_types" method="POST" onsubmit="return confirm('Are you sure you want to delete this device type?');" class="inline">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="device_type_id" value="{{ device_type[0] }}">
<button type="submit" class="bg-red-500 hover:bg-red-600 dark:bg-red-600 dark:hover:bg-red-700 hover:cursor-pointer text-white px-3 py-2 rounded-lg text-sm font-medium transition-colors" title="Delete Device Type">
<i class="fas fa-trash mr-1"></i> Delete
</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg text-center text-gray-600 dark:text-gray-400">
<p>No device types found. Add your first device type above.</p>
</div>
{% endif %}
</div>
</div>
<script src="/static/js/device_types.js"></script>
</body>
</html>
-92
View File
@@ -1,92 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Device Manager</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
<link href="/static/css/devices.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-4xl pt-20">
<h1 class="text-3xl font-bold mb-6 text-center">Device Manager</h1>
<div class="flex flex-row justify-center gap-4 mb-6 flex-wrap">
<a href="/add_device" class="text-center bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">Add New Device</a>
<a href="/bulk" class="text-center bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">Bulk Operations</a>
<a href="/device_type_stats" class="text-center bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">View Device Stats</a>
</div>
<!-- Filters Section -->
<div class="mb-6 space-y-4">
<!-- Tag Filter -->
{% if all_tag_names %}
<div class="flex items-center space-x-2">
<label class="text-sm font-medium">Filter by tag:</label>
<select id="tag-filter" class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600">
<option value="">All devices</option>
{% for tag_name in all_tag_names %}
<option value="{{ tag_name }}" {% if current_tag_filter == tag_name %}selected{% endif %}>{{ tag_name }}</option>
{% endfor %}
</select>
{% if current_tag_filter %}
<a href="/devices" class="text-red-500 hover:text-red-700 text-sm">
<i class="fas fa-times"></i> Clear filter
</a>
{% endif %}
</div>
{% endif %}
</div>
<div id="site-list" class="space-y-6">
{% for site, devices in sites_devices.items() %}
<div class="site-group bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md">
<div class="flex flex-row items-center justify-between p-4 cursor-pointer site-header">
<h2 class="text-xl font-bold mb-0 dark:text-white">{{ site }}</h2>
<button type="button" class="expand-btn text-gray-400 hover:text-gray-200 hover:cursor-pointer ml-2 flex items-center" aria-label="Expand site">
<i class="fas fa-chevron-down"></i>
</button>
</div>
<ul class="device-list hidden px-6 pb-4">
{% for device in devices %}
<li class="my-2">
<a href="/device/{{ device.id }}" class="block bg-gray-300 hover:bg-gray-100 dark:bg-zinc-900 dark:hover:bg-gray-700 dark:text-gray-200 font-semibold rounded-lg px-4 py-2 shadow transition-colors duration-150">
<div class="flex items-center justify-between">
<span><i class="fas {{ device.icon_class or 'fa-server' }} mr-2"></i>{{ device.name }}</span>
{% set ips = device_ips.get(device.id, []) %}
<span class="flex flex-row flex-wrap justify-end items-center ml-4 text-xs text-blue-300 font-normal align-middle">
{% if ips|length > 0 %}
{% for ip in ips %}
<span class="inline-block bg-gray-200 dark:bg-zinc-800 text-gray-900 dark:text-white rounded px-2 py-1 ml-1 font-mono">{{ ip[1] }}</span>
{% endfor %}
{% else %}
<span class="text-gray-400">No IPs</span>
{% endif %}
</span>
</div>
<!-- Tags -->
{% set tags = device_tags.get(device.id, []) %}
{% if tags %}
<div class="flex flex-wrap gap-1 mt-2">
{% for tag in tags %}
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs" style="background-color: {{ tag.color }}20; border: 1px solid {{ tag.color }}; color: {{ tag.color }}">
<div class="w-1.5 h-1.5 rounded-full mr-1" style="background-color: {{ tag.color }}"></div>
{{ tag.name }}
</span>
{% endfor %}
</div>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
</div>
</div>
<script src="/static/js/devices.js"></script>
</body>
</html>
-64
View File
@@ -1,64 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ tag_name }} - Tagged Devices</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-3xl pt-20">
<div class="flex items-center mb-6 relative">
<a href="/tags" class="absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 flex items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
<h1 class="text-3xl font-bold text-center w-full">
<div class="flex items-center justify-center space-x-2">
<div class="w-5 h-5 rounded-full border border-gray-600" style="background-color: {{ tag_color }}"></div>
<span>{{ tag_name }} - Tagged Devices</span>
</div>
</h1>
</div>
{% if site_devices %}
{% for site, devices in site_devices.items() %}
<div class="mb-8">
<h2 class="text-xl font-bold mb-4 dark:text-white">{{ site }}</h2>
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-4">
<table class="w-full table-auto mb-2">
<thead>
<tr class="bg-gray-200 dark:bg-zinc-700">
<th class="px-4 py-2 text-left">Device Name</th>
<th class="px-4 py-2 text-left">Description</th>
</tr>
</thead>
<tbody>
{% for device in devices %}
<tr class="border-b border-gray-700">
<td class="px-4 py-3">
<a href="/device/{{ device.id }}" class="hover:underline dark:text-white hover:cursor-pointer">{{ device.name }}</a>
</td>
<td class="px-4 py-3">{{ device.description or '' }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="bg-gray-200 dark:bg-zinc-900 font-bold">
<td class="px-4 py-2 text-right" colspan="2">Total: {{ devices|length }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
{% endfor %}
{% else %}
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-8 text-center">
<p class="text-gray-500">No devices found with this tag.</p>
</div>
{% endif %}
</div>
</div>
</body>
</html>
-54
View File
@@ -1,54 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ device_type }} Devices</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-3xl pt-20">
<div class="flex items-center mb-6 relative">
<a href="/device_type_stats" class="absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 flex items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
<h1 class="text-3xl font-bold text-center w-full">
<i class="fas {{ icon_class }}"></i> {{ device_type }} Devices
</h1>
</div>
{% for site, devices in site_devices.items() %}
<div class="mb-8">
<h2 class="text-xl font-bold mb-4 dark:text-white">{{ site }}</h2>
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-4">
<table class="w-full table-auto mb-2">
<thead>
<tr class="bg-gray-200 dark:bg-zinc-700">
<th class="px-4 py-2 text-left">Device Name</th>
<th class="px-4 py-2 text-left">Description</th>
</tr>
</thead>
<tbody>
{% for device in devices %}
<tr class="border-b border-gray-700">
<td class="px-4 py-3">
<a href="/device/{{ device.id }}" class="hover:underline dark:text-white">{{ device.name }}</a>
</td>
<td class="px-4 py-3">{{ device.description or '' }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="bg-gray-200 dark:bg-zinc-900 font-bold">
<td class="px-4 py-2 text-right" colspan="2">Total: {{ devices|length }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
{% endfor %}
</div>
</div>
</body>
</html>
-49
View File
@@ -1,49 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Define DHCP Pool - {{ subnet.name }}</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 w-full sm:max-w-3/4 pt-20">
<div class="flex items-center mb-6 relative">
<a href="/subnet/{{ subnet.id }}" class="absolute left-0 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 flex items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
<h1 class="text-3xl font-bold text-center w-full">DHCP Pool for {{ subnet.name }}</h1>
</div>
{% if error %}
<div class="text-red-500 text-center mb-4">{{ error }}</div>
{% endif %}
<form action="" method="POST" class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md flex flex-col gap-4">
<label for="start_ip" class="font-medium">Start IP Address</label>
<input type="text" id="start_ip" name="start_ip" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600" placeholder="e.g. 192.168.1.100" required value="{{ dhcp_pool.start_ip if dhcp_pool else '' }}">
<label for="end_ip" class="font-medium">End IP Address</label>
<input type="text" id="end_ip" name="end_ip" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600" placeholder="e.g. 192.168.1.200" required value="{{ dhcp_pool.end_ip if dhcp_pool else '' }}">
<label for="excluded_ips" class="font-medium">Exclude IPs (comma separated)</label>
<input type="text" id="excluded_ips" name="excluded_ips" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600" placeholder="e.g. 192.168.1.105,192.168.1.110" value="{{ dhcp_pool.excluded_ips if dhcp_pool and dhcp_pool.excluded_ips else '' }}">
<div class="flex gap-4 mt-4">
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">Save DHCP Pool</button>
{% if dhcp_pool %}
<button type="submit" name="remove" value="1" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">Remove DHCP Pool</button>
{% endif %}
</div>
</form>
{% if dhcp_pool %}
<div class="mt-8 bg-gray-200 dark:bg-zinc-800 p-4 rounded-lg">
<h2 class="text-xl font-bold mb-2">Current DHCP Pool</h2>
<div>Start: <span class="font-mono">{{ dhcp_pool.start_ip }}</span></div>
<div>End: <span class="font-mono">{{ dhcp_pool.end_ip }}</span></div>
{% if dhcp_pool.excluded_ips %}
<div>Excluded: <span class="font-mono break-words inline-block max-w-full whitespace-normal">{{ dhcp_pool.excluded_ips }}</span></div>
{% endif %}
</div>
{% endif %}
</div>
</div>
</body>
</html>
-116
View File
@@ -1,116 +0,0 @@
<header class="bg-zinc-800 shadow-md py-3 px-6 flex items-center justify-between relative">
<div class="flex items-center space-x-3 flex-shrink-0">
<a href="/" class="flex items-center space-x-3">
<img src="{{ LOGO_PNG }}" alt="Logo" class="h-8 rounded">
<span class="text-2xl font-bold text-white whitespace-nowrap">{{ NAME }} IPAM</span>
</a>
<a href="https://github.com/JDB-NET/ipam/releases" target="_blank" rel="noopener noreferrer" class="text-sm font-normal text-gray-300 hover:text-gray-100 -ml-1 mt-3">v{{ VERSION }}</a>
</div>
<div class="hidden lg:flex items-center justify-center absolute left-1/2" style="transform: translateX(calc(-50% + 1.5rem));">
<form action="/search" method="GET" class="flex items-center space-x-2">
<input type="text" name="q" id="search-input" placeholder="Search..."
class="bg-zinc-700 text-white placeholder-gray-400 px-4 py-2 rounded-md text-base focus:outline-none focus:ring-2 focus:ring-gray-500 w-100"
value="{{ request.args.get('q', '') }}">
<button type="submit" class="text-gray-200 hover:text-gray-400 hover:cursor-pointer flex-shrink-0">
<i class="fas fa-search"></i>
</button>
</form>
</div>
<nav class="hidden lg:flex items-center space-x-6 flex-shrink-0" id="main-nav">
{% if has_permission('view_index') %}
<a href="/" class="text-gray-200 hover:text-gray-400 font-medium">Home</a>
{% endif %}
{% if has_permission('view_devices') %}
<a href="/devices" class="text-gray-200 hover:text-gray-400 font-medium">Devices</a>
{% endif %}
{% if has_permission('view_racks') %}
<a href="/racks" class="text-gray-200 hover:text-gray-400 font-medium">Racks</a>
{% endif %}
{% if has_permission('view_admin') %}
<a href="/admin" class="text-gray-200 hover:text-gray-400 font-medium">Admin</a>
{% endif %}
{% if has_permission('view_help') %}
<a href="/help" class="text-gray-200 hover:text-gray-400 font-medium">Help</a>
{% endif %}
{% if current_user_name %}
<a href="/logout" class="text-gray-200 hover:text-gray-400 font-medium">Logout</a>
{% endif %}
</nav>
<button class="lg:hidden flex items-center text-gray-200 hover:cursor-pointer focus:outline-none flex-shrink-0" id="nav-toggle" aria-label="Open navigation menu">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
<div class="lg:hidden fixed top-13 left-0 right-0 bg-zinc-800 shadow-lg z-50 w-full hidden flex-col py-2" id="mobile-nav">
<form action="/search" method="GET" class="px-4 py-2 border-b border-zinc-700">
<div class="flex items-center space-x-2">
<input type="text" name="q" placeholder="Search..."
class="bg-zinc-700 text-white placeholder-gray-400 px-3 py-2.5 rounded text-base focus:outline-none focus:ring-2 focus:ring-gray-500 flex-1"
value="{{ request.args.get('q', '') }}">
<button type="submit" class="text-gray-200 hover:text-gray-400 hover:cursor-pointer flex-shrink-0">
<i class="fas fa-search"></i>
</button>
</div>
</form>
{% if has_permission('view_index') %}
<a href="/" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Home</a>
{% endif %}
{% if has_permission('view_devices') %}
<a href="/devices" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Devices</a>
{% endif %}
{% if has_permission('view_racks') %}
<a href="/racks" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Racks</a>
{% endif %}
{% if has_permission('view_admin') %}
<a href="/admin" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Admin</a>
{% endif %}
{% if has_permission('view_help') %}
<a href="/help" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Help</a>
{% endif %}
{% if current_user_name %}
<a href="/logout" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Logout</a>
{% endif %}
</div>
<script src="/static/js/header.js"></script>
<!-- Update Available Toast -->
<div id="update-toast" class="hidden fixed bottom-4 right-4 bg-gray-200 dark:bg-zinc-800 border border-gray-400 dark:border-zinc-600 rounded-lg shadow-lg max-w-md z-50">
<div class="p-4">
<div class="flex items-start justify-between mb-2">
<div class="flex items-center gap-2">
<i class="fas fa-bell text-gray-900 dark:text-gray-100"></i>
<h3 class="font-semibold text-lg text-gray-900 dark:text-gray-100">Update Available</h3>
</div>
<button id="toast-close" class="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:cursor-pointer">
<i class="fas fa-times"></i>
</button>
</div>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
Version <span id="toast-latest-version" class="font-semibold"></span> is now available. You're currently on <span id="toast-current-version" class="font-semibold"></span>.
</p>
<div class="flex gap-2 mt-3">
<a id="toast-compare-link" href="#" target="_blank" rel="noopener noreferrer" class="flex-1 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 text-gray-900 dark:text-gray-100 px-3 py-2 rounded text-center text-sm hover:cursor-pointer">
View Changes
</a>
</div>
</div>
</div>
<style>
#update-toast {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
</style>
<script src="/static/js/update_toast.js"></script>
</header>
-123
View File
@@ -1,123 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Help & User Guide</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 mx-4 py-8 pt-20">
<div class="container max-w-full mx-auto lg:px-32">
<h1 class="text-3xl font-bold mb-8 text-center">Help & User Guide</h1>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 mb-8">
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">Subnets & Devices</h2>
<div class="space-y-4">
<div>
<h3 class="text-xl font-semibold mb-1">Managing Subnets</h3>
<p>To add or edit subnets, go to the <a href="/admin" class="text-blue-600 dark:text-blue-400 hover:underline">Admin</a> page. Click <span class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded">Add Subnet</span> to create a new subnet, or use the edit button to modify existing subnets. Subnets are associated with sites and can be organised by location.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Adding a Device</h3>
<p>To add a device, visit the <a href="/devices" class="text-blue-600 dark:text-blue-400 hover:underline">Devices</a> page and click <span class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded">Add Device</span>.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Assigning IP Addresses</h3>
<p>To assign an IP address, you must first add a device. Then, from the <a href="/devices" class="text-blue-600 dark:text-blue-400 hover:underline">Devices</a> page, click on a device to view its details and use the <span class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded">Add IP</span> option to assign an IP address from a subnet.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Viewing and Editing Devices</h3>
<p>Click on any device in the <a href="/devices" class="text-blue-600 dark:text-blue-400 hover:underline">Devices</a> list to view or edit its details, including assigned IPs, device types and a description where necessary.</p>
</div>
</div>
</div>
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">Racks</h2>
<div class="space-y-4">
<div>
<h3 class="text-xl font-semibold mb-1">Adding a Rack</h3>
<p>To add a new rack, go to the <a href="/racks" class="text-blue-600 dark:text-blue-400 hover:underline">Racks</a> page and click the <span class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded">Add Rack</span> button. Fill in the details and submit the form.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Assigning Devices to Racks</h3>
<p>After adding a device, you can assign it to a rack from the <a href="/racks" class="text-blue-600 dark:text-blue-400 hover:underline">Rack</a> details page. Click on a rack from the <a href="/racks" class="text-blue-600 dark:text-blue-400 hover:underline">Racks</a> page, then use the options to add or remove devices within the rack.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Non-Networked Devices</h3>
<p>Racks can also contain non-networked devices (such as shelves, patch panels, or other equipment that does not require an IP address). To add a non-networked device, go to a rack details page and use the option to add a device by name without assigning an IP address. These devices will appear in the rack layout but will not be listed on the Devices page.</p>
</div>
</div>
</div>
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">Device Tags</h2>
<div class="space-y-4">
<div>
<h3 class="text-xl font-semibold mb-1">Managing Tags</h3>
<p>Tags help you organize and categorize your devices. Administrators can manage tags from the <a href="/tags" class="text-blue-600 dark:text-blue-400 hover:underline">Tag Management</a> page accessible from the Admin panel. Each tag has a name, customizable colour, and optional description.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Creating Tags</h3>
<p>Click <span class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded">Add Tag</span> to create a new tag. Choose a descriptive name (e.g., "Production", "Development", "Critical") and select a colour to visually distinguish the tag. Tags can be used to group devices by environment, importance, location, or any other criteria.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Assigning Tags to Devices</h3>
<p>From any device's detail page, you can assign multiple tags using the tag assignment dropdown. Each device can have unlimited tags, and removing a tag is as simple as clicking the × button next to the tag name.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Filtering Devices by Tags</h3>
<p>On the <a href="/devices" class="text-blue-600 dark:text-blue-400 hover:underline">Devices</a> page, use the tag filter dropdown to view only devices with a specific tag. This makes it easy to focus on devices in a particular environment or category.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Tag Colours and Visual Organisation</h3>
<p>Tags appear as coloured badges throughout the interface. Use consistent colour schemes (e.g., red for production, blue for development) to create visual patterns that help users quickly identify device categories.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Tag Permissions</h3>
<p>Tag management respects role-based permissions. Users need appropriate permissions to view, create, edit, delete tags, or assign/remove tags from devices. View-only users can see tags but cannot modify them.</p>
</div>
</div>
</div>
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">User Management & Audit</h2>
<div class="space-y-4">
<div>
<h3 class="text-xl font-semibold mb-1">User & Role Management</h3>
<p>Administrators can manage users and roles from the <a href="/users" class="text-blue-600 dark:text-blue-400 hover:underline">Users</a> page. This includes creating users, assigning roles, and managing custom roles with specific permission sets. Only users with the appropriate permissions can access this page.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Understanding Roles</h3>
<p>The system uses role-based access control to manage what users can do. There are three default roles:</p>
<ul class="list-disc list-inside mt-2 space-y-1 ml-4">
<li><strong>Admin:</strong> Full access to all features including user and role management</li>
<li><strong>User:</strong> Can view and manage most features (devices, subnets, racks, etc.) but cannot manage users or roles</li>
<li><strong>View Only:</strong> Read-only access to view pages but cannot make any changes</li>
</ul>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Custom Roles</h3>
<p>Administrators can create custom roles with specific permission sets. Go to the <a href="/users" class="text-blue-600 dark:text-blue-400 hover:underline">Users</a> page and click the "Roles & Permissions" tab to create and manage roles.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Permission Granularity</h3>
<p>Permissions are very granular, allowing fine-grained control over what each role can do. Permissions are organised into categories like View, Device Management, Network Management, Rack Management, and Administration.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Audit & History</h3>
<p>All changes are logged and can be reviewed on the <a href="/audit" class="text-blue-600 dark:text-blue-400 hover:underline">Audit</a> page for accountability and troubleshooting. The audit log shows who made changes, what was changed, and when.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">API Keys</h3>
<p>Each user has a unique API key that can be used to authenticate API requests. API keys can be viewed and regenerated from the <a href="/users" class="text-blue-600 dark:text-blue-400 hover:underline">Users</a> page. Keep your API key secure and never share it publicly.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
-46
View File
@@ -1,46 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ NAME }} IPAM</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-md pt-20">
<h1 class="text-3xl font-bold mb-6 text-center">{{ NAME }} IPAM</h1>
<ul class="space-y-4">
{% for site, subnets in sites_subnets.items() %}
<li class="site-group bg-gray-200 dark:bg-zinc-800 rounded-xl shadow-lg">
<div class="flex flex-row items-center justify-between p-4 cursor-pointer site-header">
<h2 class="text-xl font-bold mb-0 text-gray-900 dark:text-white">{{ site }}</h2>
<button type="button" class="expand-btn ml-2 flex items-center hover:cursor-pointer" aria-label="Expand site">
<i class="fas fa-chevron-down"></i>
</button>
</div>
<ul class="subnet-list hidden space-y-4 px-2 pb-4">
{% for subnet in subnets %}
<li class="p-4 bg-gray-300 hover:bg-gray-100 dark:bg-zinc-900 hover:dark:bg-zinc-700 rounded-lg shadow-md flex items-center justify-between">
<a href="/subnet/{{ subnet.id }}">
<p class="text-gray-900 dark:text-white text-lg font-medium">{{ subnet.name }}</p>
<p class="text-sm text-gray-800 dark:text-gray-400">{{ subnet.cidr }}</p>
</a>
<button type="button" class="export-csv-btn ml-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-600 dark:hover:bg-zinc-500 hover:cursor-pointer flex items-center justify-center rounded-full w-9 h-9" title="Export as CSV" data-subnet-id="{{ subnet.id }}">
<i class="fas fa-file-csv"></i>
</button>
</li>
{% endfor %}
</ul>
</li>
{% endfor %}
</ul>
<script src="/static/js/sitelist.js"></script>
<script src="/static/js/export_csv.js"></script>
</div>
</div>
</body>
</html>
-32
View File
@@ -1,32 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ NAME }} IPAM</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-md pt-20">
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h1 class="text-3xl font-bold mb-6 text-center">{{ NAME }} IPAM</h1>
<form action="/login" method="POST" class="flex flex-col space-y-4">
<input type="email" name="email" placeholder="Email Address" class="p-3 rounded-lg bg-gray-200 dark:bg-zinc-800 border border-gray-600" required>
<input type="password" name="password" placeholder="Password" class="p-3 rounded-lg bg-gray-200 dark:bg-zinc-800 border border-gray-600" required>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2 justify-center w-full">
<i class="fas fa-sign-in-alt"></i>
<span>Login</span>
</button>
</form>
{% if error %}
<p class="text-red-500 text-center mt-4">{{ error }}</p>
{% endif %}
</div>
</div>
</div>
</body>
</html>
-105
View File
@@ -1,105 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ rack.name }} - Rack View</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-2xl pt-20">
<div class="flex flex-col items-center mb-6 relative min-h-[3.5rem]">
<a href="/racks" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
<h1 class="text-3xl font-bold text-center w-full mb-0">{{ rack.name }}</h1>
<span class="text-base mt-1">({{ rack.height_u }}U, {{ rack.site }})</span>
<form action="/rack/{{ rack.id }}/delete" method="POST" onsubmit="return confirm('Delete this rack?');" class="hidden sm:flex absolute right-0 mr-14">
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer items-center justify-center rounded-full w-11 h-11 flex" title="Delete Rack">
<i class="fas fa-times fa-lg"></i>
</button>
</form>
<button type="button" id="export-csv" class="hidden sm:flex absolute right-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer items-center justify-center rounded-full w-11 h-11 export-csv-btn" title="Export as CSV" data-rack-id="{{ rack.id }}">
<i class="fas fa-file-csv fa-lg"></i>
</button>
</div>
<div class="flex flex-col gap-4 mb-6 items-stretch">
<div class="flex gap-4 w-full justify-center">
<a href="?side=front" id="front-btn" class="rack-side-btn px-4 py-2 rounded-lg font-semibold bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 {% if current_side == 'front' %}ring-2 ring-gray-400{% endif %}">Front</a>
<a href="?side=back" id="back-btn" class="rack-side-btn px-4 py-2 rounded-lg font-semibold bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 {% if current_side == 'back' %}ring-2 ring-gray-400{% endif %}">Back</a>
</div>
<div class="flex flex-wrap gap-4 w-full justify-center">
<button id="show-add-device-form" type="button" class="flex-1 min-w-[12rem] max-w-[16rem] bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex-shrink-0 whitespace-nowrap"> <i class="fas fa-plus"></i> Add Device</button>
<button id="show-nonnet-form" type="button" class="flex-[1.5_1.5_0%] min-w-[16rem] max-w-[28rem] bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex-shrink-0 whitespace-nowrap"> <i class="fas fa-plus"></i> Add Non-Networked Device</button>
</div>
</div>
<form id="add-device-form" action="/rack/{{ rack.id }}/add_device" method="POST" class="hidden mb-6 bg-gray-200 dark:bg-zinc-800 rounded-lg p-4">
<div class="flex flex-col md:flex-row gap-2 mb-2">
<select name="device_id" class="border p-2 rounded bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full md:flex-1" required>
<option value="" disabled selected>Select Device...</option>
{% for device in site_devices %}
{% if device.device_type_id != 2 and device.device_type_id != 6 %}
<option value="{{ device.id }}">{{ device.name }}</option>
{% endif %}
{% endfor %}
</select>
<input type="number" name="position_u" min="1" max="{{ rack.height_u }}" class="border p-2 rounded bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full md:w-24" placeholder="U" required>
<select name="side" class="border p-2 rounded bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full md:w-28" required>
<option value="front">Front</option>
<option value="back">Back</option>
</select>
<button type="submit" class="w-full md:w-auto bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">Add Device</button>
<button id="hide-add-device-form" type="button" class="w-full md:w-auto bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg md:ml-0.5 mt-2 md:mt-0 flex-shrink-0"><i class="fas fa-times"></i></button>
</div>
<div class="text-xs dark:text-gray-400">To add a multi-U device, repeat for each U position.</div>
</form>
<form id="nonnet-form" action="/rack/{{ rack.id }}/add_nonnet_device" method="POST" class="hidden flex flex-col gap-2 mt-2 mb-6 bg-gray-200 dark:bg-zinc-800 rounded-lg p-4">
<div class="flex flex-col md:flex-row gap-2">
<input type="text" name="device_name" class="border p-2 rounded bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full md:flex-1" placeholder="Device Name" required>
<input type="number" name="position_u" min="1" max="{{ rack.height_u }}" class="border p-2 rounded bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full md:w-24" placeholder="U" required>
<select name="side" class="border p-2 rounded bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full md:w-28" required>
<option value="front">Front</option>
<option value="back">Back</option>
</select>
<button type="submit" class="w-full md:w-auto bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">Add</button>
<button id="hide-nonnet-form" type="button" class="w-full md:w-auto bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg md:ml-0.5 mt-2 md:mt-0 flex-shrink-0"><i class="fas fa-times"></i></button>
</div>
<div class="text-xs dark:text-gray-400 mt-2">Add a non-networked device.</div>
</form>
<script src="/static/js/rack.js"></script>
{% if error %}
<div class="mb-4 p-3 bg-red-700 text-white rounded-lg text-center font-semibold">{{ error }}</div>
{% endif %}
<div id="rack-visual" class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-4">
<h2 class="text-xl font-bold mb-2 dark:text-white" id="rack-side-label">{{ current_side|capitalize }}</h2>
<div id="rack-units">
{% for u in range(rack.height_u, 0, -1) %}
<div class="flex items-center h-10 border-b border-gray-700 hover:bg-gray-700/30 transition group">
<span class="w-16 text-right dark:text-gray-400 text-base font-mono pr-4">U{{ u }}</span>
<span class="flex-1 ml-4 flex items-center min-h-8">
{% set found = false %}
{% for rd in rack_devices %}
{% if rd.position_u == u and rd.side == current_side %}
{% if rd.device_id %}
<a href="/device/{{ rd.device_id }}" class="dark:text-white hover:underline text-base">{{ rd.device_name }}</a>
{% else %}
<span class="dark:text-white text-base">{{ rd.nonnet_device_name }}</span>
{% endif %}
<form action="/rack/{{ rack.id }}/remove_device" method="POST" style="display:inline" onsubmit="return confirm('Are you sure you want to remove this device from the rack?');">
<input type="hidden" name="rack_device_id" value="{{ rd.id }}">
<button type="submit" class="ml-3 text-red-400 hover:text-red-600 hover:cursor-pointer"><i class="fas fa-times"></i></button>
</form>
{% set found = true %}
{% endif %}
{% endfor %}
</span>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</body>
</html>

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