105 Commits

Author SHA1 Message Date
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
Jamie b5fa9ef6ae Merge pull request #30 from JDB-NET/release-please--branches--main
chore(main): release 1.8.0
2025-12-23 01:07:23 +00:00
github-actions[bot] 19e7e978aa chore(main): release 1.8.0 2025-12-23 01:06:45 +00:00
jamie 64ae4be6d5 feat: get next available ip by api 2025-12-23 01:06:25 +00:00
jamie d7fcffd4b5 build: 🚀 redeploy 2025-12-20 11:20:25 +00:00
jamie 283c445263 fix: 🐛 global search missing from devices 2025-12-05 12:07:12 +00:00
Jamie 2af3584d80 Merge pull request #28 from JDB-NET/release-please--branches--main
chore(main): release 1.7.0
2025-12-05 01:38:23 +00:00
github-actions[bot] 59ded14858 chore(main): release 1.7.0 2025-12-05 01:38:06 +00:00
jamie 9c0e6d035c feat: add devices by tag page 2025-12-05 01:37:45 +00:00
jamie 8242e9d758 fix: 🐛 invalidate linked cache 2025-12-05 01:33:47 +00:00
jamie 47208b31ee fix: 🐛 invalidate cache when device type is added 2025-12-05 01:24:05 +00:00
Jamie f44b5327e4 Merge pull request #27 from JDB-NET/release-please--branches--main
chore(main): release 1.6.1
2025-12-05 01:19:59 +00:00
github-actions[bot] f1fb8bc7e9 chore(main): release 1.6.1 2025-12-05 01:18:45 +00:00
jamie 286bf4b665 fix: 🐛 invalidate subnet cache when device is deleted 2025-12-05 01:18:21 +00:00
Jamie fb6a3445a7 Merge pull request #25 from JDB-NET/release-please--branches--main
chore(main): release 1.6.0
2025-12-05 01:07:11 +00:00
github-actions[bot] 28267989b0 chore(main): release 1.6.0 2025-12-05 01:00:09 +00:00
jamie 47f68fd27c refactor: 🎨 database indexing and optimisation 2025-12-05 00:59:43 +00:00
jamie 3a9250f5b0 feat: in memory cache 2025-12-05 00:51:02 +00:00
jamie 3e8965de6f feat: global search 2025-12-05 00:01:58 +00:00
jamie 707846bb3c feat: backup and restore 2025-12-04 23:23:33 +00:00
jamie 69588d6518 refactor: 🎨 tidy nav bar 2025-12-04 23:01:19 +00:00
jamie 1d9209a714 refactor: 🎨 js 2025-12-04 22:59:32 +00:00
jamie 730b8701db feat: update available notification 2025-12-04 22:47:43 +00:00
jamie f0165985fc refactor: 🎨 improved audit log filtering 2025-12-04 22:32:15 +00:00
jamie f6795f5281 ci: 🚀 include all commit types 2025-12-04 22:18:58 +00:00
jamie 2163be8f79 feat: bulk operations 2025-12-04 22:15:57 +00:00
jamie f98e92da06 feat: subnet utilisation stats 2025-12-04 20:01:52 +00:00
jamie 61e3200207 refactor: 🎨 header link to github releases 2025-12-04 19:26:40 +00:00
Jamie 6eb5000c27 Merge pull request #11 from JDB-NET/release-please--branches--main
chore(main): release 1.5.1
2025-12-04 19:15:10 +00:00
github-actions[bot] 9ecd4f3977 chore(main): release 1.5.1 2025-12-04 19:14:46 +00:00
jamie 6f01c9956f fix: 🐛 audit log on mobile 2025-12-04 19:14:19 +00:00
Jamie 671b750bc4 Merge pull request #9 from JDB-NET/release-please--branches--main
chore(main): release 1.5.0
2025-11-21 20:43:15 +00:00
github-actions[bot] bc1078f673 chore(main): release 1.5.0 2025-11-21 20:42:49 +00:00
jamie ad1e576da4 feat: device tags 2025-11-21 20:42:20 +00:00
Jamie 0029abb8cd Merge pull request #7 from JDB-NET/release-please--branches--main
chore(main): release 1.4.2
2025-11-08 23:11:50 +00:00
github-actions[bot] ee72a89287 chore(main): release 1.4.2 2025-11-08 23:11:35 +00:00
jamie 5c1ad03990 fix: 🐛 ensure all fields are updated by api 2025-11-08 23:11:15 +00:00
Jamie 4b21fdc5cf Merge pull request #6 from JDB-NET/release-please--branches--main
chore(main): release 1.4.1
2025-11-06 14:48:05 +00:00
github-actions[bot] b381195200 chore(main): release 1.4.1 2025-11-06 14:40:20 +00:00
jamie 80b6de395f fix: 🐛 pagination no longer gets out of control 2025-11-06 14:39:58 +00:00
jamie d56e0647f7 fix: 🐛 styling of admin and users pages 2025-11-06 14:39:58 +00:00
Jamie 8909834a19 Merge pull request #5 from JDB-NET/release-please--branches--main
chore(main): release 1.4.0
2025-11-06 13:33:30 +00:00
jamie 59662ec4d8 docs: 📝 add api documentation to readme 2025-11-06 13:32:34 +00:00
github-actions[bot] e0b6c22c1f chore(main): release 1.4.0 2025-11-06 13:26:52 +00:00
jamie c53472c5d7 feat: full api integration 2025-11-06 13:26:33 +00:00
Jamie fdd8b36fbf Merge pull request #4 from JDB-NET/release-please--branches--main
chore(main): release 1.3.0
2025-11-06 13:09:08 +00:00
github-actions[bot] 0efa310d50 chore(main): release 1.3.0 2025-11-06 13:08:43 +00:00
jamie 3bf2697010 feat: role based access control 2025-11-06 13:08:21 +00:00
Jamie 73a94943cf Merge pull request #3 from JDB-NET/release-please--branches--main
chore(main): release 1.2.0
2025-11-06 11:21:19 +00:00
github-actions[bot] d35873c04f chore(main): release 1.2.0 2025-11-06 11:04:57 +00:00
jamie f93fa155eb fix: 🐛 missing button classes 2025-11-06 11:04:36 +00:00
jamie d68eefcf0c feat: added the ability to create/edit/remove device types 2025-11-06 11:00:18 +00:00
Jamie efd44bf968 Merge pull request #2 from JDB-NET/release-please--branches--main
chore(main): release 1.1.1
2025-11-01 18:22:14 +00:00
github-actions[bot] 4f226474c2 chore(main): release 1.1.1 2025-11-01 18:21:53 +00:00
jamie de123fafd4 fix: 🐛 image name 2025-11-01 18:21:35 +00:00
81 changed files with 9489 additions and 2592 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"]
+6 -2
View File
@@ -6,10 +6,14 @@
"settings": {}, "settings": {},
"customizations": { "customizations": {
"vscode": { "vscode": {
"extensions": ["ms-python.python"] "extensions": [
"ms-python.python",
"vivaxy.vscode-conventional-commits",
"esbenp.prettier-vscode"
]
} }
}, },
"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"
} }
+48 -1
View File
@@ -1,2 +1,49 @@
deployment.yml # Frontend dev
frontend/node_modules/
# Documentation
*.md
# Deployment files
run.sh run.sh
Dockerfile
.dockerignore
# Git
.git
.gitignore
.gitattributes
.gitea
# Python cache
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
# Virtual environments
venv/
env/
ENV/
.venv
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Environment files
.env
.env.local
.env.*.local
# Logs
*.log
logs/
# OS files
.DS_Store
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
+74
View File
@@ -0,0 +1,74 @@
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: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-lan-01 ]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Apply manifests
run: |
sudo kubectl replace -f deployment.yml --grace-period=60 --force
+2 -2
View File
@@ -1,4 +1,4 @@
__pycache__ __pycache__
tailwindcss
static/css/output.css
.env .env
frontend/node_modules/
static/dist/
-10
View File
@@ -1,10 +0,0 @@
{
"packages": {
".": {
"release-type": "simple",
"version-file": "VERSION"
}
},
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
}
-3
View File
@@ -1,3 +0,0 @@
{
".": "1.1.0"
}
+53
View File
@@ -0,0 +1,53 @@
# 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` |
## Core resources
List endpoints return `{ "items": [...] }` unless noted.
| 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`, `/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/export` |
| Users & roles | CRUD + `POST /users/{id}/regenerate-api-key` |
| Permissions | `GET /permissions` |
| Bulk | `POST /bulk/assign-ips`, `/create-devices`, `/assign-tags`, `/export-subnets` |
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
```
-18
View File
@@ -1,18 +0,0 @@
# Changelog
## [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 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"]
+118 -16
View File
@@ -1,5 +1,5 @@
<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>
@@ -11,15 +11,30 @@ A Flask-based web application for comprehensive IP Address Management (IPAM). Ma
- **Subnet Management**: Create and manage IP subnets with CIDR notation (supports /24 to /32) - **Subnet Management**: Create and manage IP subnets with CIDR notation (supports /24 to /32)
- **IP Address Tracking**: Automatic IP address generation and tracking for each subnet - **IP Address Tracking**: Automatic IP address generation and tracking for each subnet
- **Device Management**: Track devices with types (Server, VM, Switch, Firewall, WiFi AP, Printer, Other) - **Device Management**: Track devices with types (Server, VM, Switch, Firewall, WiFi AP, Printer, Other)
- **Device Tagging**: Organize devices with customizable tags featuring colors and descriptions
- **IP Assignment**: Assign IP addresses to devices with automatic hostname updates - **IP Assignment**: Assign IP addresses to devices with automatic hostname updates
- **DHCP Pool Configuration**: Configure DHCP pools with start/end IP ranges and excluded IPs - **DHCP Pool Configuration**: Configure DHCP pools with start/end IP ranges and excluded IPs
- **Rack Management**: Physical infrastructure tracking with U positions and front/back sides - **Rack Management**: Physical infrastructure tracking with U positions and front/back sides
- **Site Organization**: Organize subnets and devices by site/location - **Site Organisation**: Organize subnets and devices by site/location
- **Audit Logging**: Complete audit trail of all changes with user, action, details, and timestamps - **Audit Logging**: Complete audit trail of all changes with user, action, details, and timestamps
- **User Management**: Multi-user support with secure password authentication - **User Management**: Multi-user support with secure password authentication
- **CSV Export**: Export subnet and rack data to CSV files - **Role-Based Access Control (RBAC)**: Granular permission system with default roles (admin, user, view_only) and custom role creation
- **Device Statistics**: View device counts by type - **Web Interface**: Vue 3 SPA with automatic light/dark theme and mobile-first layout
- **Web Interface**: Modern, responsive web GUI built with Tailwind CSS and dark mode support - **REST API v2**: JSON API at `/api/v2` (session cookies for browser, API keys for automation)
## Local development
```bash
# Backend
pip install -r requirements.txt
./run.sh # builds frontend if needed, starts Flask on :5000
# Frontend hot reload (optional)
cd frontend && npm install && npm run dev
# Vite proxies /api to http://127.0.0.1:5000
```
API reference: [API.md](API.md)
## Quick Start with Docker ## Quick Start with Docker
@@ -34,30 +49,28 @@ docker run -d \
-e MYSQL_PASSWORD=your_password \ -e MYSQL_PASSWORD=your_password \
-e MYSQL_DATABASE=ipam \ -e MYSQL_DATABASE=ipam \
-e SECRET_KEY=your_secret_key \ -e SECRET_KEY=your_secret_key \
-e NAME="Your Organization" \ -e NAME="Your Organisation" \
-e LOGO_PNG="https://example.com/logo.png" \ -e LOGO_PNG="https://example.com/logo.png" \
ghcr.io/jdb-net/ipam:latest cr.jdbnet.co.uk/public/ipam:latest
``` ```
### Docker Compose ### Docker Compose
```yaml ```yaml
version: '3.8'
services: services:
ipam: ipam:
image: ghcr.io/jdb-net/ipam:latest image: cr.jdbnet.co.uk/public/ipam:latest
container_name: ipam container_name: ipam
restart: unless-stopped restart: unless-stopped
ports: ports:
- "5000:5000" # Web interface - "5000:5000"
environment: environment:
- MYSQL_HOST=10.10.2.27 - MYSQL_HOST=10.10.2.27
- MYSQL_USER=ipam - MYSQL_USER=ipam
- MYSQL_PASSWORD=your_password - MYSQL_PASSWORD=your_password
- MYSQL_DATABASE=ipam - MYSQL_DATABASE=ipam
- SECRET_KEY=your_secret_key - SECRET_KEY=your_secret_key
- NAME=Your Organization - NAME=Your Organisation
- LOGO_PNG=https://example.com/logo.png - LOGO_PNG=https://example.com/logo.png
``` ```
@@ -70,8 +83,8 @@ services:
- `MYSQL_PASSWORD`: Database password (default: password) - `MYSQL_PASSWORD`: Database password (default: password)
- `MYSQL_DATABASE`: Database name (default: ipam) - `MYSQL_DATABASE`: Database name (default: ipam)
- `SECRET_KEY`: Flask secret key for sessions (**REQUIRED in production!**) - `SECRET_KEY`: Flask secret key for sessions (**REQUIRED in production!**)
- `NAME`: Organization name displayed in header (default: JDB-NET) - `NAME`: Organisation name displayed in header (default: JDB-NET)
- `LOGO_PNG`: URL or path to organization logo (default: JDB-NET logo) - `LOGO_PNG`: URL or path to organisation logo (default: JDB-NET logo)
### Database Setup ### Database Setup
@@ -84,6 +97,10 @@ GRANT ALL PRIVILEGES ON ipam.* TO 'ipam'@'%';
FLUSH PRIVILEGES; 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 ## Usage
### First Login ### First Login
@@ -107,7 +124,7 @@ FLUSH PRIVILEGES;
1. Navigate to "Devices" from the main menu 1. Navigate to "Devices" from the main menu
2. Click "Add Device" 2. Click "Add Device"
3. Enter device name and select device type 3. Enter device name (and optional description)
4. Click "Create Device" 4. Click "Create Device"
### Assigning IP Addresses to Devices ### Assigning IP Addresses to Devices
@@ -133,6 +150,22 @@ FLUSH PRIVILEGES;
- **Height**: Rack height in U units - **Height**: Rack height in U units
3. Open a rack to assign devices to specific U positions (front or back) 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 ### Audit Log
View all changes and actions in the "Audit Log" section, with filtering by user, subnet, action type, or device name. View all changes and actions in the "Audit Log" section, with filtering by user, subnet, action type, or device name.
@@ -142,6 +175,72 @@ View all changes and actions in the "Audit Log" section, with filtering by user,
- **Subnet CSV**: Click "Export CSV" on any subnet page to download IP addresses with hostnames - **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 - **Rack CSV**: Click "Export CSV" on any rack page to download rack layout information
### Role-Based Access Control
The system uses a granular role-based access control (RBAC) system to manage user permissions:
1. **Default Roles**:
- **Admin**: Full access to all features including user and role management
- **User**: Can view and manage most features (devices, subnets, racks, etc.) but cannot manage users or roles
- **View Only**: Read-only access to view pages but cannot make any changes
2. **Custom Roles**: Administrators can create custom roles with specific permission sets from the Users page
3. **Permission Granularity**: Permissions are organized into categories:
- View permissions (access to pages)
- Device Management (add, edit, delete devices)
- Network Management (subnet operations)
- Rack Management (rack operations)
- DHCP Configuration
- Administration (user and role management)
4. **User Management**: Navigate to the Users page to:
- Create and manage users
- Assign roles to users
- Create custom roles with specific permissions
- View and regenerate API keys
### REST API
The application includes a comprehensive REST API for programmatic access:
1. **Authentication**: All API requests require an API key, which can be provided via:
- `X-API-Key` header
- `Authorization: Bearer <api_key>` header
- `?api_key=<api_key>` query parameter
2. **Base URL**: All API endpoints are prefixed with `/api/v1`
3. **Available Endpoints**:
- **Devices**: `GET`, `POST`, `PUT`, `DELETE /api/v1/devices`
- **Device Tags**: `GET /api/v1/devices/by-tag/{tag}` (filter devices by tag)
- **Tags**: `GET`, `POST`, `PUT`, `DELETE /api/v1/tags` (with `?format=simple` option)
- **Tag Assignment**: `GET`, `POST /api/v1/devices/{id}/tags`, `DELETE /api/v1/devices/{id}/tags/{tag_id}`
- **Subnets**: `GET`, `POST`, `PUT`, `DELETE /api/v1/subnets`
- **Racks**: `GET`, `POST`, `DELETE /api/v1/racks`
- **DHCP**: `GET`, `POST /api/v1/subnets/{id}/dhcp`
- **Audit Log**: `GET /api/v1/audit`
- **Users & Roles**: `GET /api/v1/users`, `GET /api/v1/roles` (admin only)
4. **API Keys**: Each user has a unique API key that can be viewed and regenerated from the Users page. API keys respect the same role-based permissions as the web interface.
5. **Documentation**: See [API.md](API.md) for the full REST API reference.
**Example API Requests**:
```bash
# List all devices
curl -H "X-API-Key: your_api_key" \
https://your-server:5000/api/v1/devices
# Get devices with a specific tag
curl -H "X-API-Key: your_api_key" \
https://your-server:5000/api/v1/devices/by-tag/production
# List all tags in simple format
curl -H "X-API-Key: your_api_key" \
https://your-server:5000/api/v1/tags?format=simple
```
## Kubernetes Deployment ## Kubernetes Deployment
The project includes a Kubernetes deployment manifest. See `deployment.yml` for details. The project includes a Kubernetes deployment manifest. See `deployment.yml` for details.
@@ -160,7 +259,7 @@ spec:
spec: spec:
containers: containers:
- name: ipam - name: ipam
image: ghcr.io/jdb-net/ipam:latest image: cr.jdbnet.co.uk/public/ipam:latest
ports: ports:
- containerPort: 5000 - containerPort: 5000
env: env:
@@ -190,6 +289,9 @@ spec:
- Ensure database connections are secured (consider SSL/TLS for MySQL connections) - Ensure database connections are secured (consider SSL/TLS for MySQL connections)
- Review audit logs regularly for unauthorized changes - Review audit logs regularly for unauthorized changes
- Limit database user permissions if possible (though CREATE/ALTER may be needed for schema initialization) - 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 ## Troubleshooting
-1
View File
@@ -1 +0,0 @@
1.1.0
+3102 -17
View File
File diff suppressed because it is too large Load Diff
+474 -33
View File
@@ -1,9 +1,12 @@
import os import os
import hashlib import hashlib
import base64 import base64
import secrets
import mysql.connector import mysql.connector
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')
@@ -18,6 +21,10 @@ def verify_password(password, hashed):
return False return False
return hash_password(password, salt) == hashed return hash_password(password, salt) == hashed
def generate_api_key():
"""Generate a secure API key"""
return secrets.token_urlsafe(32)
def get_db_connection(app=None): def get_db_connection(app=None):
if app is None: if app is None:
app = current_app app = current_app
@@ -59,8 +66,8 @@ def init_db(app=None):
details TEXT, details TEXT,
subnet_id INTEGER, subnet_id INTEGER,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES User(id), FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE SET NULL,
FOREIGN KEY (subnet_id) REFERENCES Subnet(id) FOREIGN KEY (subnet_id) REFERENCES Subnet(id) ON DELETE SET NULL
) )
''') ''')
cursor.execute(''' cursor.execute('''
@@ -73,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('''
@@ -127,31 +125,474 @@ 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
) )
''') ''')
cursor.execute('SELECT COUNT(*) FROM DeviceType') # Create Role table
if cursor.fetchone()[0] == 0: cursor.execute('''
cursor.executemany('INSERT INTO DeviceType (name, icon_class) VALUES (%s, %s)', [ CREATE TABLE IF NOT EXISTS Role (
('Server', 'fa-server'), id INTEGER PRIMARY KEY AUTO_INCREMENT,
('Virtual Machine', 'fa-boxes-stacked'), name VARCHAR(255) NOT NULL UNIQUE,
('Switch', 'fa-network-wired'), description TEXT
('Firewall', 'fa-shield-halved'), )
('WiFi AP', 'fa-wifi'), ''')
('Printer', 'fa-print'),
('Other', 'fa-question') # Create Permission table
]) cursor.execute('''
cursor.execute("SHOW COLUMNS FROM Device LIKE 'device_type_id'") CREATE TABLE IF NOT EXISTS Permission (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
category VARCHAR(255)
)
''')
# Create RolePermission junction table
cursor.execute('''
CREATE TABLE IF NOT EXISTS RolePermission (
role_id INTEGER NOT NULL,
permission_id INTEGER NOT NULL,
PRIMARY KEY (role_id, permission_id),
FOREIGN KEY (role_id) REFERENCES Role(id) ON DELETE CASCADE,
FOREIGN KEY (permission_id) REFERENCES Permission(id) ON DELETE CASCADE
)
''')
# Add role_id column to User table if it doesn't exist
cursor.execute("SHOW COLUMNS FROM User LIKE 'role_id'")
if not cursor.fetchone(): if not cursor.fetchone():
cursor.execute('ALTER TABLE Device ADD COLUMN device_type_id INTEGER DEFAULT NULL') cursor.execute('ALTER TABLE User ADD COLUMN role_id INTEGER DEFAULT NULL')
cursor.execute("SELECT id FROM DeviceType WHERE name='Other'") try:
other_id = cursor.fetchone()[0] cursor.execute('ALTER TABLE User ADD CONSTRAINT fk_user_role FOREIGN KEY (role_id) REFERENCES Role(id)')
cursor.execute('UPDATE Device SET device_type_id = %s WHERE device_type_id IS NULL', (other_id,)) except mysql.connector.Error as e:
if e.errno != 1061 and e.errno != 1826 and 'Duplicate' not in str(e):
raise
# Add api_key column to User table if it doesn't exist
cursor.execute("SHOW COLUMNS FROM User LIKE 'api_key'")
if not cursor.fetchone():
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
# This is critical - audit logs should NEVER be deleted, even when referenced entities are deleted
try: try:
cursor.execute('ALTER TABLE Device ADD CONSTRAINT fk_device_type FOREIGN KEY (device_type_id) REFERENCES DeviceType(id)') # Check and update user_id foreign key
cursor.execute('''
SELECT CONSTRAINT_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'AuditLog'
AND COLUMN_NAME = 'user_id'
AND REFERENCED_TABLE_NAME = 'User'
''')
fk_user = cursor.fetchone()
if fk_user:
fk_name = fk_user[0]
# Drop and recreate with ON DELETE SET NULL
cursor.execute(f'ALTER TABLE AuditLog DROP FOREIGN KEY {fk_name}')
cursor.execute('ALTER TABLE AuditLog ADD CONSTRAINT fk_auditlog_user FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE SET NULL')
except mysql.connector.Error as e: except mysql.connector.Error as e:
if e.errno != 1061 and e.errno != 1826 and 'Duplicate' not in str(e): # Foreign key might not exist or already be correct, continue
raise if e.errno != 1025 and e.errno != 1091: # Not "Cannot drop foreign key" or "Unknown key"
logging.warning(f"Could not update AuditLog user_id foreign key: {e}")
try:
# Check and update subnet_id foreign key
cursor.execute('''
SELECT CONSTRAINT_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'AuditLog'
AND COLUMN_NAME = 'subnet_id'
AND REFERENCED_TABLE_NAME = 'Subnet'
''')
fk_subnet = cursor.fetchone()
if fk_subnet:
fk_name = fk_subnet[0]
# Drop and recreate with ON DELETE SET NULL
cursor.execute(f'ALTER TABLE AuditLog DROP FOREIGN KEY {fk_name}')
cursor.execute('ALTER TABLE AuditLog ADD CONSTRAINT fk_auditlog_subnet FOREIGN KEY (subnet_id) REFERENCES Subnet(id) ON DELETE SET NULL')
except mysql.connector.Error as e:
# Foreign key might not exist or already be correct, continue
if e.errno != 1025 and e.errno != 1091: # Not "Cannot drop foreign key" or "Unknown key"
logging.warning(f"Could not update AuditLog subnet_id foreign key: {e}")
# Create Tag table
cursor.execute('''
CREATE TABLE IF NOT EXISTS Tag (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL UNIQUE,
color VARCHAR(7) DEFAULT '#6B7280',
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''')
# Create DeviceTag junction table
cursor.execute('''
CREATE TABLE IF NOT EXISTS DeviceTag (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
device_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY unique_device_tag (device_id, tag_id),
FOREIGN KEY (device_id) REFERENCES Device(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES Tag(id) ON DELETE CASCADE
)
''')
# 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
permissions = [
# View permissions
('view_index', 'View Home/Index page', 'View'),
('view_devices', 'View Devices page', 'View'),
('view_device', 'View Device details', 'View'),
('view_subnet', 'View Subnet details', 'View'),
('view_racks', 'View Racks page', 'View'),
('view_rack', 'View Rack details', 'View'),
('view_audit', 'View Audit Log', 'View'),
('view_admin', 'View Admin panel', 'View'),
('view_users', 'View Users page', 'View'),
('view_dhcp', 'View DHCP configuration', 'View'),
# Device permissions
('add_device', 'Add new device', 'Device'),
('edit_device', 'Edit device (rename, description)', 'Device'),
('delete_device', 'Delete device', 'Device'),
('add_device_ip', 'Add IP address to device', 'Device'),
('remove_device_ip', 'Remove IP address from device', 'Device'),
# Subnet permissions
('add_subnet', 'Add new subnet', 'Subnet'),
('edit_subnet', 'Edit subnet (name, CIDR, site)', 'Subnet'),
('delete_subnet', 'Delete subnet', 'Subnet'),
('export_subnet_csv', 'Export subnet as CSV', 'Subnet'),
# Rack permissions
('add_rack', 'Add new rack', 'Rack'),
('delete_rack', 'Delete rack', 'Rack'),
('add_device_to_rack', 'Add device to rack', 'Rack'),
('remove_device_from_rack', 'Remove device from rack', 'Rack'),
('add_nonnet_device_to_rack', 'Add non-networked device to rack', 'Rack'),
('export_rack_csv', 'Export rack as CSV', 'Rack'),
# DHCP permissions
('configure_dhcp', 'Configure DHCP pools', 'DHCP'),
# Tag permissions
('view_tags', 'View tags', 'Tag'),
('add_tag', 'Add new tag', 'Tag'),
('edit_tag', 'Edit tag', 'Tag'),
('delete_tag', 'Delete tag', 'Tag'),
('assign_device_tag', 'Assign tag to 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
('manage_users', 'Manage users (add, edit, delete)', 'Admin'),
('manage_roles', 'Manage roles and permissions', 'Admin'),
]
# Insert permissions
for perm_name, perm_desc, perm_category in permissions:
cursor.execute('SELECT id FROM Permission WHERE name = %s', (perm_name,))
if not cursor.fetchone():
cursor.execute('INSERT INTO Permission (name, description, category) VALUES (%s, %s, %s)',
(perm_name, perm_desc, perm_category))
# Create default roles if they don't exist
cursor.execute('SELECT id FROM Role WHERE name = %s', ('admin',))
admin_role = cursor.fetchone()
if not admin_role:
cursor.execute('INSERT INTO Role (name, description) VALUES (%s, %s)',
('admin', 'Administrator with full access to all features'))
admin_role_id = cursor.lastrowid
else:
admin_role_id = admin_role[0]
cursor.execute('SELECT id FROM Role WHERE name = %s', ('user',))
user_role = cursor.fetchone()
if not user_role:
cursor.execute('INSERT INTO Role (name, description) VALUES (%s, %s)',
('user', 'Standard user with access to most features except admin functions'))
user_role_id = cursor.lastrowid
else:
user_role_id = user_role[0]
cursor.execute('SELECT id FROM Role WHERE name = %s', ('view_only',))
view_only_role = cursor.fetchone()
if not view_only_role:
cursor.execute('INSERT INTO Role (name, description) VALUES (%s, %s)',
('view_only', 'View-only user with read-only access to all pages'))
view_only_role_id = cursor.lastrowid
else:
view_only_role_id = view_only_role[0]
# Assign all permissions to admin role
cursor.execute('SELECT id FROM Permission')
all_permission_ids = [row[0] for row in cursor.fetchall()]
for perm_id in all_permission_ids:
cursor.execute('SELECT role_id FROM RolePermission WHERE role_id = %s AND permission_id = %s',
(admin_role_id, perm_id))
if not cursor.fetchone():
cursor.execute('INSERT INTO RolePermission (role_id, permission_id) VALUES (%s, %s)',
(admin_role_id, perm_id))
# Assign non-admin permissions to user role
non_admin_permissions = [
'view_index', 'view_devices', 'view_device', 'view_subnet', 'view_racks', 'view_rack',
'view_audit',
'view_dhcp',
'add_device', 'edit_device', 'delete_device', 'add_device_ip', 'remove_device_ip',
'add_subnet', 'edit_subnet', 'delete_subnet', 'export_subnet_csv',
'add_rack', 'delete_rack', 'add_device_to_rack', 'remove_device_from_rack',
'add_nonnet_device_to_rack', 'export_rack_csv',
'configure_dhcp',
'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:
cursor.execute('SELECT id FROM Permission WHERE name = %s', (perm_name,))
perm_result = cursor.fetchone()
if perm_result:
perm_id = perm_result[0]
cursor.execute('SELECT role_id FROM RolePermission WHERE role_id = %s AND permission_id = %s',
(user_role_id, perm_id))
if not cursor.fetchone():
cursor.execute('INSERT INTO RolePermission (role_id, permission_id) VALUES (%s, %s)',
(user_role_id, perm_id))
# Assign view-only permissions to view_only role
# Same view permissions as user role, but excluding admin views (view_admin, view_users)
view_only_permissions = [
'view_index', 'view_devices', 'view_device', 'view_subnet', 'view_racks', 'view_rack',
'view_audit',
'view_dhcp', 'view_tags', 'view_custom_fields'
]
for perm_name in view_only_permissions:
cursor.execute('SELECT id FROM Permission WHERE name = %s', (perm_name,))
perm_result = cursor.fetchone()
if perm_result:
perm_id = perm_result[0]
cursor.execute('SELECT role_id FROM RolePermission WHERE role_id = %s AND permission_id = %s',
(view_only_role_id, perm_id))
if not cursor.fetchone():
cursor.execute('INSERT INTO RolePermission (role_id, permission_id) VALUES (%s, %s)',
(view_only_role_id, perm_id))
# Assign existing users to 'admin' role if they don't have a role
# This ensures existing users maintain admin access
cursor.execute('UPDATE User SET role_id = %s WHERE role_id IS NULL', (admin_role_id,))
# Generate API keys for users that don't have one
cursor.execute('SELECT id FROM User WHERE api_key IS NULL')
users_without_api_key = cursor.fetchall()
for (user_id,) in users_without_api_key:
api_key = generate_api_key()
cursor.execute('UPDATE User SET api_key = %s WHERE id = %s', (api_key, user_id))
cursor.execute('SELECT COUNT(*) FROM User') cursor.execute('SELECT COUNT(*) FROM User')
if cursor.fetchone()[0] == 0: if cursor.fetchone()[0] == 0:
cursor.execute('''INSERT INTO User (name, email, password) VALUES (%s, %s, %s)''', api_key = generate_api_key()
('admin', 'admin@example.com', hash_password('password'))) cursor.execute('''INSERT INTO User (name, email, password, role_id, api_key) VALUES (%s, %s, %s, %s, %s)''',
('admin', 'admin@example.com', hash_password('password'), admin_role_id, api_key))
# Create indexes for performance optimization
logging.info("Creating database indexes for performance...")
def create_index_if_not_exists(cursor, index_name, table_name, columns):
"""Helper function to create index if it doesn't exist"""
try:
# Check if index exists
cursor.execute('''
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = %s
AND index_name = %s
''', (table_name, index_name))
if cursor.fetchone()[0] == 0:
cursor.execute(f'CREATE INDEX {index_name} ON {table_name}({columns})')
logging.info(f"Created index {index_name}")
else:
logging.debug(f"Index {index_name} already exists")
except mysql.connector.Error as e:
logging.warning(f"Could not create index {index_name}: {e}")
# IPAddress table indexes
create_index_if_not_exists(cursor, 'idx_ipaddress_subnet_id', 'IPAddress', 'subnet_id')
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_subnet_hostname', 'IPAddress', 'subnet_id, hostname')
create_index_if_not_exists(cursor, 'idx_ipaddress_notes', 'IPAddress', 'notes(255)')
# DeviceIPAddress table indexes
create_index_if_not_exists(cursor, 'idx_deviceipaddress_device_id', 'DeviceIPAddress', 'device_id')
create_index_if_not_exists(cursor, 'idx_deviceipaddress_ip_id', 'DeviceIPAddress', 'ip_id')
create_index_if_not_exists(cursor, 'idx_deviceipaddress_device_ip', 'DeviceIPAddress', 'device_id, ip_id')
# AuditLog table indexes
create_index_if_not_exists(cursor, 'idx_auditlog_timestamp', 'AuditLog', 'timestamp')
create_index_if_not_exists(cursor, 'idx_auditlog_user_id', 'AuditLog', 'user_id')
create_index_if_not_exists(cursor, 'idx_auditlog_subnet_id', 'AuditLog', 'subnet_id')
create_index_if_not_exists(cursor, 'idx_auditlog_action', 'AuditLog', 'action')
create_index_if_not_exists(cursor, 'idx_auditlog_user_timestamp', 'AuditLog', 'user_id, timestamp')
create_index_if_not_exists(cursor, 'idx_auditlog_subnet_timestamp', 'AuditLog', 'subnet_id, timestamp')
# Subnet table indexes
create_index_if_not_exists(cursor, 'idx_subnet_site', 'Subnet', 'site')
create_index_if_not_exists(cursor, 'idx_subnet_site_name', 'Subnet', 'site, name')
# DeviceTag table indexes
create_index_if_not_exists(cursor, 'idx_devicetag_device_id', 'DeviceTag', 'device_id')
create_index_if_not_exists(cursor, 'idx_devicetag_tag_id', 'DeviceTag', 'tag_id')
# DHCPPool table indexes
create_index_if_not_exists(cursor, 'idx_dhcppool_subnet_id', 'DHCPPool', 'subnet_id')
# RackDevice table indexes
create_index_if_not_exists(cursor, 'idx_rackdevice_rack_id', 'RackDevice', 'rack_id')
create_index_if_not_exists(cursor, 'idx_rackdevice_device_id', 'RackDevice', 'device_id')
create_index_if_not_exists(cursor, 'idx_rackdevice_rack_side', 'RackDevice', 'rack_id, side')
# Device table indexes
# User table indexes (api_key already has UNIQUE index)
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")
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")
-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: docker.jdbnet.co.uk/public/ipam:latest
imagePullPolicy: Always
ports:
- containerPort: 5000
name: "ipam"
env:
- name: SECRET_KEY
value: "41TbN7v5peFLZPrdwSCc64J3mjmiUk5fkVWsmb2m"
- name: MYSQL_HOST
value: "10.10.2.27"
- 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>
+375
View File
@@ -0,0 +1,375 @@
const jsonHeaders = { "Content-Type": "application/json" };
async function handle<T>(res: Response): Promise<T> {
if (res.status === 401) 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;
}
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;
}
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<{ sites: Record<string, Subnet[]> }>(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<{ tags?: Tag[]; items?: Tag[] }>(await fetchApi("/api/v2/tags"));
return d.items ?? d.tags ?? [];
},
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<{ racks?: Rack[]; items?: Rack[] }>(await fetchApi("/api/v2/racks"));
return d.racks ?? 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(limit = 100) {
const d = await handle<{ logs: AuditEntry[] }>(await fetchApi(`/api/v2/audit?limit=${limit}`));
return d.logs;
},
auditExportUrl: "/api/v2/audit/export",
async users() {
const d = await handle<{ users?: UserRow[]; items?: UserRow[] }>(await fetchApi("/api/v2/users"));
return d.users ?? d.items ?? [];
},
async roles() {
const d = await handle<{ roles?: RoleRow[] }>(await fetchApi("/api/v2/roles"));
return d.roles ?? [];
},
async permissions() {
const d = await handle<{ items: { id: number; name: string; category?: string }[] }>(
await fetchApi("/api/v2/permissions"),
);
return d.items;
},
async customFields(entityType: string) {
const d = await handle<{ fields?: CustomFieldDef[] }>(await fetchApi(`/api/v2/custom_fields/${entityType}`));
return d.fields ?? [];
},
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),
}));
},
};
+216
View File
@@ -0,0 +1,216 @@
<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, Users, Tag, Layers, FileText, User } 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: "/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: "/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 min-h-screen 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 w-64 flex-col border-r border-slate-200 bg-surface-raised transition-transform dark:border-slate-800 lg:static lg:translate-x-0"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
>
<div class="flex items-center gap-3 border-b border-slate-200 p-4 dark:border-slate-800">
<img v-if="auth.org.logo" :src="auth.org.logo" alt="" class="h-8 rounded" />
<div class="min-w-0 flex-1">
<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="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="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="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-w-0 flex-1 flex-col">
<header class="flex items-center gap-3 border-b border-slate-200 bg-surface-raised px-4 py-3 dark:border-slate-800">
<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="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>
+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>
+7
View File
@@ -0,0 +1,7 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import "./style.css";
createApp(App).use(createPinia()).use(router).mount("#app");
+44
View File
@@ -0,0 +1,44 @@
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: "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", name: "dhcp", component: () => import("@/views/DhcpView.vue") },
{ 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: "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 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>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { api, type AuditEntry } from "@/api";
import { formatLocalTime } from "@/utils/datetime";
const logs = ref<AuditEntry[]>([]);
onMounted(async () => { logs.value = await api.audit(200); });
</script>
<template>
<div>
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">Audit log</h1>
<a href="/api/v2/audit/export" class="btn-secondary text-sm">Export CSV</a>
</div>
<div 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>
</tbody>
</table>
</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>
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { RouterLink } from "vue-router";
import { api, type Subnet } from "@/api";
const sites = ref<Record<string, Subnet[]>>({});
const loading = ref(true);
onMounted(async () => {
try {
const d = await api.dashboard();
sites.value = d.sites;
} finally {
loading.value = false;
}
});
</script>
<template>
<div>
<h1 class="text-2xl font-bold">Dashboard</h1>
<p class="mt-1 text-slate-500">Subnets grouped by site</p>
<div v-if="loading" class="mt-8 text-slate-500">Loading</div>
<div v-else class="mt-6 space-y-8">
<section v-for="(subnets, site) in sites" :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 subnets"
: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 bg-accent transition-all" :style="{ width: `${s.utilization ?? 0}%` }" />
</div>
<div class="mt-1 text-xs text-slate-500">{{ s.utilization ?? 0 }}% used</div>
</div>
</RouterLink>
</div>
</section>
</div>
</div>
</template>
+220
View File
@@ -0,0 +1,220 @@
<script setup lang="ts">
import { ref, onMounted, computed } 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 { 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 saving = ref(false);
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),
);
onMounted(async () => {
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;
allTags.value = tags;
subnets.value = sn;
history.value = h as IpHistoryEntry[];
});
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 saveName() {
if (!device.value) return;
saving.value = true;
await api.updateDevice(device.value.id, { name: editName.value });
device.value.name = editName.value;
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;
device.value = await api.device(device.value.id);
history.value = (await api.deviceIpHistory(device.value.id)) as IpHistoryEntry[];
} 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);
device.value = await api.device(device.value.id);
history.value = (await api.deviceIpHistory(device.value.id)) as IpHistoryEntry[];
}
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 formatTime(ts?: string) {
return formatLocalTime(ts, "Unknown");
}
</script>
<template>
<div v-if="device">
<RouterLink to="/devices" class="text-sm text-accent hover:underline"> Devices</RouterLink>
<div class="mt-4 flex flex-wrap items-start justify-between gap-4">
<div class="flex-1">
<input v-if="auth.can('edit_device')" v-model="editName" class="input-field max-w-md text-xl font-bold" @blur="saveName" />
<h1 v-else class="text-2xl font-bold">{{ device.name }}</h1>
<p class="mt-1 text-slate-500">{{ device.description || "No description" }}</p>
</div>
<button
v-if="auth.can('delete_device')"
class="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 font-mono text-sm">
<span>{{ ip.ip }} <span class="text-slate-500">({{ 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>
</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>
<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 font-semibold uppercase text-xs" :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>
</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>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useRoute, RouterLink } from "vue-router";
import { api } from "@/api";
const route = useRoute();
const pool = ref<{ start_ip?: string; end_ip?: string; excluded_ips?: string } | null>(null);
const form = ref({ start_ip: "", end_ip: "", excluded_ips: "" });
const msg = ref("");
onMounted(async () => {
try {
const d = await api.getDhcp(Number(route.params.id)) as { pools?: { start_ip: string; end_ip: string; excluded_ips?: string }[] };
if (d.pools?.[0]) {
pool.value = d.pools[0];
form.value.start_ip = d.pools[0].start_ip;
form.value.end_ip = d.pools[0].end_ip;
form.value.excluded_ips = d.pools[0].excluded_ips || "";
}
} catch { /* no pool */ }
});
async function save() {
await api.setDhcp(Number(route.params.id), {
pools: [{ start_ip: form.value.start_ip, end_ip: form.value.end_ip, excluded_ips: form.value.excluded_ips.split(",").map((s) => s.trim()).filter(Boolean) }],
});
msg.value = "Saved";
}
async function remove() {
await api.setDhcp(Number(route.params.id), { remove: true });
pool.value = null;
msg.value = "Removed";
}
</script>
<template>
<div>
<RouterLink :to="`/subnets/${route.params.id}`" class="text-sm text-accent hover:underline"> Subnet</RouterLink>
<h1 class="mt-4 text-2xl font-bold">DHCP pool</h1>
<form class="card mt-6 max-w-lg space-y-4" @submit.prevent="save">
<input v-model="form.start_ip" class="input-field" placeholder="Start IP" required />
<input v-model="form.end_ip" class="input-field" placeholder="End IP" required />
<input v-model="form.excluded_ips" class="input-field" placeholder="Excluded IPs (comma-separated)" />
<div class="flex gap-2">
<button type="submit" class="btn-primary">Save</button>
<button v-if="pool" type="button" class="btn-secondary" @click="remove">Remove pool</button>
</div>
<p v-if="msg" class="text-sm text-accent">{{ msg }}</p>
</form>
</div>
</template>
+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 side = ref("front");
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 slots = (r: Rack) => {
const h = r.height_u;
const map: Record<number, typeof r.devices> = {};
for (const d of r.devices || []) {
if (d.side === side.value) (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 class="rounded-lg px-3 py-1 text-sm" :class="side === 'front' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="side = 'front'">Front</button>
<button class="rounded-lg px-3 py-1 text-sm" :class="side === 'back' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="side = 'back'">Back</button>
<button v-if="auth.can('add_device_to_rack')" class="btn-secondary text-sm" @click="showAddDevice = true; err = ''">Add device</button>
<button v-if="auth.can('add_nonnet_device_to_rack')" class="btn-secondary text-sm" @click="showAddNonnet = true; err = ''">Add non-networked</button>
</div>
<div class="card mt-6 max-w-md font-mono text-sm">
<div v-for="row in slots(rack)" :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 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>
+90
View File
@@ -0,0 +1,90 @@
<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 err = ref("");
async function load() {
racks.value = await api.racks();
}
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>
<div 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>
+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>
+69
View File
@@ -0,0 +1,69 @@
<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";
const route = useRoute();
const auth = useAuthStore();
const subnet = ref<Subnet | null>(null);
const historyIp = ref<string | null>(null);
onMounted(async () => {
subnet.value = await api.subnet(Number(route.params.id));
});
async function saveNotes(ipId: number, notes: string) {
await api.patchIpNotes(ipId, notes);
}
</script>
<template>
<div v-if="subnet">
<RouterLink to="/" class="text-sm text-accent hover:underline"> Home</RouterLink>
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-bold">{{ subnet.name }}</h1>
<p class="font-mono text-slate-500">{{ subnet.cidr }} · {{ subnet.site || "Unassigned" }}</p>
</div>
<div class="flex gap-2">
<RouterLink :to="`/subnets/${subnet.id}/dhcp`" class="btn-secondary text-sm">DHCP</RouterLink>
<a v-if="auth.can('export_subnet_csv')" :href="`/api/v2/subnets/${subnet.id}/export`" class="btn-secondary text-sm">Export CSV</a>
</div>
</div>
<div class="card mt-6 overflow-x-auto">
<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">
<td class="p-2 font-mono">{{ ip.ip }}</td>
<td class="p-2">
<RouterLink v-if="ip.device_id" :to="`/devices/${ip.device_id}`" class="text-accent hover:underline">{{ 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>
</tbody>
</table>
</div>
<IpHistoryModal :ip="historyIp" @close="historyIp = null" />
</div>
</template>
+120
View File
@@ -0,0 +1,120 @@
<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 v-for="s in subnets" :key="s.id" class="card flex flex-wrap items-center justify-between gap-2">
<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 v-if="s.vlan_id" class="rounded-full bg-surface-overlay px-2 py-0.5 text-xs">VLAN {{ s.vlan_id }}</span>
<div class="flex gap-2">
<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>
+79
View File
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { api, type Tag } from "@/api";
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 err = ref("");
onMounted(async () => { tags.value = await api.tags(); });
async function create() {
await api.createTag(form.value);
tags.value = await api.tags();
form.value = { name: "", color: "#06b6d4", description: "" };
}
async function del(id: number) {
if (!confirm("Delete tag?")) return;
await api.deleteTag(id);
tags.value = await api.tags();
}
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;
tags.value = await api.tags();
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed";
}
}
</script>
<template>
<div>
<h1 class="text-2xl font-bold">Tags</h1>
<form 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>
<ul 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 class="flex gap-2">
<button class="text-sm text-accent hover:underline" @click="openEdit(t)">Edit</button>
<button class="text-sm text-red-500" @click="del(t.id)">Delete</button>
</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-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>
+228
View File
@@ -0,0 +1,228 @@
<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 v-for="u in users" :key="u.id" class="card flex flex-wrap items-center justify-between gap-2">
<span>{{ 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">
<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,
},
},
},
});
+2
View File
@@ -2,3 +2,5 @@ Flask
mysql-connector-python mysql-connector-python
dotenv dotenv
gunicorn gunicorn
pyotp
qrcode[pil]
-971
View File
@@ -1,971 +0,0 @@
from flask import render_template, request, redirect, url_for, send_from_directory, send_file, session
from db import init_db, hash_password, get_db_connection, verify_password
from ipaddress import ip_network
from functools import wraps
import os
import csv
from io import StringIO, BytesIO
import logging
app = None
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'logged_in' not in session:
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated_function
def add_audit_log(user_id, action, details=None, subnet_id=None, conn=None):
import datetime
close_conn = False
if conn is None:
from flask import current_app
conn = get_db_connection(current_app)
close_conn = True
cursor = conn.cursor()
utc_now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0)
cursor.execute('''INSERT INTO AuditLog (user_id, action, details, subnet_id, timestamp) VALUES (%s, %s, %s, %s, %s)''',
(user_id, action, details, subnet_id, utc_now))
if close_conn:
conn.commit()
conn.close()
def register_routes(app):
logging.basicConfig(level=logging.INFO)
@app.route('/login', methods=['GET', 'POST'])
def login():
error = None
if request.method == 'POST':
email = request.form['email']
password = request.form['password']
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT id, password FROM User WHERE email = %s', (email,))
user = cursor.fetchone()
if user and verify_password(password, user[1]):
session['logged_in'] = True
session['user_id'] = user[0]
logging.info(f"User {email} logged in successfully.")
return redirect(url_for('index'))
else:
logging.info(f"Failed login attempt for email: {email}")
error = 'Invalid email or password.'
return render_with_user('login.html', error=error)
@app.route('/logout')
def logout():
user_name = get_current_user_name()
logging.info(f"User {user_name} logged out.")
session.clear()
return redirect(url_for('login'))
@app.route('/')
@login_required
def index():
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT id, name, cidr, site FROM Subnet')
subnets = cursor.fetchall()
sites_subnets = {}
for subnet in subnets:
site = subnet[3] or 'Unassigned'
if site not in sites_subnets:
sites_subnets[site] = []
sites_subnets[site].append({'id': subnet[0], 'name': subnet[1], 'cidr': subnet[2]})
return render_with_user('index.html', sites_subnets=sites_subnets)
@app.route('/devices')
@login_required
def devices():
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('''SELECT Device.id, Device.name, DeviceType.icon_class FROM Device LEFT JOIN DeviceType ON Device.device_type_id = DeviceType.id''')
devices = cursor.fetchall()
cursor.execute('SELECT id, name, cidr, site FROM Subnet')
subnets = cursor.fetchall()
cursor.execute('SELECT DeviceIPAddress.device_id, IPAddress.id, IPAddress.ip FROM DeviceIPAddress JOIN IPAddress ON DeviceIPAddress.ip_id = IPAddress.id')
device_ips = {}
for row in cursor.fetchall():
device_ips.setdefault(row[0], []).append((row[1], row[2]))
sites_devices = {}
for device in devices:
cursor.execute('''SELECT Subnet.site FROM DeviceIPAddress JOIN IPAddress ON DeviceIPAddress.ip_id = IPAddress.id JOIN Subnet ON IPAddress.subnet_id = Subnet.id WHERE DeviceIPAddress.device_id = %s LIMIT 1''', (device[0],))
site = cursor.fetchone()
site = site[0] if site else 'Unassigned'
if site not in sites_devices:
sites_devices[site] = []
sites_devices[site].append({'id': device[0], 'name': device[1], 'icon_class': device[2]})
return render_with_user('devices.html', sites_devices=sites_devices, device_ips=device_ips)
@app.route('/add_device', methods=['GET', 'POST'])
@login_required
def add_device():
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT id, name FROM DeviceType ORDER BY name')
device_types = cursor.fetchall()
if request.method == 'POST':
name = request.form['device_name']
device_type_id = int(request.form['device_type'])
user_name = get_current_user_name()
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('INSERT INTO Device (name, device_type_id) VALUES (%s, %s)', (name, device_type_id))
conn.commit()
logging.info(f"User {user_name} added device '{name}' (type {device_type_id}).")
return redirect(url_for('devices'))
return render_with_user('add_device.html', device_types=device_types)
@app.route('/device/<int:device_id>')
@login_required
def device(device_id):
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT id, name, description, device_type_id FROM Device WHERE id = %s', (device_id,))
device = cursor.fetchone()
cursor.execute('SELECT id, name FROM DeviceType ORDER BY name')
device_types = cursor.fetchall()
cursor.execute('SELECT id, name, cidr, site FROM Subnet')
subnets = [dict(id=row[0], name=row[1], cidr=row[2], site=row[3]) for row in cursor.fetchall()]
cursor.execute('''SELECT DeviceIPAddress.id as device_ip_id, IPAddress.ip FROM DeviceIPAddress JOIN IPAddress ON DeviceIPAddress.ip_id = IPAddress.id WHERE DeviceIPAddress.device_id = %s''', (device_id,))
device_ips = [{'device_ip_id': row[0], 'ip': row[1]} for row in cursor.fetchall()]
available_ips_by_subnet = {}
for subnet in subnets:
cursor.execute('SELECT id, ip FROM IPAddress WHERE subnet_id = %s AND id NOT IN (SELECT ip_id FROM DeviceIPAddress)', (subnet['id'],))
ips = [{'id': row[0], 'ip': row[1]} for row in cursor.fetchall()]
cursor.execute('SELECT start_ip, end_ip, excluded_ips FROM DHCPPool WHERE subnet_id = %s', (subnet['id'],))
dhcp_row = cursor.fetchone()
if dhcp_row:
start_ip, end_ip, excluded_ips = dhcp_row
excluded_list = [ip for ip in (excluded_ips or '').replace(' ', '').split(',') if ip]
in_range = False
filtered_ips = []
for ip_obj in ips:
ip = ip_obj['ip']
if ip == start_ip:
in_range = True
if ip in excluded_list or not (in_range and ip not in excluded_list):
filtered_ips.append(ip_obj)
if ip == end_ip:
in_range = False
ips = filtered_ips
available_ips_by_subnet[subnet['id']] = ips
return render_with_user('device.html', device={'id': device[0], 'name': device[1], 'description': device[2], 'device_type_id': device[3]}, subnets=subnets, device_ips=device_ips, available_ips_by_subnet=available_ips_by_subnet, device_types=device_types)
@app.route('/update_device_type', methods=['POST'])
@login_required
def update_device_type():
device_id = request.form['device_id']
device_type_id = request.form['device_type_id']
user_name = get_current_user_name()
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('UPDATE Device SET device_type_id = %s WHERE id = %s', (device_type_id, device_id))
conn.commit()
logging.info(f"User {user_name} updated device {device_id} to type {device_type_id}.")
return redirect(url_for('device', device_id=device_id))
@app.route('/device/<int:device_id>/add_ip', methods=['POST'])
@login_required
def device_add_ip(device_id):
subnet_id = request.form['subnet_id']
ip_id = request.form['ip_id']
user_name = get_current_user_name()
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT id, ip FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
all_ip_rows = cursor.fetchall()
cursor.execute('SELECT ip_id FROM DeviceIPAddress')
assigned_ip_ids = [row[0] for row in cursor.fetchall()]
cursor.execute('SELECT start_ip, end_ip, excluded_ips FROM DHCPPool WHERE subnet_id = %s', (subnet_id,))
dhcp_row = cursor.fetchone()
if dhcp_row:
start_ip, end_ip, excluded_ips = dhcp_row
excluded_list = [x for x in (excluded_ips or '').replace(' ', '').split(',') if x]
cursor.execute('SELECT ip, hostname FROM IPAddress WHERE id = %s', (ip_id,))
ip_row = cursor.fetchone()
if not ip_row:
raise Exception("The selected IP address is no longer available. Please refresh and try again.")
ip = ip_row[0]
hostname = ip_row[1]
cursor.execute('SELECT start_ip, end_ip, excluded_ips FROM DHCPPool WHERE subnet_id = %s', (subnet_id,))
dhcp_row = cursor.fetchone()
if dhcp_row:
start_ip, end_ip, excluded_ips = dhcp_row
excluded_list = [x for x in (excluded_ips or '').replace(' ', '').split(',') if x]
if ip not in excluded_list:
cursor.execute('SELECT ip FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
all_ips = [row[0] for row in cursor.fetchall()]
in_range = False
reserved_for_dhcp = False
for candidate_ip in all_ips:
if candidate_ip == start_ip:
in_range = True
if in_range and candidate_ip == ip:
reserved_for_dhcp = True
break
if candidate_ip == end_ip:
in_range = False
if reserved_for_dhcp:
raise Exception("This IP is reserved for DHCP and cannot be assigned to a device.")
cursor.execute('INSERT INTO DeviceIPAddress (device_id, ip_id) VALUES (%s, %s)', (device_id, ip_id))
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
device_name = cursor.fetchone()[0]
cursor.execute('UPDATE IPAddress SET hostname = %s WHERE id = %s', (device_name, ip_id))
cursor.execute('SELECT ip, subnet_id FROM IPAddress WHERE id = %s', (ip_id,))
ip, subnet_id_val = cursor.fetchone()
cursor.execute('SELECT name, cidr FROM Subnet WHERE id = %s', (subnet_id_val,))
subnet_name, subnet_cidr = cursor.fetchone()
details = f"Assigned IP {ip} ({subnet_name} {subnet_cidr}) to device {device_name}"
add_audit_log(session['user_id'], 'device_add_ip', details, subnet_id_val, conn=conn)
conn.commit()
logging.info(f"User {user_name} assigned IP {ip} to device {device_id}.")
return redirect(url_for('device', device_id=device_id))
@app.route('/device/<int:device_id>/delete_ip', methods=['POST'])
@login_required
def device_delete_ip(device_id):
device_ip_id = request.form['device_ip_id']
user_name = get_current_user_name()
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT ip_id FROM DeviceIPAddress WHERE id = %s', (device_ip_id,))
ip_id = cursor.fetchone()[0]
cursor.execute('SELECT ip, subnet_id FROM IPAddress WHERE id = %s', (ip_id,))
ip, subnet_id_val = cursor.fetchone()
cursor.execute('SELECT name, cidr FROM Subnet WHERE id = %s', (subnet_id_val,))
subnet_name, subnet_cidr = cursor.fetchone()
cursor.execute('SELECT device_id FROM DeviceIPAddress WHERE id = %s', (device_ip_id,))
device_id_val = cursor.fetchone()[0]
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id_val,))
device_name = cursor.fetchone()[0]
details = f"Removed IP {ip} ({subnet_name} {subnet_cidr}) from device {device_name}"
add_audit_log(session['user_id'], 'device_delete_ip', details, subnet_id_val, conn=conn)
cursor.execute('DELETE FROM DeviceIPAddress WHERE id = %s', (device_ip_id,))
cursor.execute('UPDATE IPAddress SET hostname = NULL WHERE id = %s', (ip_id,))
conn.commit()
logging.info(f"User {user_name} removed IP {ip} from device {device_id}.")
return redirect(url_for('device', device_id=device_id))
@app.route('/delete_device', methods=['POST'])
@login_required
def delete_device():
device_id = request.form['device_id']
user_name = get_current_user_name()
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
device_row = cursor.fetchone()
if device_row:
device_name = device_row[0]
add_audit_log(session['user_id'], 'delete_device', f"Deleted device {device_name}", conn=conn)
cursor.execute('SELECT ip_id FROM DeviceIPAddress WHERE device_id = %s', (device_id,))
ip_ids = [row[0] for row in cursor.fetchall()]
if ip_ids:
cursor.executemany('UPDATE IPAddress SET hostname = NULL WHERE id = %s', [(ip_id,) for ip_id in ip_ids])
cursor.execute('DELETE FROM DeviceIPAddress WHERE device_id = %s', (device_id,))
cursor.execute('DELETE FROM Device WHERE id = %s', (device_id,))
conn.commit()
logging.info(f"User {user_name} deleted device '{device_name}'.")
return redirect(url_for('devices'))
@app.route('/subnet/<int:subnet_id>')
@login_required
def subnet(subnet_id):
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT id, name, cidr FROM Subnet WHERE id = %s', (subnet_id,))
subnet = cursor.fetchone()
cursor.execute('SELECT * FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
ip_addresses = cursor.fetchall()
cursor.execute('SELECT id, name, description FROM Device')
devices = cursor.fetchall()
device_name_map = {name.lower(): (id, description) for id, name, description in devices}
ip_addresses_with_device = []
for ip in ip_addresses:
hostname = ip[2]
device_id = None
device_description = None
if hostname:
match = device_name_map.get(hostname.lower())
if match:
device_id, device_description = match
ip_addresses_with_device.append((ip[0], ip[1], hostname, device_id, device_description))
return render_with_user('subnet.html', subnet={'id': subnet[0], 'name': subnet[1], 'cidr': subnet[2]}, ip_addresses=ip_addresses_with_device)
@app.route('/add_subnet', methods=['POST'])
@login_required
def add_subnet():
name = request.form['name']
cidr = request.form['cidr']
site = request.form['site']
user_name = get_current_user_name()
try:
network = ip_network(cidr, strict=False)
if network.prefixlen < 24:
return render_with_user('admin.html', subnets=[], error='Subnet must be /24 or smaller (e.g., /24, /25, ... /32)')
except Exception as e:
return render_with_user('admin.html', subnets=[], error='Invalid CIDR format.')
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('INSERT INTO Subnet (name, cidr, site) VALUES (%s, %s, %s)', (name, cidr, site))
subnet_id = cursor.lastrowid
ip_rows = [(str(ip), subnet_id) for ip in network.hosts()]
cursor.executemany('INSERT INTO IPAddress (ip, subnet_id) VALUES (%s, %s)', ip_rows)
add_audit_log(session['user_id'], 'add_subnet', f"Added subnet {name} ({cidr})", subnet_id, conn=conn)
conn.commit()
logging.info(f"User {user_name} added subnet '{name}' ({cidr}) at site '{site}'.")
return redirect(url_for('admin'))
@app.route('/delete_subnet', methods=['POST'])
@login_required
def delete_subnet():
subnet_id = request.form['subnet_id']
user_name = get_current_user_name()
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT name, cidr FROM Subnet WHERE id = %s', (subnet_id,))
subnet = cursor.fetchone()
add_audit_log(session['user_id'], 'delete_subnet', f"Deleted subnet {subnet[0]} ({subnet[1]})", subnet_id, conn=conn)
cursor.execute('SELECT id FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
ip_ids = [row[0] for row in cursor.fetchall()]
if ip_ids:
cursor.executemany('DELETE FROM DeviceIPAddress WHERE ip_id = %s', [(ip_id,) for ip_id in ip_ids])
cursor.executemany('UPDATE AuditLog SET subnet_id=NULL WHERE subnet_id = %s', [(subnet_id,) for _ in ip_ids])
cursor.execute('DELETE FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
cursor.execute('DELETE FROM Subnet WHERE id = %s', (subnet_id,))
conn.commit()
logging.info(f"User {user_name} deleted subnet {subnet_id}.")
return redirect(url_for('admin'))
@app.route('/admin', methods=['GET', 'POST'])
@login_required
def admin():
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT id, name, cidr FROM Subnet')
subnets = [dict(id=row[0], name=row[1], cidr=row[2]) for row in cursor.fetchall()]
return render_with_user('admin.html', subnets=subnets)
@app.route('/users', methods=['GET', 'POST'])
@login_required
def users():
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
if request.method == 'POST':
action = request.form['action']
user_name = get_current_user_name()
if action == 'add':
name = request.form['name']
email = request.form['email']
password = hash_password(request.form['password'])
cursor.execute('INSERT INTO User (name, email, password) VALUES (%s, %s, %s)', (name, email, password))
logging.info(f"User {user_name} added user '{name}' ({email}).")
elif action == 'edit':
user_id = request.form['user_id']
name = request.form['name']
email = request.form['email']
password = request.form['password']
if password:
password = hash_password(password)
cursor.execute('UPDATE User SET name=%s, email=%s, password=%s WHERE id=%s', (name, email, password, user_id))
else:
cursor.execute('UPDATE User SET name=%s, email=%s WHERE id=%s', (name, email, user_id))
logging.info(f"User {user_name} edited user {user_id}.")
elif action == 'delete':
user_id = request.form['user_id']
cursor.execute('UPDATE User SET name=%s WHERE id=%s', ('Deleted User', user_id))
cursor.execute('UPDATE AuditLog SET user_id=NULL WHERE user_id=%s', (user_id,))
cursor.execute('DELETE FROM User WHERE id=%s', (user_id,))
logging.info(f"User {user_name} deleted user {user_id}.")
conn.commit()
cursor.execute('SELECT id, name, email FROM User')
users = cursor.fetchall()
return render_with_user('users.html', users=users)
@app.route('/audit')
@login_required
def audit():
PER_PAGE = 25
page = int(request.args.get('page', 1))
offset = (page - 1) * PER_PAGE
user_id = request.args.get('user_id')
subnet_id = request.args.get('subnet_id')
action = request.args.get('action')
device_name = request.args.get('device_name')
query = '''SELECT AuditLog.id, COALESCE(User.name, 'Deleted User'), AuditLog.action, AuditLog.details, Subnet.name, AuditLog.timestamp FROM AuditLog LEFT JOIN User ON AuditLog.user_id = User.id LEFT JOIN Subnet ON AuditLog.subnet_id = Subnet.id WHERE 1=1'''
params = []
if user_id:
query += ' AND AuditLog.user_id = %s'
params.append(user_id)
if subnet_id:
query += ' AND AuditLog.subnet_id = %s'
params.append(subnet_id)
if action:
query += ' AND AuditLog.action = %s'
params.append(action)
if device_name:
query += ' AND AuditLog.details LIKE %s'
params.append(f'%{device_name}%')
count_query = 'SELECT COUNT(*) FROM (' + query + ') AS count_subquery'
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute(count_query, params)
total_logs = cursor.fetchone()[0]
query += ' ORDER BY AuditLog.timestamp DESC LIMIT %s OFFSET %s'
cursor.execute(query, params + [PER_PAGE, offset])
logs = cursor.fetchall()
cursor.execute('SELECT id, name FROM User')
users = cursor.fetchall()
cursor.execute('SELECT id, name FROM Subnet')
subnets = cursor.fetchall()
cursor.execute('SELECT DISTINCT action FROM AuditLog')
actions = [row[0] for row in cursor.fetchall()]
cursor.execute('SELECT name FROM Device ORDER BY name')
devices = cursor.fetchall()
query_args = request.args.to_dict()
total_pages = (total_logs + PER_PAGE - 1) // PER_PAGE
return render_with_user('audit.html', logs=logs, users=users, subnets=subnets, actions=actions, devices=devices, page=page, total_pages=total_pages, query_args=query_args)
@app.route('/get_available_ips')
@login_required
def get_available_ips():
subnet_id = request.args.get('subnet_id')
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('''SELECT id, ip FROM IPAddress WHERE subnet_id = %s AND id NOT IN (SELECT ip_id FROM DeviceIPAddress) AND (hostname IS NULL OR hostname != 'DHCP')''', (subnet_id,))
available_ips = [{'id': row[0], 'ip': row[1]} for row in cursor.fetchall()]
return {'available_ips': available_ips}
@app.route('/rename_device', methods=['POST'])
@login_required
def rename_device():
device_id = request.form['device_id']
new_name = request.form['new_name']
user_name = get_current_user_name()
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
old_name = cursor.fetchone()[0]
cursor.execute('UPDATE Device SET name = %s WHERE id = %s', (new_name, device_id))
cursor.execute('UPDATE IPAddress SET hostname = %s WHERE hostname = %s', (new_name, old_name))
conn.commit()
add_audit_log(session['user_id'], 'rename_device', f"Renamed device '{old_name}' to '{new_name}'", conn=conn)
logging.info(f"User {user_name} renamed device {device_id} from '{old_name}' to '{new_name}'.")
return redirect(url_for('device', device_id=device_id))
@app.route('/update_device_description', methods=['POST'])
@login_required
def update_device_description():
device_id = request.form['device_id']
description = request.form['description']
user_name = get_current_user_name()
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('UPDATE Device SET description = %s WHERE id = %s', (description, device_id))
conn.commit()
logging.info(f"User {user_name} updated description for device {device_id}.")
return redirect(url_for('device', device_id=device_id))
@app.route('/subnet/<int:subnet_id>/export_csv')
@login_required
def export_subnet_csv(subnet_id):
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT id, name, cidr FROM Subnet WHERE id = %s', (subnet_id,))
subnet = cursor.fetchone()
if not subnet:
return 'Subnet not found', 404
cursor.execute('SELECT * FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
ip_addresses = cursor.fetchall()
cursor.execute('SELECT id, name, description FROM Device')
devices = cursor.fetchall()
device_name_map = {name.lower(): (id, description) for id, name, description in devices}
ip_addresses_with_device = []
for ip in ip_addresses:
hostname = ip[2]
device_id = None
device_description = None
if hostname:
match = device_name_map.get(hostname.lower())
if match:
device_id, device_description = match
ip_addresses_with_device.append((ip[0], ip[1], hostname, device_id, device_description))
output = StringIO()
writer = csv.writer(output)
writer.writerow(['IP Address', 'Hostname', 'Description'])
for ip in ip_addresses_with_device:
ip_addr = ip[1] or ''
hostname = ip[2] or ''
description = (ip[4] or '').split('\n')[0] if ip[4] else ''
writer.writerow([ip_addr, hostname, description])
csv_bytes = output.getvalue().encode('utf-8')
output_bytes = BytesIO(csv_bytes)
output_bytes.seek(0)
filename = f"{subnet[1]}_{subnet[2]}_subnet.csv".replace(' ', '_')
return send_file(
output_bytes,
mimetype='text/csv',
as_attachment=True,
download_name=filename
)
@app.route('/subnet/<int:subnet_id>/dhcp', methods=['GET', 'POST'])
@login_required
def dhcp_pool(subnet_id):
error = None
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT id, name, cidr FROM Subnet WHERE id = %s', (subnet_id,))
subnet = cursor.fetchone()
dhcp_pool = None
cursor.execute('''SELECT start_ip, end_ip, excluded_ips FROM DHCPPool WHERE subnet_id = %s''', (subnet_id,))
row = cursor.fetchone()
if row:
dhcp_pool = {'start_ip': row[0], 'end_ip': row[1], 'excluded_ips': row[2] if len(row) > 2 else ''}
if request.method == 'POST':
user_name = get_current_user_name()
if 'remove' in request.form:
cursor.execute('DELETE FROM DHCPPool WHERE subnet_id = %s', (subnet_id,))
cursor.execute('UPDATE IPAddress SET hostname=NULL WHERE subnet_id=%s AND hostname="DHCP"', (subnet_id,))
conn.commit()
dhcp_pool = None
add_audit_log(session['user_id'], 'dhcp_pool_remove', f"Removed DHCP pool for subnet {subnet[1]} ({subnet[2]})", subnet_id, conn=conn)
else:
start_ip = request.form['start_ip']
end_ip = request.form['end_ip']
excluded_ips = request.form.get('excluded_ips', '').replace(' ', '')
excluded_list = [ip for ip in excluded_ips.split(',') if ip]
cursor.execute('SELECT ip FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
all_ips = [row[0] for row in cursor.fetchall()]
if start_ip not in all_ips or end_ip not in all_ips:
error = 'Start and End IP must be within the subnet.'
else:
cursor.execute('UPDATE IPAddress SET hostname=NULL WHERE subnet_id=%s AND hostname="DHCP"', (subnet_id,))
if dhcp_pool:
cursor.execute('''UPDATE DHCPPool SET start_ip = %s, end_ip = %s, excluded_ips = %s WHERE subnet_id = %s''', (start_ip, end_ip, excluded_ips, subnet_id))
action = 'dhcp_pool_update'
details = f"Updated DHCP pool for subnet {subnet[1]} ({subnet[2]}): {start_ip} - {end_ip}, excluded: {excluded_ips}"
else:
cursor.execute('''INSERT INTO DHCPPool (subnet_id, start_ip, end_ip, excluded_ips) VALUES (%s, %s, %s, %s)''', (subnet_id, start_ip, end_ip, excluded_ips))
action = 'dhcp_pool_create'
details = f"Created DHCP pool for subnet {subnet[1]} ({subnet[2]}): {start_ip} - {end_ip}, excluded: {excluded_ips}"
in_range = False
for ip in all_ips:
if ip == start_ip:
in_range = True
if in_range and ip not in excluded_list:
cursor.execute('UPDATE IPAddress SET hostname="DHCP" WHERE subnet_id=%s AND ip=%s', (subnet_id, ip))
if ip == end_ip:
break
conn.commit()
dhcp_pool = {'start_ip': start_ip, 'end_ip': end_ip, 'excluded_ips': excluded_ips}
add_audit_log(session['user_id'], action, details, subnet_id, conn=conn)
return render_with_user('dhcp.html', subnet={'id': subnet[0], 'name': subnet[1]}, dhcp_pool=dhcp_pool, error=error)
@app.route('/device_type_stats')
@login_required
def device_type_stats():
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT DeviceType.name, DeviceType.icon_class, COUNT(Device.id) as count
FROM DeviceType
LEFT JOIN Device ON Device.device_type_id = DeviceType.id
GROUP BY DeviceType.id, DeviceType.name, DeviceType.icon_class
ORDER BY DeviceType.name
''')
stats = cursor.fetchall()
return render_with_user('device_type_stats.html', stats=stats)
@app.route('/devices/type/<device_type>')
@login_required
def devices_by_type(device_type):
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT id, icon_class FROM DeviceType WHERE name = %s', (device_type,))
row = cursor.fetchone()
if not row:
return f"Device type '{device_type}' not found", 404
device_type_id, icon_class = row
cursor.execute('''
SELECT DISTINCT Device.id, Device.name, Device.description, Subnet.site
FROM Device
LEFT JOIN DeviceIPAddress ON Device.id = DeviceIPAddress.device_id
LEFT JOIN IPAddress ON DeviceIPAddress.ip_id = IPAddress.id
LEFT JOIN Subnet ON IPAddress.subnet_id = Subnet.id
WHERE Device.device_type_id = %s
''', (device_type_id,))
devices = cursor.fetchall()
seen_ids = set()
site_devices = {}
for device_id, name, description, site in devices:
if device_id in seen_ids:
continue
seen_ids.add(device_id)
site = site or 'Unassigned'
if site not in site_devices:
site_devices[site] = []
site_devices[site].append({'id': device_id, 'name': name, 'description': description})
return render_with_user('devices_by_type.html', device_type=device_type, icon_class=icon_class, site_devices=site_devices)
@app.route('/racks')
@login_required
def racks():
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute('SELECT * FROM Rack')
racks = cursor.fetchall()
rack_ids = [rack['id'] for rack in racks]
usage = {rack_id: 0 for rack_id in rack_ids}
if rack_ids:
format_strings = ','.join(['%s'] * len(rack_ids))
cursor.execute(f'SELECT rack_id, COUNT(*) as used FROM RackDevice WHERE rack_id IN ({format_strings}) AND side = %s GROUP BY rack_id', tuple(rack_ids) + ('front',))
for row in cursor.fetchall():
usage[row['rack_id']] = row['used']
for rack in racks:
rack['used_u'] = usage.get(rack['id'], 0)
rack['percent_full'] = int((rack['used_u'] / rack['height_u']) * 100) if rack['height_u'] else 0
return render_with_user('racks.html', racks=racks)
@app.route('/rack/add', methods=['GET', 'POST'])
@login_required
def add_rack():
from flask import current_app
if request.method == 'POST':
name = request.form['name']
site = request.form['site']
height_u = int(request.form['height_u'])
user_name = get_current_user_name()
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('INSERT INTO Rack (name, site, height_u) VALUES (%s, %s, %s)', (name, site, height_u))
rack_id = cursor.lastrowid
add_audit_log(session['user_id'], 'add_rack', f"Added rack '{name}' at site '{site}' ({height_u}U)", conn=conn)
conn.commit()
logging.info(f"User {user_name} added rack '{name}' at site '{site}' ({height_u}U).")
return redirect(url_for('racks'))
return render_with_user('add_rack.html')
@app.route('/rack/<int:rack_id>')
@login_required
def rack(rack_id):
from flask import current_app, request
side = request.args.get('side', 'front')
with get_db_connection(current_app) as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute('SELECT * FROM Rack WHERE id = %s', (rack_id,))
rack = cursor.fetchone()
if not rack:
return 'Rack not found', 404
cursor.execute('SELECT * FROM RackDevice WHERE rack_id = %s', (rack_id,))
rack_devices = cursor.fetchall()
device_ids = [rd['device_id'] for rd in rack_devices if rd['device_id']]
device_names = {}
if device_ids:
format_strings = ','.join(['%s'] * len(device_ids))
cursor.execute(f'SELECT id, name FROM Device WHERE id IN ({format_strings})', tuple(device_ids))
for row in cursor.fetchall():
device_names[row['id']] = row['name']
for rd in rack_devices:
if rd['device_id']:
rd['device_name'] = device_names.get(rd['device_id'], 'Unknown')
else:
rd['device_name'] = rd['nonnet_device_name']
cursor.execute('''
SELECT DISTINCT Device.id, Device.name, Device.device_type_id, Device.description
FROM Device
JOIN DeviceIPAddress ON Device.id = DeviceIPAddress.device_id
JOIN IPAddress ON DeviceIPAddress.ip_id = IPAddress.id
JOIN Subnet ON IPAddress.subnet_id = Subnet.id
WHERE Device.device_type_id NOT IN (2, 6)
AND Subnet.site = %s
''', (rack['site'],))
site_devices = cursor.fetchall()
return render_with_user('rack.html', rack=rack, rack_devices=rack_devices, site_devices=site_devices, current_side=side)
@app.route('/rack/<int:rack_id>/add_device', methods=['POST'])
@login_required
def rack_add_device(rack_id):
device_id = int(request.form['device_id'])
position_u = int(request.form['position_u'])
side = request.form['side']
user_name = get_current_user_name()
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute('SELECT height_u FROM Rack WHERE id = %s', (rack_id,))
rack = cursor.fetchone()
if not rack:
return 'Rack not found', 404
if position_u < 1 or position_u > rack['height_u']:
cursor.execute('SELECT * FROM RackDevice WHERE rack_id = %s', (rack_id,))
rack_devices = cursor.fetchall()
device_ids = [rd['device_id'] for rd in rack_devices]
device_names = {}
if device_ids:
format_strings = ','.join(['%s'] * len(device_ids))
cursor.execute(f'SELECT id, name FROM Device WHERE id IN ({format_strings})', tuple(device_ids))
for row in cursor.fetchall():
device_names[row['id']] = row['name']
for rd in rack_devices:
rd['device_name'] = device_names.get(rd['device_id'], 'Unknown')
cursor.execute('SELECT id, name, device_type_id FROM Device')
all_devices = cursor.fetchall()
site_devices = [d for d in all_devices if d['device_type_id'] not in (2, 6)]
error = f"Invalid U position: {position_u}. Rack is {rack['height_u']}U tall."
return render_with_user('rack.html', rack=rack, rack_devices=rack_devices, site_devices=site_devices, current_side=side, error=error)
cursor.execute('SELECT COUNT(*) FROM RackDevice WHERE rack_id = %s AND position_u = %s AND side = %s', (rack_id, position_u, side))
if cursor.fetchone()['COUNT(*)'] > 0:
cursor.execute('SELECT * FROM RackDevice WHERE rack_id = %s', (rack_id,))
rack_devices = cursor.fetchall()
device_ids = [rd['device_id'] for rd in rack_devices]
device_names = {}
if device_ids:
format_strings = ','.join(['%s'] * len(device_ids))
cursor.execute(f'SELECT id, name FROM Device WHERE id IN ({format_strings})', tuple(device_ids))
for row in cursor.fetchall():
device_names[row['id']] = row['name']
for rd in rack_devices:
rd['device_name'] = device_names.get(rd['device_id'], 'Unknown')
cursor.execute('SELECT id, name, device_type_id FROM Device')
all_devices = cursor.fetchall()
site_devices = [d for d in all_devices if d['device_type_id'] not in (2, 6)]
error = f"U{position_u} on the {side} is already occupied."
return render_with_user('rack.html', rack=rack, rack_devices=rack_devices, site_devices=site_devices, current_side=side, error=error)
cursor.execute('INSERT INTO RackDevice (rack_id, device_id, position_u, side) VALUES (%s, %s, %s, %s)', (rack_id, device_id, position_u, side))
cursor2 = conn.cursor()
cursor2.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
device_name = cursor2.fetchone()
cursor2.execute('SELECT name FROM Rack WHERE id = %s', (rack_id,))
rack_name = cursor2.fetchone()
add_audit_log(session['user_id'], 'rack_add_device', f"Assigned device '{device_name[0] if device_name else device_id}' to rack '{rack_name[0] if rack_name else rack_id}' U{position_u} ({side})", conn=conn)
conn.commit()
logging.info(f"User {user_name} assigned device {device_id} to rack {rack_id} at U{position_u} ({side}).")
return redirect(url_for('rack', rack_id=rack_id))
@app.route('/rack/<int:rack_id>/add_nonnet_device', methods=['POST'])
@login_required
def rack_add_nonnet_device(rack_id):
device_name = request.form['device_name']
position_u = int(request.form['position_u'])
side = request.form['side']
user_name = get_current_user_name()
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute('SELECT height_u FROM Rack WHERE id = %s', (rack_id,))
rack = cursor.fetchone()
if not rack:
return 'Rack not found', 404
if position_u < 1 or position_u > rack['height_u']:
error = f"Invalid U position: {position_u}. Rack is {rack['height_u']}U tall."
cursor.execute('SELECT * FROM RackDevice WHERE rack_id = %s', (rack_id,))
rack_devices = cursor.fetchall()
device_ids = [rd['device_id'] for rd in rack_devices if rd['device_id']]
device_names = {}
if device_ids:
format_strings = ','.join(['%s'] * len(device_ids))
cursor.execute(f'SELECT id, name FROM Device WHERE id IN ({format_strings})', tuple(device_ids))
for row in cursor.fetchall():
device_names[row['id']] = row['name']
for rd in rack_devices:
if rd['device_id']:
rd['device_name'] = device_names.get(rd['device_id'], 'Unknown')
else:
rd['device_name'] = rd['nonnet_device_name']
cursor.execute('SELECT id, name, device_type_id FROM Device WHERE device_type_id NOT IN (2, 6)')
site_devices = cursor.fetchall()
return render_with_user('rack.html', rack=rack, rack_devices=rack_devices, site_devices=site_devices, current_side=side, error=error)
cursor.execute('SELECT COUNT(*) FROM RackDevice WHERE rack_id = %s AND position_u = %s AND side = %s', (rack_id, position_u, side))
if cursor.fetchone()['COUNT(*)'] > 0:
error = f"U{position_u} on the {side} is already occupied."
cursor.execute('SELECT * FROM RackDevice WHERE rack_id = %s', (rack_id,))
rack_devices = cursor.fetchall()
device_ids = [rd['device_id'] for rd in rack_devices if rd['device_id']]
device_names = {}
if device_ids:
format_strings = ','.join(['%s'] * len(device_ids))
cursor.execute(f'SELECT id, name FROM Device WHERE id IN ({format_strings})', tuple(device_ids))
for row in cursor.fetchall():
device_names[row['id']] = row['name']
for rd in rack_devices:
if rd['device_id']:
rd['device_name'] = device_names.get(rd['device_id'], 'Unknown')
else:
rd['device_name'] = rd['nonnet_device_name']
cursor.execute('SELECT id, name, device_type_id FROM Device WHERE device_type_id NOT IN (2, 6)')
site_devices = cursor.fetchall()
return render_with_user('rack.html', rack=rack, rack_devices=rack_devices, site_devices=site_devices, current_side=side, error=error)
cursor.execute('INSERT INTO RackDevice (rack_id, device_id, position_u, side, nonnet_device_name) VALUES (%s, NULL, %s, %s, %s)', (rack_id, position_u, side, device_name))
add_audit_log(session['user_id'], 'rack_add_nonnet_device', f"Added non-networked device '{device_name}' to rack '{rack_id}' U{position_u} ({side})", conn=conn)
conn.commit()
logging.info(f"User {user_name} added non-networked device '{device_name}' to rack {rack_id} at U{position_u} ({side}).")
return redirect(url_for('rack', rack_id=rack_id))
@app.route('/rack/<int:rack_id>/remove_device', methods=['POST'])
@login_required
def rack_remove_device(rack_id):
rack_device_id = int(request.form['rack_device_id'])
user_name = get_current_user_name()
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute('SELECT device_id, nonnet_device_name, position_u, side FROM RackDevice WHERE id = %s', (rack_device_id,))
rd = cursor.fetchone()
if rd['device_id']:
cursor.execute('SELECT name FROM Device WHERE id = %s', (rd['device_id'],))
device_name_row = cursor.fetchone()
device_label = device_name_row['name'] if device_name_row and 'name' in device_name_row else rd['device_id']
else:
device_label = rd['nonnet_device_name']
cursor.execute('SELECT name FROM Rack WHERE id = %s', (rack_id,))
rack_name_row = cursor.fetchone()
rack_label = rack_name_row['name'] if rack_name_row and 'name' in rack_name_row else rack_id
add_audit_log(session['user_id'], 'rack_remove_device', f"Removed device '{device_label}' from rack '{rack_label}' U{rd['position_u']} ({rd['side']})", conn=conn)
cursor.execute('DELETE FROM RackDevice WHERE id = %s', (rack_device_id,))
conn.commit()
logging.info(f"User {user_name} removed device '{device_label}' from rack {rack_label} at U{rd['position_u']} ({rd['side']}).")
return redirect(url_for('rack', rack_id=rack_id))
@app.route('/rack/<int:rack_id>/delete', methods=['POST'])
@login_required
def delete_rack(rack_id):
user_name = get_current_user_name()
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT name FROM Rack WHERE id = %s', (rack_id,))
rack_name = cursor.fetchone()
cursor.execute('DELETE FROM Rack WHERE id = %s', (rack_id,))
add_audit_log(session['user_id'], 'delete_rack', f"Deleted rack '{rack_name[0] if rack_name else rack_id}'", conn=conn)
conn.commit()
logging.info(f"User {user_name} deleted rack {rack_id}.")
return redirect(url_for('racks'))
@app.route('/rack/<int:rack_id>/export_csv')
@login_required
def export_rack_csv(rack_id):
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute('SELECT * FROM Rack WHERE id = %s', (rack_id,))
rack = cursor.fetchone()
if not rack:
return 'Rack not found', 404
cursor.execute('SELECT * FROM RackDevice WHERE rack_id = %s', (rack_id,))
rack_devices = cursor.fetchall()
device_ids = [rd['device_id'] for rd in rack_devices]
device_names = {}
if device_ids:
format_strings = ','.join(['%s'] * len(device_ids))
cursor.execute(f'SELECT id, name FROM Device WHERE id IN ({format_strings})', tuple(device_ids))
for row in cursor.fetchall():
device_names[row['id']] = row['name']
for rd in rack_devices:
rd['device_name'] = device_names.get(rd['device_id'], 'Unknown')
output = StringIO()
writer = csv.writer(output)
writer.writerow([f"Rack: {rack['name']} ({rack['height_u']}U, {rack['site']})"])
writer.writerow([])
for side in ['front', 'back']:
writer.writerow([side.capitalize()])
writer.writerow(['U', 'Device'])
for u in range(rack['height_u'], 0, -1):
found = False
for rd in rack_devices:
if rd['position_u'] == u and rd['side'] == side:
writer.writerow([u, rd['device_name']])
found = True
break
if not found:
writer.writerow([u, ''])
writer.writerow([])
csv_bytes = output.getvalue().encode('utf-8')
output_bytes = BytesIO(csv_bytes)
output_bytes.seek(0)
filename = f"{rack['name']}_rack.csv".replace(' ', '_')
return send_file(
output_bytes,
mimetype='text/csv',
as_attachment=True,
download_name=filename
)
@app.route('/help')
@login_required
def help():
return render_with_user('help.html')
def get_current_user_name():
user_id = session.get('user_id')
if not user_id:
return ''
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
cursor.execute('SELECT name FROM User WHERE id = %s', (user_id,))
row = cursor.fetchone()
return row[0] if row else ''
def render_with_user(*args, **kwargs):
if 'current_user_name' not in kwargs:
kwargs['current_user_name'] = get_current_user_name()
return render_template(*args, **kwargs)
app.add_url_rule('/login', 'login', login, methods=['GET', 'POST'])
app.add_url_rule('/logout', 'logout', logout)
app.add_url_rule('/', 'index', index)
app.add_url_rule('/devices', 'devices', devices)
app.add_url_rule('/add_device', 'add_device', add_device, methods=['GET', 'POST'])
app.add_url_rule('/device/<int:device_id>', 'device', device)
app.add_url_rule('/device/<int:device_id>/add_ip', 'device_add_ip', device_add_ip, methods=['POST'])
app.add_url_rule('/device/<int:device_id>/delete_ip', 'device_delete_ip', device_delete_ip, methods=['POST'])
app.add_url_rule('/delete_device', 'delete_device', delete_device, methods=['POST'])
app.add_url_rule('/subnet/<int:subnet_id>', 'subnet', subnet)
app.add_url_rule('/add_subnet', 'add_subnet', add_subnet, methods=['POST'])
app.add_url_rule('/delete_subnet', 'delete_subnet', delete_subnet, methods=['POST'])
app.add_url_rule('/admin', 'admin', admin, methods=['GET', 'POST'])
app.add_url_rule('/users', 'users', users, methods=['GET', 'POST'])
app.add_url_rule('/audit', 'audit', audit)
app.add_url_rule('/get_available_ips', 'get_available_ips', get_available_ips)
app.add_url_rule('/rename_device', 'rename_device', rename_device, methods=['POST'])
app.add_url_rule('/update_device_description', 'update_device_description', update_device_description, methods=['POST'])
app.add_url_rule('/subnet/<int:subnet_id>/export_csv', 'export_subnet_csv', export_subnet_csv)
app.add_url_rule('/subnet/<int:subnet_id>/dhcp', 'dhcp_pool', dhcp_pool, methods=['GET', 'POST'])
app.add_url_rule('/device_type_stats', 'device_type_stats', device_type_stats)
app.add_url_rule('/devices/type/<device_type>', 'devices_by_type', devices_by_type)
app.add_url_rule('/racks', 'racks', racks)
app.add_url_rule('/rack/add', 'add_rack', add_rack, methods=['GET', 'POST'])
app.add_url_rule('/rack/<int:rack_id>', 'rack', rack)
app.add_url_rule('/rack/<int:rack_id>/add_device', 'rack_add_device', rack_add_device, methods=['POST'])
app.add_url_rule('/rack/<int:rack_id>/add_nonnet_device', 'rack_add_nonnet_device', rack_add_nonnet_device, methods=['POST'])
app.add_url_rule('/rack/<int:rack_id>/remove_device', 'rack_remove_device', rack_remove_device, methods=['POST'])
app.add_url_rule('/rack/<int:rack_id>/delete', 'delete_rack', delete_rack, methods=['POST'])
app.add_url_rule('/rack/<int:rack_id>/export_csv', 'export_rack_csv', export_rack_csv)
app.add_url_rule('/help', 'help', help)
+6 -5
View File
@@ -1,7 +1,8 @@
#!/bin/bash #!/bin/bash
set -e
echo "Generating CSS..." if [ ! -f static/dist/index.html ]; then
./tailwindcss -i ./static/css/input.css -o ./static/css/output.css --content "./templates/*.html,./static/js/*.js" --minify echo "Building frontend..."
(cd frontend && npm ci && npm run build)
fi
echo "Starting app..." echo "Starting app..."
gunicorn --bind 0.0.0.0:5000 app:app --log-level debug python app.py
-16
View File
@@ -1,16 +0,0 @@
h2 {
cursor: pointer;
}
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;
}
-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');
});
}
});
-110
View File
@@ -1,110 +0,0 @@
document.addEventListener('DOMContentLoaded', function() {
// 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');
}
});
});
// Search functionality
const searchInput = document.getElementById('search');
searchInput.addEventListener('keypress', function(event) {
if (event.key === 'Enter') {
event.preventDefault();
const query = this.value.toLowerCase();
document.querySelectorAll('.site-group').forEach(siteGroup => {
let anyVisible = false;
siteGroup.querySelectorAll('.device-list li').forEach(li => {
const deviceName = li.querySelector('span').textContent.toLowerCase();
const ipSpans = li.querySelectorAll('span.inline-block');
let match = deviceName.includes(query);
if (!match) {
ipSpans.forEach(ipSpan => {
if (ipSpan.textContent.toLowerCase().includes(query)) {
match = true;
}
});
}
li.style.display = match ? '' : 'none';
const card = li.querySelector('a');
if (match) {
anyVisible = true;
siteGroup.querySelector('.device-list').classList.remove('hidden');
const icon = siteGroup.querySelector('.expand-btn i');
if (icon && icon.classList.contains('fa-chevron-down')) {
icon.classList.remove('fa-chevron-down');
icon.classList.add('fa-chevron-up');
}
if (card) {
card.style.transition = 'background-color 0.3s';
card.style.backgroundColor = '#2563eb';
card.style.color = '#fff';
setTimeout(() => {
card.style.backgroundColor = '';
card.style.color = '';
}, 2000);
}
} else {
if (card) {
card.style.backgroundColor = '';
card.style.color = '';
}
}
});
siteGroup.style.display = anyVisible ? '' : 'none';
});
}
});
// 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');
}
});
});
-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');
}
});
});
});
-79
View File
@@ -1,79 +0,0 @@
document.addEventListener('DOMContentLoaded', () => {
const form = document.querySelector('form');
if (form) {
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 = '';
}
});
}
});
}
// 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' });
});
});
-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="javascript:window.history.back()" 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 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 px-4 py-2 rounded-lg w-full">Add Rack</button>
</form>
</div>
</div>
</body>
</html>
-53
View File
@@ -1,53 +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 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">Admin</h1>
<div class="flex justify-center gap-4 mb-6">
<a href="/audit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg shadow text-center w-40">Audit Log</a>
<a href="/users" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg shadow text-center w-40">Users</a>
</div>
<hr class="border-t-2 border-gray-600 rounded-lg mb-4">
<h1 class="text-2xl font-bold mb-6 text-center">Add Subnet</h1>
{% if error %}
<div class="text-red-500 text-center mb-4">{{ error }}</div>
{% endif %}
<form action="/add_subnet" method="POST" class="mb-6" onsubmit="return validateSubnetForm();">
<div class="flex flex-col space-y-4">
<input type="text" name="name" placeholder="Subnet Name" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600" required>
<input type="text" name="cidr" id="cidr-input" placeholder="CIDR (e.g., 192.168.1.0/24)" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600" required>
<input type="text" name="site" placeholder="Site/Location" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600" required>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">Add Subnet</button>
<span id="cidr-error" class="text-red-500 text-sm hidden"></span>
</div>
</form>
<hr class="border-t-2 border-gray-600 rounded-lg mb-4">
<h1 class="text-2xl font-bold mb-6 text-center">Delete Subnet</h1>
<form action="/delete_subnet" method="POST" class="mb-6 flex items-center space-x-4 justify-center" onsubmit="return confirm('Are you sure you want to delete this subnet and all its IPs? This action is irreversible.');">
<select name="subnet_id" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600" required>
<option value="" disabled selected>Select Subnet</option>
{% for subnet in subnets %}
<option value="{{ subnet.id }}">{{ subnet.name }} ({{ subnet.cidr }})</option>
{% endfor %}
</select>
<button type="submit" class="text-red-500 hover:text-red-700 rounded-full p-3" title="Delete Subnet">
<i class="fas fa-trash fa-lg"></i>
</button>
</form>
</div>
</div>
</div>
<script src="/static/js/add_subnet.js"></script>
</body>
</html>
-107
View File
@@ -1,107 +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>
<form method="GET" class="flex flex-wrap gap-4 mb-6 justify-center">
<select name="user_id" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
<option value="">All Users</option>
{% for user in users %}
<option value="{{ user[0] }}" {% if request.args.get('user_id') == user[0]|string %}selected{% endif %}>{{ user[1] }}</option>
{% endfor %}
</select>
<select name="subnet_id" class="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>
<select name="action" class="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>
<select name="device_name" class="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>
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg flex items-center gap-2">
<i class="fas fa-search"></i>
<span>Filter</span>
</button>
</form>
<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</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 truncate" title="{{ log[3] }}">{{ log[3][:100] ~ ('…' if log[3]|length > 100 else '') }}</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>
{% 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 flex items-center gap-2">
<i class="fa fa-angle-left"></i>
<span class="hidden sm:inline">Prev</span>
</a>
{% endif %}
{% for p in range(1, total_pages+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 {{ '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 %}
{% 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 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>
document.addEventListener('DOMContentLoaded', function() {
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();
}
});
});
</script>
</body>
</html>
-87
View File
@@ -1,87 +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 w-auto min-w-[20rem] 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 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 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 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" 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 px-4 py-2 rounded-lg w-full">Add IP</button>
</div>
</form>
<div class="allocated-ips">
<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 py-1 mr-2 text-lg"><i class="fas fa-trash"></i></button>
</form>
</li>
{% endfor %}
</ul>
</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 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>
-44
View File
@@ -1,44 +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>
</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>
-59
View File
@@ -1,59 +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">
<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="/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>
<div class="mb-6">
<input type="text" id="search" placeholder="Search devices or IPs..." class="border p-3 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full">
</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 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="flex items-center justify-between 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">
<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>
</a>
</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
</div>
</div>
<script src="/static/js/devices.js"></script>
</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 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 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>
-37
View File
@@ -1,37 +0,0 @@
<header class="bg-zinc-800 shadow-md py-3 px-6 flex items-center justify-between relative">
<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">{{ NAME }} IPAM <span class="text-sm font-normal text-gray-300">v{{ VERSION }}</span></span>
</a>
<div class="hidden lg:block absolute left-1/2 transform -translate-x-1/2 text-white text-lg font-medium whitespace-nowrap">
{% if current_user_name %}Hello, {{ current_user_name }}{% endif %}
</div>
<nav class="hidden md:flex items-center space-x-6" id="main-nav">
<a href="/" class="text-gray-200 hover:text-gray-400 font-medium">Home</a>
<a href="/devices" class="text-gray-200 hover:text-gray-400 font-medium">Devices</a>
<a href="/racks" class="text-gray-200 hover:text-gray-400 font-medium">Racks</a>
<a href="/admin" class="text-gray-200 hover:text-gray-400 font-medium">Admin</a>
<a href="/audit" class="text-gray-200 hover:text-gray-400 font-medium">Audit Log</a>
<a href="/help" class="text-gray-200 hover:text-gray-400 font-medium">Help</a>
{% if current_user_name %}
<a href="/logout" class="text-gray-200 hover:text-gray-400 font-medium">Logout</a>
{% endif %}
</nav>
<button class="md:hidden flex items-center text-gray-200 focus:outline-none" 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="md:hidden absolute top-16 right-6 bg-zinc-800 rounded-lg shadow-lg z-50 w-48 hidden flex-col py-2" id="mobile-nav">
<a href="/" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Home</a>
<a href="/devices" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Devices</a>
<a href="/racks" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Racks</a>
<a href="/admin" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Admin</a>
<a href="/audit" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Audit Log</a>
<a href="/help" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Help</a>
{% 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>
</header>
-72
View File
@@ -1,72 +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 flex items-center justify-center mx-4">
<div class="container py-8 max-w-2xl pt-20">
<h1 class="text-3xl font-bold mb-8 text-center">Help & User Guide</h1>
<div class="space-y-10 text-lg">
<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 remove 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. Subnets are associated with sites.</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">User Management & Audit</h2>
<div class="space-y-4">
<div>
<h3 class="text-xl font-semibold mb-1">User Management & Admin</h3>
<p>Users can manage themselves and other users from the <a href="/users" class="text-blue-600 dark:text-blue-400 hover:underline">Users</a> page. Use this area to add, remove, or update user accounts.</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.</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" 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 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 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>
-139
View File
@@ -1,139 +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 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 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>
<script>
document.addEventListener('DOMContentLoaded', function() {
var btn = document.getElementById('export-csv');
if (btn) {
btn.addEventListener('click', function() {
window.location = '/rack/' + btn.getAttribute('data-rack-id') + '/export_csv';
});
}
});
</script>
</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 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 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 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 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 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 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>
function showBothAddButtons() {
document.getElementById('show-add-device-form').classList.remove('hidden');
document.getElementById('show-nonnet-form').classList.remove('hidden');
}
document.addEventListener('DOMContentLoaded', function() {
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();
};
});
</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"><i class="fas fa-times"></i></button>
</form>
{% set found = true %}
{% endif %}
{% endfor %}
</span>
</div>
{% endfor %}
</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>Racks</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-8 text-center">Racks</h1>
<div class="flex justify-center mb-6">
<a href="/rack/add" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg inline-block"><i class="fas fa-plus"></i> Add Rack</a>
</div>
<div class="space-y-6">
{% for rack in racks %}
<a href="/rack/{{ rack.id }}" class="bg-gray-200 hover:bg-gray-100 dark:bg-zinc-800 hover:dark:bg-zinc-700 rounded-lg shadow-md p-4 flex items-start justify-between hover:ring-2 hover:ring-gray-400 transition group">
<div>
<h2 class="text-xl font-bold dark:text-white mb-1">{{ rack.name }}</h2>
<div class="dark:text-gray-400">Site: {{ rack.site }} | Height: {{ rack.height_u }}U</div>
</div>
<div class="ml-6 flex-shrink-0">
<div class="relative flex items-center justify-center w-16 h-16 group">
<svg class="w-16 h-16 rotate-[-90deg]" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="42" stroke="#374151" stroke-width="12" fill="none" />
<circle cx="50" cy="50" r="42" stroke="#6b7280" stroke-width="12" fill="none" stroke-dasharray="264" stroke-dashoffset="{{ 264 - (264 * rack.percent_full / 100) }}" style="transition: stroke-dashoffset 0.5s;" />
</svg>
<div class="absolute inset-0 flex flex-col items-center justify-center">
<span class="text-sm font-bold">{{ rack.percent_full }}%</span>
<span class="text-xs dark:text-gray-300">Full</span>
</div>
</div>
</div>
</a>
{% else %}
<div class="text-center text-gray-400">No racks defined yet.</div>
{% endfor %}
</div>
</div>
</div>
</body>
</html>
-81
View File
@@ -1,81 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ subnet.name }} - Subnet Details</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<script src="/static/js/subnet.js"></script>
<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="javascript:window.history.back()" 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">{{ subnet.name }} ({{ subnet.cidr }})</h1>
<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 items-center justify-center rounded-full w-11 h-11 export-csv-btn" title="Export as CSV" data-subnet-id="{{ subnet.id }}">
<i class="fas fa-file-csv fa-lg"></i>
</button>
</div>
<div class="flex justify-center mb-4">
<a href="/subnet/{{ subnet.id }}/dhcp" class="hidden sm:flex bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg shadow items-center gap-2">
<i class="fas fa-network-wired"></i> Define DHCP Pool
</a>
</div>
<button id="toggle-desc" class="sm:hidden bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg mb-4 w-full">Show Descriptions</button>
<form action="" method="POST">
<table class="table-auto w-full mb-6">
<thead>
<tr>
<th class="text-center text-gray-700 dark:text-gray-400">IP Address</th>
<th class="text-center text-gray-700 dark:text-gray-400">Hostname</th>
<th class="text-center text-gray-700 dark:text-gray-400 hidden sm:table-cell" id="desc-col-header">Description</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-700">
{% for ip in ip_addresses %}
<tr>
<td class="font-bold text-center">{{ ip[1] }}</td>
<td class="text-center">
{% if ip[2] == 'DHCP' %}
<span class="font-semibold">DHCP</span>
{% elif ip[2] and ip[3] %}
<a href="/device/{{ ip[3] }}" class="hover:text-blue-300">{{ ip[2] }}</a>
{% elif ip[2] %}
{{ ip[2] }}
{% else %}
{{ '' }}
{% endif %}
</td>
<td class="text-left align-top hidden sm:table-cell desc-col">
<textarea readonly rows="1" class="border border-gray-600 rounded w-full resize-y cursor-pointer p-2">{{ ip[4].split('\n')[0] if ip[4] else '' }}</textarea>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
</div>
</div>
<script src="/static/js/export_csv.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
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';
});
}
});
</script>
</body>
</html>
-48
View File
@@ -1,48 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>User Management</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-6xl pt-20">
<h1 class="text-3xl font-bold mb-6 text-center">User Management</h1>
<form action="/users" 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">
<div class="flex flex-col space-y-4 items-center w-full">
<input type="text" name="name" placeholder="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="email" name="email" placeholder="Email Address" 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="password" name="password" placeholder="Password" 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>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg max-w-md w-full sm:w-auto">Add User</button>
</div>
</form>
<h2 class="text-xl font-bold mb-4">Existing Users</h2>
<ul class="space-y-4">
{% for user in users %}
<li class="bg-gray-200 dark:bg-zinc-800 p-4 rounded-lg flex justify-between items-center">
<form action="/users" method="POST" class="flex flex-row items-center space-x-2">
<input type="hidden" name="action" value="edit">
<input type="hidden" name="user_id" value="{{ user[0] }}">
<input type="text" name="name" value="{{ user[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-52">
<input type="email" name="email" value="{{ user[2] }}" class="border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-80">
<input type="password" name="password" placeholder="New Password (leave blank to keep)" class="border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-80">
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-3 py-1 rounded-lg">Save</button>
</form>
<form action="/users" method="POST" onsubmit="return confirm('Are you sure you want to delete this user?');">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="user_id" value="{{ user[0] }}">
<button type="submit" class="text-red-500 hover:text-red-700 mx-4" title="Delete User"><i class="fas fa-trash"></i></button>
</form>
</li>
{% endfor %}
</ul>
</div>
</div>
</body>
</html>
+130
View File
@@ -0,0 +1,130 @@
# v1 → v2 Breaking Changes
This document lists breaking changes when upgrading from IPAM v1.x to v2.0.
**Upgrade steps:** pull/deploy the v2 image or code, restart the application. Database migrations run automatically on startup via `init_db()`. No manual SQL is required for standard upgrades.
---
## Removed features
| Feature | v1 | v2 |
|---------|----|----|
| Response caching / rate limiting | Flask-Limiter + in-app cache layer | Removed — direct DB queries |
| In-app update checker | `/check_update` + header toast | Removed — check releases yourself |
| Feature flags | Admin toggles for racks, tags, IP notes, bulk ops | Removed — features always available, gated by permissions only |
| Backup & restore | Admin UI + `/backup/*` routes | Removed — use your own DB backup strategy |
| Help page | `/help` | Removed |
| Interactive API docs | `/api-docs` web page | Removed — see [API.md](API.md) |
| Custom field “searchable” flag | UI checkbox + DB column | Removed — was never wired to search |
| Device types | Device type CRUD, `device_type_id` on devices, rack placement filters | Removed — devices are untyped; use tags or custom fields instead |
### Dependencies removed
- `requests` (update checker)
- `Flask-Limiter` and the custom cache module (already gone in v2 prep)
---
## Removed routes
| Method | v1 path | Notes |
|--------|---------|-------|
| GET | `/check_update` | Update checker |
| GET/POST | `/backup`, `/backup/create`, `/backup/download/<file>`, `/backup/restore`, `/backup/delete/<file>` | Backup UI |
| GET | `/help` | Help page |
| GET | `/api-docs` | Swagger-style docs |
| GET/POST | `/admin/feature_flags` | Feature flag toggles |
| GET | `/custom_fields/<entity_type>` | Duplicate of API; use `/api/v1/custom_fields/{entity_type}` |
| GET | `/api/device/<id>/ip_history` | Moved — see below |
| GET | `/api/ip/<ip>/history` | Moved — see below |
| GET/POST/PUT/DELETE | `/api/v1/device-types`, `/api/v2/device-types` | Device types removed |
---
## API changes
### v2 is API-only + Vue SPA
v2 removes **all Jinja/HTML routes**. The UI is a Vue 3 SPA served from `static/dist/`. All functionality goes through `/api/v2/*`.
| v1 | v2 |
|----|-----|
| `/api/v1/*` | **`/api/v2/*`** (v1 removed) |
| Session via HTML login forms | `POST /api/v2/auth/login` + session cookie |
| API key only on API routes | **Same routes** accept session cookie **or** API key |
| `{ "devices": [...] }` list responses | **`{ "items": [...] }`** for list endpoints (where normalized) |
### Version
`GET /api/v2/info` reports `"api_version": "2.0"`.
### IP history endpoints
| v1 (removed) | v2 replacement | Auth |
|--------------|----------------|------|
| `GET /api/device/<id>/ip_history` | `GET /api/v2/devices/<id>/ip-history` | Session or API key |
| `GET /api/ip/<ip>/history` | `GET /api/v2/ips/<ip>/history` | Session or API key |
### Audit logging for API calls
v1 logged **every** API request (including GETs) to the audit log as `api_usage`.
v2 logs **mutating** requests only: `POST`, `PUT`, `DELETE`, `PATCH`. GET traffic is no longer audited. If you relied on GET audit entries for compliance, adjust your monitoring.
---
## Database migrations (automatic)
On startup, v2 runs these migrations against existing databases:
1. **Drop `FeatureFlags` table** — feature flags removed
2. **Drop `CustomFieldDefinition.searchable` column** — if present
3. **Remove orphaned permission `view_help`** — help page removed
4. **Drop `Device.device_type_id` column and `DeviceType` table** — device types removed
5. **Remove device-type permissions**`view_device_types`, `add_device_type`, etc.
No data loss for core IPAM entities (subnets, IPs, devices, racks, tags, users, audit log). Device type labels are not migrated; use tags or custom fields if you need classification.
---
## Permissions & sessions
- **`view_help`** permission is removed from the database on upgrade.
- **Device-type permissions** (`view_device_types`, `add_device_type`, etc.) are removed on upgrade.
- Feature-flag toggles no longer hide UI; use **roles and permissions** instead.
- Permissions are **cached in the session at login**. If an admin changes a user's role, that user must **log out and back in** for permission changes to take effect.
---
## Configuration
No new required environment variables. Removed features did not introduce new config keys.
Docker images no longer need a `/backups` volume mount for in-app backup (remove if you added it only for IPAM backup).
---
## Codebase layout (operators / fork maintainers)
| v1 | v2 |
|----|----|
| `app.py` + `routes.py` + `cache.py` + `totp_utils.py` | Single `app.py` + `db.py` |
| `templates/`, legacy `static/js` | **Deleted** — replaced by `frontend/``static/dist/` |
| Server-rendered Tailwind | Vue 3 + Vite + Tailwind (auto light/dark, cyan accents) |
---
## Recommended pre-upgrade checklist
1. **Back up your MariaDB/MySQL database** using your normal tooling (`mysqldump`, volume snapshot, etc.).
2. Note any integrations using removed routes (update checker, backup API, legacy IP history paths).
3. Deploy v2 and restart the container/process once.
4. Verify login, home page, devices, and one API call with your API key.
5. Have affected users re-login if roles were changed recently.
---
## Questions
For API reference after upgrade, see [API.md](API.md).