67 Commits

Author SHA1 Message Date
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
82 changed files with 9312 additions and 903 deletions
+5 -1
View File
@@ -6,7 +6,11 @@
"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 -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64; chmod +x tailwindcss-linux-x64; mv tailwindcss-linux-x64 tailwindcss",
+8 -1
View File
@@ -4,7 +4,8 @@ CHANGELOG.md
*.md *.md
# Deployment files # Deployment files
deployment.yml deployment-dev.yml
deployment-prod.yml
run.sh run.sh
Dockerfile Dockerfile
.dockerignore .dockerignore
@@ -49,3 +50,9 @@ tailwindcss
# OS files # OS files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Minified files
**/*.js
!**/*.min.js
device_types.css
devices.css
+34
View File
@@ -0,0 +1,34 @@
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
deploy:
name: Deploy to Kubernetes
needs: release
runs-on: k3s-internal-htz-01
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Deploy to Kubernetes
run: |
sudo kubectl replace -f deployment-dev.yml --grace-period=60 --force
+60
View File
@@ -0,0 +1,60 @@
name: Release
on:
pull_request:
branches:
- main
types: [closed]
jobs:
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
deploy:
name: Deploy to Kubernetes
needs: release
runs-on: k3s-internal-htz-01
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Deploy to Kubernetes
run: |
sudo kubectl replace -f deployment-prod.yml --grace-period=60 --force
-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
+1
View File
@@ -2,3 +2,4 @@ __pycache__
tailwindcss tailwindcss
static/css/output.css static/css/output.css
.env .env
backups/
-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.4.2"
}
-66
View File
@@ -1,66 +0,0 @@
# Changelog
## [1.4.2](https://github.com/JDB-NET/ipam/compare/v1.4.1...v1.4.2) (2025-11-08)
### Bug Fixes
* :bug: ensure all fields are updated by api ([5c1ad03](https://github.com/JDB-NET/ipam/commit/5c1ad039904b2c8c8629242b5558b03da5ad782c))
## [1.4.1](https://github.com/JDB-NET/ipam/compare/v1.4.0...v1.4.1) (2025-11-06)
### Bug Fixes
* :bug: pagination no longer gets out of control ([80b6de3](https://github.com/JDB-NET/ipam/commit/80b6de395fc4ddb4e7cd3ece89b423af2667d298))
* :bug: styling of admin and users pages ([d56e064](https://github.com/JDB-NET/ipam/commit/d56e0647f74fba1db1f504e02364406691ede9f3))
## [1.4.0](https://github.com/JDB-NET/ipam/compare/v1.3.0...v1.4.0) (2025-11-06)
### Features
* :sparkles: full api integration ([c53472c](https://github.com/JDB-NET/ipam/commit/c53472c5d760e28e53a737cb0546e85c9a422d15))
## [1.3.0](https://github.com/JDB-NET/ipam/compare/v1.2.0...v1.3.0) (2025-11-06)
### Features
* :sparkles: role based access control ([3bf2697](https://github.com/JDB-NET/ipam/commit/3bf269701030bc1f14a48c5af488286c424dbfa7))
## [1.2.0](https://github.com/JDB-NET/ipam/compare/v1.1.1...v1.2.0) (2025-11-06)
### Features
* :sparkles: added the ability to create/edit/remove device types ([d68eefc](https://github.com/JDB-NET/ipam/commit/d68eefcf0cc4a59cda9cedb3e126d974ee45d2ad))
### Bug Fixes
* :bug: missing button classes ([f93fa15](https://github.com/JDB-NET/ipam/commit/f93fa155eb5d6c9ff4ed19f332c3ad6fff328d31))
## [1.1.1](https://github.com/JDB-NET/ipam/compare/v1.1.0...v1.1.1) (2025-11-01)
### Bug Fixes
* :bug: image name ([de123fa](https://github.com/JDB-NET/ipam/commit/de123fafd40d97ea6e545bd8dd1d3a812e2a709f))
## [1.1.0](https://github.com/JDB-NET/ipam/compare/v1.0.0...v1.1.0) (2025-11-01)
### Features
* Added icon on login button. Closes [#1](https://github.com/JDB-NET/ipam/issues/1) ([6e068b6](https://github.com/JDB-NET/ipam/commit/6e068b672592f7d23ca66a0a6189b5763d89a698))
* Added light mode up to admin ([38c8402](https://github.com/JDB-NET/ipam/commit/38c840251f03c8f1e1a2c407efa77621df70ce2f))
* Rack stuff now complete ([5d220d3](https://github.com/JDB-NET/ipam/commit/5d220d354df83db8b2bfbf8e2c87bd78ba91f6e5))
### Bug Fixes
* Back buttons now hidden on mobile ([40a7a2f](https://github.com/JDB-NET/ipam/commit/40a7a2f2d58f6c89a7e7e74908c088e7eddf966a))
* Corrected image in deployment ([9ecd492](https://github.com/JDB-NET/ipam/commit/9ecd492065fcd226d274f8e343d401437e1c8de8))
* Fixed back button on device page ([9734e4d](https://github.com/JDB-NET/ipam/commit/9734e4df0b27461867393c132991f9e2ec907de4))
* Fixed database initialisation and dropped to 1 worker ([7cd6a0f](https://github.com/JDB-NET/ipam/commit/7cd6a0f96d8dc20743603d55498d8c1af8069690))
+5 -2
View File
@@ -1,12 +1,15 @@
FROM python:3.13-slim FROM python:3.13-slim
LABEL org.opencontainers.image.vendor="JDB-NET"
WORKDIR /app WORKDIR /app
COPY . /app COPY . /app
ARG VERSION=unknown
ENV VERSION=${VERSION}
RUN pip install -r requirements.txt RUN pip install -r requirements.txt
RUN apt-get update && apt-get install -y curl RUN apt-get update && apt-get install -y curl mariadb-client-compat
RUN rm -rf /var/lib/apt/lists/* RUN rm -rf /var/lib/apt/lists/*
RUN curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 \ RUN curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 \
&& chmod +x tailwindcss-linux-x64 \ && chmod +x tailwindcss-linux-x64 \
&& ./tailwindcss-linux-x64 -i ./static/css/input.css -o ./static/css/output.css --content "./templates/*.html,./static/js/*.js" --minify \ && ./tailwindcss-linux-x64 -i ./static/css/input.css -o ./static/css/output.css --content "./templates/*.html,./static/js/*.min.js" --minify \
&& rm tailwindcss-linux-x64 && rm tailwindcss-linux-x64
EXPOSE 5000 EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app", "--log-level", "warning"] CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app", "--log-level", "warning"]
+42 -12
View File
@@ -11,10 +11,11 @@ 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
- **Role-Based Access Control (RBAC)**: Granular permission system with default roles (admin, user, view_only) and custom role creation - **Role-Based Access Control (RBAC)**: Granular permission system with default roles (admin, user, view_only) and custom role creation
@@ -31,36 +32,37 @@ A Flask-based web application for comprehensive IP Address Management (IPAM). Ma
docker run -d \ docker run -d \
--name ipam \ --name ipam \
-p 5000:5000 \ -p 5000:5000 \
-v ./backups:/app/backups \
-e MYSQL_HOST=10.10.2.27 \ -e MYSQL_HOST=10.10.2.27 \
-e MYSQL_USER=ipam \ -e MYSQL_USER=ipam \
-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
volumes:
- ./backups:/app/backups
``` ```
## Configuration ## Configuration
@@ -72,8 +74,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
@@ -135,6 +137,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.
@@ -182,6 +200,9 @@ The application includes a comprehensive REST API for programmatic access:
3. **Available Endpoints**: 3. **Available Endpoints**:
- **Devices**: `GET`, `POST`, `PUT`, `DELETE /api/v1/devices` - **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` - **Subnets**: `GET`, `POST`, `PUT`, `DELETE /api/v1/subnets`
- **Racks**: `GET`, `POST`, `DELETE /api/v1/racks` - **Racks**: `GET`, `POST`, `DELETE /api/v1/racks`
- **Device Types**: `GET /api/v1/device-types` - **Device Types**: `GET /api/v1/device-types`
@@ -193,10 +214,19 @@ The application includes a comprehensive REST API for programmatic access:
5. **Documentation**: Full API documentation is available in the Help page of the web interface. 5. **Documentation**: Full API documentation is available in the Help page of the web interface.
**Example API Request**: **Example API Requests**:
```bash ```bash
# List all devices
curl -H "X-API-Key: your_api_key" \ curl -H "X-API-Key: your_api_key" \
https://your-server:5000/api/v1/devices 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
@@ -217,7 +247,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:
-1
View File
@@ -1 +0,0 @@
1.4.2
+20 -12
View File
@@ -1,4 +1,6 @@
from flask import Flask, session from flask import Flask, session
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from db import init_db, hash_password, get_db_connection from db import init_db, hash_password, get_db_connection
from routes import register_routes from routes import register_routes
import os import os
@@ -15,29 +17,35 @@ app.config['MYSQL_USER'] = os.environ.get('MYSQL_USER', 'user')
app.config['MYSQL_PASSWORD'] = os.environ.get('MYSQL_PASSWORD', 'password') app.config['MYSQL_PASSWORD'] = os.environ.get('MYSQL_PASSWORD', 'password')
app.config['MYSQL_DATABASE'] = os.environ.get('MYSQL_DATABASE', 'ipam') app.config['MYSQL_DATABASE'] = os.environ.get('MYSQL_DATABASE', 'ipam')
# Initialize rate limiter
limiter = Limiter(
app=app,
key_func=get_remote_address,
default_limits=["200 per hour", "50 per minute"],
storage_uri="memory://"
)
@app.context_processor @app.context_processor
def inject_env_vars(): def inject_env_vars():
version = 'unknown' version = os.environ.get('VERSION', 'unknown')
try:
version_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'VERSION')
if os.path.exists(version_file):
with open(version_file, 'r') as f:
version = f.read().strip()
except Exception:
pass
# Import has_permission from routes after routes are registered # Import has_permission and is_feature_enabled from routes after routes are registered
from routes import has_permission from routes import has_permission, is_feature_enabled
return { return {
'NAME': os.environ.get('NAME', 'JDB-NET'), 'NAME': os.environ.get('NAME', 'JDB-NET'),
'LOGO_PNG': os.environ.get('LOGO_PNG', 'https://assets.s3.jdbnet.co.uk/logo/128x128.png'), 'LOGO_PNG': os.environ.get('LOGO_PNG', 'https://assets.s3.jdbnet.co.uk/logo/128x128.png'),
'VERSION': version, 'VERSION': version,
'has_permission': has_permission 'has_permission': has_permission,
'is_feature_enabled': is_feature_enabled
} }
register_routes(app) register_routes(app, limiter)
init_db(app) init_db(app)
# Start cache pre-warming in background
from routes import prewarm_cache
prewarm_cache(app)
if __name__ == '__main__': if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True) app.run(host='0.0.0.0', port=5000, debug=True)
+191
View File
@@ -0,0 +1,191 @@
"""
In-memory caching module with TTL support and cache invalidation
"""
import time
import sys
from threading import Lock
from functools import wraps
class Cache:
"""Simple in-memory cache with TTL support and size limiting"""
def __init__(self, max_size_mb=50):
self._cache = {}
self._lock = Lock()
self._max_size_bytes = max_size_mb * 1024 * 1024 # Convert MB to bytes
self._access_order = [] # Track access order for LRU eviction
def _get_size(self, obj):
"""Estimate size of an object in bytes"""
size = sys.getsizeof(obj)
if isinstance(obj, dict):
size += sum(self._get_size(k) + self._get_size(v) for k, v in obj.items())
elif isinstance(obj, (list, tuple)):
size += sum(self._get_size(item) for item in obj)
elif isinstance(obj, str):
size += sys.getsizeof(obj) - sys.getsizeof('')
return size
def _get_cache_size(self):
"""Get approximate total size of cache in bytes"""
total_size = sys.getsizeof(self._cache)
for key, (value, expiry) in self._cache.items():
total_size += self._get_size(key) + self._get_size(value) + sys.getsizeof(expiry)
return total_size
def _evict_if_needed(self):
"""Evict entries if cache exceeds size limit"""
current_size = self._get_cache_size()
if current_size <= self._max_size_bytes:
return
# First, remove expired entries
current_time = time.time()
expired_keys = []
for key in list(self._cache.keys()):
_, expiry = self._cache[key]
if expiry is not None and current_time >= expiry:
expired_keys.append(key)
for key in expired_keys:
if key in self._cache:
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
# If still over limit, remove oldest entries (LRU)
current_size = self._get_cache_size()
while current_size > self._max_size_bytes and self._access_order:
oldest_key = self._access_order.pop(0)
if oldest_key in self._cache:
del self._cache[oldest_key]
current_size = self._get_cache_size()
def get(self, key):
"""Get value from cache if it exists and hasn't expired"""
with self._lock:
if key in self._cache:
value, expiry = self._cache[key]
if expiry is None or time.time() < expiry:
# Update access order (move to end for LRU)
if key in self._access_order:
self._access_order.remove(key)
self._access_order.append(key)
return value
else:
# Expired, remove it
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
return None
def set(self, key, value, ttl=None):
"""Set value in cache with optional TTL (time to live in seconds)"""
with self._lock:
# Remove old entry if it exists
if key in self._cache:
if key in self._access_order:
self._access_order.remove(key)
expiry = None if ttl is None else time.time() + ttl
self._cache[key] = (value, expiry)
self._access_order.append(key)
# Evict if needed to stay under size limit
self._evict_if_needed()
def delete(self, key):
"""Delete a key from cache"""
with self._lock:
if key in self._cache:
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
def clear(self, pattern=None):
"""Clear cache entries. If pattern is provided, only clear keys matching the pattern."""
with self._lock:
if pattern is None:
self._cache.clear()
self._access_order.clear()
else:
keys_to_delete = [key for key in self._cache.keys() if pattern in key]
for key in keys_to_delete:
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
def invalidate_subnet(self, subnet_id):
"""Invalidate all cache entries related to a specific subnet"""
patterns = [
f'subnet:{subnet_id}',
f'subnet_list',
f'index',
f'admin',
f'utilization:{subnet_id}'
]
with self._lock:
keys_to_delete = []
for key in self._cache.keys():
for pattern in patterns:
if pattern in key:
keys_to_delete.append(key)
break
for key in keys_to_delete:
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
def invalidate_device(self, device_id):
"""Invalidate all cache entries related to a specific device"""
patterns = [
f'device:{device_id}',
f'device_list',
f'devices',
f'device_types'
]
with self._lock:
keys_to_delete = []
for key in self._cache.keys():
for pattern in patterns:
if pattern in key:
keys_to_delete.append(key)
break
for key in keys_to_delete:
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
def invalidate_all(self):
"""Invalidate all cache entries"""
self.clear()
# Global cache instance
cache = Cache()
def cached(ttl=None, key_prefix=''):
"""
Decorator to cache function results
Args:
ttl: Time to live in seconds (None = no expiration)
key_prefix: Prefix for cache key
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
# Create cache key from function name, args, and kwargs
cache_key = f"{key_prefix}{func.__name__}:{str(args)}:{str(sorted(kwargs.items()))}"
# Try to get from cache
cached_value = cache.get(cache_key)
if cached_value is not None:
return cached_value
# Call function and cache result
result = func(*args, **kwargs)
cache.set(cache_key, result, ttl)
return result
return wrapper
return decorator
+268 -7
View File
@@ -3,6 +3,7 @@ import hashlib
import base64 import base64
import secrets import secrets
import mysql.connector import mysql.connector
import logging
from flask import current_app from flask import current_app
def hash_password(password, salt=None): def hash_password(password, salt=None):
@@ -64,8 +65,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('''
@@ -132,6 +133,7 @@ def init_db(app=None):
FOREIGN KEY (device_id) REFERENCES Device(id) ON DELETE CASCADE FOREIGN KEY (device_id) REFERENCES Device(id) ON DELETE CASCADE
) )
''') ''')
# Initialize default device types only if table is empty
cursor.execute('SELECT COUNT(*) FROM DeviceType') cursor.execute('SELECT COUNT(*) FROM DeviceType')
if cursor.fetchone()[0] == 0: if cursor.fetchone()[0] == 0:
cursor.executemany('INSERT INTO DeviceType (name, icon_class) VALUES (%s, %s)', [ cursor.executemany('INSERT INTO DeviceType (name, icon_class) VALUES (%s, %s)', [
@@ -143,12 +145,20 @@ def init_db(app=None):
('Printer', 'fa-print'), ('Printer', 'fa-print'),
('Other', 'fa-question') ('Other', 'fa-question')
]) ])
conn.commit() # Commit the inserts before querying
# Add device_type_id column if it doesn't exist
cursor.execute("SHOW COLUMNS FROM Device LIKE 'device_type_id'") cursor.execute("SHOW COLUMNS FROM Device LIKE 'device_type_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 Device ADD COLUMN device_type_id INTEGER DEFAULT NULL')
cursor.execute("SELECT id FROM DeviceType WHERE name='Other'")
other_id = cursor.fetchone()[0] # Set default device_type_id for devices that don't have one
cursor.execute('UPDATE Device SET device_type_id = %s WHERE device_type_id IS NULL', (other_id,)) # Use the first available device type, or leave NULL if no types exist
cursor.execute('SELECT id FROM DeviceType ORDER BY id LIMIT 1')
first_type_result = cursor.fetchone()
if first_type_result:
first_type_id = first_type_result[0]
cursor.execute('UPDATE Device SET device_type_id = %s WHERE device_type_id IS NULL', (first_type_id,))
try: try:
cursor.execute('ALTER TABLE Device ADD CONSTRAINT fk_device_type FOREIGN KEY (device_type_id) REFERENCES DeviceType(id)') cursor.execute('ALTER TABLE Device ADD CONSTRAINT fk_device_type FOREIGN KEY (device_type_id) REFERENCES DeviceType(id)')
except mysql.connector.Error as e: except mysql.connector.Error as e:
@@ -199,6 +209,173 @@ def init_db(app=None):
if not cursor.fetchone(): if not cursor.fetchone():
cursor.execute('ALTER TABLE User ADD COLUMN api_key VARCHAR(255) DEFAULT NULL UNIQUE') cursor.execute('ALTER TABLE User ADD COLUMN api_key VARCHAR(255) DEFAULT NULL UNIQUE')
# Add 2FA columns to User table if they don't exist
cursor.execute("SHOW COLUMNS FROM User LIKE 'totp_secret'")
if not cursor.fetchone():
cursor.execute('ALTER TABLE User ADD COLUMN totp_secret VARCHAR(255) DEFAULT NULL')
cursor.execute("SHOW COLUMNS FROM User LIKE 'totp_enabled'")
if not cursor.fetchone():
cursor.execute('ALTER TABLE User ADD COLUMN totp_enabled BOOLEAN DEFAULT FALSE')
cursor.execute("SHOW COLUMNS FROM User LIKE 'backup_codes'")
if not cursor.fetchone():
cursor.execute('ALTER TABLE User ADD COLUMN backup_codes TEXT DEFAULT NULL')
cursor.execute("SHOW COLUMNS FROM User LIKE 'two_fa_setup_complete'")
if not cursor.fetchone():
cursor.execute('ALTER TABLE User ADD COLUMN two_fa_setup_complete BOOLEAN DEFAULT FALSE')
# Add require_2fa column to Role table if it doesn't exist
cursor.execute("SHOW COLUMNS FROM Role LIKE 'require_2fa'")
if not cursor.fetchone():
cursor.execute('ALTER TABLE Role ADD COLUMN require_2fa BOOLEAN DEFAULT FALSE')
# Ensure AuditLog foreign keys have ON DELETE SET NULL to preserve audit logs
# This is critical - audit logs should NEVER be deleted, even when referenced entities are deleted
try:
# 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:
# 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 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,
searchable BOOLEAN DEFAULT FALSE,
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')
# Create FeatureFlags table
cursor.execute('''
CREATE TABLE IF NOT EXISTS FeatureFlags (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
feature_key VARCHAR(255) NOT NULL UNIQUE,
enabled BOOLEAN DEFAULT TRUE,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
''')
# Initialize default feature flags
default_features = [
('racks', True, 'Enable rack management functionality'),
('ip_address_notes', True, 'Enable IP address notes/descriptions editing on subnet page'),
('device_tags', True, 'Enable device tagging functionality'),
('bulk_operations', True, 'Enable bulk operations for devices and IPs')
]
for feature_key, enabled, description in default_features:
cursor.execute('SELECT id FROM FeatureFlags WHERE feature_key = %s', (feature_key,))
if not cursor.fetchone():
cursor.execute('INSERT INTO FeatureFlags (feature_key, enabled, description) VALUES (%s, %s, %s)',
(feature_key, enabled, description))
# Define all permissions with categories # Define all permissions with categories
permissions = [ permissions = [
# View permissions # View permissions
@@ -246,6 +423,18 @@ def init_db(app=None):
('edit_device_type', 'Edit device type', 'Device Type'), ('edit_device_type', 'Edit device type', 'Device Type'),
('delete_device_type', 'Delete device type', 'Device Type'), ('delete_device_type', 'Delete device type', 'Device Type'),
# 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 # Admin permissions
('manage_users', 'Manage users (add, edit, delete)', 'Admin'), ('manage_users', 'Manage users (add, edit, delete)', 'Admin'),
('manage_roles', 'Manage roles and permissions', 'Admin'), ('manage_roles', 'Manage roles and permissions', 'Admin'),
@@ -306,7 +495,9 @@ def init_db(app=None):
'add_rack', 'delete_rack', 'add_device_to_rack', 'remove_device_from_rack', 'add_rack', 'delete_rack', 'add_device_to_rack', 'remove_device_from_rack',
'add_nonnet_device_to_rack', 'export_rack_csv', 'add_nonnet_device_to_rack', 'export_rack_csv',
'configure_dhcp', 'configure_dhcp',
'add_device_type', 'edit_device_type', 'delete_device_type' 'add_device_type', 'edit_device_type', 'delete_device_type',
'view_tags', 'add_tag', 'edit_tag', 'delete_tag', 'assign_device_tag', 'remove_device_tag',
'view_custom_fields', 'manage_custom_fields'
] ]
for perm_name in non_admin_permissions: for perm_name in non_admin_permissions:
@@ -325,7 +516,7 @@ def init_db(app=None):
view_only_permissions = [ view_only_permissions = [
'view_index', 'view_devices', 'view_device', 'view_subnet', 'view_racks', 'view_rack', 'view_index', 'view_devices', 'view_device', 'view_subnet', 'view_racks', 'view_rack',
'view_audit', 'view_device_types', 'view_device_type_stats', 'view_devices_by_type', 'view_audit', 'view_device_types', 'view_device_type_stats', 'view_devices_by_type',
'view_dhcp', 'view_help' 'view_dhcp', 'view_help', 'view_tags', 'view_custom_fields'
] ]
for perm_name in view_only_permissions: for perm_name in view_only_permissions:
@@ -355,5 +546,75 @@ def init_db(app=None):
api_key = generate_api_key() api_key = generate_api_key()
cursor.execute('''INSERT INTO User (name, email, password, role_id, api_key) VALUES (%s, %s, %s, %s, %s)''', 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)) ('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
create_index_if_not_exists(cursor, 'idx_device_device_type_id', 'Device', 'device_type_id')
# 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")
conn.commit() conn.commit()
conn.close() conn.close()
+2 -2
View File
@@ -15,7 +15,7 @@ spec:
spec: spec:
containers: containers:
- name: ipam - name: ipam
image: ghcr.io/jdb-net/ipam:latest image: cr.jdbnet.co.uk/public/ipam:dev
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
- containerPort: 5000 - containerPort: 5000
@@ -24,7 +24,7 @@ spec:
- name: SECRET_KEY - name: SECRET_KEY
value: "41TbN7v5peFLZPrdwSCc64J3mjmiUk5fkVWsmb2m" value: "41TbN7v5peFLZPrdwSCc64J3mjmiUk5fkVWsmb2m"
- name: MYSQL_HOST - name: MYSQL_HOST
value: "10.10.2.27" value: "10.10.25.4"
- name: MYSQL_USER - name: MYSQL_USER
value: "ipam" value: "ipam"
- name: MYSQL_PASSWORD - name: MYSQL_PASSWORD
+64
View File
@@ -0,0 +1,64 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: ipam
namespace: ipam
spec:
replicas: 1
selector:
matchLabels:
app: ipam
template:
metadata:
labels:
app: ipam
spec:
containers:
- name: ipam
image: cr.jdbnet.co.uk/public/ipam:latest
imagePullPolicy: Always
ports:
- containerPort: 5000
name: "ipam"
env:
- name: SECRET_KEY
value: "41TbN7v5peFLZPrdwSCc64J3mjmiUk5fkVWsmb2m"
- name: MYSQL_HOST
value: "10.10.25.4"
- name: MYSQL_USER
value: "ipam"
- name: MYSQL_PASSWORD
value: "WXPmo05sGCfjGe"
- name: MYSQL_DATABASE
value: "ipam"
---
apiVersion: v1
kind: Service
metadata:
name: ipam-ingress-service
namespace: ipam
spec:
selector:
app: ipam
ports:
- protocol: TCP
port: 80
targetPort: 5000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ipam-ingress
namespace: ipam
spec:
rules:
- host: ipam.jdb143.uk
http:
paths:
- pathType: Prefix
path: "/"
backend:
service:
name: ipam-ingress-service
port:
number: 80
+4
View File
@@ -2,3 +2,7 @@ Flask
mysql-connector-python mysql-connector-python
dotenv dotenv
gunicorn gunicorn
requests
pyotp
qrcode[pil]
Flask-Limiter
+3720 -160
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
echo "Generating CSS..." echo "Generating CSS..."
./tailwindcss -i ./static/css/input.css -o ./static/css/output.css --content "./templates/*.html,./static/js/*.js" --minify ./tailwindcss -i ./static/css/input.css -o ./static/css/output.css --content "./templates/*.html,./static/js/*.min.js" --minify
echo "Starting app..." echo "Starting app..."
python app.py python app.py
+1
View File
@@ -0,0 +1 @@
.icon-suggestions{max-height:240px;overflow-y:auto;border-radius:.5rem;box-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -2px rgba(0,0,0,.05)}.icon-suggestion-item{display:flex;align-items:center;gap:.75rem;padding:.75rem 1rem;cursor:pointer;transition:background-color .15s ease-in-out;border-bottom:1px solid rgba(0,0,0,.1)}.icon-suggestion-item:last-child{border-bottom:none}.icon-suggestion-item:hover{background-color:rgba(0,0,0,.05)}.dark .icon-suggestion-item{border-bottom-color:rgba(255,255,255,.1)}.dark .icon-suggestion-item:hover{background-color:rgba(255,255,255,.1)}.icon-suggestion-item i{width:20px;text-align:center;font-size:1.125rem;color:#4b5563}.dark .icon-suggestion-item i{color:#d1d5db}.icon-suggestion-item span{font-family:'Courier New',monospace;font-size:.875rem;color:#374151}.dark .icon-suggestion-item span{color:#e5e7eb}.icon-preview{display:flex;align-items:center;justify-content:center;min-width:2rem}.icon-suggestions::-webkit-scrollbar{width:8px}.icon-suggestions::-webkit-scrollbar-track{background:rgba(0,0,0,.05);border-radius:4px}.dark .icon-suggestions::-webkit-scrollbar-track{background:rgba(255,255,255,.05)}.icon-suggestions::-webkit-scrollbar-thumb{background:rgba(0,0,0,.2);border-radius:4px}.dark .icon-suggestions::-webkit-scrollbar-thumb{background:rgba(255,255,255,.2)}.icon-suggestions::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.3)}.dark .icon-suggestions::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.3)}
+1 -1
View File
@@ -1,7 +1,7 @@
h2 { h2 {
cursor: pointer; cursor: pointer;
} }
form:not(.mb-6), .mt-4 { .container form:not(.mb-6), .mt-4 {
display: none; display: none;
} }
.allocated-ips { .allocated-ips {
+1
View File
@@ -0,0 +1 @@
h2{cursor:pointer}.container form:not(.mb-6),.mt-4{display:none}.allocated-ips{display:block;margin-top:1rem}.button-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:1rem;justify-items:center}
+1
View File
@@ -0,0 +1 @@
function validateSubnetForm(){let e=document.getElementById("cidr-input"),t=document.getElementById("cidr-error");return/^(?:\d{1,3}\.){3}\d{1,3}\/([0-9]|[1-2][0-9]|3[0-2])$/.test(e.value.trim())?(t.textContent="",t.classList.add("hidden"),e.classList.remove("border-red-500"),!0):(t.textContent="Please enter a valid CIDR (e.g., 192.168.1.0/24)",t.classList.remove("hidden"),e.classList.add("border-red-500"),!1)}
+147
View File
@@ -0,0 +1,147 @@
function showAddSubnetModal() {
document.getElementById('add-subnet-modal').classList.remove('hidden');
document.getElementById('add-subnet-name').value = '';
document.getElementById('add-subnet-cidr').value = '';
document.getElementById('add-subnet-site').value = '';
document.getElementById('add-subnet-vlan-id').value = '';
document.getElementById('add-subnet-vlan-description').value = '';
document.getElementById('add-subnet-vlan-notes').value = '';
document.getElementById('vlan-id-error').classList.add('hidden');
}
function closeAddSubnetModal() {
document.getElementById('add-subnet-modal').classList.add('hidden');
document.getElementById('cidr-error').classList.add('hidden');
document.getElementById('vlan-id-error').classList.add('hidden');
}
function editSubnet(subnetId, name, cidr, site, vlanId, vlanDescription, vlanNotes) {
document.getElementById('edit-subnet-id').value = subnetId;
document.getElementById('edit-subnet-name').value = name;
document.getElementById('edit-subnet-cidr').value = cidr;
document.getElementById('edit-subnet-site').value = site;
document.getElementById('edit-subnet-vlan-id').value = vlanId || '';
document.getElementById('edit-subnet-vlan-description').value = vlanDescription || '';
document.getElementById('edit-subnet-vlan-notes').value = vlanNotes || '';
document.getElementById('edit-subnet-modal').classList.remove('hidden');
}
function closeEditSubnetModal() {
document.getElementById('edit-subnet-modal').classList.add('hidden');
document.getElementById('edit-cidr-error').classList.add('hidden');
document.getElementById('edit-vlan-id-error').classList.add('hidden');
}
function validateVlanId(vlanIdValue, errorElementId) {
if (!vlanIdValue || vlanIdValue.trim() === '') {
return true; // VLAN ID is optional
}
const vlanId = parseInt(vlanIdValue.trim());
if (isNaN(vlanId)) {
const errorElement = document.getElementById(errorElementId);
if (errorElement) {
errorElement.textContent = 'VLAN ID must be a valid integer';
errorElement.classList.remove('hidden');
}
return false;
}
if (vlanId < 1 || vlanId > 4094) {
const errorElement = document.getElementById(errorElementId);
if (errorElement) {
errorElement.textContent = 'VLAN ID must be between 1 and 4094';
errorElement.classList.remove('hidden');
}
return false;
}
const errorElement = document.getElementById(errorElementId);
if (errorElement) {
errorElement.classList.add('hidden');
}
return true;
}
function validateSubnetForm() {
const cidrInput = document.getElementById('add-subnet-cidr');
const cidrError = document.getElementById('cidr-error');
const cidr = cidrInput.value.trim();
// Basic CIDR validation
const cidrPattern = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/;
if (!cidrPattern.test(cidr)) {
cidrError.textContent = 'Invalid CIDR format. Use format like 192.168.1.0/24';
cidrError.classList.remove('hidden');
return false;
}
// Check prefix length
const parts = cidr.split('/');
if (parts.length === 2) {
const prefixLen = parseInt(parts[1]);
if (prefixLen < 24 || prefixLen > 32) {
cidrError.textContent = 'Subnet must be /24 or smaller (e.g., /24, /25, ... /32)';
cidrError.classList.remove('hidden');
return false;
}
}
cidrError.classList.add('hidden');
// Validate VLAN ID
const vlanIdInput = document.getElementById('add-subnet-vlan-id');
if (!validateVlanId(vlanIdInput.value, 'vlan-id-error')) {
return false;
}
return true;
}
function validateEditSubnetForm() {
const cidrInput = document.getElementById('edit-subnet-cidr');
const cidrError = document.getElementById('edit-cidr-error');
const cidr = cidrInput.value.trim();
// Basic CIDR validation
const cidrPattern = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/;
if (!cidrPattern.test(cidr)) {
cidrError.textContent = 'Invalid CIDR format. Use format like 192.168.1.0/24';
cidrError.classList.remove('hidden');
return false;
}
// Check prefix length
const parts = cidr.split('/');
if (parts.length === 2) {
const prefixLen = parseInt(parts[1]);
if (prefixLen < 24 || prefixLen > 32) {
cidrError.textContent = 'Subnet must be /24 or smaller (e.g., /24, /25, ... /32)';
cidrError.classList.remove('hidden');
return false;
}
}
cidrError.classList.add('hidden');
// Validate VLAN ID
const vlanIdInput = document.getElementById('edit-subnet-vlan-id');
if (!validateVlanId(vlanIdInput.value, 'edit-vlan-id-error')) {
return false;
}
return true;
}
// Close modals when clicking outside
window.onclick = function(event) {
const addModal = document.getElementById('add-subnet-modal');
const editModal = document.getElementById('edit-subnet-modal');
if (event.target === addModal) {
closeAddSubnetModal();
}
if (event.target === editModal) {
closeEditSubnetModal();
}
}
+1
View File
@@ -0,0 +1 @@
function showAddSubnetModal(){document.getElementById("add-subnet-modal").classList.remove("hidden"),document.getElementById("add-subnet-name").value="",document.getElementById("add-subnet-cidr").value="",document.getElementById("add-subnet-site").value="",document.getElementById("add-subnet-vlan-id").value="",document.getElementById("add-subnet-vlan-description").value="",document.getElementById("add-subnet-vlan-notes").value="",document.getElementById("vlan-id-error").classList.add("hidden")}function closeAddSubnetModal(){document.getElementById("add-subnet-modal").classList.add("hidden"),document.getElementById("cidr-error").classList.add("hidden"),document.getElementById("vlan-id-error").classList.add("hidden")}function editSubnet(e,t,d,n,l,i,a){document.getElementById("edit-subnet-id").value=e,document.getElementById("edit-subnet-name").value=t,document.getElementById("edit-subnet-cidr").value=d,document.getElementById("edit-subnet-site").value=n,document.getElementById("edit-subnet-vlan-id").value=l||"",document.getElementById("edit-subnet-vlan-description").value=i||"",document.getElementById("edit-subnet-vlan-notes").value=a||"",document.getElementById("edit-subnet-modal").classList.remove("hidden")}function closeEditSubnetModal(){document.getElementById("edit-subnet-modal").classList.add("hidden"),document.getElementById("edit-cidr-error").classList.add("hidden"),document.getElementById("edit-vlan-id-error").classList.add("hidden")}function validateVlanId(e,t){if(!e||""===e.trim())return!0;let d=parseInt(e.trim());if(isNaN(d)){let n=document.getElementById(t);return n&&(n.textContent="VLAN ID must be a valid integer",n.classList.remove("hidden")),!1}if(d<1||d>4094){let l=document.getElementById(t);return l&&(l.textContent="VLAN ID must be between 1 and 4094",l.classList.remove("hidden")),!1}let i=document.getElementById(t);return i&&i.classList.add("hidden"),!0}function validateSubnetForm(){let e=document.getElementById("add-subnet-cidr"),t=document.getElementById("cidr-error"),d=e.value.trim();if(!/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(d))return t.textContent="Invalid CIDR format. Use format like 192.168.1.0/24",t.classList.remove("hidden"),!1;let n=d.split("/");if(2===n.length){let l=parseInt(n[1]);if(l<24||l>32)return t.textContent="Subnet must be /24 or smaller (e.g., /24, /25, ... /32)",t.classList.remove("hidden"),!1}t.classList.add("hidden");let i=document.getElementById("add-subnet-vlan-id");return!!validateVlanId(i.value,"vlan-id-error")}function validateEditSubnetForm(){let e=document.getElementById("edit-subnet-cidr"),t=document.getElementById("edit-cidr-error"),d=e.value.trim();if(!/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(d))return t.textContent="Invalid CIDR format. Use format like 192.168.1.0/24",t.classList.remove("hidden"),!1;let n=d.split("/");if(2===n.length){let l=parseInt(n[1]);if(l<24||l>32)return t.textContent="Subnet must be /24 or smaller (e.g., /24, /25, ... /32)",t.classList.remove("hidden"),!1}t.classList.add("hidden");let i=document.getElementById("edit-subnet-vlan-id");return!!validateVlanId(i.value,"edit-vlan-id-error")}window.onclick=function(e){let t=document.getElementById("add-subnet-modal"),d=document.getElementById("edit-subnet-modal");e.target===t&&closeAddSubnetModal(),e.target===d&&closeEditSubnetModal()};
+79
View File
@@ -0,0 +1,79 @@
// API Documentation Interactive Functions
function getApiKey() {
return document.getElementById('apiKey').value;
}
function showStatus(message, isError = false) {
const status = document.getElementById('connectionStatus');
status.textContent = message;
status.className = `mt-2 text-sm ${isError ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}`;
}
async function testConnection() {
const apiKey = getApiKey();
if (!apiKey) {
showStatus('Please enter your API key', true);
return;
}
try {
const response = await axios.get('/api/v1/devices', {
headers: { 'X-API-Key': apiKey }
});
showStatus('✓ Connection successful');
} catch (error) {
if (error.response?.status === 401) {
showStatus('✗ Invalid API key', true);
} else if (error.response?.status === 403) {
showStatus('✗ Insufficient permissions', true);
} else {
showStatus('✗ Connection failed', true);
}
}
}
async function tryEndpoint(method, url, data, responseId) {
const apiKey = getApiKey();
if (!apiKey) {
showStatus('Please enter your API key first', true);
return;
}
try {
const config = {
method: method,
url: url,
headers: { 'X-API-Key': apiKey }
};
if (data) {
config.data = data;
}
const response = await axios(config);
document.getElementById(responseId + '-response').classList.remove('hidden');
document.getElementById(responseId).textContent = JSON.stringify(response.data, null, 2);
} catch (error) {
document.getElementById(responseId + '-response').classList.remove('hidden');
const errorMessage = error.response?.data?.error || error.message;
document.getElementById(responseId).textContent = `Error (${error.response?.status || 'Network'}): ${errorMessage}`;
}
}
async function tryEndpointWithId(method, baseUrl, inputId, responseId) {
const id = document.getElementById(inputId).value;
if (!id) {
alert('Please enter an ID');
return;
}
await tryEndpoint(method, baseUrl + encodeURIComponent(id), null, responseId);
}
// Auto-populate API key if user is logged in
document.addEventListener('DOMContentLoaded', function() {
const apiKeyInput = document.getElementById('apiKey');
if (apiKeyInput && apiKeyInput.value) {
testConnection();
}
});
+1
View File
@@ -0,0 +1 @@
function getApiKey(){return document.getElementById("apiKey").value}function showStatus(e,t=!1){let n=document.getElementById("connectionStatus");n.textContent=e,n.className=`mt-2 text-sm ${t?"text-red-600 dark:text-red-400":"text-green-600 dark:text-green-400"}`}async function testConnection(){let e=getApiKey();if(!e){showStatus("Please enter your API key",!0);return}try{await axios.get("/api/v1/devices",{headers:{"X-API-Key":e}}),showStatus("✓ Connection successful")}catch(t){t.response?.status===401?showStatus("✗ Invalid API key",!0):t.response?.status===403?showStatus("✗ Insufficient permissions",!0):showStatus("✗ Connection failed",!0)}}async function tryEndpoint(e,t,n,s){let a=getApiKey();if(!a){showStatus("Please enter your API key first",!0);return}try{let o={method:e,url:t,headers:{"X-API-Key":a}};n&&(o.data=n);let r=await axios(o);document.getElementById(s+"-response").classList.remove("hidden"),document.getElementById(s).textContent=JSON.stringify(r.data,null,2)}catch(i){document.getElementById(s+"-response").classList.remove("hidden");let d=i.response?.data?.error||i.message;document.getElementById(s).textContent=`Error (${i.response?.status||"Network"}): ${d}`}}async function tryEndpointWithId(e,t,n,s){let a=document.getElementById(n).value;if(!a){alert("Please enter an ID");return}await tryEndpoint(e,t+encodeURIComponent(a),null,s)}document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("apiKey");e&&e.value&&testConnection()});
+122
View File
@@ -0,0 +1,122 @@
document.addEventListener('DOMContentLoaded', function() {
// Filter toggle functionality
const filterToggle = document.getElementById('filter-toggle');
const filterForm = document.getElementById('audit-filter-form');
const filterArrow = document.getElementById('filter-arrow');
if (filterToggle && filterForm && filterArrow) {
filterToggle.addEventListener('click', function() {
filterForm.classList.toggle('hidden');
// Toggle rotation using inline style for better compatibility
if (filterForm.classList.contains('hidden')) {
filterArrow.style.transform = 'rotate(0deg)';
} else {
filterArrow.style.transform = 'rotate(180deg)';
}
});
// Set initial arrow rotation if form is visible (has active filters or expand_filters param)
if (!filterForm.classList.contains('hidden')) {
filterArrow.style.transform = 'rotate(180deg)';
}
}
// Format timestamps
document.querySelectorAll('td[data-utc]').forEach(function(td) {
const utc = td.getAttribute('data-utc');
if (utc) {
const date = new Date(utc + 'Z');
td.textContent = date.toLocaleString();
}
});
// Parse and display visual diffs
document.querySelectorAll('.diff-container').forEach(function(container) {
const details = container.getAttribute('data-details');
if (!details) return;
// Try to parse common change patterns
let html = details;
// Pattern 1: "Changed X from 'old' to 'new'"
html = html.replace(/Changed (.+?) from ['"](.+?)['"] to ['"](.+?)['"]/gi, function(match, field, oldVal, newVal) {
return `Changed ${field} from <span class="diff-removed">${oldVal}</span> to <span class="diff-added">${newVal}</span>`;
});
// Pattern 2: "Renamed X to Y"
html = html.replace(/Renamed (.+?) to ['"](.+?)['"]/gi, function(match, oldVal, newVal) {
return `Renamed <span class="diff-removed">${oldVal}</span> to <span class="diff-added">${newVal}</span>`;
});
// Pattern 3: "Updated X: old -> new"
html = html.replace(/Updated (.+?):\s*(.+?)\s*->\s*(.+?)(?:\s|$)/gi, function(match, field, oldVal, newVal) {
return `Updated ${field}: <span class="diff-removed">${oldVal}</span> → <span class="diff-added">${newVal}</span>`;
});
// Pattern 4: "Set X to Y" (when it was previously something else, look for context)
html = html.replace(/Set (.+?) to ['"](.+?)['"]/gi, function(match, field, newVal) {
return `Set ${field} to <span class="diff-added">${newVal}</span>`;
});
// Pattern 5: "Removed X" or "Deleted X"
html = html.replace(/(Removed|Deleted) ['"](.+?)['"]/gi, function(match, action, val) {
return `${action} <span class="diff-removed">${val}</span>`;
});
// Pattern 6: "Added X"
html = html.replace(/Added ['"](.+?)['"]/gi, function(match, val) {
return `Added <span class="diff-added">${val}</span>`;
});
// Pattern 7: "Assigned X to Y" or "Unassigned X from Y"
// Capture everything after "to " or "from " to preserve all spaces in target
html = html.replace(/(Assigned|Unassigned) (.+?) (to|from) (.+)$/gi, function(match, action, item, prep, target) {
const actionClass = action === 'Assigned' ? 'diff-added' : 'diff-removed';
// Preserve the space between prep and target
return `${action} <span class="${actionClass}">${item}</span> ${prep} ${target}`;
});
// Pattern 8: Generic "from X to Y" pattern
html = html.replace(/from ['"](.+?)['"] to ['"](.+?)['"]/gi, function(match, oldVal, newVal) {
return `from <span class="diff-removed">${oldVal}</span> to <span class="diff-added">${newVal}</span>`;
});
container.innerHTML = html || details;
});
// Export button handler
const exportBtn = document.getElementById('export-btn');
if (exportBtn) {
exportBtn.addEventListener('click', function() {
const form = document.getElementById('audit-filter-form');
const formData = new FormData(form);
const params = new URLSearchParams();
// Add all form fields to params
for (const [key, value] of formData.entries()) {
if (value) {
if (key === 'user_ids') {
// Handle multiple user_ids
params.append('user_ids', value);
} else {
params.append(key, value);
}
}
}
// Handle multiple user_ids separately
const userSelect = form.querySelector('select[name="user_ids"]');
if (userSelect) {
const selectedUsers = Array.from(userSelect.selectedOptions).map(opt => opt.value);
params.delete('user_ids');
selectedUsers.forEach(userId => {
params.append('user_ids', userId);
});
}
// Redirect to export endpoint
window.location.href = '/audit/export_csv?' + params.toString();
});
}
});
+1
View File
@@ -0,0 +1 @@
document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("filter-toggle"),t=document.getElementById("audit-filter-form"),n=document.getElementById("filter-arrow");e&&t&&n&&(e.addEventListener("click",function(){t.classList.toggle("hidden"),t.classList.contains("hidden")?n.style.transform="rotate(0deg)":n.style.transform="rotate(180deg)"}),t.classList.contains("hidden")||(n.style.transform="rotate(180deg)")),document.querySelectorAll("td[data-utc]").forEach(function(e){let t=e.getAttribute("data-utc");if(t){let n=new Date(t+"Z");e.textContent=n.toLocaleString()}}),document.querySelectorAll(".diff-container").forEach(function(e){let t=e.getAttribute("data-details");if(!t)return;let n=t;n=(n=(n=(n=(n=(n=(n=(n=n.replace(/Changed (.+?) from ['"](.+?)['"] to ['"](.+?)['"]/gi,function(e,t,n,d){return`Changed ${t} from <span class="diff-removed">${n}</span> to <span class="diff-added">${d}</span>`})).replace(/Renamed (.+?) to ['"](.+?)['"]/gi,function(e,t,n){return`Renamed <span class="diff-removed">${t}</span> to <span class="diff-added">${n}</span>`})).replace(/Updated (.+?):\s*(.+?)\s*->\s*(.+?)(?:\s|$)/gi,function(e,t,n,d){return`Updated ${t}: <span class="diff-removed">${n}</span> → <span class="diff-added">${d}</span>`})).replace(/Set (.+?) to ['"](.+?)['"]/gi,function(e,t,n){return`Set ${t} to <span class="diff-added">${n}</span>`})).replace(/(Removed|Deleted) ['"](.+?)['"]/gi,function(e,t,n){return`${t} <span class="diff-removed">${n}</span>`})).replace(/Added ['"](.+?)['"]/gi,function(e,t){return`Added <span class="diff-added">${t}</span>`})).replace(/(Assigned|Unassigned) (.+?) (to|from) (.+)$/gi,function(e,t,n,d,a){return`${t} <span class="${"Assigned"===t?"diff-added":"diff-removed"}">${n}</span> ${d} ${a}`})).replace(/from ['"](.+?)['"] to ['"](.+?)['"]/gi,function(e,t,n){return`from <span class="diff-removed">${t}</span> to <span class="diff-added">${n}</span>`}),e.innerHTML=n||t});let d=document.getElementById("export-btn");d&&d.addEventListener("click",function(){let e=document.getElementById("audit-filter-form"),t=new FormData(e),n=new URLSearchParams;for(let[d,a]of t.entries())a&&("user_ids"===d?n.append("user_ids",a):n.append(d,a));let s=e.querySelector('select[name="user_ids"]');if(s){let r=Array.from(s.selectedOptions).map(e=>e.value);n.delete("user_ids"),r.forEach(e=>{n.append("user_ids",e)})}window.location.href="/audit/export_csv?"+n.toString()})});
+143
View File
@@ -0,0 +1,143 @@
document.addEventListener('DOMContentLoaded', function() {
const messageDiv = document.getElementById('message');
function showMessage(text, isError = false) {
messageDiv.textContent = text;
messageDiv.className = isError
? 'mb-4 p-4 rounded-lg bg-red-200 dark:bg-red-800 text-red-800 dark:text-red-200'
: 'mb-4 p-4 rounded-lg bg-green-200 dark:bg-green-800 text-green-800 dark:text-green-200';
messageDiv.classList.remove('hidden');
setTimeout(() => {
messageDiv.classList.add('hidden');
}, 5000);
}
// Create backup button
const createBackupBtn = document.getElementById('create-backup-btn');
if (createBackupBtn) {
createBackupBtn.addEventListener('click', function() {
createBackupBtn.disabled = true;
createBackupBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Creating...';
fetch('/backup/create', {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage(`Backup created successfully: ${data.filename}`);
setTimeout(() => window.location.reload(), 1500);
} else {
showMessage(data.error || 'Failed to create backup', true);
createBackupBtn.disabled = false;
createBackupBtn.innerHTML = '<i class="fas fa-database"></i> <span>Create Backup</span>';
}
})
.catch(error => {
showMessage('Error creating backup: ' + error.message, true);
createBackupBtn.disabled = false;
createBackupBtn.innerHTML = '<i class="fas fa-database"></i> <span>Create Backup</span>';
});
});
}
// Upload and restore form
const uploadRestoreForm = document.getElementById('upload-restore-form');
if (uploadRestoreForm) {
uploadRestoreForm.addEventListener('submit', function(e) {
e.preventDefault();
if (!confirm('WARNING: This will replace all current database data with the backup. Are you sure you want to continue?')) {
return;
}
const formData = new FormData(this);
const submitBtn = this.querySelector('button[type="submit"]');
const originalText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Restoring...';
fetch('/backup/restore', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage('Database restored successfully. Page will reload...');
setTimeout(() => window.location.reload(), 2000);
} else {
showMessage(data.error || 'Failed to restore backup', true);
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
})
.catch(error => {
showMessage('Error restoring backup: ' + error.message, true);
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
});
});
}
// Existing backup restore form
const existingRestoreForm = document.getElementById('existing-restore-form');
if (existingRestoreForm) {
existingRestoreForm.addEventListener('submit', function(e) {
e.preventDefault();
if (!confirm('WARNING: This will replace all current database data with the backup. Are you sure you want to continue?')) {
return;
}
const formData = new FormData(this);
const submitBtn = this.querySelector('button[type="submit"]');
const originalText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Restoring...';
fetch('/backup/restore', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage('Database restored successfully. Page will reload...');
setTimeout(() => window.location.reload(), 2000);
} else {
showMessage(data.error || 'Failed to restore backup', true);
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
})
.catch(error => {
showMessage('Error restoring backup: ' + error.message, true);
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
});
});
}
});
function deleteBackup(filename) {
if (!confirm(`Are you sure you want to delete backup "${filename}"?`)) {
return;
}
fetch(`/backup/delete/${filename}`, {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
window.location.reload();
} else {
alert('Error: ' + (data.error || 'Failed to delete backup'));
}
})
.catch(error => {
alert('Error: ' + error.message);
});
}
+1
View File
@@ -0,0 +1 @@
document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("message");function t(t,a=!1){e.textContent=t,e.className=a?"mb-4 p-4 rounded-lg bg-red-200 dark:bg-red-800 text-red-800 dark:text-red-200":"mb-4 p-4 rounded-lg bg-green-200 dark:bg-green-800 text-green-800 dark:text-green-200",e.classList.remove("hidden"),setTimeout(()=>{e.classList.add("hidden")},5e3)}let a=document.getElementById("create-backup-btn");a&&a.addEventListener("click",function(){a.disabled=!0,a.innerHTML='<i class="fas fa-spinner fa-spin"></i> Creating...',fetch("/backup/create",{method:"POST"}).then(e=>e.json()).then(e=>{e.success?(t(`Backup created successfully: ${e.filename}`),setTimeout(()=>window.location.reload(),1500)):(t(e.error||"Failed to create backup",!0),a.disabled=!1,a.innerHTML='<i class="fas fa-database"></i> <span>Create Backup</span>')}).catch(e=>{t("Error creating backup: "+e.message,!0),a.disabled=!1,a.innerHTML='<i class="fas fa-database"></i> <span>Create Backup</span>'})});let r=document.getElementById("upload-restore-form");r&&r.addEventListener("submit",function(e){if(e.preventDefault(),!confirm("WARNING: This will replace all current database data with the backup. Are you sure you want to continue?"))return;let a=new FormData(this),r=this.querySelector('button[type="submit"]'),n=r.innerHTML;r.disabled=!0,r.innerHTML='<i class="fas fa-spinner fa-spin"></i> Restoring...',fetch("/backup/restore",{method:"POST",body:a}).then(e=>e.json()).then(e=>{e.success?(t("Database restored successfully. Page will reload..."),setTimeout(()=>window.location.reload(),2e3)):(t(e.error||"Failed to restore backup",!0),r.disabled=!1,r.innerHTML=n)}).catch(e=>{t("Error restoring backup: "+e.message,!0),r.disabled=!1,r.innerHTML=n})});let n=document.getElementById("existing-restore-form");n&&n.addEventListener("submit",function(e){if(e.preventDefault(),!confirm("WARNING: This will replace all current database data with the backup. Are you sure you want to continue?"))return;let a=new FormData(this),r=this.querySelector('button[type="submit"]'),n=r.innerHTML;r.disabled=!0,r.innerHTML='<i class="fas fa-spinner fa-spin"></i> Restoring...',fetch("/backup/restore",{method:"POST",body:a}).then(e=>e.json()).then(e=>{e.success?(t("Database restored successfully. Page will reload..."),setTimeout(()=>window.location.reload(),2e3)):(t(e.error||"Failed to restore backup",!0),r.disabled=!1,r.innerHTML=n)}).catch(e=>{t("Error restoring backup: "+e.message,!0),r.disabled=!1,r.innerHTML=n})})});function deleteBackup(e){confirm(`Are you sure you want to delete backup "${e}"?`)&&fetch(`/backup/delete/${e}`,{method:"POST"}).then(e=>e.json()).then(e=>{e.success?window.location.reload():alert("Error: "+(e.error||"Failed to delete backup"))}).catch(e=>{alert("Error: "+e.message)})}
+183
View File
@@ -0,0 +1,183 @@
function showTab(tabName) {
// Hide all panels
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.add('hidden'));
// Update all tab buttons to inactive state
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
btn.classList.add('border-transparent', 'text-gray-500');
});
// Show selected panel
document.getElementById('panel-' + tabName).classList.remove('hidden');
// Update selected tab to active state
const activeTab = document.getElementById('tab-' + tabName);
activeTab.classList.remove('border-transparent', 'text-gray-500');
activeTab.classList.add('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
}
document.addEventListener('DOMContentLoaded', function() {
// Update selected IP count
document.getElementById('bulk-ip-select')?.addEventListener('change', function() {
document.getElementById('selected-ip-count').textContent = this.selectedOptions.length;
});
document.getElementById('bulk-tag-device-select')?.addEventListener('change', function() {
document.getElementById('selected-tag-device-count').textContent = this.selectedOptions.length;
});
// Load available IPs when subnet changes
document.getElementById('bulk-subnet-select')?.addEventListener('change', function() {
const subnetId = this.value;
const ipSelect = document.getElementById('bulk-ip-select');
if (!subnetId) {
ipSelect.innerHTML = '<option value="" disabled>Select a subnet first...</option>';
document.getElementById('selected-ip-count').textContent = '0';
return;
}
ipSelect.innerHTML = '<option value="" disabled>Loading...</option>';
fetch(`/get_available_ips?subnet_id=${subnetId}`)
.then(response => response.json())
.then(data => {
ipSelect.innerHTML = '';
if (data.available_ips.length === 0) {
ipSelect.innerHTML = '<option value="" disabled>No available IPs in this subnet</option>';
} else {
data.available_ips.forEach(ip => {
const option = document.createElement('option');
option.value = ip.id;
option.textContent = ip.ip;
ipSelect.appendChild(option);
});
}
document.getElementById('selected-ip-count').textContent = '0';
})
.catch(() => {
ipSelect.innerHTML = '<option value="" disabled>Error loading IPs</option>';
});
});
// Bulk IP Assignment
document.getElementById('bulk-assign-ips-form')?.addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const resultDiv = document.getElementById('assign-ips-result');
resultDiv.classList.remove('hidden');
resultDiv.innerHTML = '<p class="text-blue-500">Processing...</p>';
fetch('/bulk/assign_ips', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
let html = '<div class="space-y-2">';
if (data.success.length > 0) {
html += `<div class="text-green-600 dark:text-green-400"><strong>Successfully assigned ${data.success.length} IP(s):</strong><ul class="list-disc list-inside mt-2">`;
data.success.forEach(item => {
html += `<li>${item.ip}</li>`;
});
html += '</ul></div>';
}
if (data.failed.length > 0) {
html += `<div class="text-red-600 dark:text-red-400"><strong>Failed ${data.failed.length} assignment(s):</strong><ul class="list-disc list-inside mt-2">`;
data.failed.forEach(item => {
const ipDisplay = item.ip ? ` (${item.ip})` : '';
html += `<li>IP ID ${item.ip_id}${ipDisplay}: ${item.reason}</li>`;
});
html += '</ul></div>';
}
html += '</div>';
resultDiv.innerHTML = html;
// Reload IP list if successful
if (data.success.length > 0) {
const subnetSelect = document.getElementById('bulk-subnet-select');
if (subnetSelect.value) {
subnetSelect.dispatchEvent(new Event('change'));
}
}
})
.catch(error => {
resultDiv.innerHTML = `<p class="text-red-600">Error: ${error.message}</p>`;
});
});
// Bulk Device Creation
document.getElementById('bulk-create-devices-form')?.addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const resultDiv = document.getElementById('create-devices-result');
resultDiv.classList.remove('hidden');
resultDiv.innerHTML = '<p class="text-blue-500">Processing...</p>';
fetch('/bulk/create_devices', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
let html = '<div class="space-y-2">';
if (data.success.length > 0) {
html += `<div class="text-green-600 dark:text-green-400"><strong>Successfully created ${data.success.length} device(s):</strong><ul class="list-disc list-inside mt-2">`;
data.success.forEach(item => {
html += `<li>${item.name}</li>`;
});
html += '</ul></div>';
}
if (data.failed.length > 0) {
html += `<div class="text-red-600 dark:text-red-400"><strong>Failed ${data.failed.length} creation(s):</strong><ul class="list-disc list-inside mt-2">`;
data.failed.forEach(item => {
html += `<li>${item.name}: ${item.reason}</li>`;
});
html += '</ul></div>';
}
html += '</div>';
resultDiv.innerHTML = html;
if (data.success.length > 0) {
setTimeout(() => window.location.reload(), 2000);
}
})
.catch(error => {
resultDiv.innerHTML = `<p class="text-red-600">Error: ${error.message}</p>`;
});
});
// Bulk Tag Assignment
document.getElementById('bulk-assign-tags-form')?.addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const resultDiv = document.getElementById('assign-tags-result');
resultDiv.classList.remove('hidden');
resultDiv.innerHTML = '<p class="text-blue-500">Processing...</p>';
fetch('/bulk/assign_tags', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
let html = '<div class="space-y-2">';
if (data.success.length > 0) {
html += `<div class="text-green-600 dark:text-green-400"><strong>Successfully assigned ${data.success.length} tag(s):</strong><ul class="list-disc list-inside mt-2">`;
data.success.forEach(item => {
html += `<li>${item.device_name}: ${item.tag_name}</li>`;
});
html += '</ul></div>';
}
if (data.failed.length > 0) {
html += `<div class="text-red-600 dark:text-red-400"><strong>Failed ${data.failed.length} assignment(s):</strong><ul class="list-disc list-inside mt-2">`;
data.failed.forEach(item => {
html += `<li>Device ID ${item.device_id}, Tag ID ${item.tag_id}: ${item.reason}</li>`;
});
html += '</ul></div>';
}
html += '</div>';
resultDiv.innerHTML = html;
})
.catch(error => {
resultDiv.innerHTML = `<p class="text-red-600">Error: ${error.message}</p>`;
});
});
});
+1
View File
@@ -0,0 +1 @@
function showTab(e){document.querySelectorAll(".tab-panel").forEach(e=>e.classList.add("hidden")),document.querySelectorAll(".tab-btn").forEach(e=>{e.classList.remove("border-gray-600","text-gray-900","dark:text-gray-100"),e.classList.add("border-transparent","text-gray-500")}),document.getElementById("panel-"+e).classList.remove("hidden");let t=document.getElementById("tab-"+e);t.classList.remove("border-transparent","text-gray-500"),t.classList.add("border-gray-600","text-gray-900","dark:text-gray-100")}document.addEventListener("DOMContentLoaded",function(){document.getElementById("bulk-ip-select")?.addEventListener("change",function(){document.getElementById("selected-ip-count").textContent=this.selectedOptions.length}),document.getElementById("bulk-tag-device-select")?.addEventListener("change",function(){document.getElementById("selected-tag-device-count").textContent=this.selectedOptions.length}),document.getElementById("bulk-subnet-select")?.addEventListener("change",function(){let e=this.value,t=document.getElementById("bulk-ip-select");if(!e){t.innerHTML='<option value="" disabled>Select a subnet first...</option>',document.getElementById("selected-ip-count").textContent="0";return}t.innerHTML='<option value="" disabled>Loading...</option>',fetch(`/get_available_ips?subnet_id=${e}`).then(e=>e.json()).then(e=>{t.innerHTML="",0===e.available_ips.length?t.innerHTML='<option value="" disabled>No available IPs in this subnet</option>':e.available_ips.forEach(e=>{let s=document.createElement("option");s.value=e.id,s.textContent=e.ip,t.appendChild(s)}),document.getElementById("selected-ip-count").textContent="0"}).catch(()=>{t.innerHTML='<option value="" disabled>Error loading IPs</option>'})}),document.getElementById("bulk-assign-ips-form")?.addEventListener("submit",function(e){e.preventDefault();let t=new FormData(this),s=document.getElementById("assign-ips-result");s.classList.remove("hidden"),s.innerHTML='<p class="text-blue-500">Processing...</p>',fetch("/bulk/assign_ips",{method:"POST",body:t}).then(e=>e.json()).then(e=>{let t='<div class="space-y-2">';if(e.success.length>0&&(t+=`<div class="text-green-600 dark:text-green-400"><strong>Successfully assigned ${e.success.length} IP(s):</strong><ul class="list-disc list-inside mt-2">`,e.success.forEach(e=>{t+=`<li>${e.ip}</li>`}),t+="</ul></div>"),e.failed.length>0&&(t+=`<div class="text-red-600 dark:text-red-400"><strong>Failed ${e.failed.length} assignment(s):</strong><ul class="list-disc list-inside mt-2">`,e.failed.forEach(e=>{let s=e.ip?` (${e.ip})`:"";t+=`<li>IP ID ${e.ip_id}${s}: ${e.reason}</li>`}),t+="</ul></div>"),t+="</div>",s.innerHTML=t,e.success.length>0){let n=document.getElementById("bulk-subnet-select");n.value&&n.dispatchEvent(new Event("change"))}}).catch(e=>{s.innerHTML=`<p class="text-red-600">Error: ${e.message}</p>`})}),document.getElementById("bulk-create-devices-form")?.addEventListener("submit",function(e){e.preventDefault();let t=new FormData(this),s=document.getElementById("create-devices-result");s.classList.remove("hidden"),s.innerHTML='<p class="text-blue-500">Processing...</p>',fetch("/bulk/create_devices",{method:"POST",body:t}).then(e=>e.json()).then(e=>{let t='<div class="space-y-2">';e.success.length>0&&(t+=`<div class="text-green-600 dark:text-green-400"><strong>Successfully created ${e.success.length} device(s):</strong><ul class="list-disc list-inside mt-2">`,e.success.forEach(e=>{t+=`<li>${e.name}</li>`}),t+="</ul></div>"),e.failed.length>0&&(t+=`<div class="text-red-600 dark:text-red-400"><strong>Failed ${e.failed.length} creation(s):</strong><ul class="list-disc list-inside mt-2">`,e.failed.forEach(e=>{t+=`<li>${e.name}: ${e.reason}</li>`}),t+="</ul></div>"),t+="</div>",s.innerHTML=t,e.success.length>0&&setTimeout(()=>window.location.reload(),2e3)}).catch(e=>{s.innerHTML=`<p class="text-red-600">Error: ${e.message}</p>`})}),document.getElementById("bulk-assign-tags-form")?.addEventListener("submit",function(e){e.preventDefault();let t=new FormData(this),s=document.getElementById("assign-tags-result");s.classList.remove("hidden"),s.innerHTML='<p class="text-blue-500">Processing...</p>',fetch("/bulk/assign_tags",{method:"POST",body:t}).then(e=>e.json()).then(e=>{let t='<div class="space-y-2">';e.success.length>0&&(t+=`<div class="text-green-600 dark:text-green-400"><strong>Successfully assigned ${e.success.length} tag(s):</strong><ul class="list-disc list-inside mt-2">`,e.success.forEach(e=>{t+=`<li>${e.device_name}: ${e.tag_name}</li>`}),t+="</ul></div>"),e.failed.length>0&&(t+=`<div class="text-red-600 dark:text-red-400"><strong>Failed ${e.failed.length} assignment(s):</strong><ul class="list-disc list-inside mt-2">`,e.failed.forEach(e=>{t+=`<li>Device ID ${e.device_id}, Tag ID ${e.tag_id}: ${e.reason}</li>`}),t+="</ul></div>"),t+="</div>",s.innerHTML=t}).catch(e=>{s.innerHTML=`<p class="text-red-600">Error: ${e.message}</p>`})})});
+328
View File
@@ -0,0 +1,328 @@
// Custom Fields Management JavaScript
// Get initial tab from URL parameter or default to 'device'
const urlParams = new URLSearchParams(window.location.search);
let currentTab = urlParams.get('tab') || 'device';
if (currentTab !== 'device' && currentTab !== 'subnet') {
currentTab = 'device';
}
// Switch to the correct tab on page load
if (currentTab === 'subnet') {
switchTab('subnet');
} else {
// Ensure device tab is active on load
switchTab('device');
}
// Function to get current active tab
function getCurrentTab() {
return currentTab;
}
let fieldData = {};
// Tab switching
function switchTab(entityType) {
currentTab = entityType;
// Update tab buttons
document.getElementById('tab-device').classList.remove('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
document.getElementById('tab-device').classList.add('border-transparent', 'text-gray-500');
document.getElementById('tab-subnet').classList.remove('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
document.getElementById('tab-subnet').classList.add('border-transparent', 'text-gray-500');
if (entityType === 'device') {
document.getElementById('tab-device').classList.remove('border-transparent', 'text-gray-500');
document.getElementById('tab-device').classList.add('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
document.getElementById('device-fields-tab').classList.remove('hidden');
document.getElementById('subnet-fields-tab').classList.add('hidden');
} else {
document.getElementById('tab-subnet').classList.remove('border-transparent', 'text-gray-500');
document.getElementById('tab-subnet').classList.add('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
document.getElementById('device-fields-tab').classList.add('hidden');
document.getElementById('subnet-fields-tab').classList.remove('hidden');
}
// Update URL without reloading page
const newUrl = new URL(window.location);
newUrl.searchParams.set('tab', entityType);
window.history.pushState({}, '', newUrl);
}
// Show add field modal
function showAddFieldModal(entityType) {
// Determine the target entity type - prioritize explicit parameter, then read from DOM
let targetEntityType = entityType;
if (!targetEntityType) {
// Read from active tab button - check which tab has the active styling
const deviceTab = document.getElementById('tab-device');
const subnetTab = document.getElementById('tab-subnet');
if (deviceTab && deviceTab.classList.contains('border-gray-600')) {
targetEntityType = 'device';
} else if (subnetTab && subnetTab.classList.contains('border-gray-600')) {
targetEntityType = 'subnet';
} else {
// Fallback to currentTab variable
targetEntityType = currentTab || 'device';
}
}
// Ensure targetEntityType is valid
if (targetEntityType !== 'device' && targetEntityType !== 'subnet') {
targetEntityType = 'device';
}
// Ensure we're on the correct tab
if (targetEntityType !== currentTab) {
switchTab(targetEntityType);
}
document.getElementById('modal-title').textContent = 'Add Custom Field';
document.getElementById('form-action').value = 'add_field';
document.getElementById('form-field-id').value = '';
// Always set entity_type explicitly - double check it's set
const entityTypeInput = document.getElementById('form-entity-type');
entityTypeInput.value = targetEntityType;
// Debug: log to verify
console.log('Opening modal for entity type:', targetEntityType, 'currentTab:', currentTab, 'input value:', entityTypeInput.value);
// Reset form
document.getElementById('field-name').value = '';
document.getElementById('field-key').value = '';
document.getElementById('field-type').value = 'text';
document.getElementById('field-required').checked = false;
document.getElementById('field-default-value').value = '';
document.getElementById('field-help-text').value = '';
document.getElementById('field-display-order').value = '0';
document.getElementById('field-searchable').checked = false;
// Reset validation fields
document.getElementById('field-min-length').value = '';
document.getElementById('field-max-length').value = '';
document.getElementById('field-regex-pattern').value = '';
document.getElementById('field-min-value').value = '';
document.getElementById('field-max-value').value = '';
document.getElementById('field-select-options').value = '';
updateFieldTypeOptions();
document.getElementById('field-modal').classList.remove('hidden');
}
// Close field modal
function closeFieldModal() {
document.getElementById('field-modal').classList.add('hidden');
}
// Update field type options visibility
function updateFieldTypeOptions() {
const fieldType = document.getElementById('field-type').value;
// Hide all validation sections
document.getElementById('text-validation').classList.add('hidden');
document.getElementById('number-validation').classList.add('hidden');
document.getElementById('select-validation').classList.add('hidden');
// Show relevant validation section
if (fieldType === 'text' || fieldType === 'textarea') {
document.getElementById('text-validation').classList.remove('hidden');
} else if (fieldType === 'number' || fieldType === 'decimal') {
document.getElementById('number-validation').classList.remove('hidden');
} else if (fieldType === 'select') {
document.getElementById('select-validation').classList.remove('hidden');
}
}
// Auto-generate field key from name
function generateFieldKey(name) {
return name.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
// Edit field
function editField(fieldId, entityType) {
// Get field data from embedded JSON
const fieldsDataElement = document.getElementById('fields-data');
if (!fieldsDataElement) {
console.error('Fields data not found');
return;
}
try {
const fieldsData = JSON.parse(fieldsDataElement.textContent);
const fields = fieldsData[entityType] || [];
const field = fields.find(f => f.id === fieldId);
if (field) {
populateEditForm(field, entityType);
} else {
console.error('Field not found:', fieldId, entityType);
}
} catch (error) {
console.error('Error parsing fields data:', error);
}
}
function populateEditForm(field, entityType) {
document.getElementById('modal-title').textContent = 'Edit Custom Field';
document.getElementById('form-action').value = 'edit_field';
document.getElementById('form-field-id').value = field.id;
document.getElementById('form-entity-type').value = entityType;
document.getElementById('field-name').value = field.name || '';
document.getElementById('field-key').value = field.field_key || '';
document.getElementById('field-type').value = field.field_type || 'text';
document.getElementById('field-required').checked = field.required || false;
document.getElementById('field-default-value').value = field.default_value || '';
document.getElementById('field-help-text').value = field.help_text || '';
document.getElementById('field-display-order').value = field.display_order || 0;
document.getElementById('field-searchable').checked = field.searchable || false;
// Parse validation rules
let validationRules = {};
if (field.validation_rules) {
if (typeof field.validation_rules === 'string') {
try {
validationRules = JSON.parse(field.validation_rules);
} catch (e) {
validationRules = {};
}
} else {
validationRules = field.validation_rules;
}
}
// Populate validation fields
document.getElementById('field-min-length').value = validationRules.min_length || '';
document.getElementById('field-max-length').value = validationRules.max_length || '';
document.getElementById('field-regex-pattern').value = validationRules.regex_pattern || '';
document.getElementById('field-min-value').value = validationRules.min_value || '';
document.getElementById('field-max-value').value = validationRules.max_value || '';
if (validationRules.select_options) {
document.getElementById('field-select-options').value = validationRules.select_options.join(', ');
} else {
document.getElementById('field-select-options').value = '';
}
updateFieldTypeOptions();
document.getElementById('field-modal').classList.remove('hidden');
}
// Move field up/down
function moveField(entityType, fieldId, direction) {
// Get all fields for this entity type
const tbody = document.getElementById(`${entityType}-fields-tbody`);
const rows = Array.from(tbody.querySelectorAll('tr'));
const currentIndex = rows.findIndex(row => row.dataset.fieldId == fieldId);
if (currentIndex === -1) return;
let targetIndex;
if (direction === 'up' && currentIndex > 0) {
targetIndex = currentIndex - 1;
} else if (direction === 'down' && currentIndex < rows.length - 1) {
targetIndex = currentIndex + 1;
} else {
return;
}
// Swap rows
const currentRow = rows[currentIndex];
const targetRow = rows[targetIndex];
tbody.insertBefore(currentRow, direction === 'up' ? targetRow : targetRow.nextSibling);
// Update display orders and submit
const fieldOrders = {};
Array.from(tbody.querySelectorAll('tr')).forEach((row, index) => {
fieldOrders[row.dataset.fieldId] = index;
});
// Submit reorder
const form = document.createElement('form');
form.method = 'POST';
form.action = '/custom_fields';
const actionInput = document.createElement('input');
actionInput.type = 'hidden';
actionInput.name = 'action';
actionInput.value = 'reorder';
form.appendChild(actionInput);
const entityTypeInput = document.createElement('input');
entityTypeInput.type = 'hidden';
entityTypeInput.name = 'entity_type';
entityTypeInput.value = entityType;
form.appendChild(entityTypeInput);
const ordersInput = document.createElement('input');
ordersInput.type = 'hidden';
ordersInput.name = 'field_orders';
ordersInput.value = JSON.stringify(fieldOrders);
form.appendChild(ordersInput);
document.body.appendChild(form);
form.submit();
}
// Event listeners
document.addEventListener('DOMContentLoaded', function() {
// Auto-generate field key from name
const nameInput = document.getElementById('field-name');
const keyInput = document.getElementById('field-key');
if (nameInput && keyInput) {
nameInput.addEventListener('input', function() {
// Only auto-generate if key is empty or matches previous generated value
if (!keyInput.value || keyInput.dataset.autoGenerated === 'true') {
keyInput.value = generateFieldKey(this.value);
keyInput.dataset.autoGenerated = 'true';
}
});
keyInput.addEventListener('input', function() {
// Mark as manually edited
this.dataset.autoGenerated = 'false';
});
}
// Update field type options when type changes
const fieldTypeSelect = document.getElementById('field-type');
if (fieldTypeSelect) {
fieldTypeSelect.addEventListener('change', updateFieldTypeOptions);
}
// Ensure entity_type is set correctly before form submission
const fieldForm = document.getElementById('field-form');
if (fieldForm) {
fieldForm.addEventListener('submit', function(e) {
const entityTypeInput = document.getElementById('form-entity-type');
// Always ensure entity_type is set to currentTab
// This handles cases where the modal was opened without explicitly setting it
if (!entityTypeInput.value || entityTypeInput.value.trim() === '') {
entityTypeInput.value = currentTab;
console.log('Entity type was empty, setting to:', currentTab);
}
// Double-check it's a valid value
if (entityTypeInput.value !== 'device' && entityTypeInput.value !== 'subnet') {
entityTypeInput.value = currentTab;
console.log('Entity type was invalid, setting to currentTab:', currentTab);
}
console.log('Submitting form with entity_type:', entityTypeInput.value, 'currentTab:', currentTab);
});
}
});
// Close modal when clicking outside
window.onclick = function(event) {
const modal = document.getElementById('field-modal');
if (event.target === modal) {
closeFieldModal();
}
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("site-select"),t=document.getElementById("subnet-select"),n=document.getElementById("ip-select"),i=document.querySelector(".rename-btn"),l=document.querySelector(".save-btn"),s=document.querySelector(".cancel-btn"),a=document.querySelector('input[name="new_name"]'),d=document.querySelector("h1");e.addEventListener("change",function(){let e=this.value,n=null;Array.from(t.options).forEach(t=>{t.value&&(t.getAttribute("data-site")===e?(t.style.display="",n||(n=t.value)):t.style.display="none")}),t.value=n||"";let i=new Event("change",{bubbles:!0});t.dispatchEvent(i)}),t.addEventListener("change",function(){let e=this.value;if(!e){n.innerHTML='<option value="" disabled selected>Select IP</option>';return}fetch(`/get_available_ips?subnet_id=${e}`).then(e=>e.json()).then(e=>{n.innerHTML='<option value="" disabled selected>Select IP</option>',e.available_ips.forEach(e=>{let t=document.createElement("option");t.value=e.id,t.textContent=e.ip,n.appendChild(t)})})}),i&&l&&s&&a&&d&&(i.addEventListener("click",function(e){e.preventDefault(),a.classList.remove("hidden"),l.classList.remove("hidden"),s.classList.remove("hidden"),d.classList.add("hidden"),a.focus()}),s.addEventListener("click",function(e){e.preventDefault(),a.classList.add("hidden"),l.classList.add("hidden"),s.classList.add("hidden"),d.classList.remove("hidden")}))});
+6
View File
@@ -0,0 +1,6 @@
const fontAwesomeIcons=["fa-server","fa-router","fa-network-wired","fa-switch","fa-hub","fa-ethernet","fa-satellite-dish","fa-broadcast-tower","fa-tower-cell","fa-wifi","fa-network","fa-project-diagram","fa-sitemap","fa-diagram-project","fa-cloud","fa-shield-halved","fa-shield","fa-shield-alt","fa-firewall","fa-lock","fa-unlock","fa-key","fa-fingerprint","fa-user-shield","fa-user-lock","fa-print","fa-boxes-stacked","fa-database","fa-hard-drive","fa-memory","fa-microchip","fa-cpu","fa-usb","fa-fan","fa-battery-full","fa-power-off","fa-plug","fa-bolt","fa-lightbulb","fa-monitor","fa-display","fa-tv","fa-camera","fa-video","fa-laptop","fa-desktop","fa-tablet","fa-mobile-alt","fa-phone","fa-keyboard","fa-mouse","fa-microphone","fa-headphones","fa-speaker","fa-box","fa-package","fa-archive","fa-folder","fa-file","fa-hdd","fa-ssd","fa-floppy-disk","fa-disk","fa-save","fa-folder-open","fa-folder-plus","fa-chart-line","fa-chart-bar","fa-chart-pie","fa-graph","fa-analytics","fa-database","fa-file-database","fa-file-chart-line","fa-file-chart-pie","fa-globe","fa-earth","fa-map","fa-location","fa-map-marker","fa-building","fa-warehouse","fa-home","fa-office","fa-industry","fa-robot","fa-cog","fa-gear","fa-wrench","fa-tools","fa-question","fa-code","fa-terminal","fa-console","fa-bug","fa-bug-slash","fa-id-card","fa-credit-card","fa-qrcode","fa-barcode","fa-rfid","fa-truck","fa-shipping-fast","fa-conveyor-belt","fa-pallet","fa-dolly","fa-cube","fa-cubes","fa-layer-group","fa-stack","fa-th","fa-th-large","fa-th-list","fa-list","fa-list-ul","fa-list-ol","fa-table","fa-columns","fa-grid","fa-window-maximize","fa-window-restore","fa-window-minimize","fa-window-close","fa-expand","fa-compress","fa-sync","fa-sync-alt","fa-redo","fa-undo","fa-refresh","fa-download","fa-upload","fa-exchange-alt","fa-share","fa-link","fa-unlink","fa-chain","fa-chain-broken","fa-arrows-alt","fa-arrows","fa-move","fa-clock","fa-hourglass","fa-stopwatch","fa-timer","fa-calendar","fa-calendar-alt","fa-calendar-check","fa-calendar-times","fa-history","fa-play","fa-pause","fa-stop","fa-step-backward","fa-step-forward","fa-fast-backward","fa-fast-forward","fa-eject","fa-record-vinyl","fa-compact-disc","fa-cd","fa-dvd","fa-user-shield","fa-user-lock","fa-user-secret","fa-user-cog","fa-user-gear","fa-user-tie","fa-user-ninja","fa-users","fa-users-cog","fa-user-group","fa-user-friends","fa-user-plus","fa-user-minus","fa-user-times","fa-user-check","fa-user-xmark","fa-user-slash"];function initIconSearch(){let a=document.querySelectorAll(".icon-search-input");a.forEach(a=>{let e=a.closest(".icon-search-container"),f=e.querySelector(".icon-preview"),s=e.querySelector(".icon-suggestions");if(f&&s){if(a.value&&a.value.trim()){let i=a.value.trim().startsWith("fa-")?a.value.trim():`fa-${a.value.trim()}`;f.innerHTML=`<i class="fas ${i}"></i>`,f.classList.remove("hidden")}a.addEventListener("input",e=>{let i=e.target.value.toLowerCase().trim();if(i){let r=i.startsWith("fa-")?i:`fa-${i}`;f.innerHTML=`<i class="fas ${r}"></i>`,f.classList.remove("hidden")}else f.classList.add("hidden");if(i.length>0){let t=fontAwesomeIcons.filter(a=>a.includes(i)||a.replace("fa-","").includes(i)).slice(0,10);t.length>0?(s.innerHTML=t.map(a=>`
<div class="icon-suggestion-item" data-icon="${a}">
<i class="fas ${a}"></i>
<span>${a}</span>
</div>
`).join(""),s.classList.remove("hidden"),s.querySelectorAll(".icon-suggestion-item").forEach(e=>{e.addEventListener("click",()=>{a.value=e.dataset.icon,f.innerHTML=`<i class="fas ${e.dataset.icon}"></i>`,f.classList.remove("hidden"),s.classList.add("hidden")})})):s.classList.add("hidden")}else s.classList.add("hidden")}),document.addEventListener("click",a=>{e.contains(a.target)||s.classList.add("hidden")}),a.addEventListener("blur",()=>{let e=a.value.trim();if(e&&f){let s=e.startsWith("fa-")?e:`fa-${e}`;f.innerHTML=`<i class="fas ${s}"></i>`,f.classList.remove("hidden")}})}})}"loading"===document.readyState?document.addEventListener("DOMContentLoaded",initIconSearch):initIconSearch();
+13 -50
View File
@@ -1,5 +1,18 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Tag filter functionality
const tagFilter = document.getElementById('tag-filter');
if (tagFilter) {
tagFilter.addEventListener('change', function() {
const selectedTag = this.value;
if (selectedTag) {
window.location.href = '/devices?tag=' + encodeURIComponent(selectedTag);
} else {
window.location.href = '/devices';
}
});
}
// Expand/collapse site groups // Expand/collapse site groups
document.querySelectorAll('.site-header').forEach(header => { document.querySelectorAll('.site-header').forEach(header => {
header.addEventListener('click', function(e) { header.addEventListener('click', function(e) {
@@ -17,56 +30,6 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}); });
// 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 // Scroll to Top Button
const scrollToTopButton = document.createElement('button'); const scrollToTopButton = document.createElement('button');
scrollToTopButton.innerHTML = '<i class="fas fa-arrow-up"></i>'; scrollToTopButton.innerHTML = '<i class="fas fa-arrow-up"></i>';
+14
View File
@@ -0,0 +1,14 @@
document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("tag-filter");e&&e.addEventListener("change",function(){let e=this.value;e?window.location.href="/devices?tag="+encodeURIComponent(e):window.location.href="/devices"}),document.querySelectorAll(".site-header").forEach(e=>{e.addEventListener("click",function(e){let t=this.closest(".site-group").querySelector(".device-list"),s=this.querySelector(".expand-btn i");t.classList.contains("hidden")?(t.classList.remove("hidden"),s.classList.remove("fa-chevron-down"),s.classList.add("fa-chevron-up")):(t.classList.add("hidden"),s.classList.remove("fa-chevron-up"),s.classList.add("fa-chevron-down"))})});let t=document.createElement("button");t.innerHTML='<i class="fas fa-arrow-up"></i>',t.style.fontSize="26px",t.className="fixed bottom-5 right-5 bg-gray-200 dark:bg-zinc-800 text-black dark:text-white p-3 rounded-full shadow-lg hidden",t.style.width="60px",t.style.height="60px",t.style.borderRadius="50%",document.body.appendChild(t);let s=document.createElement("style");s.textContent=`
@keyframes bob {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
.bobbing {
animation: bob 1.5s infinite;
}
`,document.head.appendChild(s),t.classList.add("bobbing"),window.addEventListener("scroll",()=>{window.scrollY>200?t.classList.remove("hidden"):t.classList.add("hidden")}),t.addEventListener("click",()=>{window.scrollTo({top:0,behavior:"smooth"})})});
+1
View File
@@ -0,0 +1 @@
document.querySelectorAll(".export-csv-btn").forEach(t=>{t.addEventListener("click",function(t){t.stopPropagation();let e=this.getAttribute("data-subnet-id");window.location.href=`/subnet/${e}/export_csv`})});
+1
View File
@@ -0,0 +1 @@
document.addEventListener("DOMContentLoaded",function(){let t=document.getElementById("nav-toggle"),e=document.getElementById("mobile-nav");t.addEventListener("click",function(){e.classList.toggle("hidden")}),document.addEventListener("click",function(n){e.contains(n.target)||t.contains(n.target)||e.classList.add("hidden")})});
+104
View File
@@ -0,0 +1,104 @@
// IP History Modal functionality
document.addEventListener('DOMContentLoaded', function() {
const modal = document.getElementById('ip-history-modal');
const closeBtn = document.getElementById('close-ip-history-modal');
const content = document.getElementById('ip-history-content');
const ipAddressSpan = document.getElementById('modal-ip-address');
// Open modal when IP is clicked
document.querySelectorAll('.ip-history-btn').forEach(btn => {
btn.addEventListener('click', function() {
const ip = this.getAttribute('data-ip');
ipAddressSpan.textContent = ip;
modal.classList.remove('hidden');
modal.classList.add('flex');
loadIPHistory(ip);
});
});
// Close modal
closeBtn.addEventListener('click', function() {
modal.classList.add('hidden');
modal.classList.remove('flex');
});
// Close modal when clicking outside
modal.addEventListener('click', function(e) {
if (e.target === modal) {
modal.classList.add('hidden');
modal.classList.remove('flex');
}
});
// Close modal with Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && !modal.classList.contains('hidden')) {
modal.classList.add('hidden');
modal.classList.remove('flex');
}
});
function loadIPHistory(ip) {
content.innerHTML = '<div class="text-center text-gray-600 dark:text-gray-400">Loading...</div>';
fetch(`/api/ip/${encodeURIComponent(ip)}/history`)
.then(response => response.json())
.then(data => {
if (data.history && data.history.length > 0) {
displayHistory(data.history);
} else {
content.innerHTML = '<div class="text-center text-gray-600 dark:text-gray-400">No history found for this IP address.</div>';
}
})
.catch(error => {
console.error('Error loading IP history:', error);
content.innerHTML = '<div class="text-center text-red-500">Error loading IP history. Please try again.</div>';
});
}
function displayHistory(history) {
let html = '<div class="space-y-3">';
history.forEach((entry, index) => {
const isAssigned = entry.action === 'assigned';
const icon = isAssigned ? 'fa-plus-circle text-green-500' : 'fa-minus-circle text-red-500';
const actionText = isAssigned ? 'Assigned' : 'Removed';
// Format timestamp
let timestamp = 'Unknown';
if (entry.timestamp) {
try {
const date = new Date(entry.timestamp);
timestamp = date.toLocaleString();
} catch (e) {
timestamp = entry.timestamp;
}
}
html += `
<div class="flex items-start gap-3 pb-3 ${index < history.length - 1 ? 'border-b border-gray-400 dark:border-zinc-600' : ''}">
<div class="flex-shrink-0 mt-1">
<i class="fas ${icon}"></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-semibold">${actionText}</span>
<span class="text-gray-600 dark:text-gray-400">to</span>
<span class="font-semibold">${entry.device_name || 'Unknown'}</span>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400 mt-1">
${entry.subnet_name || 'Unknown'} (${entry.subnet_cidr || 'N/A'})
</div>
<div class="text-xs text-gray-500 dark:text-gray-500 mt-1">
by ${entry.user_name || 'Unknown'}${timestamp}
</div>
</div>
</div>
`;
});
html += '</div>';
content.innerHTML = html;
}
});
+20
View File
@@ -0,0 +1,20 @@
document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("ip-history-modal"),t=document.getElementById("close-ip-history-modal"),s=document.getElementById("ip-history-content"),i=document.getElementById("modal-ip-address");document.querySelectorAll(".ip-history-btn").forEach(t=>{t.addEventListener("click",function(){var t;let a=this.getAttribute("data-ip");i.textContent=a,e.classList.remove("hidden"),e.classList.add("flex"),t=a,s.innerHTML='<div class="text-center text-gray-600 dark:text-gray-400">Loading...</div>',fetch(`/api/ip/${encodeURIComponent(t)}/history`).then(e=>e.json()).then(e=>{var t;let i;e.history&&e.history.length>0?(t=e.history,i='<div class="space-y-3">',t.forEach((e,s)=>{let a="assigned"===e.action,n="Unknown";if(e.timestamp)try{let d=new Date(e.timestamp);n=d.toLocaleString()}catch(r){n=e.timestamp}i+=`
<div class="flex items-start gap-3 pb-3 ${s<t.length-1?"border-b border-gray-400 dark:border-zinc-600":""}">
<div class="flex-shrink-0 mt-1">
<i class="fas ${a?"fa-plus-circle text-green-500":"fa-minus-circle text-red-500"}"></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-semibold">${a?"Assigned":"Removed"}</span>
<span class="text-gray-600 dark:text-gray-400">to</span>
<span class="font-semibold">${e.device_name||"Unknown"}</span>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400 mt-1">
${e.subnet_name||"Unknown"} (${e.subnet_cidr||"N/A"})
</div>
<div class="text-xs text-gray-500 dark:text-gray-500 mt-1">
by ${e.user_name||"Unknown"}${n}
</div>
</div>
</div>
`}),i+="</div>",s.innerHTML=i):s.innerHTML='<div class="text-center text-gray-600 dark:text-gray-400">No history found for this IP address.</div>'}).catch(e=>{console.error("Error loading IP history:",e),s.innerHTML='<div class="text-center text-red-500">Error loading IP history. Please try again.</div>'})})}),t.addEventListener("click",function(){e.classList.add("hidden"),e.classList.remove("flex")}),e.addEventListener("click",function(t){t.target===e&&(e.classList.add("hidden"),e.classList.remove("flex"))}),document.addEventListener("keydown",function(t){"Escape"!==t.key||e.classList.contains("hidden")||(e.classList.add("hidden"),e.classList.remove("flex"))})});
+41
View File
@@ -0,0 +1,41 @@
document.addEventListener('DOMContentLoaded', function() {
// Export CSV button
const exportBtn = document.getElementById('export-csv');
if (exportBtn) {
exportBtn.addEventListener('click', function() {
const rackId = exportBtn.getAttribute('data-rack-id');
if (rackId) {
window.location = '/rack/' + rackId + '/export_csv';
}
});
}
// Form toggle functionality
function showBothAddButtons() {
document.getElementById('show-add-device-form').classList.remove('hidden');
document.getElementById('show-nonnet-form').classList.remove('hidden');
}
showBothAddButtons();
document.getElementById('show-nonnet-form').onclick = function() {
document.getElementById('nonnet-form').classList.remove('hidden');
this.classList.add('hidden');
};
document.getElementById('hide-nonnet-form').onclick = function() {
document.getElementById('nonnet-form').classList.add('hidden');
showBothAddButtons();
};
document.getElementById('show-add-device-form').onclick = function() {
document.getElementById('add-device-form').classList.remove('hidden');
this.classList.add('hidden');
};
document.getElementById('hide-add-device-form').onclick = function() {
document.getElementById('add-device-form').classList.add('hidden');
showBothAddButtons();
};
});
+1
View File
@@ -0,0 +1 @@
document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("export-csv");function d(){document.getElementById("show-add-device-form").classList.remove("hidden"),document.getElementById("show-nonnet-form").classList.remove("hidden")}e&&e.addEventListener("click",function(){let d=e.getAttribute("data-rack-id");d&&(window.location="/rack/"+d+"/export_csv")}),d(),document.getElementById("show-nonnet-form").onclick=function(){document.getElementById("nonnet-form").classList.remove("hidden"),this.classList.add("hidden")},document.getElementById("hide-nonnet-form").onclick=function(){document.getElementById("nonnet-form").classList.add("hidden"),d()},document.getElementById("show-add-device-form").onclick=function(){document.getElementById("add-device-form").classList.remove("hidden"),this.classList.add("hidden")},document.getElementById("hide-add-device-form").onclick=function(){document.getElementById("add-device-form").classList.add("hidden"),d()}});
+1
View File
@@ -0,0 +1 @@
document.addEventListener("DOMContentLoaded",function(){document.querySelectorAll(".site-header").forEach(e=>{e.addEventListener("click",function(e){if(e.target.closest("button"))return;let s=this.closest(".site-group").querySelector(".subnet-list"),t=this.querySelector(".expand-btn i");s.classList.contains("hidden")?(s.classList.remove("hidden"),t.classList.remove("fa-chevron-down"),t.classList.add("fa-chevron-up")):(s.classList.add("hidden"),t.classList.remove("fa-chevron-up"),t.classList.add("fa-chevron-down"))})}),document.querySelectorAll(".expand-btn").forEach(e=>{e.addEventListener("click",function(e){e.stopPropagation();let s=this.closest(".site-group").querySelector(".subnet-list"),t=this.querySelector("i");s.classList.contains("hidden")?(s.classList.remove("hidden"),t.classList.remove("fa-chevron-down"),t.classList.add("fa-chevron-up")):(s.classList.add("hidden"),t.classList.remove("fa-chevron-up"),t.classList.add("fa-chevron-down"))})})});
+205 -2
View File
@@ -1,6 +1,17 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const form = document.querySelector('form'); // Only target the form on the subnet page, not the header search form
// Look for a form that's not in the header (header forms have action="/search")
const allForms = document.querySelectorAll('form');
let form = null;
for (let f of allForms) {
if (f.action !== '/search' && f.method === 'POST') {
form = f;
break;
}
}
if (form) { if (form) {
// Check if search input already exists to prevent duplicates
if (!document.querySelector('input[placeholder="Search by IP or Hostname"]')) {
form.addEventListener('submit', (event) => { form.addEventListener('submit', (event) => {
event.preventDefault(); event.preventDefault();
}); });
@@ -20,8 +31,10 @@ document.addEventListener('DOMContentLoaded', () => {
rows.forEach(row => { rows.forEach(row => {
const ipCell = row.querySelector('td:nth-child(1)').textContent.toLowerCase(); const ipCell = row.querySelector('td:nth-child(1)').textContent.toLowerCase();
const hostnameCell = row.querySelector('td:nth-child(2)').textContent.toLowerCase(); const hostnameCell = row.querySelector('td:nth-child(2)').textContent.toLowerCase();
const descCell = row.querySelector('td:nth-child(3)');
const descText = descCell ? descCell.textContent.toLowerCase() : '';
if (ipCell.includes(searchTerm) || hostnameCell.includes(searchTerm)) { if (ipCell.includes(searchTerm) || hostnameCell.includes(searchTerm) || descText.includes(searchTerm)) {
row.style.backgroundColor = 'rgba(59, 130, 246, 0.5)'; row.style.backgroundColor = 'rgba(59, 130, 246, 0.5)';
row.scrollIntoView({ behavior: 'smooth', block: 'center' }); row.scrollIntoView({ behavior: 'smooth', block: 'center' });
@@ -35,6 +48,21 @@ document.addEventListener('DOMContentLoaded', () => {
} }
}); });
} }
}
// Description toggle functionality
const toggleBtn = document.getElementById('toggle-desc');
const descCols = document.querySelectorAll('.desc-col');
const descHeader = document.getElementById('desc-col-header');
let shown = false;
if (toggleBtn) {
toggleBtn.addEventListener('click', function() {
shown = !shown;
descCols.forEach(col => col.classList.toggle('hidden', !shown));
if (descHeader) descHeader.classList.toggle('hidden', !shown);
toggleBtn.textContent = shown ? 'Hide Descriptions' : 'Show Descriptions';
});
}
// Scroll to Top Button // Scroll to Top Button
const scrollToTopButton = document.createElement('button'); const scrollToTopButton = document.createElement('button');
@@ -76,4 +104,179 @@ document.addEventListener('DOMContentLoaded', () => {
scrollToTopButton.addEventListener('click', () => { scrollToTopButton.addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
}); });
// Force scrollbar thumb to render on page load
// This fixes the issue where scrollbar thumb is missing on initial page load
// The scrollbar only renders its thumb after a scroll event has occurred
requestAnimationFrame(() => {
const isScrollable = document.documentElement.scrollHeight > document.documentElement.clientHeight;
if (isScrollable && window.scrollY === 0) {
// Trigger a minimal scroll to force scrollbar rendering, then scroll back
window.scrollBy(0, 1);
requestAnimationFrame(() => {
window.scrollBy(0, -1);
});
}
});
// Scroll to IP anchor if present in URL hash
if (window.location.hash) {
const hash = window.location.hash.substring(1);
const element = document.getElementById(hash);
if (element) {
setTimeout(() => {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Highlight the row briefly
element.style.backgroundColor = 'rgba(59, 130, 246, 0.5)';
setTimeout(() => {
element.style.backgroundColor = '';
}, 3000);
}, 100);
}
}
// Auto-resize all description textareas (both editable and readonly)
const allDescTextareas = document.querySelectorAll('.desc-col textarea');
allDescTextareas.forEach(textarea => {
textarea.style.overflow = 'hidden';
textarea.style.resize = 'none';
function autoResize() {
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
}
autoResize();
});
// IP Notes inline editing functionality
const ipNotesTextareas = document.querySelectorAll('.ip-notes-textarea');
const originalValues = new Map();
// Helper function to show toast notification
function showToast(message, type = 'success') {
const toast = document.createElement('div');
toast.className = `fixed top-20 right-4 px-4 py-3 rounded-lg shadow-lg z-50 ${
type === 'success'
? 'bg-green-500 text-white'
: 'bg-red-500 text-white'
}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.transition = 'opacity 0.3s';
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
ipNotesTextareas.forEach(textarea => {
// Store original value
originalValues.set(textarea, textarea.value);
// Ensure overflow is hidden and resize is disabled
textarea.style.overflow = 'hidden';
textarea.style.resize = 'none';
// Auto-resize textarea
function autoResize() {
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
}
autoResize();
// Handle input to auto-resize
textarea.addEventListener('input', autoResize);
// Handle blur event to save notes
textarea.addEventListener('blur', async function() {
const ipId = this.getAttribute('data-ip-id');
const deviceDesc = this.getAttribute('data-device-desc') || '';
const fullValue = this.value;
const originalValue = originalValues.get(this);
// Extract IP notes: everything after the device description
let ipNotes = '';
if (deviceDesc) {
// If device description exists, check if textarea starts with it
const deviceDescTrimmed = deviceDesc.trim();
const fullValueTrimmed = fullValue.trim();
if (fullValueTrimmed.startsWith(deviceDescTrimmed)) {
// Remove device description from the beginning
ipNotes = fullValueTrimmed.substring(deviceDescTrimmed.length).trim();
// Also handle case where there's a newline separator
if (ipNotes.startsWith('\n')) {
ipNotes = ipNotes.substring(1).trim();
}
} else {
// Device description was modified or removed - extract everything as IP notes
// This shouldn't normally happen, but handle gracefully
ipNotes = fullValueTrimmed;
}
} else {
// No device description, so entire value is IP notes
ipNotes = fullValue.trim();
}
// Only save if value changed
if (fullValue !== originalValue) {
// Show loading indicator
const originalBg = this.style.backgroundColor;
this.style.backgroundColor = 'rgba(59, 130, 246, 0.2)';
this.disabled = true;
try {
const response = await fetch(`/ip/${ipId}/update_notes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ notes: ipNotes })
});
const data = await response.json();
if (data.success) {
// Update the displayed value to reflect what was saved
let newDisplayValue = '';
if (deviceDesc) {
newDisplayValue = deviceDesc;
if (ipNotes) {
newDisplayValue += '\n' + ipNotes;
}
} else {
newDisplayValue = ipNotes;
}
this.value = newDisplayValue;
originalValues.set(this, newDisplayValue);
autoResize();
showToast('Notes saved successfully', 'success');
} else {
// Restore original value on error
this.value = originalValue;
autoResize();
showToast(data.error || 'Failed to save notes', 'error');
}
} catch (error) {
// Restore original value on error
this.value = originalValue;
autoResize();
showToast('Error saving notes. Please try again.', 'error');
console.error('Error saving IP notes:', error);
} finally {
this.style.backgroundColor = originalBg;
this.disabled = false;
}
}
});
// Handle Escape key to cancel editing
textarea.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
this.value = originalValues.get(this);
autoResize();
this.blur();
}
});
});
}); });
+14
View File
@@ -0,0 +1,14 @@
document.addEventListener("DOMContentLoaded",()=>{let e=document.querySelectorAll("form"),t=null;for(let l of e)if("/search"!==l.action&&"POST"===l.method){t=l;break}if(t&&!document.querySelector('input[placeholder="Search by IP or Hostname"]')){t.addEventListener("submit",e=>{e.preventDefault()});let o=document.createElement("input");o.type="text",o.placeholder="Search by IP or Hostname",o.className="p-2 w-full rounded-lg bg-gray-200 dark:bg-zinc-800 border border-gray-600 focus:outline-none focus:border-blue-400 mb-4 text-center",t.insertAdjacentElement("beforebegin",o),o.addEventListener("keypress",e=>{if("Enter"===e.key){e.preventDefault();let t=o.value.toLowerCase(),l=document.querySelectorAll("tbody tr");l.forEach(e=>{let l=e.querySelector("td:nth-child(1)").textContent.toLowerCase(),o=e.querySelector("td:nth-child(2)").textContent.toLowerCase(),s=e.querySelector("td:nth-child(3)"),r=s?s.textContent.toLowerCase():"";l.includes(t)||o.includes(t)||r.includes(t)?(e.style.backgroundColor="rgba(59, 130, 246, 0.5)",e.scrollIntoView({behavior:"smooth",block:"center"}),setTimeout(()=>{e.style.backgroundColor=""},3e3)):e.style.backgroundColor=""})}})}let s=document.getElementById("toggle-desc"),r=document.querySelectorAll(".desc-col"),n=document.getElementById("desc-col-header"),i=!1;s&&s.addEventListener("click",function(){i=!i,r.forEach(e=>e.classList.toggle("hidden",!i)),n&&n.classList.toggle("hidden",!i),s.textContent=i?"Hide Descriptions":"Show Descriptions"});let a=document.createElement("button");a.innerHTML='<i class="fas fa-arrow-up"></i>',a.style.fontSize="26px",a.className="fixed bottom-5 right-5 bg-gray-200 dark:bg-zinc-800 text-black dark:text-white p-3 rounded-full shadow-lg hidden",a.style.width="60px",a.style.height="60px",a.style.borderRadius="50%",document.body.appendChild(a);let d=document.createElement("style");if(d.textContent=`
@keyframes bob {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
.bobbing {
animation: bob 1.5s infinite;
}
`,document.head.appendChild(d),a.classList.add("bobbing"),window.addEventListener("scroll",()=>{window.scrollY>200?a.classList.remove("hidden"):a.classList.add("hidden")}),a.addEventListener("click",()=>{window.scrollTo({top:0,behavior:"smooth"})}),requestAnimationFrame(()=>{let e=document.documentElement.scrollHeight>document.documentElement.clientHeight;e&&0===window.scrollY&&(window.scrollBy(0,1),requestAnimationFrame(()=>{window.scrollBy(0,-1)}))}),window.location.hash){let c=window.location.hash.substring(1),h=document.getElementById(c);h&&setTimeout(()=>{h.scrollIntoView({behavior:"smooth",block:"center"}),h.style.backgroundColor="rgba(59, 130, 246, 0.5)",setTimeout(()=>{h.style.backgroundColor=""},3e3)},100)}let u=document.querySelectorAll(".desc-col textarea");u.forEach(e=>{function t(){e.style.height="auto",e.style.height=e.scrollHeight+"px"}e.style.overflow="hidden",e.style.resize="none",t()});let y=document.querySelectorAll(".ip-notes-textarea"),b=new Map;function g(e,t="success"){let l=document.createElement("div");l.className=`fixed top-20 right-4 px-4 py-3 rounded-lg shadow-lg z-50 ${"success"===t?"bg-green-500 text-white":"bg-red-500 text-white"}`,l.textContent=e,document.body.appendChild(l),setTimeout(()=>{l.style.transition="opacity 0.3s",l.style.opacity="0",setTimeout(()=>l.remove(),300)},3e3)}y.forEach(e=>{function t(){e.style.height="auto",e.style.height=e.scrollHeight+"px"}b.set(e,e.value),e.style.overflow="hidden",e.style.resize="none",t(),e.addEventListener("input",t),e.addEventListener("blur",async function(){let e=this.getAttribute("data-ip-id"),l=this.getAttribute("data-device-desc")||"",o=this.value,s=b.get(this),r="";if(l){let n=l.trim(),i=o.trim();i.startsWith(n)?(r=i.substring(n.length).trim()).startsWith("\n")&&(r=r.substring(1).trim()):r=i}else r=o.trim();if(o!==s){let a=this.style.backgroundColor;this.style.backgroundColor="rgba(59, 130, 246, 0.2)",this.disabled=!0;try{let d=await fetch(`/ip/${e}/update_notes`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({notes:r})}),c=await d.json();if(c.success){let h="";l?(h=l,r&&(h+="\n"+r)):h=r,this.value=h,b.set(this,h),t(),g("Notes saved successfully","success")}else this.value=s,t(),g(c.error||"Failed to save notes","error")}catch(u){this.value=s,t(),g("Error saving notes. Please try again.","error"),console.error("Error saving IP notes:",u)}finally{this.style.backgroundColor=a,this.disabled=!1}}}),e.addEventListener("keydown",function(e){"Escape"===e.key&&(this.value=b.get(this),t(),this.blur())})})});
+164
View File
@@ -0,0 +1,164 @@
// Auto-save custom fields on blur (subnet page)
document.addEventListener('DOMContentLoaded', () => {
const customFieldsForm = document.getElementById('custom-fields-form');
if (!customFieldsForm) {
return; // No custom fields form on this page
}
const subnetId = customFieldsForm.action.match(/\/subnet\/(\d+)\/update_custom_fields/)?.[1];
if (!subnetId) {
return;
}
// Get all form fields
const formFields = customFieldsForm.querySelectorAll('input, textarea, select');
const originalValues = new Map();
// Store original values
formFields.forEach(field => {
if (field.type === 'checkbox') {
originalValues.set(field, field.checked);
} else {
originalValues.set(field, field.value);
}
});
// Helper function to show toast notification (reuse from subnet.js if available)
function showToast(message, type = 'success') {
const toast = document.createElement('div');
toast.className = `fixed top-20 right-4 px-4 py-3 rounded-lg shadow-lg z-50 ${
type === 'success'
? 'bg-green-500 text-white'
: 'bg-red-500 text-white'
}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.transition = 'opacity 0.3s';
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// Auto-resize textareas
const textareas = customFieldsForm.querySelectorAll('textarea');
textareas.forEach(textarea => {
textarea.style.overflow = 'hidden';
textarea.style.resize = 'none';
function autoResize() {
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
}
autoResize();
textarea.addEventListener('input', autoResize);
});
// Check if form has changes
function hasChanges() {
for (const field of formFields) {
let currentValue;
if (field.type === 'checkbox') {
currentValue = field.checked;
} else {
currentValue = field.value;
}
const originalValue = originalValues.get(field);
if (currentValue !== originalValue) {
return true;
}
}
return false;
}
// Save all custom fields
let saveInProgress = false;
async function saveCustomFields() {
if (saveInProgress) {
return; // Prevent multiple simultaneous saves
}
if (!hasChanges()) {
return; // No changes to save
}
saveInProgress = true;
// Show loading indicator on form
const originalOpacity = customFieldsForm.style.opacity;
customFieldsForm.style.opacity = '0.6';
customFieldsForm.style.pointerEvents = 'none';
try {
// Create FormData from form and convert to JSON
const formData = new FormData(customFieldsForm);
const data = {};
// Process all fields
for (const [key, value] of formData.entries()) {
data[key] = value;
}
// Handle checkboxes that weren't checked (they don't appear in FormData)
formFields.forEach(field => {
if (field.type === 'checkbox' && !field.checked) {
data[field.name] = '';
}
});
const response = await fetch(customFieldsForm.action, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
if (response.ok) {
// Update original values
formFields.forEach(field => {
if (field.type === 'checkbox') {
originalValues.set(field, field.checked);
} else {
originalValues.set(field, field.value);
}
});
showToast('Custom fields saved successfully', 'success');
} else {
const data = await response.json().catch(() => ({}));
const errorMsg = data.errors ? data.errors.join(', ') : (data.error || 'Failed to save custom fields');
showToast(errorMsg, 'error');
}
} catch (error) {
showToast('Error saving custom fields. Please try again.', 'error');
console.error('Error saving custom fields:', error);
} finally {
customFieldsForm.style.opacity = originalOpacity;
customFieldsForm.style.pointerEvents = '';
saveInProgress = false;
}
}
// Add blur event listeners to all fields
formFields.forEach(field => {
// Skip if it's a checkbox (we'll handle change event instead)
if (field.type === 'checkbox') {
field.addEventListener('change', () => {
// Small delay to ensure value is updated
setTimeout(saveCustomFields, 100);
});
} else {
field.addEventListener('blur', saveCustomFields);
}
});
// Prevent form submission (since we're using auto-save)
customFieldsForm.addEventListener('submit', (e) => {
e.preventDefault();
saveCustomFields();
});
});
+1
View File
@@ -0,0 +1 @@
document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("custom-fields-form");if(!e)return;let t=e.action.match(/\/subnet\/(\d+)\/update_custom_fields/)?.[1];if(!t)return;let r=e.querySelectorAll("input, textarea, select"),s=new Map;function o(e,t="success"){let r=document.createElement("div");r.className=`fixed top-20 right-4 px-4 py-3 rounded-lg shadow-lg z-50 ${"success"===t?"bg-green-500 text-white":"bg-red-500 text-white"}`,r.textContent=e,document.body.appendChild(r),setTimeout(()=>{r.style.transition="opacity 0.3s",r.style.opacity="0",setTimeout(()=>r.remove(),300)},3e3)}r.forEach(e=>{"checkbox"===e.type?s.set(e,e.checked):s.set(e,e.value)});let n=e.querySelectorAll("textarea");function c(){for(let e of r){let t;t="checkbox"===e.type?e.checked:e.value;let o=s.get(e);if(t!==o)return!0}return!1}n.forEach(e=>{function t(){e.style.height="auto",e.style.height=e.scrollHeight+"px"}e.style.overflow="hidden",e.style.resize="none",t(),e.addEventListener("input",t)});let l=!1;async function i(){if(l||!c())return;l=!0;let t=e.style.opacity;e.style.opacity="0.6",e.style.pointerEvents="none";try{let n=new FormData(e),i={};for(let[a,d]of n.entries())i[a]=d;r.forEach(e=>{"checkbox"!==e.type||e.checked||(i[e.name]="")});let u=await fetch(e.action,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)});if(u.ok)r.forEach(e=>{"checkbox"===e.type?s.set(e,e.checked):s.set(e,e.value)}),o("Custom fields saved successfully","success");else{let y=await u.json().catch(()=>({})),f=y.errors?y.errors.join(", "):y.error||"Failed to save custom fields";o(f,"error")}}catch(h){o("Error saving custom fields. Please try again.","error"),console.error("Error saving custom fields:",h)}finally{e.style.opacity=t,e.style.pointerEvents="",l=!1}}r.forEach(e=>{"checkbox"===e.type?e.addEventListener("change",()=>{setTimeout(i,100)}):e.addEventListener("blur",i)}),e.addEventListener("submit",e=>{e.preventDefault(),i()})});
+69
View File
@@ -0,0 +1,69 @@
// Tag Management JavaScript
function showAddTagModal() {
document.getElementById('add-tag-modal').classList.remove('hidden');
document.getElementById('add-tag-name').value = '';
document.getElementById('add-tag-color').value = '#6B7280';
document.getElementById('add-tag-description').value = '';
updateColorPreview('add');
}
function closeAddTagModal() {
document.getElementById('add-tag-modal').classList.add('hidden');
}
function editTag(tagId, name, color, description) {
document.getElementById('edit-tag-id').value = tagId;
document.getElementById('edit-tag-name').value = name;
document.getElementById('edit-tag-color').value = color;
document.getElementById('edit-tag-description').value = description || '';
updateColorPreview('edit');
document.getElementById('edit-tag-modal').classList.remove('hidden');
}
function closeEditTagModal() {
document.getElementById('edit-tag-modal').classList.add('hidden');
}
function updateColorPreview(mode) {
const colorInput = document.getElementById(`${mode}-tag-color`);
const preview = document.getElementById(`${mode}-color-preview`);
preview.textContent = colorInput.value.toUpperCase();
}
// Event listeners
document.addEventListener('DOMContentLoaded', function() {
const addColorInput = document.getElementById('add-tag-color');
const editColorInput = document.getElementById('edit-tag-color');
if (addColorInput) {
addColorInput.addEventListener('input', () => updateColorPreview('add'));
}
if (editColorInput) {
editColorInput.addEventListener('input', () => updateColorPreview('edit'));
}
// Handle edit tag button clicks
document.querySelectorAll('.edit-tag-btn').forEach(button => {
button.addEventListener('click', function() {
const tagId = this.dataset.tagId;
const tagName = this.dataset.tagName;
const tagColor = this.dataset.tagColor;
const tagDescription = this.dataset.tagDescription;
editTag(tagId, tagName, tagColor, tagDescription);
});
});
});
// Close modals when clicking outside
window.onclick = function(event) {
const addModal = document.getElementById('add-tag-modal');
const editModal = document.getElementById('edit-tag-modal');
if (event.target === addModal) {
closeAddTagModal();
}
if (event.target === editModal) {
closeEditTagModal();
}
}
+1
View File
@@ -0,0 +1 @@
function showAddTagModal(){document.getElementById("add-tag-modal").classList.remove("hidden"),document.getElementById("add-tag-name").value="",document.getElementById("add-tag-color").value="#6B7280",document.getElementById("add-tag-description").value="",updateColorPreview("add")}function closeAddTagModal(){document.getElementById("add-tag-modal").classList.add("hidden")}function editTag(e,t,d,a){document.getElementById("edit-tag-id").value=e,document.getElementById("edit-tag-name").value=t,document.getElementById("edit-tag-color").value=d,document.getElementById("edit-tag-description").value=a||"",updateColorPreview("edit"),document.getElementById("edit-tag-modal").classList.remove("hidden")}function closeEditTagModal(){document.getElementById("edit-tag-modal").classList.add("hidden")}function updateColorPreview(e){let t=document.getElementById(`${e}-tag-color`),d=document.getElementById(`${e}-color-preview`);d.textContent=t.value.toUpperCase()}document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("add-tag-color"),t=document.getElementById("edit-tag-color");e&&e.addEventListener("input",()=>updateColorPreview("add")),t&&t.addEventListener("input",()=>updateColorPreview("edit")),document.querySelectorAll(".edit-tag-btn").forEach(e=>{e.addEventListener("click",function(){let e=this.dataset.tagId,t=this.dataset.tagName,d=this.dataset.tagColor,a=this.dataset.tagDescription;editTag(e,t,d,a)})})}),window.onclick=function(e){let t=document.getElementById("add-tag-modal"),d=document.getElementById("edit-tag-modal");e.target===t&&closeAddTagModal(),e.target===d&&closeEditTagModal()};
+40
View File
@@ -0,0 +1,40 @@
document.addEventListener('DOMContentLoaded', function() {
// Check if toast was dismissed in this session
const toastDismissed = sessionStorage.getItem('update-toast-dismissed');
if (toastDismissed) {
return;
}
// Check for updates
fetch('/check_update')
.then(response => response.json())
.then(data => {
if (data.update_available) {
const toast = document.getElementById('update-toast');
const currentVersionEl = document.getElementById('toast-current-version');
const latestVersionEl = document.getElementById('toast-latest-version');
const compareLink = document.getElementById('toast-compare-link');
const closeBtn = document.getElementById('toast-close');
// Set versions (don't add 'v' prefix for dev versions)
currentVersionEl.textContent = (data.current_version === 'dev' ? '' : 'v') + data.current_version;
latestVersionEl.textContent = (data.latest_version === 'dev' ? '' : 'v') + data.latest_version;
// Set compare link (current version to latest version)
compareLink.href = `https://git.jdbnet.co.uk/jamie/ipam/compare/v${data.current_version}...v${data.latest_version}`;
// Show toast
toast.classList.remove('hidden');
// Close button handler
closeBtn.addEventListener('click', function() {
toast.classList.add('hidden');
sessionStorage.setItem('update-toast-dismissed', 'true');
});
}
})
.catch(error => {
console.error('Error checking for updates:', error);
});
});
+1
View File
@@ -0,0 +1 @@
document.addEventListener("DOMContentLoaded",function(){let e=sessionStorage.getItem("update-toast-dismissed");!e&&fetch("/check_update").then(e=>e.json()).then(e=>{if(e.update_available){let t=document.getElementById("update-toast"),n=document.getElementById("toast-current-version"),s=document.getElementById("toast-latest-version"),a=document.getElementById("toast-compare-link"),d=document.getElementById("toast-close");n.textContent=("dev"===e.current_version?"":"v")+e.current_version,s.textContent=("dev"===e.latest_version?"":"v")+e.latest_version,a.href=`https://git.jdbnet.co.uk/jamie/ipam/compare/v${e.current_version}...v${e.latest_version}`,t.classList.remove("hidden"),d.addEventListener("click",function(){t.classList.add("hidden"),sessionStorage.setItem("update-toast-dismissed","true")})}}).catch(e=>{console.error("Error checking for updates:",e)})});
+200
View File
@@ -0,0 +1,200 @@
// These variables are set inline in the template from server data
// permissions and rolePermissions are passed from the template
function showTab(tab) {
document.getElementById('users-tab').classList.add('hidden');
document.getElementById('roles-tab').classList.add('hidden');
document.getElementById('tab-users').classList.remove('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
document.getElementById('tab-users').classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
document.getElementById('tab-roles').classList.remove('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
document.getElementById('tab-roles').classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
if (tab === 'users') {
document.getElementById('users-tab').classList.remove('hidden');
document.getElementById('tab-users').classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
document.getElementById('tab-users').classList.add('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
} else {
document.getElementById('roles-tab').classList.remove('hidden');
document.getElementById('tab-roles').classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
document.getElementById('tab-roles').classList.add('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
}
}
function editUser(userId, name, email, roleId, apiKey) {
document.getElementById('edit-user-id').value = userId;
document.getElementById('edit-user-name').value = name;
document.getElementById('edit-user-email').value = email;
document.getElementById('edit-user-password').value = '';
document.getElementById('edit-user-role').value = (roleId === null || roleId === 'null') ? '' : roleId;
document.getElementById('edit-user-api-key').textContent = apiKey || 'No API Key';
document.getElementById('edit-user-modal').classList.remove('hidden');
}
function closeEditUserModal() {
document.getElementById('edit-user-modal').classList.add('hidden');
}
function showAddRoleModal() {
// Make sure edit modal is closed first
document.getElementById('edit-role-modal').classList.add('hidden');
// Clear any form data
const addForm = document.querySelector('#add-role-modal form');
if (addForm) {
addForm.reset();
}
// Show add modal
document.getElementById('add-role-modal').classList.remove('hidden');
}
function closeAddRoleModal() {
document.getElementById('add-role-modal').classList.add('hidden');
}
function editRole(roleId, roleName, roleDescription, require2fa) {
// Make sure add modal is closed first
document.getElementById('add-role-modal').classList.add('hidden');
document.getElementById('edit-role-id').value = roleId;
document.getElementById('edit-role-name').value = roleName;
document.getElementById('edit-role-description').value = roleDescription || '';
document.getElementById('edit-role-require-2fa').checked = require2fa === true || require2fa === 'True' || require2fa === 1;
const permissionsDiv = document.getElementById('edit-role-permissions');
permissionsDiv.innerHTML = '';
const rolePerms = rolePermissions[roleId] || [];
// Group permissions by merged categories
const viewPerms = permissions.filter(p => p[3] === 'View');
const devicePerms = permissions.filter(p => p[3] === 'Device');
const deviceTypePerms = permissions.filter(p => p[3] === 'Device Type');
const subnetPerms = permissions.filter(p => p[3] === 'Subnet');
const dhcpPerms = permissions.filter(p => p[3] === 'DHCP');
const rackPerms = permissions.filter(p => p[3] === 'Rack');
const adminPerms = permissions.filter(p => p[3] === 'Admin');
let html = '';
// View Permissions
html += ' <!-- View Permissions -->\n';
html += ' <div class="col-span-full">\n';
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">View Permissions</h4>\n';
html += ' <div class="grid grid-cols-1 md:grid-cols-2 gap-2">\n';
viewPerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
html += ' </div>\n';
html += ' </div>\n';
html += ' \n';
// Device Management
html += ' <!-- Device Management -->\n';
html += ' <div>\n';
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Device Management</h4>\n';
devicePerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
deviceTypePerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
html += ' </div>\n';
html += ' \n';
// Network Management
html += ' <!-- Network Management -->\n';
html += ' <div>\n';
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Network Management</h4>\n';
subnetPerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
dhcpPerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
html += ' </div>\n';
html += ' \n';
// Rack Management
html += ' <!-- Rack Management -->\n';
html += ' <div>\n';
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Rack Management</h4>\n';
rackPerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
html += ' </div>\n';
html += ' \n';
// Admin
html += ' <!-- Admin -->\n';
html += ' <div>\n';
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Administration</h4>\n';
adminPerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
html += ' </div>\n';
permissionsDiv.innerHTML = html;
document.getElementById('edit-role-modal').classList.remove('hidden');
}
function closeEditRoleModal() {
document.getElementById('edit-role-modal').classList.add('hidden');
}
function deleteRole(roleId, roleName) {
if (confirm(`Are you sure you want to delete the role "${roleName}"?`)) {
const form = document.createElement('form');
form.method = 'POST';
form.action = '/users';
form.innerHTML = `
<input type="hidden" name="action" value="delete_role">
<input type="hidden" name="role_id" value="${roleId}">
`;
document.body.appendChild(form);
form.submit();
}
}
// Close modals when clicking outside
window.onclick = function(event) {
const editUserModal = document.getElementById('edit-user-modal');
const editRoleModal = document.getElementById('edit-role-modal');
const addRoleModal = document.getElementById('add-role-modal');
if (event.target === editUserModal) {
closeEditUserModal();
}
if (event.target === editRoleModal) {
closeEditRoleModal();
}
if (event.target === addRoleModal) {
closeAddRoleModal();
}
}
+32
View File
@@ -0,0 +1,32 @@
function showTab(e){document.getElementById("users-tab").classList.add("hidden"),document.getElementById("roles-tab").classList.add("hidden"),document.getElementById("tab-users").classList.remove("border-blue-500","text-blue-600","dark:text-blue-400"),document.getElementById("tab-users").classList.add("border-transparent","text-gray-600","dark:text-gray-400"),document.getElementById("tab-roles").classList.remove("border-blue-500","text-blue-600","dark:text-blue-400"),document.getElementById("tab-roles").classList.add("border-transparent","text-gray-600","dark:text-gray-400"),"users"===e?(document.getElementById("users-tab").classList.remove("hidden"),document.getElementById("tab-users").classList.remove("border-transparent","text-gray-600","dark:text-gray-400"),document.getElementById("tab-users").classList.add("border-blue-500","text-blue-600","dark:text-blue-400")):(document.getElementById("roles-tab").classList.remove("hidden"),document.getElementById("tab-roles").classList.remove("border-transparent","text-gray-600","dark:text-gray-400"),document.getElementById("tab-roles").classList.add("border-blue-500","text-blue-600","dark:text-blue-400"))}function editUser(e,t,s,l,d){document.getElementById("edit-user-id").value=e,document.getElementById("edit-user-name").value=t,document.getElementById("edit-user-email").value=s,document.getElementById("edit-user-password").value="",document.getElementById("edit-user-role").value=null===l||"null"===l?"":l,document.getElementById("edit-user-api-key").textContent=d||"No API Key",document.getElementById("edit-user-modal").classList.remove("hidden")}function closeEditUserModal(){document.getElementById("edit-user-modal").classList.add("hidden")}function showAddRoleModal(){document.getElementById("edit-role-modal").classList.add("hidden");let e=document.querySelector("#add-role-modal form");e&&e.reset(),document.getElementById("add-role-modal").classList.remove("hidden")}function closeAddRoleModal(){document.getElementById("add-role-modal").classList.add("hidden")}function editRole(e,t,s,l){document.getElementById("add-role-modal").classList.add("hidden"),document.getElementById("edit-role-id").value=e,document.getElementById("edit-role-name").value=t,document.getElementById("edit-role-description").value=s||"",document.getElementById("edit-role-require-2fa").checked=!0===l||"True"===l||1===l;let d=document.getElementById("edit-role-permissions");d.innerHTML="";let a=rolePermissions[e]||[],r=permissions.filter(e=>"View"===e[3]),n=permissions.filter(e=>"Device"===e[3]),o=permissions.filter(e=>"Device Type"===e[3]),i=permissions.filter(e=>"Subnet"===e[3]),c=permissions.filter(e=>"DHCP"===e[3]),m=permissions.filter(e=>"Rack"===e[3]),b=permissions.filter(e=>"Admin"===e[3]),u="";u+=" <!-- View Permissions -->\n",u+=' <div class="col-span-full">\n',u+=' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">View Permissions</h4>\n',u+=' <div class="grid grid-cols-1 md:grid-cols-2 gap-2">\n',r.forEach(e=>{let t=a.includes(e[0])?"checked":"";u+=` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${e[0]}" ${t} class="mr-2">
<span class="text-sm">${e[2]}</span>
</label>
`}),u+=" </div>\n",u+=" </div>\n",u+=" \n",u+=" <!-- Device Management -->\n",u+=" <div>\n",u+=' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Device Management</h4>\n',n.forEach(e=>{let t=a.includes(e[0])?"checked":"";u+=` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${e[0]}" ${t} class="mr-2">
<span class="text-sm">${e[2]}</span>
</label>
`}),o.forEach(e=>{let t=a.includes(e[0])?"checked":"";u+=` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${e[0]}" ${t} class="mr-2">
<span class="text-sm">${e[2]}</span>
</label>
`}),u+=" </div>\n",u+=" \n",u+=" <!-- Network Management -->\n",u+=" <div>\n",u+=' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Network Management</h4>\n',i.forEach(e=>{let t=a.includes(e[0])?"checked":"";u+=` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${e[0]}" ${t} class="mr-2">
<span class="text-sm">${e[2]}</span>
</label>
`}),c.forEach(e=>{let t=a.includes(e[0])?"checked":"";u+=` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${e[0]}" ${t} class="mr-2">
<span class="text-sm">${e[2]}</span>
</label>
`}),u+=" </div>\n",u+=" \n",u+=" <!-- Rack Management -->\n",u+=" <div>\n",u+=' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Rack Management</h4>\n',m.forEach(e=>{let t=a.includes(e[0])?"checked":"";u+=` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${e[0]}" ${t} class="mr-2">
<span class="text-sm">${e[2]}</span>
</label>
`}),u+=" </div>\n",u+=" \n",u+=" <!-- Admin -->\n",u+=" <div>\n",u+=' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Administration</h4>\n',b.forEach(e=>{let t=a.includes(e[0])?"checked":"";u+=` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${e[0]}" ${t} class="mr-2">
<span class="text-sm">${e[2]}</span>
</label>
`}),u+=" </div>\n",d.innerHTML=u,document.getElementById("edit-role-modal").classList.remove("hidden")}function closeEditRoleModal(){document.getElementById("edit-role-modal").classList.add("hidden")}function deleteRole(e,t){if(confirm(`Are you sure you want to delete the role "${t}"?`)){let s=document.createElement("form");s.method="POST",s.action="/users",s.innerHTML=`
<input type="hidden" name="action" value="delete_role">
<input type="hidden" name="role_id" value="${e}">
`,document.body.appendChild(s),s.submit()}}window.onclick=function(e){let t=document.getElementById("edit-user-modal"),s=document.getElementById("edit-role-modal"),l=document.getElementById("add-role-modal");e.target===t&&closeEditUserModal(),e.target===s&&closeEditRoleModal(),e.target===l&&closeAddRoleModal()};
+144
View File
@@ -0,0 +1,144 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Account Settings - {{ 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-3xl pt-20">
<h1 class="text-3xl font-bold mb-6 text-center">
<i class="fas fa-user-cog mr-2"></i>
Account Settings
</h1>
{% if success %}
<div class="bg-green-200 dark:bg-green-900 text-green-800 dark:text-green-200 p-4 rounded-lg mb-6">
<i class="fas fa-check-circle mr-2"></i>{{ success }}
</div>
{% endif %}
{% if error %}
<div class="bg-red-200 dark:bg-red-900 text-red-800 dark:text-red-200 p-4 rounded-lg mb-6">
<i class="fas fa-exclamation-circle mr-2"></i>{{ error }}
</div>
{% endif %}
<div class="space-y-6">
<!-- Change Password Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-bold mb-4">
<i class="fas fa-key mr-2"></i>
Change Password
</h2>
<form method="POST" action="/account/change-password" class="space-y-4">
<div>
<label for="current_password" class="block text-sm font-medium mb-2">Current Password</label>
<input type="password" name="current_password" id="current_password"
class="w-full p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border border-gray-600" required>
</div>
<div>
<label for="new_password" class="block text-sm font-medium mb-2">New Password</label>
<input type="password" name="new_password" id="new_password"
class="w-full p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border border-gray-600" required>
</div>
<div>
<label for="confirm_password" class="block text-sm font-medium mb-2">Confirm New Password</label>
<input type="password" name="confirm_password" id="confirm_password"
class="w-full p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border border-gray-600" required>
</div>
<button type="submit" class="w-full bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
<i class="fas fa-save"></i>
<span>Change Password</span>
</button>
</form>
</div>
<!-- Two-Factor Authentication Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-bold mb-4">
<i class="fas fa-shield-alt mr-2"></i>
Two-Factor Authentication
</h2>
{% if totp_enabled %}
<div class="space-y-4">
<div class="bg-green-200 dark:bg-green-900 text-green-800 dark:text-green-200 p-4 rounded-lg">
<i class="fas fa-check-circle mr-2"></i>
2FA is currently <strong>enabled</strong> for your account.
</div>
<form method="POST" action="/account/disable-2fa" class="mt-4" onsubmit="return confirm('Are you sure you want to disable two-factor authentication? This will make your account less secure.');">
<input type="hidden" name="confirm_disable" value="true">
<button type="submit" class="w-full bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
<i class="fas fa-times"></i>
<span>Disable 2FA</span>
</button>
</form>
<div class="mt-6">
<h3 class="text-lg font-semibold mb-3">
<i class="fas fa-key mr-2"></i>
Backup Codes
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Backup codes can be used to access your account if you lose your authenticator device.
Each code can only be used once.
</p>
{% if backup_codes %}
<div class="bg-gray-300 dark:bg-zinc-900 p-6 rounded-lg mb-4">
<div class="grid grid-cols-2 gap-4 font-mono text-center">
{% for code_pair in backup_codes %}
<div class="p-3 bg-gray-200 dark:bg-zinc-800 rounded border border-gray-400 dark:border-zinc-600">
{{ code_pair }}
</div>
{% endfor %}
</div>
</div>
<button onclick="window.print()" class="w-full bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
<i class="fas fa-print"></i>
<span>Print Backup Codes</span>
</button>
{% else %}
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
You don't have any backup codes. Generate new ones below.
</p>
{% endif %}
<form method="POST" action="/account/regenerate-backup-codes" class="mt-4" onsubmit="return confirm('This will invalidate your existing backup codes. Are you sure you want to generate new ones?');">
<button type="submit" class="w-full bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
<i class="fas fa-redo"></i>
<span>Regenerate Backup Codes</span>
</button>
</form>
</div>
</div>
{% else %}
<div class="space-y-4">
<div class="bg-yellow-200 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 p-4 rounded-lg">
<i class="fas fa-exclamation-triangle mr-2"></i>
2FA is currently <strong>disabled</strong> for your account.
{% if role_requires_2fa %}
<br><strong>Note:</strong> Your role requires 2FA. You should enable it now.
{% endif %}
</div>
<a href="/account/enable-2fa" class="block w-full bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center text-center">
<i class="fas fa-shield-alt"></i>
<span>Enable 2FA</span>
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</body>
</html>
+1 -1
View File
@@ -13,7 +13,7 @@
<div class="flex-1 flex items-center justify-center mx-4"> <div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-md pt-20"> <div class="container py-8 max-w-md pt-20">
<div class="flex items-center mb-6 relative"> <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> <a href="/devices" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
<h1 class="text-3xl font-bold text-center w-full">Add Device</h1> <h1 class="text-3xl font-bold text-center w-full">Add Device</h1>
</div> </div>
<form action="/add_device" method="POST" class="flex flex-col space-y-4"> <form action="/add_device" method="POST" class="flex flex-col space-y-4">
+107 -69
View File
@@ -21,7 +21,7 @@
{% endif %} {% endif %}
<!-- Quick Links --> <!-- Quick Links -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
<a href="/audit" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors"> <a href="/audit" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<i class="fas fa-clipboard-list text-3xl text-gray-600 dark:text-gray-400"></i> <i class="fas fa-clipboard-list text-3xl text-gray-600 dark:text-gray-400"></i>
@@ -42,6 +42,50 @@
</div> </div>
<i class="fas fa-chevron-right text-gray-400"></i> <i class="fas fa-chevron-right text-gray-400"></i>
</a> </a>
{% if has_permission('view_tags') and is_feature_enabled('device_tags') %}
<a href="/tags" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
<div class="flex items-center space-x-4">
<i class="fas fa-tags text-3xl text-gray-600 dark:text-gray-400"></i>
<div>
<h3 class="text-lg font-bold">Tag Management</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Manage device tags</p>
</div>
</div>
<i class="fas fa-chevron-right text-gray-400"></i>
</a>
{% endif %}
{% if has_permission('view_custom_fields') %}
<a href="/custom_fields" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
<div class="flex items-center space-x-4">
<i class="fas fa-list-ul text-3xl text-gray-600 dark:text-gray-400"></i>
<div>
<h3 class="text-lg font-bold">Custom Fields</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Manage custom fields for devices and subnets</p>
</div>
</div>
<i class="fas fa-chevron-right text-gray-400"></i>
</a>
{% endif %}
<a href="/api-docs" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
<div class="flex items-center space-x-4">
<i class="fas fa-code text-3xl text-gray-600 dark:text-gray-400"></i>
<div>
<h3 class="text-lg font-bold">API Documentation</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Interactive API reference</p>
</div>
</div>
<i class="fas fa-chevron-right text-gray-400"></i>
</a>
<a href="/backup" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
<div class="flex items-center space-x-4">
<i class="fas fa-database text-3xl text-gray-600 dark:text-gray-400"></i>
<div>
<h3 class="text-lg font-bold">Backup & Restore</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Database backup and restore</p>
</div>
</div>
<i class="fas fa-chevron-right text-gray-400"></i>
</a>
</div> </div>
<!-- Subnet Management Section --> <!-- Subnet Management Section -->
@@ -63,6 +107,8 @@
<th class="text-center p-3">Name</th> <th class="text-center p-3">Name</th>
<th class="text-center p-3">CIDR</th> <th class="text-center p-3">CIDR</th>
<th class="text-center p-3">Site</th> <th class="text-center p-3">Site</th>
<th class="text-center p-3">VLAN ID</th>
<th class="text-center p-3">Utilisation</th>
<th class="text-center p-3">Actions</th> <th class="text-center p-3">Actions</th>
</tr> </tr>
</thead> </thead>
@@ -74,13 +120,28 @@
<td class="p-3 text-center"> <td class="p-3 text-center">
<span class="px-2 py-1 bg-gray-300 dark:bg-zinc-700 rounded text-sm">{{ subnet.site }}</span> <span class="px-2 py-1 bg-gray-300 dark:bg-zinc-700 rounded text-sm">{{ subnet.site }}</span>
</td> </td>
<td class="p-3 text-center">
{% if subnet.vlan_id %}
<span class="px-2 py-1 bg-gray-300 dark:bg-zinc-700 rounded text-sm font-mono">{{ subnet.vlan_id }}</span>
{% else %}
<span class="text-sm text-gray-500"></span>
{% endif %}
</td>
<td class="p-3 text-center">
{% if subnet.utilization %}
<span class="text-sm text-gray-600 dark:text-gray-400">{{ subnet.utilization.percent }}%</span>
<span class="text-xs text-gray-500 dark:text-gray-500 ml-1">({{ subnet.utilization.used }}/{{ subnet.utilization.total }})</span>
{% else %}
<span class="text-sm text-gray-500"></span>
{% endif %}
</td>
<td class="p-3 text-center"> <td class="p-3 text-center">
<div class="flex items-center justify-center space-x-2"> <div class="flex items-center justify-center space-x-2">
<a href="/subnet/{{ subnet.id }}" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300" title="View Subnet"> <a href="/subnet/{{ subnet.id }}" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300" title="View Subnet">
<i class="fas fa-eye"></i> <i class="fas fa-eye"></i>
</a> </a>
{% if can_edit_subnet %} {% if can_edit_subnet %}
<button onclick="editSubnet({{ subnet.id }}, '{{ subnet.name|replace("'", "\\'") }}', '{{ subnet.cidr|replace("'", "\\'") }}', '{{ subnet.site|replace("'", "\\'") }}')" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Edit Subnet"> <button onclick="editSubnet({{ subnet.id }}, '{{ subnet.name|replace("'", "\\'") }}', '{{ subnet.cidr|replace("'", "\\'") }}', '{{ subnet.site|replace("'", "\\'") }}', {{ subnet.vlan_id if subnet.vlan_id else 'null' }}, '{{ subnet.vlan_description|replace("'", "\\'") if subnet.vlan_description else "" }}', '{{ subnet.vlan_notes|replace("'", "\\'")|replace("\n", "\\n") if subnet.vlan_notes else "" }}')" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Edit Subnet">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</button> </button>
{% endif %} {% endif %}
@@ -106,6 +167,40 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
<!-- Feature Flags Section -->
{% if has_permission('manage_users') %}
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md mt-8">
<h2 class="text-2xl font-bold mb-6">Feature Flags</h2>
<form action="/admin/feature_flags" method="POST">
<div class="space-y-4">
{% for flag in feature_flags %}
<div class="flex items-center justify-between p-4 bg-gray-300 dark:bg-zinc-700 rounded-lg">
<div class="flex-1">
<label for="feature_{{ flag.key }}" class="text-lg font-semibold cursor-pointer">
{{ flag.key|replace('_', ' ')|title }}
</label>
{% if flag.description %}
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">{{ flag.description }}</p>
{% endif %}
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" name="feature_{{ flag.key }}" id="feature_{{ flag.key }}"
class="sr-only peer" {% if flag.enabled %}checked{% endif %}>
<div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-gray-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-gray-500 dark:peer-checked:bg-zinc-600"></div>
</label>
</div>
{% endfor %}
</div>
<div class="flex justify-end mt-6">
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg font-medium">
Save Changes
</button>
</div>
</form>
</div>
{% endif %}
</div> </div>
</div> </div>
@@ -123,7 +218,11 @@
<input type="text" name="name" id="add-subnet-name" placeholder="Subnet Name" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required> <input type="text" name="name" id="add-subnet-name" placeholder="Subnet Name" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
<input type="text" name="cidr" id="add-subnet-cidr" placeholder="CIDR (e.g., 192.168.1.0/24)" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required> <input type="text" name="cidr" id="add-subnet-cidr" placeholder="CIDR (e.g., 192.168.1.0/24)" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
<input type="text" name="site" id="add-subnet-site" placeholder="Site/Location" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required> <input type="text" name="site" id="add-subnet-site" placeholder="Site/Location" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
<input type="number" name="vlan_id" id="add-subnet-vlan-id" placeholder="VLAN ID (1-4094, optional)" min="1" max="4094" 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">
<input type="text" name="vlan_description" id="add-subnet-vlan-description" placeholder="VLAN Description (optional)" 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">
<textarea name="vlan_notes" id="add-subnet-vlan-notes" placeholder="VLAN Notes (optional)" rows="3" 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 resize-y"></textarea>
<span id="cidr-error" class="text-red-500 text-sm hidden"></span> <span id="cidr-error" class="text-red-500 text-sm hidden"></span>
<span id="vlan-id-error" class="text-red-500 text-sm hidden"></span>
</div> </div>
<div class="flex justify-end space-x-2 mt-6"> <div class="flex justify-end space-x-2 mt-6">
<button type="button" onclick="closeAddSubnetModal()" class="px-4 py-2 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button> <button type="button" onclick="closeAddSubnetModal()" class="px-4 py-2 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
@@ -148,7 +247,11 @@
<input type="text" name="name" id="edit-subnet-name" placeholder="Subnet Name" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required> <input type="text" name="name" id="edit-subnet-name" placeholder="Subnet Name" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
<input type="text" name="cidr" id="edit-subnet-cidr" placeholder="CIDR (e.g., 192.168.1.0/24)" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required> <input type="text" name="cidr" id="edit-subnet-cidr" placeholder="CIDR (e.g., 192.168.1.0/24)" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
<input type="text" name="site" id="edit-subnet-site" placeholder="Site/Location" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required> <input type="text" name="site" id="edit-subnet-site" placeholder="Site/Location" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
<input type="number" name="vlan_id" id="edit-subnet-vlan-id" placeholder="VLAN ID (1-4094, optional)" min="1" max="4094" 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">
<input type="text" name="vlan_description" id="edit-subnet-vlan-description" placeholder="VLAN Description (optional)" 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">
<textarea name="vlan_notes" id="edit-subnet-vlan-notes" placeholder="VLAN Notes (optional)" rows="3" 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 resize-y"></textarea>
<span id="edit-cidr-error" class="text-red-500 text-sm hidden"></span> <span id="edit-cidr-error" class="text-red-500 text-sm hidden"></span>
<span id="edit-vlan-id-error" class="text-red-500 text-sm hidden"></span>
</div> </div>
<div class="flex justify-end space-x-2 mt-6"> <div class="flex justify-end space-x-2 mt-6">
<button type="button" onclick="closeEditSubnetModal()" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button> <button type="button" onclick="closeEditSubnetModal()" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
@@ -158,72 +261,7 @@
</div> </div>
</div> </div>
<script src="/static/js/add_subnet.js"></script> <script src="/static/js/add_subnet.min.js"></script>
<script> <script src="/static/js/admin.min.js"></script>
function showAddSubnetModal() {
document.getElementById('add-subnet-modal').classList.remove('hidden');
document.getElementById('add-subnet-name').value = '';
document.getElementById('add-subnet-cidr').value = '';
document.getElementById('add-subnet-site').value = '';
}
function closeAddSubnetModal() {
document.getElementById('add-subnet-modal').classList.add('hidden');
document.getElementById('cidr-error').classList.add('hidden');
}
function editSubnet(subnetId, name, cidr, site) {
document.getElementById('edit-subnet-id').value = subnetId;
document.getElementById('edit-subnet-name').value = name;
document.getElementById('edit-subnet-cidr').value = cidr;
document.getElementById('edit-subnet-site').value = site;
document.getElementById('edit-subnet-modal').classList.remove('hidden');
}
function closeEditSubnetModal() {
document.getElementById('edit-subnet-modal').classList.add('hidden');
document.getElementById('edit-cidr-error').classList.add('hidden');
}
function validateEditSubnetForm() {
const cidrInput = document.getElementById('edit-subnet-cidr');
const cidrError = document.getElementById('edit-cidr-error');
const cidr = cidrInput.value.trim();
// Basic CIDR validation
const cidrPattern = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/;
if (!cidrPattern.test(cidr)) {
cidrError.textContent = 'Invalid CIDR format. Use format like 192.168.1.0/24';
cidrError.classList.remove('hidden');
return false;
}
// Check prefix length
const parts = cidr.split('/');
if (parts.length === 2) {
const prefixLen = parseInt(parts[1]);
if (prefixLen < 24 || prefixLen > 32) {
cidrError.textContent = 'Subnet must be /24 or smaller (e.g., /24, /25, ... /32)';
cidrError.classList.remove('hidden');
return false;
}
}
cidrError.classList.add('hidden');
return true;
}
// Close modals when clicking outside
window.onclick = function(event) {
const addModal = document.getElementById('add-subnet-modal');
const editModal = document.getElementById('edit-subnet-modal');
if (event.target === addModal) {
closeAddSubnetModal();
}
if (event.target === editModal) {
closeEditSubnetModal();
}
}
</script>
</body> </body>
</html> </html>
+331
View File
@@ -0,0 +1,331 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Documentation - IPAM</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 mx-4 py-8 pt-20">
<div class="container max-w-7xl mx-auto">
<h1 class="text-3xl font-bold mb-8 text-center">API Documentation</h1>
<!-- Authentication Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8 mb-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">Authentication</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<p class="mb-4">All API requests require authentication using an API key. You can provide the API key in one of three ways:</p>
<ul class="list-disc list-inside space-y-2 ml-4">
<li><strong>Header:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">X-API-Key: your_api_key</code></li>
<li><strong>Authorization Header:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">Authorization: Bearer your_api_key</code></li>
<li><strong>Query Parameter:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">?api_key=your_api_key</code></li>
</ul>
<p class="mt-4"><strong>Base URL:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">/api/v1</code></p>
</div>
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h3 class="font-semibold mb-2">Your API Key</h3>
<div class="flex items-center space-x-2">
<input type="text" id="apiKey" value="{{ api_key or '' }}" readonly
class="flex-1 px-3 py-2 bg-gray-100 dark:bg-zinc-600 border border-gray-400 dark:border-zinc-500 rounded text-sm font-mono"
placeholder="API key not found">
<button onclick="testConnection()" class="px-4 py-2 bg-gray-500 hover:bg-gray-600 dark:bg-zinc-600 dark:hover:bg-zinc-500 text-white rounded text-sm transition-colors">
<i class="fas fa-plug mr-2"></i>Test
</button>
</div>
<div id="connectionStatus" class="mt-2 text-sm"></div>
</div>
</div>
</div>
<!-- Interactive Endpoints -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8 mb-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
<i class="fas fa-play-circle mr-2"></i>Interactive Testing
</h2>
<p class="text-gray-600 dark:text-gray-300 mb-6">Test GET endpoints directly in your browser. Other methods are documented below.</p>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- GET /devices -->
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-2">
<span class="px-2 py-1 bg-green-600 text-white rounded text-xs font-mono">GET</span>
<code class="text-sm">/api/v1/devices</code>
</div>
<button onclick="tryEndpoint('GET', '/api/v1/devices', null, 'devices-list')"
class="px-3 py-1 bg-gray-500 hover:bg-gray-600 dark:bg-zinc-600 dark:hover:bg-zinc-500 text-white rounded text-xs transition-colors">
<i class="fas fa-play mr-1"></i>Try
</button>
</div>
<p class="text-xs text-gray-600 dark:text-gray-300 mb-2">List all devices</p>
<div id="devices-list-response" class="hidden">
<pre class="bg-gray-100 dark:bg-zinc-600 p-2 rounded text-xs overflow-x-auto max-h-32" id="devices-list"></pre>
</div>
</div>
<!-- GET /devices/{id} -->
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-2">
<span class="px-2 py-1 bg-green-600 text-white rounded text-xs font-mono">GET</span>
<code class="text-sm">/api/v1/devices/{id}</code>
</div>
<div class="flex items-center space-x-1">
<input type="number" id="device-id" placeholder="ID" class="px-2 py-1 border rounded text-xs w-16">
<button onclick="tryEndpointWithId('GET', '/api/v1/devices/', 'device-id', 'device-detail')"
class="px-3 py-1 bg-gray-500 hover:bg-gray-600 dark:bg-zinc-600 dark:hover:bg-zinc-500 text-white rounded text-xs transition-colors">
<i class="fas fa-play mr-1"></i>Try
</button>
</div>
</div>
<p class="text-xs text-gray-600 dark:text-gray-300 mb-2">Get device by ID</p>
<div id="device-detail-response" class="hidden">
<pre class="bg-gray-100 dark:bg-zinc-600 p-2 rounded text-xs overflow-x-auto max-h-32" id="device-detail"></pre>
</div>
</div>
<!-- GET /devices/by-tag/{tag} -->
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-2">
<span class="px-2 py-1 bg-green-600 text-white rounded text-xs font-mono">GET</span>
<code class="text-sm">/api/v1/devices/by-tag/{tag}</code>
</div>
<div class="flex items-center space-x-1">
<input type="text" id="tag-name" placeholder="Tag" class="px-2 py-1 border rounded text-xs w-20">
<button onclick="tryEndpointWithId('GET', '/api/v1/devices/by-tag/', 'tag-name', 'devices-by-tag')"
class="px-3 py-1 bg-gray-500 hover:bg-gray-600 dark:bg-zinc-600 dark:hover:bg-zinc-500 text-white rounded text-xs transition-colors">
<i class="fas fa-play mr-1"></i>Try
</button>
</div>
</div>
<p class="text-xs text-gray-600 dark:text-gray-300 mb-2">Filter devices by tag</p>
<div id="devices-by-tag-response" class="hidden">
<pre class="bg-gray-100 dark:bg-zinc-600 p-2 rounded text-xs overflow-x-auto max-h-32" id="devices-by-tag"></pre>
</div>
</div>
<!-- GET /tags -->
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-2">
<span class="px-2 py-1 bg-green-600 text-white rounded text-xs font-mono">GET</span>
<code class="text-sm">/api/v1/tags</code>
</div>
<button onclick="tryEndpoint('GET', '/api/v1/tags', null, 'tags-list')"
class="px-3 py-1 bg-gray-500 hover:bg-gray-600 dark:bg-zinc-600 dark:hover:bg-zinc-500 text-white rounded text-xs transition-colors">
<i class="fas fa-play mr-1"></i>Try
</button>
</div>
<p class="text-xs text-gray-600 dark:text-gray-300 mb-2">List all tags</p>
<div id="tags-list-response" class="hidden">
<pre class="bg-gray-100 dark:bg-zinc-600 p-2 rounded text-xs overflow-x-auto max-h-32" id="tags-list"></pre>
</div>
</div>
</div>
</div>
<!-- Complete API Documentation -->
<div class="space-y-6">
<!-- Devices Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
<i class="fas fa-server mr-2"></i>Devices
</h2>
<div class="space-y-4">
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<h4 class="font-semibold mb-2">Read Operations</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/devices</code> - List all devices</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/devices/{id}</code> - Get device details</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/devices/by-tag/{tag}</code> - Get devices by tag</li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-2">Write Operations</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/devices</code> - Create device</li>
<li><code class="bg-blue-200 dark:bg-blue-800 px-2 py-1 rounded text-xs">PUT /api/v1/devices/{id}</code> - Update device</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/devices/{id}</code> - Delete device</li>
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/devices/{id}/ips</code> - Add IP to device</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/devices/{id}/ips/{ip_id}</code> - Remove IP</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Subnets Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
<i class="fas fa-network-wired mr-2"></i>Subnets
</h2>
<div class="space-y-4">
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<h4 class="font-semibold mb-2">Read Operations</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/subnets</code> - List all subnets</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/subnets/{id}</code> - Get subnet details</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/subnets/{id}/next_free_ip</code> - Get next free IP address</li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-2">Write Operations</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/subnets</code> - Create subnet</li>
<li><code class="bg-blue-200 dark:bg-blue-800 px-2 py-1 rounded text-xs">PUT /api/v1/subnets/{id}</code> - Update subnet</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/subnets/{id}</code> - Delete subnet</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Racks Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
<i class="fas fa-building mr-2"></i>Racks
</h2>
<div class="space-y-4">
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<h4 class="font-semibold mb-2">Read Operations</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/racks</code> - List all racks</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/racks/{id}</code> - Get rack details</li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-2">Write Operations</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/racks</code> - Create rack</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/racks/{id}</code> - Delete rack</li>
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/racks/{id}/devices</code> - Add device to rack</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/racks/{id}/devices/{device_id}</code> - Remove device</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Tags Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
<i class="fas fa-tags mr-2"></i>Tags
</h2>
<div class="space-y-4">
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<h4 class="font-semibold mb-2">Read Operations</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/tags</code> - List all tags</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/tags?format=simple</code> - List tags in simple format</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/tags/{id}</code> - Get tag details</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/devices/{id}/tags</code> - Get device tags</li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-2">Write Operations</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/tags</code> - Create tag</li>
<li><code class="bg-blue-200 dark:bg-blue-800 px-2 py-1 rounded text-xs">PUT /api/v1/tags/{id}</code> - Update tag</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/tags/{id}</code> - Delete tag</li>
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/devices/{id}/tags</code> - Assign tag to device</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/devices/{id}/tags/{tag_id}</code> - Remove tag</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Additional Endpoints -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
<i class="fas fa-cogs mr-2"></i>Additional Endpoints
</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h4 class="font-semibold mb-3"><i class="fas fa-info-circle mr-2"></i>System Information</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/info</code> - System information</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/device-types</code> - List device types</li>
</ul>
</div>
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h4 class="font-semibold mb-3"><i class="fas fa-dharmachakra mr-2"></i>DHCP Management</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/subnets/{id}/dhcp</code> - Get DHCP config</li>
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/subnets/{id}/dhcp</code> - Generate DHCP config</li>
</ul>
</div>
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h4 class="font-semibold mb-3"><i class="fas fa-users mr-2"></i>User & Role Management</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/users</code> - List users</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/roles</code> - List roles</li>
</ul>
</div>
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h4 class="font-semibold mb-3"><i class="fas fa-clipboard-list mr-2"></i>Audit Log</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/audit</code> - List audit entries</li>
</ul>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-2">Supports filtering with query parameters</p>
</div>
</div>
</div>
<!-- Response Format & Permissions -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
<i class="fas fa-info-circle mr-2"></i>Response Format & Permissions
</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-3">Success Responses</h3>
<p class="mb-3 text-sm">All API responses are in JSON format. Successful requests return:</p>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-green-200 dark:bg-green-800 px-2 py-1 rounded text-xs">200 OK</code> - Request successful</li>
<li><code class="bg-green-200 dark:bg-green-800 px-2 py-1 rounded text-xs">201 Created</code> - Resource created</li>
<li><code class="bg-green-200 dark:bg-green-800 px-2 py-1 rounded text-xs">204 No Content</code> - Success with no response body</li>
</ul>
</div>
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-3">Error Responses</h3>
<p class="mb-3 text-sm">Error responses include descriptive messages:</p>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">400 Bad Request</code> - Invalid request data</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">401 Unauthorized</code> - Missing or invalid API key</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">403 Forbidden</code> - Insufficient permissions</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">404 Not Found</code> - Resource not found</li>
</ul>
</div>
</div>
<div class="mt-6 bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-3"><i class="fas fa-shield-alt mr-2"></i>Permissions</h3>
<p class="text-sm">API endpoints respect the same role-based permissions as the web interface. Users can only perform actions that their role allows. If a user lacks the required permission, the API will return a <code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">403 Forbidden</code> error with details about the missing permission.</p>
</div>
</div>
</div>
</div>
</div>
<script src="/static/js/api_docs.min.js"></script>
</body>
</html>
+82 -25
View File
@@ -13,42 +13,105 @@
<div class="flex-1 flex items-center justify-center mx-4"> <div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-8xl pt-20"> <div class="container py-8 max-w-8xl pt-20">
<h1 class="text-3xl font-bold mb-6 text-center">Audit Log</h1> <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"> <!-- Collapsible Filter Section -->
<option value="">All Users</option> <div class="bg-gray-200 dark:bg-zinc-800 rounded-lg mb-6">
<button type="button" id="filter-toggle" class="w-full flex items-center justify-between p-4 hover:bg-gray-300 dark:hover:bg-zinc-700 rounded-lg transition-colors hover:cursor-pointer">
<h2 class="text-lg font-semibold">Filters</h2>
<i class="fas fa-chevron-down transition-transform duration-200 transform" id="filter-arrow"></i>
</button>
<!-- Advanced Filter Form -->
<form method="GET" id="audit-filter-form" class="px-4 pb-4 {% if not (search_query or selected_user_ids or request.args.get('subnet_id') or request.args.get('action') or request.args.get('device_name') or date_from or date_to or request.args.get('expand_filters')) %}hidden{% endif %}">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4">
<!-- Search -->
<div class="lg:col-span-3">
<label class="block text-sm font-medium mb-1">Search</label>
<input type="text" name="search" value="{{ search_query or '' }}" placeholder="Search in details, user, action, subnet..." class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
</div>
<!-- Multiple Users -->
<div>
<label class="block text-sm font-medium mb-1">Users</label>
<select name="user_ids" multiple size="5" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
{% for user in users %} {% for user in users %}
<option value="{{ user[0] }}" {% if request.args.get('user_id') == user[0]|string %}selected{% endif %}>{{ user[1] }}</option> <option value="{{ user[0] }}" {% if selected_user_ids and user[0]|string in selected_user_ids %}selected{% endif %}>{{ user[1] }}</option>
{% endfor %} {% endfor %}
</select> </select>
<select name="subnet_id" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600"> <p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Hold Ctrl/Cmd to select multiple</p>
</div>
<!-- Subnet -->
<div>
<label class="block text-sm font-medium mb-1">Subnet</label>
<select name="subnet_id" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
<option value="">All Subnets</option> <option value="">All Subnets</option>
{% for subnet in subnets %} {% for subnet in subnets %}
<option value="{{ subnet[0] }}" {% if request.args.get('subnet_id') == subnet[0]|string %}selected{% endif %}>{{ subnet[1] }}</option> <option value="{{ subnet[0] }}" {% if request.args.get('subnet_id') == subnet[0]|string %}selected{% endif %}>{{ subnet[1] }}</option>
{% endfor %} {% endfor %}
</select> </select>
<select name="action" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600"> </div>
<!-- Action -->
<div>
<label class="block text-sm font-medium mb-1">Action</label>
<select name="action" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
<option value="">All Actions</option> <option value="">All Actions</option>
{% for a in actions %} {% for a in actions %}
<option value="{{ a }}" {% if request.args.get('action') == a %}selected{% endif %}>{{ a }}</option> <option value="{{ a }}" {% if request.args.get('action') == a %}selected{% endif %}>{{ a }}</option>
{% endfor %} {% endfor %}
</select> </select>
<select name="device_name" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600"> </div>
<!-- Device Name -->
<div>
<label class="block text-sm font-medium mb-1">Device</label>
<select name="device_name" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
<option value="">All Devices</option> <option value="">All Devices</option>
{% for device in devices %} {% for device in devices %}
<option value="{{ device[0] }}" {% if request.args.get('device_name') == device[0] %}selected{% endif %}>{{ device[0] }}</option> <option value="{{ device[0] }}" {% if request.args.get('device_name') == device[0] %}selected{% endif %}>{{ device[0] }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div>
<!-- Date From -->
<div>
<label class="block text-sm font-medium mb-1">Date From</label>
<input type="date" name="date_from" value="{{ date_from or '' }}" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
</div>
<!-- Date To -->
<div>
<label class="block text-sm font-medium mb-1">Date To</label>
<input type="date" name="date_to" value="{{ date_to or '' }}" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
</div>
</div>
<div class="flex gap-2 justify-center">
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2"> <button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
<i class="fas fa-search"></i> <i class="fas fa-search"></i>
<span>Filter</span> <span>Filter</span>
</button> </button>
<a href="/audit?expand_filters=1" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
<i class="fas fa-times"></i>
<span>Clear</span>
</a>
<button type="button" id="export-btn" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
<i class="fas fa-file-csv"></i>
<span>Export CSV</span>
</button>
</div>
</form> </form>
</div>
<!-- Audit Log Table -->
<div class="overflow-x-auto">
<table class="w-full table-auto bg-gray-200 dark:bg-zinc-800 rounded-lg overflow-hidden"> <table class="w-full table-auto bg-gray-200 dark:bg-zinc-800 rounded-lg overflow-hidden">
<thead> <thead>
<tr class="bg-gray-400 dark:bg-zinc-700"> <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">User</th>
<th class="px-4 py-2 text-center">Action</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 details-cell">Details</th>
<th class="px-4 py-2 text-center">Subnet</th> <th class="px-4 py-2 text-center">Subnet</th>
<th class="px-4 py-2 text-center">Timestamp</th> <th class="px-4 py-2 text-center">Timestamp</th>
</tr> </tr>
@@ -58,19 +121,23 @@
<tr class="border-b border-gray-700"> <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[1] or 'Unknown' }}</td>
<td class="px-4 py-2 text-center">{{ log[2] }}</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 details-cell">
<div class="diff-container" data-details="{{ log[3]|e }}"></div>
</td>
<td class="px-4 py-2 text-center">{{ log[4] or 'N/A' }}</td> <td class="px-4 py-2 text-center">{{ log[4] or 'N/A' }}</td>
<td class="px-4 py-2 text-center" data-utc="{{ log[5] }}">{{ log[5] }}</td> <td class="px-4 py-2 text-center" data-utc="{{ log[5] }}">{{ log[5] }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
{% if total_pages > 1 %} {% if total_pages > 1 %}
<div class="flex justify-center mt-6 space-x-2"> <div class="flex justify-center mt-6 space-x-2">
{% if page > 1 %} {% if page > 1 %}
{% set prev_args = query_args.copy() %} {% set prev_args = query_args.copy() %}
{% set _ = prev_args.update({'page': page-1}) %} {% 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"> <a href="{{ url_for('audit', **prev_args) }}" class="px-3 py-1 rounded bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer flex items-center gap-2">
<i class="fa fa-angle-left"></i> <i class="fa fa-angle-left"></i>
<span class="hidden sm:inline">Prev</span> <span class="hidden sm:inline">Prev</span>
</a> </a>
@@ -85,7 +152,7 @@
{% if start_page > 1 %} {% if start_page > 1 %}
{% set page_args = query_args.copy() %} {% set page_args = query_args.copy() %}
{% set _ = page_args.update({'page': 1}) %} {% set _ = page_args.update({'page': 1}) %}
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded {{ 'bg-gray-200 dark:bg-gray-500' if 1 == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">1</a> <a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded hover:cursor-pointer {{ 'bg-gray-200 dark:bg-gray-500' if 1 == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">1</a>
{% if start_page > 2 %} {% if start_page > 2 %}
<span class="px-3 py-1 text-gray-600 dark:text-gray-400"></span> <span class="px-3 py-1 text-gray-600 dark:text-gray-400"></span>
{% endif %} {% endif %}
@@ -95,7 +162,7 @@
{% for p in range(start_page, end_page + 1) %} {% for p in range(start_page, end_page + 1) %}
{% set page_args = query_args.copy() %} {% set page_args = query_args.copy() %}
{% set _ = page_args.update({'page': p}) %} {% 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> <a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded hover:cursor-pointer {{ 'bg-gray-200 dark:bg-gray-500' if p == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">{{ p }}</a>
{% endfor %} {% endfor %}
{# Show last page if we're not near the end #} {# Show last page if we're not near the end #}
@@ -105,13 +172,13 @@
{% endif %} {% endif %}
{% set page_args = query_args.copy() %} {% set page_args = query_args.copy() %}
{% set _ = page_args.update({'page': total_pages}) %} {% set _ = page_args.update({'page': total_pages}) %}
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded {{ 'bg-gray-200 dark:bg-gray-500' if total_pages == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">{{ total_pages }}</a> <a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded hover:cursor-pointer {{ 'bg-gray-200 dark:bg-gray-500' if total_pages == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">{{ total_pages }}</a>
{% endif %} {% endif %}
{% if page < total_pages %} {% if page < total_pages %}
{% set next_args = query_args.copy() %} {% set next_args = query_args.copy() %}
{% set _ = next_args.update({'page': page+1}) %} {% 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"> <a href="{{ url_for('audit', **next_args) }}" class="px-3 py-1 rounded bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer flex items-center gap-2">
<span class="hidden sm:inline">Next</span> <span class="hidden sm:inline">Next</span>
<i class="fa fa-angle-right"></i> <i class="fa fa-angle-right"></i>
</a> </a>
@@ -120,16 +187,6 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<script> <script src="/static/js/audit.min.js"></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> </body>
</html> </html>
+126
View File
@@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Backup & Restore</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-4xl pt-20">
<div class="flex items-center mb-6 relative">
<a href="/admin" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-full w-11 h-11 hover:cursor-pointer"><i class="fas fa-arrow-left"></i></a>
<h1 class="text-3xl font-bold text-center w-full">Backup & Restore</h1>
</div>
<div id="message" class="hidden mb-4 p-4 rounded-lg"></div>
<!-- Create Backup Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg p-6 mb-6">
<h2 class="text-xl font-bold mb-4">Create Backup</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Create a new database backup. This will export the entire database to a SQL file.</p>
<button id="create-backup-btn" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
<i class="fas fa-database"></i>
<span>Create Backup</span>
</button>
</div>
<!-- Restore Backup Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg p-6 mb-6">
<h2 class="text-xl font-bold mb-4">Restore Backup</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Restore the database from a backup file. <strong class="text-red-600 dark:text-red-400">Warning: This will replace all current data!</strong></p>
<div class="space-y-4">
<!-- Upload Backup File -->
<div>
<label class="block text-sm font-medium mb-2">Upload Backup File</label>
<form id="upload-restore-form" enctype="multipart/form-data" class="flex gap-2">
<label class="flex-1 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 border border-gray-600 rounded-lg px-4 py-2 cursor-pointer flex items-center justify-center hover:cursor-pointer">
<input type="file" name="backup_file" accept=".sql" required class="hidden" onchange="updateFileLabel(this)">
<span id="file-label" class="text-sm">Choose File</span>
</label>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">
<i class="fas fa-upload"></i> Upload & Restore
</button>
</form>
</div>
<!-- Or Select Existing Backup -->
{% if backups %}
<div>
<label class="block text-sm font-medium mb-2">Or Restore from Existing Backup</label>
<form id="existing-restore-form" class="flex gap-2">
<select name="backup_filename" required class="flex-1 border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
<option value="">Select a backup...</option>
{% for backup in backups %}
<option value="{{ backup.filename }}">{{ backup.filename }} ({{ (backup.size / 1024 / 1024)|round(2) }} MB, {{ backup.created }})</option>
{% endfor %}
</select>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">
<i class="fas fa-undo"></i> Restore
</button>
</form>
</div>
{% endif %}
</div>
</div>
<!-- Available Backups Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg p-6">
<h2 class="text-xl font-bold mb-4">Available Backups</h2>
{% if backups %}
<div class="overflow-x-auto">
<table class="w-full table-auto">
<thead>
<tr class="bg-gray-400 dark:bg-zinc-700">
<th class="px-4 py-2 text-left">Filename</th>
<th class="px-4 py-2 text-left">Size</th>
<th class="px-4 py-2 text-left">Created</th>
<th class="px-4 py-2 text-center">Actions</th>
</tr>
</thead>
<tbody>
{% for backup in backups %}
<tr class="border-b border-gray-700">
<td class="px-4 py-2">{{ backup.filename }}</td>
<td class="px-4 py-2">{{ (backup.size / 1024 / 1024)|round(2) }} MB</td>
<td class="px-4 py-2">{{ backup.created }}</td>
<td class="px-4 py-2 text-center">
<div class="flex gap-2 justify-center">
<a href="/backup/download/{{ backup.filename }}" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-3 py-1 rounded text-sm" title="Download">
<i class="fas fa-download"></i>
</a>
<button onclick="deleteBackup('{{ backup.filename }}')" class="bg-red-300 hover:bg-red-400 dark:bg-red-700 dark:hover:bg-red-600 hover:cursor-pointer px-3 py-1 rounded text-sm" title="Delete">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-gray-600 dark:text-gray-400">No backups available. Create your first backup above.</p>
{% endif %}
</div>
</div>
</div>
<script src="/static/js/backup.min.js"></script>
<script>
function updateFileLabel(input) {
const label = document.getElementById('file-label');
if (input.files && input.files[0]) {
label.textContent = input.files[0].name;
} else {
label.textContent = 'Choose File';
}
}
</script>
</body>
</html>
+157
View File
@@ -0,0 +1,157 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bulk Operations</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 mx-4 py-8 pt-20">
<div class="container max-w-6xl mx-auto">
<div class="flex items-center mb-6 relative">
<a href="/devices" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
<h1 class="text-3xl font-bold text-center w-full">Bulk Operations</h1>
</div>
<!-- Tabs -->
<div class="mb-6 border-b border-gray-600">
<div class="flex space-x-4">
<button onclick="showTab('assign-ips')" id="tab-assign-ips" class="tab-btn px-4 py-2 font-medium border-b-2 border-gray-600 text-gray-900 dark:text-gray-100 hover:cursor-pointer">Bulk IP Assignment</button>
<button onclick="showTab('create-devices')" id="tab-create-devices" class="tab-btn px-4 py-2 font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:cursor-pointer">Bulk Device Creation</button>
{% if is_feature_enabled('device_tags') %}
<button onclick="showTab('assign-tags')" id="tab-assign-tags" class="tab-btn px-4 py-2 font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:cursor-pointer">Bulk Tag Assignment</button>
{% endif %}
<button onclick="showTab('export')" id="tab-export" class="tab-btn px-4 py-2 font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:cursor-pointer">Bulk Export</button>
</div>
</div>
<!-- Bulk IP Assignment -->
<div id="panel-assign-ips" class="tab-panel bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
{% if can_add_device_ip %}
<h2 class="text-2xl font-bold mb-4">Bulk IP Assignment</h2>
<p class="text-gray-600 dark:text-gray-400 mb-4">Select a device and assign multiple IPs from a subnet. Hold Ctrl/Cmd to select multiple IPs.</p>
<form id="bulk-assign-ips-form" class="space-y-4">
<div>
<label class="block mb-2 font-medium">Select Device:</label>
<select id="bulk-device-select" name="device_id" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
<option value="">Select a device...</option>
{% for device in devices %}
<option value="{{ device[0] }}">{{ device[1] }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block mb-2 font-medium">Select Subnet:</label>
<select id="bulk-subnet-select" name="subnet_id" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
<option value="">Select a subnet...</option>
{% for subnet in subnets %}
<option value="{{ subnet[0] }}">{{ subnet[1] }} ({{ subnet[2] }}) - {{ subnet[3] or 'Unassigned' }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block mb-2 font-medium">Select IPs (hold Ctrl/Cmd to select multiple):</label>
<select id="bulk-ip-select" name="ip_ids[]" multiple size="15" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
<option value="" disabled>Select a subnet first...</option>
</select>
<p class="text-sm text-gray-500 mt-1">Selected: <span id="selected-ip-count">0</span> IPs</p>
</div>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg">Assign IPs</button>
</form>
<div id="assign-ips-result" class="mt-4 hidden"></div>
{% else %}
<p class="text-gray-500">You don't have permission to assign IPs to devices.</p>
{% endif %}
</div>
<!-- Bulk Device Creation -->
<div id="panel-create-devices" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
{% if can_add_device %}
<h2 class="text-2xl font-bold mb-4">Bulk Device Creation</h2>
<p class="text-gray-600 dark:text-gray-400 mb-4">Create multiple devices at once. Enter one device name per line.</p>
<form id="bulk-create-devices-form" class="space-y-4">
<div>
<label class="block mb-2 font-medium">Device Names (one per line):</label>
<textarea id="device-names" name="device_names" rows="10" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" placeholder="Device 1&#10;Device 2&#10;Device 3" required></textarea>
<p class="text-sm text-gray-500 mt-1">Enter device names, one per line</p>
</div>
<div>
<label class="block mb-2 font-medium">Device Type:</label>
<select name="device_type" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
{% for dtype in device_types %}
<option value="{{ dtype[0] }}">{{ dtype[1] }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg">Create Devices</button>
</form>
<div id="create-devices-result" class="mt-4 hidden"></div>
{% else %}
<p class="text-gray-500">You don't have permission to create devices.</p>
{% endif %}
</div>
<!-- Bulk Tag Assignment -->
{% if is_feature_enabled('device_tags') %}
<div id="panel-assign-tags" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
{% if can_assign_device_tag %}
<h2 class="text-2xl font-bold mb-4">Bulk Tag Assignment</h2>
<p class="text-gray-600 dark:text-gray-400 mb-4">Select multiple devices and assign one or more tags to them.</p>
<form id="bulk-assign-tags-form" class="space-y-4">
<div>
<label class="block mb-2 font-medium">Select Devices (hold Ctrl/Cmd to select multiple):</label>
<select id="bulk-tag-device-select" name="device_ids[]" multiple size="10" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full">
{% for device in devices %}
<option value="{{ device[0] }}">{{ device[1] }}</option>
{% endfor %}
</select>
<p class="text-sm text-gray-500 mt-1">Selected: <span id="selected-tag-device-count">0</span> devices</p>
</div>
<div>
<label class="block mb-2 font-medium">Select Tags (hold Ctrl/Cmd to select multiple):</label>
<select name="tag_ids[]" multiple size="5" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
{% for tag in tags %}
<option value="{{ tag[0] }}">{{ tag[1] }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg">Assign Tags</button>
</form>
<div id="assign-tags-result" class="mt-4 hidden"></div>
{% else %}
<p class="text-gray-500">You don't have permission to assign tags to devices.</p>
{% endif %}
</div>
{% endif %}
<!-- Bulk Export -->
<div id="panel-export" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
{% if can_export_subnet_csv %}
<h2 class="text-2xl font-bold mb-4">Bulk Subnet Export</h2>
<p class="text-gray-600 dark:text-gray-400 mb-4">Select multiple subnets and export them to a single CSV file.</p>
<form id="bulk-export-form" method="POST" action="/bulk/export_subnets" class="space-y-4">
<div>
<label class="block mb-2 font-medium">Select Subnets (hold Ctrl/Cmd to select multiple):</label>
<select name="subnet_ids[]" multiple size="10" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
{% for subnet in subnets %}
<option value="{{ subnet[0] }}">{{ subnet[1] }} ({{ subnet[2] }}) - {{ subnet[3] or 'Unassigned' }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg">Export to CSV</button>
</form>
{% else %}
<p class="text-gray-500">You don't have permission to export subnets.</p>
{% endif %}
</div>
</div>
</div>
<script src="/static/js/bulk_operations.min.js"></script>
</body>
</html>
+336
View File
@@ -0,0 +1,336 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Custom Fields 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 mx-4 py-8 pt-20">
<div class="container max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Custom Fields Management</h1>
{% if can_manage %}
<button onclick="showAddFieldModal()" id="add-field-btn" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg font-medium">
<i class="fas fa-plus mr-2"></i>Add Field
</button>
{% endif %}
</div>
{% if error %}
<div class="bg-red-200 dark:bg-red-800 text-red-800 dark:text-red-200 p-4 rounded-lg mb-6">
{{ error }}
</div>
{% endif %}
<!-- Tabs -->
<div class="mb-6 border-b border-gray-600">
<div class="flex space-x-4">
<button onclick="switchTab('device')" id="tab-device" class="tab-btn px-4 py-2 font-medium border-b-2 {% if active_tab == 'device' %}border-gray-600 text-gray-900 dark:text-gray-100{% else %}border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300{% endif %} hover:cursor-pointer">
Device Fields
</button>
<button onclick="switchTab('subnet')" id="tab-subnet" class="tab-btn px-4 py-2 font-medium border-b-2 {% if active_tab == 'subnet' %}border-gray-600 text-gray-900 dark:text-gray-100{% else %}border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300{% endif %} hover:cursor-pointer">
Subnet Fields
</button>
</div>
</div>
<!-- Device Fields Tab -->
<div id="device-fields-tab" class="tab-content {% if active_tab != 'device' %}hidden{% endif %}">
{% if device_fields %}
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-gray-600">
<th class="text-left p-3">Order</th>
<th class="text-left p-3">Name</th>
<th class="text-left p-3">Field Key</th>
<th class="text-left p-3">Type</th>
<th class="text-center p-3">Required</th>
<th class="text-center p-3">Searchable</th>
<th class="text-center p-3">Actions</th>
</tr>
</thead>
<tbody id="device-fields-tbody">
{% for field in device_fields %}
<tr class="border-b border-gray-600 hover:bg-gray-300 dark:hover:bg-zinc-700" data-field-id="{{ field.id }}">
<td class="p-3">
<div class="flex items-center space-x-2">
<button onclick="moveField('device', {{ field.id }}, 'up')" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" title="Move Up">
<i class="fas fa-arrow-up text-xs"></i>
</button>
<span class="text-sm">{{ field.display_order }}</span>
<button onclick="moveField('device', {{ field.id }}, 'down')" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" title="Move Down">
<i class="fas fa-arrow-down text-xs"></i>
</button>
</div>
</td>
<td class="p-3 font-medium">{{ field.name }}</td>
<td class="p-3 font-mono text-sm">{{ field.field_key }}</td>
<td class="p-3 text-sm">{{ field.field_type }}</td>
<td class="p-3 text-center">
{% if field.required %}
<i class="fas fa-check text-green-500"></i>
{% else %}
<i class="fas fa-times text-gray-400"></i>
{% endif %}
</td>
<td class="p-3 text-center">
{% if field.searchable %}
<i class="fas fa-check text-green-500"></i>
{% else %}
<i class="fas fa-times text-gray-400"></i>
{% endif %}
</td>
<td class="p-3 text-center">
<div class="flex items-center justify-center space-x-2">
{% if can_manage %}
<button onclick="editField({{ field.id }}, 'device')" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Edit Field">
<i class="fas fa-edit"></i>
</button>
<form action="/custom_fields" method="POST" onsubmit="return confirm('Are you sure you want to delete this field? Existing data will be preserved.');" class="inline">
<input type="hidden" name="action" value="delete_field">
<input type="hidden" name="field_id" value="{{ field.id }}">
<button type="submit" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Delete Field">
<i class="fas fa-trash"></i>
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
<div class="text-center py-8 text-gray-500">
<i class="fas fa-list text-4xl mb-4"></i>
<p>No device custom fields defined. Add your first field to get started.</p>
</div>
</div>
{% endif %}
</div>
<!-- Subnet Fields Tab -->
<div id="subnet-fields-tab" class="tab-content {% if active_tab != 'subnet' %}hidden{% endif %}">
{% if subnet_fields %}
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-gray-600">
<th class="text-left p-3">Order</th>
<th class="text-left p-3">Name</th>
<th class="text-left p-3">Field Key</th>
<th class="text-left p-3">Type</th>
<th class="text-center p-3">Required</th>
<th class="text-center p-3">Searchable</th>
<th class="text-center p-3">Actions</th>
</tr>
</thead>
<tbody id="subnet-fields-tbody">
{% for field in subnet_fields %}
<tr class="border-b border-gray-600 hover:bg-gray-300 dark:hover:bg-zinc-700" data-field-id="{{ field.id }}">
<td class="p-3">
<div class="flex items-center space-x-2">
<button onclick="moveField('subnet', {{ field.id }}, 'up')" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" title="Move Up">
<i class="fas fa-arrow-up text-xs"></i>
</button>
<span class="text-sm">{{ field.display_order }}</span>
<button onclick="moveField('subnet', {{ field.id }}, 'down')" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" title="Move Down">
<i class="fas fa-arrow-down text-xs"></i>
</button>
</div>
</td>
<td class="p-3 font-medium">{{ field.name }}</td>
<td class="p-3 font-mono text-sm">{{ field.field_key }}</td>
<td class="p-3 text-sm">{{ field.field_type }}</td>
<td class="p-3 text-center">
{% if field.required %}
<i class="fas fa-check text-green-500"></i>
{% else %}
<i class="fas fa-times text-gray-400"></i>
{% endif %}
</td>
<td class="p-3 text-center">
{% if field.searchable %}
<i class="fas fa-check text-green-500"></i>
{% else %}
<i class="fas fa-times text-gray-400"></i>
{% endif %}
</td>
<td class="p-3 text-center">
<div class="flex items-center justify-center space-x-2">
{% if can_manage %}
<button onclick="editField({{ field.id }}, 'subnet')" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Edit Field">
<i class="fas fa-edit"></i>
</button>
<form action="/custom_fields" method="POST" onsubmit="return confirm('Are you sure you want to delete this field? Existing data will be preserved.');" class="inline">
<input type="hidden" name="action" value="delete_field">
<input type="hidden" name="field_id" value="{{ field.id }}">
<button type="submit" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Delete Field">
<i class="fas fa-trash"></i>
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
<div class="text-center py-8 text-gray-500">
<i class="fas fa-list text-4xl mb-4"></i>
<p>No subnet custom fields defined. Add your first field to get started.</p>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Add/Edit Field Modal -->
<div id="field-modal" class="hidden fixed inset-0 bg-black/30 backdrop-blur-md flex items-center justify-center z-50">
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-lg max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold" id="modal-title">Add Custom Field</h2>
<button onclick="closeFieldModal()" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-times"></i>
</button>
</div>
<form id="field-form" action="/custom_fields" method="POST">
<input type="hidden" name="action" id="form-action" value="add_field">
<input type="hidden" name="field_id" id="form-field-id">
<input type="hidden" name="entity_type" id="form-entity-type">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Field Name *</label>
<input type="text" name="name" id="field-name" placeholder="e.g., Serial Number"
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>
</div>
<div>
<label class="block text-sm font-medium mb-1">Field Key *</label>
<input type="text" name="field_key" id="field-key" placeholder="e.g., serial_number"
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 font-mono text-sm" required>
<p class="text-xs text-gray-500 mt-1">Internal identifier (lowercase, underscores only)</p>
</div>
<div>
<label class="block text-sm font-medium mb-1">Field Type *</label>
<select name="field_type" id="field-type"
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 onchange="updateFieldTypeOptions()">
<option value="text">Text</option>
<option value="textarea">Textarea</option>
<option value="ip_address">IP Address</option>
<option value="date">Date</option>
<option value="datetime">Date & Time</option>
<option value="number">Number (Integer)</option>
<option value="decimal">Decimal/Float</option>
<option value="email">Email</option>
<option value="url">URL</option>
<option value="boolean">Boolean/Checkbox</option>
<option value="select">Select/Dropdown</option>
</select>
</div>
<div class="flex items-center space-x-2">
<input type="checkbox" name="required" id="field-required" class="w-4 h-4">
<label for="field-required" class="text-sm font-medium">Required</label>
</div>
<div>
<label class="block text-sm font-medium mb-1">Default Value</label>
<input type="text" name="default_value" id="field-default-value" placeholder="Default value (optional)"
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">
</div>
<div>
<label class="block text-sm font-medium mb-1">Help Text</label>
<textarea name="help_text" id="field-help-text" placeholder="Help text/description (optional)" rows="2"
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 resize-y"></textarea>
</div>
<div>
<label class="block text-sm font-medium mb-1">Display Order</label>
<input type="number" name="display_order" id="field-display-order" value="0" min="0"
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">
</div>
<div class="flex items-center space-x-2">
<input type="checkbox" name="searchable" id="field-searchable" class="w-4 h-4">
<label for="field-searchable" class="text-sm font-medium">Searchable</label>
</div>
<!-- Validation Rules Section -->
<div id="validation-rules-section" class="border-t border-gray-600 pt-4">
<h3 class="text-lg font-medium mb-3">Validation Rules</h3>
<!-- Text/Textarea validation -->
<div id="text-validation" class="hidden space-y-2">
<div>
<label class="block text-sm font-medium mb-1">Min Length</label>
<input type="number" name="min_length" id="field-min-length" min="0"
class="border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full">
</div>
<div>
<label class="block text-sm font-medium mb-1">Max Length</label>
<input type="number" name="max_length" id="field-max-length" min="1"
class="border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full">
</div>
<div>
<label class="block text-sm font-medium mb-1">Regex Pattern</label>
<input type="text" name="regex_pattern" id="field-regex-pattern" placeholder="^[A-Z].*$"
class="border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full font-mono text-sm">
</div>
</div>
<!-- Number/Decimal validation -->
<div id="number-validation" class="hidden space-y-2">
<div>
<label class="block text-sm font-medium mb-1">Min Value</label>
<input type="number" name="min_value" id="field-min-value" step="any"
class="border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full">
</div>
<div>
<label class="block text-sm font-medium mb-1">Max Value</label>
<input type="number" name="max_value" id="field-max-value" step="any"
class="border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full">
</div>
</div>
<!-- Select validation -->
<div id="select-validation" class="hidden">
<label class="block text-sm font-medium mb-1">Options (comma-separated) *</label>
<input type="text" name="select_options" id="field-select-options" placeholder="option1, option2, option3"
class="border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full">
<p class="text-xs text-gray-500 mt-1">Enter options separated by commas</p>
</div>
</div>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button type="button" onclick="closeFieldModal()"
class="px-4 py-2 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
<button type="submit"
class="px-4 py-2 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Save Field</button>
</div>
</form>
</div>
</div>
<!-- Embed field data for JavaScript -->
<script type="application/json" id="fields-data">
{
"device": {{ device_fields|tojson }},
"subnet": {{ subnet_fields|tojson }}
}
</script>
<script src="/static/js/custom_fields.min.js"></script>
</body>
</html>
+215 -3
View File
@@ -11,7 +11,7 @@
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col"> <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' %} {% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4"> <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="container py-8 max-w-2xl pt-20">
<div class="flex items-center mb-8 relative justify-between gap-4"> <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> <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> <h1 class="text-3xl font-bold text-center flex-1 min-w-0 truncate">{{ device.name }}</h1>
@@ -60,7 +60,7 @@
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg w-full">Add IP</button> <button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg w-full">Add IP</button>
</div> </div>
</form> </form>
<div class="allocated-ips"> <div class="allocated-ips mb-6">
<h3 class="text-lg font-bold mb-2">Allocated IPs:</h3> <h3 class="text-lg font-bold mb-2">Allocated IPs:</h3>
<ul class="space-y-2"> <ul class="space-y-2">
{% for ip in device_ips %} {% for ip in device_ips %}
@@ -74,14 +74,226 @@
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
<!-- IP History Section -->
{% if ip_history %}
<div class="ip-history mb-6">
<h3 class="text-lg font-bold mb-2">IP Assignment History:</h3>
<div class="bg-gray-200 dark:bg-zinc-700 rounded-lg p-4 max-h-96 overflow-y-auto">
<div class="space-y-3">
{% for entry in ip_history %}
<div class="flex items-start gap-3 pb-3 {% if not loop.last %}border-b border-gray-400 dark:border-zinc-600{% endif %}">
<div class="flex-shrink-0 mt-1">
{% if entry.action == 'assigned' %}
<i class="fas fa-plus-circle text-green-500"></i>
{% else %}
<i class="fas fa-minus-circle text-red-500"></i>
{% endif %}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-mono font-semibold">{{ entry.ip }}</span>
<span class="text-sm text-gray-600 dark:text-gray-400">
{% if entry.action == 'assigned' %}Assigned{% else %}Removed{% endif %}
</span>
<span class="text-sm text-gray-600 dark:text-gray-400">
to {{ entry.device_name }}
</span>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400 mt-1">
{{ entry.subnet_name }} ({{ entry.subnet_cidr }})
</div>
<div class="text-xs text-gray-500 dark:text-gray-500 mt-1">
by {{ entry.user_name }} • {{ entry.timestamp.strftime('%Y-%m-%d %H:%M:%S') if entry.timestamp else 'Unknown' }}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- Tags Section -->
{% if is_feature_enabled('device_tags') %}
<div class="tags-section mb-6">
<h3 class="text-lg font-bold mb-2">Tags:</h3>
<div class="flex flex-wrap gap-2 mb-4">
{% if device_tags %}
{% for tag in device_tags %}
<div class="flex items-center space-x-1 px-3 py-1 rounded-full text-sm" style="background-color: {{ tag.color }}20; border: 1px solid {{ tag.color }}">
<div class="w-2 h-2 rounded-full" style="background-color: {{ tag.color }}"></div>
<span>{{ tag.name }}</span>
{% if can_remove_device_tag %}
<form action="/device/{{ device.id }}/remove_tag" method="POST" class="inline">
<input type="hidden" name="tag_id" value="{{ tag.id }}">
<button type="submit" class="ml-1 text-red-500 hover:text-red-700 text-xs" title="Remove tag">
<i class="fas fa-times"></i>
</button>
</form>
{% endif %}
</div>
{% endfor %}
{% else %}
<span class="text-gray-500">No tags assigned</span>
{% endif %}
</div>
{% if can_assign_device_tag and all_tags %}
<form action="/device/{{ device.id }}/assign_tag" method="POST" class="flex gap-2">
<select name="tag_id" class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 flex-1" required>
<option value="" disabled selected>Select a tag to assign...</option>
{% for tag in all_tags %}
{% set already_assigned = device_tags|selectattr('id', 'equalto', tag.id)|list|length > 0 %}
{% if not already_assigned %}
<option value="{{ tag.id }}">{{ tag.name }}</option>
{% endif %}
{% endfor %}
</select>
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">
<i class="fas fa-plus mr-1"></i>Assign Tag
</button>
</form>
{% endif %}
</div>
{% endif %}
<form action="/update_device_description" method="POST" class="mb-6 mt-4"> <form action="/update_device_description" method="POST" class="mb-6 mt-4">
<input type="hidden" name="device_id" value="{{ device.id }}"> <input type="hidden" name="device_id" value="{{ device.id }}">
<label for="description" class="block mb-2 text-lg font-bold">Description</label> <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> <textarea id="description" name="description" rows="3" class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full resize-y" placeholder="Enter device description...">{{ device.description or '' }}</textarea>
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg w-full mt-2">Save Description</button> <button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg w-full mt-2">Save Description</button>
</form> </form>
<!-- Custom Fields Section -->
{% if custom_fields %}
<div class="custom-fields-section mb-6">
<h3 class="text-lg font-bold mb-4">Custom Fields</h3>
{% if can_edit_device %}
<form action="/device/{{ device.id }}/update_custom_fields" method="POST" id="custom-fields-form">
<div class="space-y-4">
{% for field in custom_fields %}
<div class="custom-field-item">
<label for="custom_field_{{ field.field_key }}" class="block mb-1 text-sm font-medium">
{{ field.name }}
{% if field.required %}<span class="text-red-500">*</span>{% endif %}
{% if field.help_text %}
<span class="text-xs text-gray-500 dark:text-gray-400 ml-2" title="{{ field.help_text }}">
<i class="fas fa-info-circle"></i>
</span>
{% endif %}
</label>
{% if field.field_type == 'textarea' %}
<textarea name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full resize-y"
{% if field.required %}required{% endif %}
placeholder="{{ field.help_text or '' }}">{{ field.current_value or field.default_value or '' }}</textarea>
{% elif field.field_type == 'boolean' %}
<div class="flex items-center space-x-2">
<input type="checkbox" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
value="true"
{% if field.current_value or (not field.current_value and field.default_value == 'true') %}checked{% endif %}
class="w-4 h-4">
<label for="custom_field_{{ field.field_key }}" class="text-sm">Yes</label>
</div>
{% elif field.field_type == 'select' %}
{% set options = [] %}
{% if field.validation_rules and field.validation_rules.select_options %}
{% set options = field.validation_rules.select_options %}
{% endif %}
<select name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full"
{% if field.required %}required{% endif %}>
{% if not field.required %}
<option value="">-- None --</option>
{% endif %}
{% for option in options %}
<option value="{{ option }}" {% if field.current_value == option or (not field.current_value and field.default_value == option) %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
{% elif field.field_type == 'date' %}
<input type="date" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full"
value="{{ field.current_value or field.default_value or '' }}"
{% if field.required %}required{% endif %}>
{% elif field.field_type == 'datetime' %}
<input type="datetime-local" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full"
value="{{ field.current_value or field.default_value or '' }}"
{% if field.required %}required{% endif %}>
{% elif field.field_type == 'number' %}
<input type="number" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full"
value="{{ field.current_value or field.default_value or '' }}"
{% if field.required %}required{% endif %}
{% if field.validation_rules and field.validation_rules.min_value %}min="{{ field.validation_rules.min_value }}"{% endif %}
{% if field.validation_rules and field.validation_rules.max_value %}max="{{ field.validation_rules.max_value }}"{% endif %}>
{% elif field.field_type == 'decimal' %}
<input type="number" step="any" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full"
value="{{ field.current_value or field.default_value or '' }}"
{% if field.required %}required{% endif %}
{% if field.validation_rules and field.validation_rules.min_value %}min="{{ field.validation_rules.min_value }}"{% endif %}
{% if field.validation_rules and field.validation_rules.max_value %}max="{{ field.validation_rules.max_value }}"{% endif %}>
{% elif field.field_type == 'ip_address' %}
<input type="text" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full font-mono"
value="{{ field.current_value or field.default_value or '' }}"
placeholder="192.168.1.1"
{% if field.required %}required{% endif %}>
{% elif field.field_type == 'email' %}
<input type="email" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full"
value="{{ field.current_value or field.default_value or '' }}"
placeholder="user@example.com"
{% if field.required %}required{% endif %}>
{% elif field.field_type == 'url' %}
<input type="url" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full"
value="{{ field.current_value or field.default_value or '' }}"
placeholder="https://example.com"
{% if field.required %}required{% endif %}>
{% else %}
<input type="text" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full"
value="{{ field.current_value or field.default_value or '' }}"
placeholder="{{ field.help_text or '' }}"
{% if field.required %}required{% endif %}
{% if field.validation_rules and field.validation_rules.min_length %}minlength="{{ field.validation_rules.min_length }}"{% endif %}
{% if field.validation_rules and field.validation_rules.max_length %}maxlength="{{ field.validation_rules.max_length }}"{% endif %}>
{% endif %}
</div>
{% endfor %}
</div>
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg w-full mt-4">Save Custom Fields</button>
</form>
{% else %}
<div class="space-y-4">
{% for field in custom_fields %}
<div class="custom-field-item">
<label class="block mb-1 text-sm font-medium">{{ field.name }}</label>
<div class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full">
{{ field.current_value or field.default_value or '-' }}
</div> </div>
</div> </div>
<script src="/static/js/device.js"></script> {% endfor %}
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
<script src="/static/js/device.min.js"></script>
</body> </body>
</html> </html>
+2 -2
View File
@@ -6,7 +6,7 @@
<title>Device Type Management</title> <title>Device Type Management</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}"> <link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet"> <link href="/static/css/output.css" rel="stylesheet">
<link href="/static/css/device_types.css" rel="stylesheet"> <link href="/static/css/device_types.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head> </head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col"> <body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
@@ -95,7 +95,7 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<script src="/static/js/device_types.js"></script> <script src="/static/js/device_types.min.js"></script>
</body> </body>
</html> </html>
+43 -6
View File
@@ -7,19 +7,40 @@
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}"> <link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet"> <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="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"> <link href="/static/css/devices.min.css" rel="stylesheet">
</head> </head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col"> <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' %} {% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4"> <div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-4xl pt-20"> <div class="container py-8 max-w-4xl pt-20">
<h1 class="text-3xl font-bold mb-6 text-center">Device Manager</h1> <h1 class="text-3xl font-bold mb-6 text-center">Device Manager</h1>
<div class="flex flex-row justify-center gap-4 mb-6"> <div class="flex flex-row justify-center gap-4 mb-6 flex-wrap">
<a href="/add_device" class="text-center bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">Add New Device</a> <a href="/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>
{% if is_feature_enabled('bulk_operations') %}
<a href="/bulk" class="text-center bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">Bulk Operations</a>
{% endif %}
<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> <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>
<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"> <!-- Filters Section -->
<div class="mb-6 space-y-4">
<!-- Tag Filter -->
{% if is_feature_enabled('device_tags') and all_tag_names %}
<div class="flex items-center space-x-2">
<label class="text-sm font-medium">Filter by tag:</label>
<select id="tag-filter" class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600">
<option value="">All devices</option>
{% for tag_name in all_tag_names %}
<option value="{{ tag_name }}" {% if current_tag_filter == tag_name %}selected{% endif %}>{{ tag_name }}</option>
{% endfor %}
</select>
{% if current_tag_filter %}
<a href="/devices" class="text-red-500 hover:text-red-700 text-sm">
<i class="fas fa-times"></i> Clear filter
</a>
{% endif %}
</div>
{% endif %}
</div> </div>
<div id="site-list" class="space-y-6"> <div id="site-list" class="space-y-6">
{% for site, devices in sites_devices.items() %} {% for site, devices in sites_devices.items() %}
@@ -33,7 +54,8 @@
<ul class="device-list hidden px-6 pb-4"> <ul class="device-list hidden px-6 pb-4">
{% for device in devices %} {% for device in devices %}
<li class="my-2"> <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"> <a href="/device/{{ device.id }}" class="block bg-gray-300 hover:bg-gray-100 dark:bg-zinc-900 dark:hover:bg-gray-700 dark:text-gray-200 font-semibold rounded-lg px-4 py-2 shadow transition-colors duration-150">
<div class="flex items-center justify-between">
<span><i class="fas {{ device.icon_class or 'fa-server' }} mr-2"></i>{{ device.name }}</span> <span><i class="fas {{ device.icon_class or 'fa-server' }} mr-2"></i>{{ device.name }}</span>
{% set ips = device_ips.get(device.id, []) %} {% 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"> <span class="flex flex-row flex-wrap justify-end items-center ml-4 text-xs text-blue-300 font-normal align-middle">
@@ -45,6 +67,21 @@
<span class="text-gray-400">No IPs</span> <span class="text-gray-400">No IPs</span>
{% endif %} {% endif %}
</span> </span>
</div>
<!-- Tags -->
{% if is_feature_enabled('device_tags') %}
{% set tags = device_tags.get(device.id, []) %}
{% if tags %}
<div class="flex flex-wrap gap-1 mt-2">
{% for tag in tags %}
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs" style="background-color: {{ tag.color }}20; border: 1px solid {{ tag.color }}; color: {{ tag.color }}">
<div class="w-1.5 h-1.5 rounded-full mr-1" style="background-color: {{ tag.color }}"></div>
{{ tag.name }}
</span>
{% endfor %}
</div>
{% endif %}
{% endif %}
</a> </a>
</li> </li>
{% endfor %} {% endfor %}
@@ -54,6 +91,6 @@
</div> </div>
</div> </div>
</div> </div>
<script src="/static/js/devices.js"></script> <script src="/static/js/devices.min.js"></script>
</body> </body>
</html> </html>
+64
View File
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ tag_name }} - Tagged Devices</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-3xl pt-20">
<div class="flex items-center mb-6 relative">
<a href="/tags" class="absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 flex items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
<h1 class="text-3xl font-bold text-center w-full">
<div class="flex items-center justify-center space-x-2">
<div class="w-5 h-5 rounded-full border border-gray-600" style="background-color: {{ tag_color }}"></div>
<span>{{ tag_name }} - Tagged Devices</span>
</div>
</h1>
</div>
{% if site_devices %}
{% for site, devices in site_devices.items() %}
<div class="mb-8">
<h2 class="text-xl font-bold mb-4 dark:text-white">{{ site }}</h2>
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-4">
<table class="w-full table-auto mb-2">
<thead>
<tr class="bg-gray-200 dark:bg-zinc-700">
<th class="px-4 py-2 text-left">Device Name</th>
<th class="px-4 py-2 text-left">Description</th>
</tr>
</thead>
<tbody>
{% for device in devices %}
<tr class="border-b border-gray-700">
<td class="px-4 py-3">
<a href="/device/{{ device.id }}" class="hover:underline dark:text-white hover:cursor-pointer">{{ device.name }}</a>
</td>
<td class="px-4 py-3">{{ device.description or '' }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="bg-gray-200 dark:bg-zinc-900 font-bold">
<td class="px-4 py-2 text-right" colspan="2">Total: {{ devices|length }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
{% endfor %}
{% else %}
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-8 text-center">
<p class="text-gray-500">No devices found with this tag.</p>
</div>
{% endif %}
</div>
</div>
</body>
</html>
+133
View File
@@ -0,0 +1,133 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Enable Two-Factor Authentication - {{ 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-2xl pt-20">
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<div class="mb-4">
<a href="/account" class="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200">
<i class="fas fa-arrow-left mr-2"></i>Back to Account Settings
</a>
</div>
<h1 class="text-3xl font-bold mb-6 text-center">
<i class="fas fa-shield-alt mr-2"></i>
Enable Two-Factor Authentication
</h1>
{% if step == 'generate' %}
<div class="space-y-4">
<p class="text-center text-gray-700 dark:text-gray-300">
Two-factor authentication adds an extra layer of security to your account.
</p>
<p class="text-center text-gray-600 dark:text-gray-400 text-sm">
You'll need an authenticator app like Google Authenticator, Authy, or Microsoft Authenticator.
</p>
<form method="POST" class="mt-6">
<input type="hidden" name="action" value="generate">
<button type="submit" class="w-full bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
<i class="fas fa-qrcode"></i>
<span>Generate QR Code</span>
</button>
</form>
</div>
{% elif step == 'verify' %}
<div class="space-y-6">
<div class="text-center">
<p class="mb-4 text-gray-700 dark:text-gray-300">
Scan this QR code with your authenticator app:
</p>
<div class="flex justify-center mb-4">
<img src="data:image/png;base64,{{ qr_code }}" alt="QR Code" class="border-4 border-gray-400 dark:border-zinc-600 rounded-lg p-2 bg-white">
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
Or enter this secret manually:
</p>
<div class="bg-gray-300 dark:bg-zinc-900 p-3 rounded-lg font-mono text-sm break-all text-center">
{{ secret }}
</div>
</div>
<form method="POST" class="space-y-4">
<input type="hidden" name="action" value="verify">
<div>
<label for="code" class="block text-sm font-medium mb-2">Enter the 6-digit code from your app:</label>
<input type="text" name="code" id="code" maxlength="6" pattern="[0-9]{6}"
class="w-full p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border border-gray-600 text-center text-2xl font-mono tracking-widest"
placeholder="000000" required autofocus>
</div>
{% if error %}
<div class="bg-red-200 dark:bg-red-900 text-red-800 dark:text-red-200 p-3 rounded-lg">
<i class="fas fa-exclamation-circle mr-2"></i>{{ error }}
</div>
{% endif %}
<button type="submit" class="w-full bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
<i class="fas fa-check"></i>
<span>Verify & Enable 2FA</span>
</button>
</form>
</div>
{% elif step == 'backup_codes' %}
<div class="space-y-6">
<div class="bg-yellow-200 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 p-4 rounded-lg">
<p class="font-semibold mb-2">
<i class="fas fa-exclamation-triangle mr-2"></i>
Important: Save Your Backup Codes
</p>
<p class="text-sm">
These codes can be used to access your account if you lose your authenticator device.
Store them in a safe place. Each code can only be used once.
</p>
</div>
<div class="bg-gray-300 dark:bg-zinc-900 p-6 rounded-lg">
<h2 class="text-xl font-bold mb-4 text-center">Your Backup Codes</h2>
<div class="grid grid-cols-2 gap-4 font-mono text-center">
{% for code_pair in backup_codes %}
<div class="p-3 bg-gray-200 dark:bg-zinc-800 rounded border border-gray-400 dark:border-zinc-600">
{{ code_pair }}
</div>
{% endfor %}
</div>
</div>
<div class="flex gap-4">
<button onclick="window.print()" class="flex-1 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
<i class="fas fa-print"></i>
<span>Print Codes</span>
</button>
<a href="/account" class="flex-1 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center text-center">
<i class="fas fa-check"></i>
<span>Continue</span>
</a>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<script>
// Auto-focus code input and move cursor on input
const codeInput = document.getElementById('code');
if (codeInput) {
codeInput.addEventListener('input', function(e) {
this.value = this.value.replace(/[^0-9]/g, '');
if (this.value.length === 6) {
this.form.submit();
}
});
}
</script>
</body>
</html>
+129 -34
View File
@@ -1,67 +1,162 @@
<header class="bg-zinc-800 shadow-md py-3 px-6 flex items-center justify-between relative"> <header class="bg-zinc-800 shadow-md py-3 px-6 relative flex items-center justify-between lg:grid lg:grid-cols-[auto_minmax(18rem,1fr)_auto] lg:gap-6">
<div class="flex items-center space-x-3 flex-shrink-0">
<a href="/" class="flex items-center space-x-3"> <a href="/" class="flex items-center space-x-3">
<img src="{{ LOGO_PNG }}" alt="Logo" class="h-8 rounded"> <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> <span class="text-2xl font-bold text-white whitespace-nowrap">{{ NAME }} IPAM</span>
</a> </a>
<div class="hidden lg:block absolute left-1/2 transform -translate-x-1/2 text-white text-lg font-medium whitespace-nowrap"> <a href="https://git.jdbnet.co.uk/jamie/ipam/releases" target="_blank" rel="noopener noreferrer" class="text-sm font-normal text-gray-300 hover:text-gray-100 -ml-1 mt-3">{{ VERSION }}</a>
{% if current_user_name %}Hello, {{ current_user_name }}{% endif %}
</div> </div>
<nav class="hidden md:flex items-center space-x-6" id="main-nav">
<div class="hidden lg:flex items-center min-w-0">
<form action="/search" method="GET" class="flex items-center space-x-2 w-full max-w-2xl mx-auto">
<input type="text" name="q" id="search-input" placeholder="Search..."
class="bg-zinc-700 text-white placeholder-gray-400 px-4 py-2 rounded-md text-base focus:outline-none focus:ring-2 focus:ring-gray-500 w-full min-w-0"
value="{{ request.args.get('q', '') }}">
<button type="submit" class="text-gray-200 hover:text-gray-400 hover:cursor-pointer flex-shrink-0">
<i class="fas fa-search"></i>
</button>
</form>
</div>
<nav class="hidden lg:flex items-center space-x-6 flex-shrink-0 whitespace-nowrap justify-self-end" id="main-nav">
{% if has_permission('view_index') %} {% if has_permission('view_index') %}
<a href="/" class="text-gray-200 hover:text-gray-400 font-medium">Home</a> <a href="/" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
<i class="fas fa-home"></i>
<span>Home</span>
</a>
{% endif %} {% endif %}
{% if has_permission('view_devices') %} {% if has_permission('view_devices') %}
<a href="/devices" class="text-gray-200 hover:text-gray-400 font-medium">Devices</a> <a href="/devices" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
<i class="fas fa-server"></i>
<span>Devices</span>
</a>
{% endif %} {% endif %}
{% if has_permission('view_racks') %} {% if has_permission('view_racks') and is_feature_enabled('racks') %}
<a href="/racks" class="text-gray-200 hover:text-gray-400 font-medium">Racks</a> <a href="/racks" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
<i class="fas fa-th"></i>
<span>Racks</span>
</a>
{% endif %} {% endif %}
{% if has_permission('view_admin') %} {% if has_permission('view_admin') %}
<a href="/admin" class="text-gray-200 hover:text-gray-400 font-medium">Admin</a> <a href="/admin" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
{% endif %} <i class="fas fa-cog"></i>
{% if has_permission('view_users') %} <span>Admin</span>
<a href="/users" class="text-gray-200 hover:text-gray-400 font-medium">Users</a> </a>
{% endif %}
{% if has_permission('view_audit') %}
<a href="/audit" class="text-gray-200 hover:text-gray-400 font-medium">Audit Log</a>
{% endif %} {% endif %}
{% if has_permission('view_help') %} {% if has_permission('view_help') %}
<a href="/help" class="text-gray-200 hover:text-gray-400 font-medium">Help</a> <a href="/help" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
<i class="fas fa-question-circle"></i>
<span>Help</span>
</a>
{% endif %} {% endif %}
{% if current_user_name %} {% if current_user_name %}
<a href="/logout" class="text-gray-200 hover:text-gray-400 font-medium">Logout</a> <a href="/account" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
<i class="fas fa-user-cog"></i>
<span>Account</span>
</a>
<a href="/logout" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
<i class="fas fa-sign-out-alt"></i>
<span>Logout</span>
</a>
{% endif %} {% endif %}
</nav> </nav>
<button class="md:hidden flex items-center text-gray-200 hover:cursor-pointer focus:outline-none" id="nav-toggle" aria-label="Open navigation menu"> <button class="lg:hidden flex items-center text-gray-200 hover:cursor-pointer focus:outline-none flex-shrink-0" id="nav-toggle" aria-label="Open navigation menu">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <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> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg> </svg>
</button> </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"> <div class="lg:hidden fixed top-13 left-0 right-0 bg-zinc-800 shadow-lg z-50 w-full hidden flex-col py-2" id="mobile-nav">
<form action="/search" method="GET" class="px-4 py-2 border-b border-zinc-700">
<div class="flex items-center space-x-2">
<input type="text" name="q" placeholder="Search..."
class="bg-zinc-700 text-white placeholder-gray-400 px-3 py-2.5 rounded text-base focus:outline-none focus:ring-2 focus:ring-gray-500 flex-1"
value="{{ request.args.get('q', '') }}">
<button type="submit" class="text-gray-200 hover:text-gray-400 hover:cursor-pointer flex-shrink-0">
<i class="fas fa-search"></i>
</button>
</div>
</form>
{% if has_permission('view_index') %} {% if has_permission('view_index') %}
<a href="/" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Home</a> <a href="/" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
<i class="fas fa-home"></i>
<span>Home</span>
</a>
{% endif %} {% endif %}
{% if has_permission('view_devices') %} {% if has_permission('view_devices') %}
<a href="/devices" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Devices</a> <a href="/devices" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
<i class="fas fa-server"></i>
<span>Devices</span>
</a>
{% endif %} {% endif %}
{% if has_permission('view_racks') %} {% if has_permission('view_racks') and is_feature_enabled('racks') %}
<a href="/racks" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Racks</a> <a href="/racks" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
<i class="fas fa-th"></i>
<span>Racks</span>
</a>
{% endif %} {% endif %}
{% if has_permission('view_admin') %} {% if has_permission('view_admin') %}
<a href="/admin" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Admin</a> <a href="/admin" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
{% endif %} <i class="fas fa-cog"></i>
{% if has_permission('view_users') %} <span>Admin</span>
<a href="/users" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Users</a> </a>
{% endif %}
{% if has_permission('view_audit') %}
<a href="/audit" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Audit Log</a>
{% endif %} {% endif %}
{% if has_permission('view_help') %} {% if has_permission('view_help') %}
<a href="/help" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Help</a> <a href="/help" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
<i class="fas fa-question-circle"></i>
<span>Help</span>
</a>
{% endif %} {% endif %}
{% if current_user_name %} {% if current_user_name %}
<a href="/logout" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Logout</a> <a href="/account" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
<i class="fas fa-user-cog"></i>
<span>Account</span>
</a>
<a href="/logout" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
<i class="fas fa-sign-out-alt"></i>
<span>Logout</span>
</a>
{% endif %} {% endif %}
</div> </div>
<script src="/static/js/header.js"></script> <script src="/static/js/header.min.js"></script>
<!-- Update Available Toast -->
<div id="update-toast" class="hidden fixed bottom-4 right-4 bg-gray-200 dark:bg-zinc-800 border border-gray-400 dark:border-zinc-600 rounded-lg shadow-lg max-w-md z-50">
<div class="p-4">
<div class="flex items-start justify-between mb-2">
<div class="flex items-center gap-2">
<i class="fas fa-bell text-gray-900 dark:text-gray-100"></i>
<h3 class="font-semibold text-lg text-gray-900 dark:text-gray-100">Update Available</h3>
</div>
<button id="toast-close" class="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:cursor-pointer">
<i class="fas fa-times"></i>
</button>
</div>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
Version <span id="toast-latest-version" class="font-semibold"></span> is now available. You're currently on <span id="toast-current-version" class="font-semibold"></span>.
</p>
<div class="flex gap-2 mt-3">
<a id="toast-compare-link" href="#" target="_blank" rel="noopener noreferrer" class="flex-1 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 text-gray-900 dark:text-gray-100 px-3 py-2 rounded text-center text-sm hover:cursor-pointer">
View Changes
</a>
</div>
</div>
</div>
<style>
#update-toast {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
</style>
<script src="/static/js/update_toast.min.js"></script>
</header> </header>
+33 -79
View File
@@ -10,10 +10,11 @@
</head> </head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col"> <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' %} {% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4"> <div class="flex-1 mx-4 py-8 pt-20">
<div class="container py-8 max-w-2xl pt-20"> <div class="container max-w-full mx-auto lg:px-32">
<h1 class="text-3xl font-bold mb-8 text-center">Help & User Guide</h1> <h1 class="text-3xl font-bold mb-8 text-center">Help & User Guide</h1>
<div class="space-y-10 text-lg">
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 mb-8">
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8"> <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> <h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">Subnets & Devices</h2>
<div class="space-y-4"> <div class="space-y-4">
@@ -52,6 +53,35 @@
</div> </div>
</div> </div>
</div> </div>
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">Device Tags</h2>
<div class="space-y-4">
<div>
<h3 class="text-xl font-semibold mb-1">Managing Tags</h3>
<p>Tags help you organize and categorize your devices. Administrators can manage tags from the <a href="/tags" class="text-blue-600 dark:text-blue-400 hover:underline">Tag Management</a> page accessible from the Admin panel. Each tag has a name, customizable colour, and optional description.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Creating Tags</h3>
<p>Click <span class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded">Add Tag</span> to create a new tag. Choose a descriptive name (e.g., "Production", "Development", "Critical") and select a colour to visually distinguish the tag. Tags can be used to group devices by environment, importance, location, or any other criteria.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Assigning Tags to Devices</h3>
<p>From any device's detail page, you can assign multiple tags using the tag assignment dropdown. Each device can have unlimited tags, and removing a tag is as simple as clicking the × button next to the tag name.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Filtering Devices by Tags</h3>
<p>On the <a href="/devices" class="text-blue-600 dark:text-blue-400 hover:underline">Devices</a> page, use the tag filter dropdown to view only devices with a specific tag. This makes it easy to focus on devices in a particular environment or category.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Tag Colours and Visual Organisation</h3>
<p>Tags appear as coloured badges throughout the interface. Use consistent colour schemes (e.g., red for production, blue for development) to create visual patterns that help users quickly identify device categories.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Tag Permissions</h3>
<p>Tag management respects role-based permissions. Users need appropriate permissions to view, create, edit, delete tags, or assign/remove tags from devices. View-only users can see tags but cannot modify them.</p>
</div>
</div>
</div>
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8"> <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> <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 class="space-y-4">
@@ -86,82 +116,6 @@
</div> </div>
</div> </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">API Documentation</h2>
<div class="space-y-4">
<div>
<h3 class="text-xl font-semibold mb-1">Authentication</h3>
<p>All API requests require authentication using an API key. You can provide the API key in one of three ways:</p>
<ul class="list-disc list-inside mt-2 space-y-1 ml-4">
<li><strong>Header:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">X-API-Key: your_api_key</code></li>
<li><strong>Authorization Header:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">Authorization: Bearer your_api_key</code></li>
<li><strong>Query Parameter:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">?api_key=your_api_key</code></li>
</ul>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Base URL</h3>
<p>All API endpoints are prefixed with <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">/api/v1</code></p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Available Endpoints</h3>
<div class="space-y-3 mt-2">
<div>
<h4 class="font-semibold">Devices</h4>
<ul class="list-disc list-inside ml-4 text-sm space-y-1">
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/devices</code> - List all devices</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/devices/{id}</code> - Get device details</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/devices</code> - Create device</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">PUT /api/v1/devices/{id}</code> - Update device</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">DELETE /api/v1/devices/{id}</code> - Delete device</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/devices/{id}/ips</code> - Add IP to device</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">DELETE /api/v1/devices/{id}/ips/{ip_id}</code> - Remove IP from device</li>
</ul>
</div>
<div>
<h4 class="font-semibold">Subnets</h4>
<ul class="list-disc list-inside ml-4 text-sm space-y-1">
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/subnets</code> - List all subnets</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/subnets/{id}</code> - Get subnet details</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/subnets</code> - Create subnet</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">PUT /api/v1/subnets/{id}</code> - Update subnet</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">DELETE /api/v1/subnets/{id}</code> - Delete subnet</li>
</ul>
</div>
<div>
<h4 class="font-semibold">Racks</h4>
<ul class="list-disc list-inside ml-4 text-sm space-y-1">
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/racks</code> - List all racks</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/racks/{id}</code> - Get rack details</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/racks</code> - Create rack</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">DELETE /api/v1/racks/{id}</code> - Delete rack</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/racks/{id}/devices</code> - Add device to rack</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">DELETE /api/v1/racks/{id}/devices/{rack_device_id}</code> - Remove device from rack</li>
</ul>
</div>
<div>
<h4 class="font-semibold">Other</h4>
<ul class="list-disc list-inside ml-4 text-sm space-y-1">
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/info</code> - Get API info and user details</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/device-types</code> - List device types</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/subnets/{id}/dhcp</code> - Get DHCP pools</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/subnets/{id}/dhcp</code> - Configure DHCP pools</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/audit</code> - Get audit log</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/users</code> - List users (admin only)</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/roles</code> - List roles (admin only)</li>
</ul>
</div>
</div>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Permissions</h3>
<p>API endpoints respect the same role-based permissions as the web interface. Users can only perform actions that their role allows. If a user lacks the required permission, the API will return a <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">403 Forbidden</code> error.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Response Format</h3>
<p>All API responses are in JSON format. Successful requests return <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">200 OK</code> or <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">201 Created</code> with the requested data. Errors return appropriate HTTP status codes with an error message in the response body.</p>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
+2 -2
View File
@@ -38,8 +38,8 @@
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
<script src="/static/js/sitelist.js"></script> <script src="/static/js/sitelist.min.js"></script>
<script src="/static/js/export_csv.js"></script> <script src="/static/js/export_csv.min.js"></script>
</div> </div>
</div> </div>
</body> </body>
+1 -35
View File
@@ -24,16 +24,6 @@
<button type="button" id="export-csv" class="hidden sm:flex absolute right-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer items-center justify-center rounded-full w-11 h-11 export-csv-btn" title="Export as CSV" data-rack-id="{{ rack.id }}"> <button type="button" id="export-csv" class="hidden sm:flex absolute right-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer items-center justify-center rounded-full w-11 h-11 export-csv-btn" title="Export as CSV" data-rack-id="{{ rack.id }}">
<i class="fas fa-file-csv fa-lg"></i> <i class="fas fa-file-csv fa-lg"></i>
</button> </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>
<div class="flex flex-col gap-4 mb-6 items-stretch"> <div class="flex flex-col gap-4 mb-6 items-stretch">
<div class="flex gap-4 w-full justify-center"> <div class="flex gap-4 w-full justify-center">
@@ -78,31 +68,7 @@
</div> </div>
<div class="text-xs dark:text-gray-400 mt-2">Add a non-networked device.</div> <div class="text-xs dark:text-gray-400 mt-2">Add a non-networked device.</div>
</form> </form>
<script> <script src="/static/js/rack.min.js"></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 %} {% if error %}
<div class="mb-4 p-3 bg-red-700 text-white rounded-lg text-center font-semibold">{{ error }}</div> <div class="mb-4 p-3 bg-red-700 text-white rounded-lg text-center font-semibold">{{ error }}</div>
{% endif %} {% endif %}
+65
View File
@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Regenerate Backup Codes - {{ 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-2xl pt-20">
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<div class="mb-4">
<a href="/account" class="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200">
<i class="fas fa-arrow-left mr-2"></i>Back to Account Settings
</a>
</div>
<h1 class="text-3xl font-bold mb-6 text-center">
<i class="fas fa-key mr-2"></i>
New Backup Codes
</h1>
<div class="space-y-6">
<div class="bg-yellow-200 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 p-4 rounded-lg">
<p class="font-semibold mb-2">
<i class="fas fa-exclamation-triangle mr-2"></i>
Important: Save Your New Backup Codes
</p>
<p class="text-sm">
Your old backup codes have been invalidated. These new codes can be used to access your account if you lose your authenticator device.
Store them in a safe place. Each code can only be used once.
</p>
</div>
<div class="bg-gray-300 dark:bg-zinc-900 p-6 rounded-lg">
<h2 class="text-xl font-bold mb-4 text-center">Your New Backup Codes</h2>
<div class="grid grid-cols-2 gap-4 font-mono text-center">
{% for code_pair in backup_codes %}
<div class="p-3 bg-gray-200 dark:bg-zinc-800 rounded border border-gray-400 dark:border-zinc-600">
{{ code_pair }}
</div>
{% endfor %}
</div>
</div>
<div class="flex gap-4">
<button onclick="window.print()" class="flex-1 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
<i class="fas fa-print"></i>
<span>Print Codes</span>
</button>
<a href="/account" class="flex-1 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center text-center">
<i class="fas fa-check"></i>
<span>Done</span>
</a>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
+179
View File
@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Search - {{ 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 container mx-auto px-4 py-8 pt-20">
<div class="max-w-6xl mx-auto">
<h1 class="text-3xl font-bold mb-6">Search Results</h1>
{% if query %}
<p class="text-lg mb-6">Search results for: <span class="font-semibold">"{{ query }}"</span></p>
{% else %}
<p class="text-lg mb-6 text-gray-600 dark:text-gray-400">Enter a search query to find IPs, devices, subnets, tags, racks, and sites.</p>
{% endif %}
{% if query %}
{% set total_results = results.subnets|length + results.ips|length + results.devices|length + results.tags|length + results.racks|length + results.sites|length %}
{% if total_results == 0 %}
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-8 text-center">
<i class="fas fa-search text-4xl text-gray-400 mb-4"></i>
<p class="text-xl font-semibold mb-2">No results found</p>
<p class="text-gray-600 dark:text-gray-400">Try a different search term or check your spelling.</p>
</div>
{% else %}
<div class="space-y-6">
<!-- Subnets -->
{% if results.subnets %}
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold mb-4 flex items-center">
<i class="fas fa-network-wired mr-2"></i>
Subnets ({{ results.subnets|length }})
</h2>
<div class="space-y-2">
{% for subnet in results.subnets %}
<a href="/subnet/{{ subnet.id }}" class="block p-3 bg-gray-300 dark:bg-zinc-900 hover:bg-gray-100 dark:hover:bg-zinc-700 rounded-lg transition-colors">
<div class="flex items-center justify-between">
<div>
<p class="font-semibold text-lg">{{ subnet.name }}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">{{ subnet.cidr }}</p>
</div>
<div class="text-right">
<p class="text-sm text-gray-600 dark:text-gray-400">{{ subnet.site }}</p>
</div>
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- IP Addresses -->
{% if results.ips %}
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold mb-4 flex items-center">
<i class="fas fa-map-marker-alt mr-2"></i>
IP Addresses ({{ results.ips|length }})
</h2>
<div class="space-y-2">
{% for ip in results.ips %}
<a href="/subnet/{{ ip.subnet_id }}#ip-{{ ip.id }}" class="block p-3 bg-gray-300 dark:bg-zinc-900 hover:bg-gray-100 dark:hover:bg-zinc-700 rounded-lg transition-colors">
<div class="flex items-center justify-between">
<div>
<p class="font-semibold text-lg">{{ ip.ip }}</p>
{% if ip.hostname %}
<p class="text-sm text-gray-600 dark:text-gray-400">{{ ip.hostname }}</p>
{% endif %}
<p class="text-sm text-gray-600 dark:text-gray-400">{{ ip.subnet_name }} ({{ ip.subnet_cidr }})</p>
</div>
<div class="text-right">
<p class="text-sm text-gray-600 dark:text-gray-400">{{ ip.site }}</p>
</div>
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Devices -->
{% if results.devices %}
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold mb-4 flex items-center">
<i class="fas fa-server mr-2"></i>
Devices ({{ results.devices|length }})
</h2>
<div class="space-y-2">
{% for device in results.devices %}
<a href="/device/{{ device.id }}" class="block p-3 bg-gray-300 dark:bg-zinc-900 hover:bg-gray-100 dark:hover:bg-zinc-700 rounded-lg transition-colors">
<div>
<p class="font-semibold text-lg">{{ device.name }}</p>
{% if device.description %}
<p class="text-sm text-gray-600 dark:text-gray-400">{{ device.description }}</p>
{% endif %}
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Tags -->
{% if results.tags %}
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold mb-4 flex items-center">
<i class="fas fa-tags mr-2"></i>
Tags ({{ results.tags|length }})
</h2>
<div class="space-y-2">
{% for tag in results.tags %}
<a href="/tags" class="block p-3 bg-gray-300 dark:bg-zinc-900 hover:bg-gray-100 dark:hover:bg-zinc-700 rounded-lg transition-colors">
<div>
<p class="font-semibold text-lg">{{ tag.name }}</p>
{% if tag.description %}
<p class="text-sm text-gray-600 dark:text-gray-400">{{ tag.description }}</p>
{% endif %}
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Racks -->
{% if results.racks %}
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold mb-4 flex items-center">
<i class="fas fa-th mr-2"></i>
Racks ({{ results.racks|length }})
</h2>
<div class="space-y-2">
{% for rack in results.racks %}
<a href="/rack/{{ rack.id }}" class="block p-3 bg-gray-300 dark:bg-zinc-900 hover:bg-gray-100 dark:hover:bg-zinc-700 rounded-lg transition-colors">
<div class="flex items-center justify-between">
<div>
<p class="font-semibold text-lg">{{ rack.name }}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">{{ rack.height_u }}U</p>
</div>
<div class="text-right">
<p class="text-sm text-gray-600 dark:text-gray-400">{{ rack.site }}</p>
</div>
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Sites -->
{% if results.sites %}
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold mb-4 flex items-center">
<i class="fas fa-map-marker-alt mr-2"></i>
Sites ({{ results.sites|length }})
</h2>
<div class="space-y-2">
{% for site in results.sites %}
<div class="p-3 bg-gray-300 dark:bg-zinc-900 rounded-lg">
<p class="font-semibold text-lg">{{ site }}</p>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endif %}
{% endif %}
</div>
</div>
</body>
</html>
+128
View File
@@ -0,0 +1,128 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Setup Two-Factor Authentication - {{ 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-2xl 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">
<i class="fas fa-shield-alt mr-2"></i>
Setup Two-Factor Authentication
</h1>
{% if step == 'generate' %}
<div class="space-y-4">
<p class="text-center text-gray-700 dark:text-gray-300">
Your role requires two-factor authentication. Let's set it up now.
</p>
<p class="text-center text-gray-600 dark:text-gray-400 text-sm">
You'll need an authenticator app like Google Authenticator, Authy, or Microsoft Authenticator.
</p>
<form method="POST" class="mt-6">
<input type="hidden" name="action" value="generate">
<button type="submit" class="w-full bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
<i class="fas fa-qrcode"></i>
<span>Generate QR Code</span>
</button>
</form>
</div>
{% elif step == 'verify' %}
<div class="space-y-6">
<div class="text-center">
<p class="mb-4 text-gray-700 dark:text-gray-300">
Scan this QR code with your authenticator app:
</p>
<div class="flex justify-center mb-4">
<img src="data:image/png;base64,{{ qr_code }}" alt="QR Code" class="border-4 border-gray-400 dark:border-zinc-600 rounded-lg p-2 bg-white">
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
Or enter this secret manually:
</p>
<div class="bg-gray-300 dark:bg-zinc-900 p-3 rounded-lg font-mono text-sm break-all text-center">
{{ secret }}
</div>
</div>
<form method="POST" class="space-y-4">
<input type="hidden" name="action" value="verify">
<div>
<label for="code" class="block text-sm font-medium mb-2">Enter the 6-digit code from your app:</label>
<input type="text" name="code" id="code" maxlength="6" pattern="[0-9]{6}"
class="w-full p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border border-gray-600 text-center text-2xl font-mono tracking-widest"
placeholder="000000" required autofocus>
</div>
{% if error %}
<div class="bg-red-200 dark:bg-red-900 text-red-800 dark:text-red-200 p-3 rounded-lg">
<i class="fas fa-exclamation-circle mr-2"></i>{{ error }}
</div>
{% endif %}
<button type="submit" class="w-full bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
<i class="fas fa-check"></i>
<span>Verify & Enable 2FA</span>
</button>
</form>
</div>
{% elif step == 'backup_codes' %}
<div class="space-y-6">
<div class="bg-yellow-200 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 p-4 rounded-lg">
<p class="font-semibold mb-2">
<i class="fas fa-exclamation-triangle mr-2"></i>
Important: Save Your Backup Codes
</p>
<p class="text-sm">
These codes can be used to access your account if you lose your authenticator device.
Store them in a safe place. Each code can only be used once.
</p>
</div>
<div class="bg-gray-300 dark:bg-zinc-900 p-6 rounded-lg">
<h2 class="text-xl font-bold mb-4 text-center">Your Backup Codes</h2>
<div class="grid grid-cols-2 gap-4 font-mono text-center">
{% for code_pair in backup_codes %}
<div class="p-3 bg-gray-200 dark:bg-zinc-800 rounded border border-gray-400 dark:border-zinc-600">
{{ code_pair }}
</div>
{% endfor %}
</div>
</div>
<div class="flex gap-4">
<button onclick="window.print()" class="flex-1 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
<i class="fas fa-print"></i>
<span>Print Codes</span>
</button>
<a href="/" class="flex-1 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center text-center">
<i class="fas fa-check"></i>
<span>Continue</span>
</a>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<script>
// Auto-focus code input and move cursor on input
const codeInput = document.getElementById('code');
if (codeInput) {
codeInput.addEventListener('input', function(e) {
this.value = this.value.replace(/[^0-9]/g, '');
if (this.value.length === 6) {
this.form.submit();
}
});
}
</script>
</body>
</html>
+248 -25
View File
@@ -6,7 +6,6 @@
<title>{{ subnet.name }} - Subnet Details</title> <title>{{ subnet.name }} - Subnet Details</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}"> <link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet"> <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"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head> </head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col"> <body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
@@ -14,18 +13,213 @@
<div class="flex-1 flex items-center justify-center mx-4"> <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="container py-8 w-full sm:max-w-3/4 pt-20">
<div class="flex items-center mb-6 relative"> <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> <a href="/" 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> <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 hover:cursor-pointer items-center justify-center rounded-full w-11 h-11 export-csv-btn" title="Export as CSV" data-subnet-id="{{ subnet.id }}"> <button type="button" id="export-csv" class="hidden sm:flex absolute right-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer items-center justify-center rounded-full w-11 h-11 export-csv-btn" title="Export as CSV" data-subnet-id="{{ subnet.id }}">
<i class="fas fa-file-csv fa-lg"></i> <i class="fas fa-file-csv fa-lg"></i>
</button> </button>
</div> </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"> <!-- Info Grid: 3 columns on desktop, 1 on mobile -->
<i class="fas fa-network-wired"></i> Define DHCP Pool <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<!-- Utilisation Stats Column -->
{% if utilization %}
<div class="bg-gray-200 dark:bg-zinc-800 p-4 rounded-lg">
<h3 class="text-lg font-bold mb-3 flex items-center gap-2">
<i class="fas fa-chart-pie"></i>
Utilisation
</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Used:</span>
<span class="font-medium">{{ utilization.percent }}%</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Assigned:</span>
<span>{{ utilization.assigned }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">DHCP:</span>
<span>{{ utilization.dhcp }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Available:</span>
<span>{{ utilization.available }}</span>
</div>
<div class="flex justify-between border-t border-gray-600 pt-2 mt-2">
<span class="text-gray-600 dark:text-gray-400">Total:</span>
<span class="font-medium">{{ utilization.total }}</span>
</div>
</div>
</div>
{% endif %}
<!-- VLAN Information Column -->
{% if subnet.vlan_id or subnet.vlan_description or subnet.vlan_notes %}
<div class="bg-gray-200 dark:bg-zinc-800 p-4 rounded-lg">
<h3 class="text-lg font-bold mb-3 flex items-center gap-2">
<i class="fas fa-network-wired"></i>
VLAN
</h3>
<div class="space-y-2 text-sm">
{% if subnet.vlan_id %}
<div>
<span class="text-gray-600 dark:text-gray-400">ID:</span>
<span class="ml-2 px-2 py-1 bg-gray-300 dark:bg-zinc-700 rounded text-sm font-mono">{{ subnet.vlan_id }}</span>
</div>
{% endif %}
{% if subnet.vlan_description %}
<div>
<span class="text-gray-600 dark:text-gray-400 block mb-1">Description:</span>
<span>{{ subnet.vlan_description }}</span>
</div>
{% endif %}
{% if subnet.vlan_notes %}
<div>
<span class="text-gray-600 dark:text-gray-400 block mb-1">Notes:</span>
<div class="whitespace-pre-wrap text-xs">{{ subnet.vlan_notes }}</div>
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Custom Fields Column -->
{% if custom_fields %}
<div class="bg-gray-200 dark:bg-zinc-800 p-4 rounded-lg">
<h3 class="text-lg font-bold mb-3 flex items-center gap-2">
<i class="fas fa-list-ul"></i>
Custom Fields
</h3>
{% if can_edit_subnet %}
<form action="/subnet/{{ subnet.id }}/update_custom_fields" method="POST" id="custom-fields-form">
<div class="space-y-3">
{% for field in custom_fields %}
<div class="custom-field-item">
<label for="custom_field_{{ field.field_key }}" class="block mb-1 text-xs font-medium">
{{ field.name }}
{% if field.required %}<span class="text-red-500">*</span>{% endif %}
</label>
{% if field.field_type == 'textarea' %}
<textarea name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full resize-y text-sm"
{% if field.required %}required{% endif %}
placeholder="{{ field.help_text or '' }}">{{ field.current_value or field.default_value or '' }}</textarea>
{% elif field.field_type == 'boolean' %}
<div class="flex items-center space-x-2">
<input type="checkbox" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
value="true"
{% if field.current_value or (not field.current_value and field.default_value == 'true') %}checked{% endif %}
class="w-4 h-4">
<label for="custom_field_{{ field.field_key }}" class="text-sm">Yes</label>
</div>
{% elif field.field_type == 'select' %}
{% set options = [] %}
{% if field.validation_rules and field.validation_rules.select_options %}
{% set options = field.validation_rules.select_options %}
{% endif %}
<select name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full text-sm"
{% if field.required %}required{% endif %}>
{% if not field.required %}
<option value="">-- None --</option>
{% endif %}
{% for option in options %}
<option value="{{ option }}" {% if field.current_value == option or (not field.current_value and field.default_value == option) %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
{% elif field.field_type == 'date' %}
<input type="date" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full text-sm"
value="{{ field.current_value or field.default_value or '' }}"
{% if field.required %}required{% endif %}>
{% elif field.field_type == 'datetime' %}
<input type="datetime-local" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full text-sm"
value="{{ field.current_value or field.default_value or '' }}"
{% if field.required %}required{% endif %}>
{% elif field.field_type == 'number' %}
<input type="number" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full text-sm"
value="{{ field.current_value or field.default_value or '' }}"
{% if field.required %}required{% endif %}
{% if field.validation_rules and field.validation_rules.min_value %}min="{{ field.validation_rules.min_value }}"{% endif %}
{% if field.validation_rules and field.validation_rules.max_value %}max="{{ field.validation_rules.max_value }}"{% endif %}>
{% elif field.field_type == 'decimal' %}
<input type="number" step="any" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full text-sm"
value="{{ field.current_value or field.default_value or '' }}"
{% if field.required %}required{% endif %}
{% if field.validation_rules and field.validation_rules.min_value %}min="{{ field.validation_rules.min_value }}"{% endif %}
{% if field.validation_rules and field.validation_rules.max_value %}max="{{ field.validation_rules.max_value }}"{% endif %}>
{% elif field.field_type == 'ip_address' %}
<input type="text" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full font-mono text-sm"
value="{{ field.current_value or field.default_value or '' }}"
placeholder="192.168.1.1"
{% if field.required %}required{% endif %}>
{% elif field.field_type == 'email' %}
<input type="email" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full text-sm"
value="{{ field.current_value or field.default_value or '' }}"
placeholder="user@example.com"
{% if field.required %}required{% endif %}>
{% elif field.field_type == 'url' %}
<input type="url" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full text-sm"
value="{{ field.current_value or field.default_value or '' }}"
placeholder="https://example.com"
{% if field.required %}required{% endif %}>
{% else %}
<input type="text" name="custom_field_{{ field.field_key }}"
id="custom_field_{{ field.field_key }}"
class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full text-sm"
value="{{ field.current_value or field.default_value or '' }}"
placeholder="{{ field.help_text or '' }}"
{% if field.required %}required{% endif %}
{% if field.validation_rules and field.validation_rules.min_length %}minlength="{{ field.validation_rules.min_length }}"{% endif %}
{% if field.validation_rules and field.validation_rules.max_length %}maxlength="{{ field.validation_rules.max_length }}"{% endif %}>
{% endif %}
</div>
{% endfor %}
</div>
</form>
{% else %}
<div class="space-y-3">
{% for field in custom_fields %}
<div class="custom-field-item">
<label class="block mb-1 text-xs font-medium">{{ field.name }}</label>
<div class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full text-sm">
{{ field.current_value or field.default_value or '-' }}
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
</div>
<!-- Actions Row -->
<div class="flex justify-center mb-4 gap-2">
<a href="/subnet/{{ subnet.id }}/dhcp" class="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 flex">
<i class="fas fa-network-wired"></i> <span class="hidden sm:inline">Define </span>DHCP Pool
</a> </a>
</div> </div>
<button id="toggle-desc" class="sm:hidden bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg mb-4 w-full">Show Descriptions</button> <button id="toggle-desc" class="sm:hidden bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg mb-4 w-full">Show Descriptions</button>
<!-- IP Address Table -->
<form action="" method="POST"> <form action="" method="POST">
<table class="table-auto w-full mb-6"> <table class="table-auto w-full mb-6">
<thead> <thead>
@@ -37,8 +231,12 @@
</thead> </thead>
<tbody class="divide-y divide-gray-700"> <tbody class="divide-y divide-gray-700">
{% for ip in ip_addresses %} {% for ip in ip_addresses %}
<tr> <tr id="ip-{{ ip[0] }}">
<td class="font-bold text-center">{{ ip[1] }}</td> <td class="font-bold text-center">
<button type="button" class="ip-history-btn hover:text-blue-400 cursor-pointer" data-ip="{{ ip[1] }}" title="View IP history">
{{ ip[1] }}
</button>
</td>
<td class="text-center"> <td class="text-center">
{% if ip[2] == 'DHCP' %} {% if ip[2] == 'DHCP' %}
<span class="font-semibold">DHCP</span> <span class="font-semibold">DHCP</span>
@@ -51,7 +249,29 @@
{% endif %} {% endif %}
</td> </td>
<td class="text-left align-top hidden sm:table-cell desc-col"> <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> {% set device_desc = ip[4] if ip[4] else '' %}
{% set ip_notes = ip[5] if ip|length > 5 and ip[5] else '' %}
{% if ip_notes_enabled %}
{% set combined_desc = '' %}
{% if device_desc %}
{% set combined_desc = device_desc %}
{% endif %}
{% if ip_notes %}
{% if combined_desc %}
{% set combined_desc = combined_desc + '\n' + ip_notes %}
{% else %}
{% set combined_desc = ip_notes %}
{% endif %}
{% endif %}
{% if can_edit_subnet %}
<textarea data-ip-id="{{ ip[0] }}" data-device-desc="{{ device_desc|e }}" rows="1" class="ip-notes-textarea border border-gray-600 rounded w-full p-2 bg-gray-200 dark:bg-zinc-800" style="overflow: hidden; resize: none;">{{ combined_desc }}</textarea>
{% else %}
<textarea readonly rows="1" class="border border-gray-600 rounded w-full cursor-pointer p-2" style="overflow: hidden; resize: none;">{{ combined_desc }}</textarea>
{% endif %}
{% else %}
{# IP notes disabled - show only device description, read-only #}
<textarea readonly rows="1" class="border border-gray-600 rounded w-full cursor-pointer p-2 bg-gray-200 dark:bg-zinc-800" style="overflow: hidden; resize: none;">{{ device_desc }}</textarea>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -60,22 +280,25 @@
</form> </form>
</div> </div>
</div> </div>
<script src="/static/js/export_csv.js"></script>
<script> <!-- IP History Modal -->
document.addEventListener('DOMContentLoaded', function() { <div id="ip-history-modal" class="hidden fixed inset-0 bg-black/30 backdrop-blur-md flex items-center justify-center z-50">
const toggleBtn = document.getElementById('toggle-desc'); <div class="bg-gray-200 dark:bg-zinc-800 rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto shadow-2xl border border-gray-300 dark:border-zinc-700">
const descCols = document.querySelectorAll('.desc-col'); <div class="flex justify-between items-center mb-4">
const descHeader = document.getElementById('desc-col-header'); <h2 class="text-2xl font-bold">IP History: <span id="modal-ip-address" class="font-mono"></span></h2>
let shown = false; <button type="button" id="close-ip-history-modal" class="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:cursor-pointer text-2xl">&times;</button>
if (toggleBtn) { </div>
toggleBtn.addEventListener('click', function() { <div id="ip-history-content" class="space-y-3">
shown = !shown; <div class="text-center text-gray-600 dark:text-gray-400">Loading...</div>
descCols.forEach(col => col.classList.toggle('hidden', !shown)); </div>
if (descHeader) descHeader.classList.toggle('hidden', !shown); </div>
toggleBtn.textContent = shown ? 'Hide Descriptions' : 'Show Descriptions'; </div>
});
} <script src="/static/js/export_csv.min.js"></script>
}); <script src="/static/js/subnet.min.js"></script>
</script> <script src="/static/js/ip_history.min.js"></script>
{% if can_edit_subnet %}
<script src="/static/js/subnet_custom_fields.min.js"></script>
{% endif %}
</body> </body>
</html> </html>
+180
View File
@@ -0,0 +1,180 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tag 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 mx-4 py-8 pt-20">
<div class="container max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Tag Management</h1>
{% if can_add_tag %}
<button onclick="showAddTagModal()" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg font-medium">
<i class="fas fa-plus mr-2"></i>Add Tag
</button>
{% endif %}
</div>
{% if error %}
<div class="bg-red-200 dark:bg-red-800 text-red-800 dark:text-red-200 p-4 rounded-lg mb-6">
{{ error }}
</div>
{% endif %}
{% if tags %}
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-gray-600">
<th class="text-left p-3">Name</th>
<th class="text-left p-3">Colour</th>
<th class="text-left p-3">Description</th>
<th class="text-center p-3">Devices</th>
<th class="text-center p-3">Created</th>
<th class="text-center p-3">Actions</th>
</tr>
</thead>
<tbody>
{% for tag in tags %}
<tr class="border-b border-gray-600 hover:bg-gray-300 dark:hover:bg-zinc-700">
<td class="p-3">
<div class="flex items-center space-x-2">
<div class="w-4 h-4 rounded-full border border-gray-600" style="background-color: {{ tag.color }}"></div>
<span class="font-medium">{{ tag.name }}</span>
</div>
</td>
<td class="p-3">
<span class="font-mono text-sm">{{ tag.color }}</span>
</td>
<td class="p-3">
<span class="text-sm">{{ tag.description or '-' }}</span>
</td>
<td class="p-3 text-center">
{% if tag.device_count > 0 %}
<a href="/devices/tag/{{ tag.id }}" class="text-blue-400 hover:text-blue-600 hover:cursor-pointer">
{{ tag.device_count }}
</a>
{% else %}
<span class="text-gray-500">0</span>
{% endif %}
</td>
<td class="p-3 text-center text-sm">
{{ tag.created_at.strftime('%Y-%m-%d') if tag.created_at else '-' }}
</td>
<td class="p-3 text-center">
<div class="flex items-center justify-center space-x-2">
{% if can_edit_tag %}
<button class="edit-tag-btn text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer"
title="Edit Tag"
data-tag-id="{{ tag.id }}"
data-tag-name="{{ tag.name }}"
data-tag-color="{{ tag.color }}"
data-tag-description="{{ tag.description or '' }}">
<i class="fas fa-edit"></i>
</button>
{% endif %}
{% if can_delete_tag %}
<form action="/tags" method="POST" onsubmit="return confirm('Are you sure you want to delete this tag? This will remove it from all devices.');" class="inline">
<input type="hidden" name="action" value="delete_tag">
<input type="hidden" name="tag_id" value="{{ tag.id }}">
<button type="submit" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Delete Tag">
<i class="fas fa-trash"></i>
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
<div class="text-center py-8 text-gray-500">
<i class="fas fa-tags text-4xl mb-4"></i>
<p>No tags found. Add your first tag to get started.</p>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Add Tag Modal -->
<div id="add-tag-modal" class="hidden fixed inset-0 bg-black/30 backdrop-blur-md flex items-center justify-center z-50">
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-lg max-w-md w-full mx-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">Add New Tag</h2>
<button onclick="closeAddTagModal()" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-times"></i>
</button>
</div>
<form action="/tags" method="POST">
<input type="hidden" name="action" value="add_tag">
<div class="space-y-4">
<input type="text" name="name" id="add-tag-name" placeholder="Tag 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>
<div class="flex items-center space-x-2">
<label class="text-sm font-medium">Colour:</label>
<input type="color" name="color" id="add-tag-color" value="#6B7280"
class="w-12 h-10 border border-gray-600 rounded cursor-pointer">
<span id="add-color-preview" class="text-sm font-mono">#6B7280</span>
</div>
<textarea name="description" id="add-tag-description" placeholder="Description (optional)" rows="3"
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 resize-y"></textarea>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button type="button" onclick="closeAddTagModal()"
class="px-4 py-2 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
<button type="submit"
class="px-4 py-2 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Add Tag</button>
</div>
</form>
</div>
</div>
<!-- Edit Tag Modal -->
<div id="edit-tag-modal" class="hidden fixed inset-0 bg-black/30 backdrop-blur-md flex items-center justify-center z-50">
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-lg max-w-md w-full mx-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">Edit Tag</h2>
<button onclick="closeEditTagModal()" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-times"></i>
</button>
</div>
<form action="/tags" method="POST">
<input type="hidden" name="action" value="edit_tag">
<input type="hidden" name="tag_id" id="edit-tag-id">
<div class="space-y-4">
<input type="text" name="name" id="edit-tag-name" placeholder="Tag 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>
<div class="flex items-center space-x-2">
<label class="text-sm font-medium">Colour:</label>
<input type="color" name="color" id="edit-tag-color"
class="w-12 h-10 border border-gray-600 rounded cursor-pointer">
<span id="edit-color-preview" class="text-sm font-mono"></span>
</div>
<textarea name="description" id="edit-tag-description" placeholder="Description (optional)" rows="3"
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 resize-y"></textarea>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button type="button" onclick="closeEditTagModal()"
class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
<button type="submit"
class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Save Changes</button>
</div>
</form>
</div>
</div>
<script src="/static/js/tags.min.js"></script>
</body>
</html>
+27 -197
View File
@@ -127,7 +127,7 @@
</div> </div>
{% if can_manage_roles %} {% if can_manage_roles %}
<div class="flex space-x-2"> <div class="flex space-x-2">
<button type="button" onclick="editRole({{ role[0] }}, '{{ role[1]|replace("'", "\\'") }}', '{{ (role[2] or '')|replace("'", "\\'") }}'); return false;" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Edit Role"> <button type="button" onclick="editRole({{ role[0] }}, '{{ role[1]|replace("'", "\\'") }}', '{{ (role[2] or '')|replace("'", "\\'") }}', {{ 'true' if role[3] else 'false' }}); return false;" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Edit Role">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</button> </button>
<button type="button" onclick="deleteRole({{ role[0] }}, '{{ role[1]|replace("'", "\\'") }}'); return false;" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Delete Role"> <button type="button" onclick="deleteRole({{ role[0] }}, '{{ role[1]|replace("'", "\\'") }}'); return false;" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Delete Role">
@@ -211,6 +211,18 @@
<input type="text" name="role_name" placeholder="Role Name" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600" required> <input type="text" name="role_name" placeholder="Role Name" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600" required>
<input type="text" name="role_description" placeholder="Description (optional)" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600"> <input type="text" name="role_description" placeholder="Description (optional)" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600">
</div> </div>
<div class="mb-4">
<label class="flex items-center cursor-pointer">
<input type="checkbox" name="require_2fa" class="mr-2 w-4 h-4">
<span class="text-sm">
<i class="fas fa-shield-alt mr-1"></i>
Require Two-Factor Authentication for this role
</span>
</label>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1 ml-6">
Users with this role will be required to set up 2FA on their next login.
</p>
</div>
<div class="mb-4"> <div class="mb-4">
<h3 class="font-bold mb-3">Permissions</h3> <h3 class="font-bold mb-3">Permissions</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 max-h-96 overflow-y-auto border border-gray-600 rounded-lg p-4 bg-gray-300 dark:bg-zinc-900"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4 max-h-96 overflow-y-auto border border-gray-600 rounded-lg p-4 bg-gray-300 dark:bg-zinc-900">
@@ -322,6 +334,18 @@
<input type="text" name="role_name" id="edit-role-name" placeholder="Role Name" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600" required> <input type="text" name="role_name" id="edit-role-name" placeholder="Role Name" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600" required>
<input type="text" name="role_description" id="edit-role-description" placeholder="Description (optional)" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600"> <input type="text" name="role_description" id="edit-role-description" placeholder="Description (optional)" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600">
</div> </div>
<div class="mb-4">
<label class="flex items-center cursor-pointer">
<input type="checkbox" name="require_2fa" id="edit-role-require-2fa" class="mr-2 w-4 h-4">
<span class="text-sm">
<i class="fas fa-shield-alt mr-1"></i>
Require Two-Factor Authentication for this role
</span>
</label>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1 ml-6">
Users with this role will be required to set up 2FA on their next login.
</p>
</div>
<div class="mb-4"> <div class="mb-4">
<h3 class="font-bold mb-3">Permissions</h3> <h3 class="font-bold mb-3">Permissions</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 max-h-96 overflow-y-auto border border-gray-600 rounded-lg p-4 bg-gray-300 dark:bg-zinc-900" id="edit-role-permissions"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4 max-h-96 overflow-y-auto border border-gray-600 rounded-lg p-4 bg-gray-300 dark:bg-zinc-900" id="edit-role-permissions">
@@ -337,204 +361,10 @@
</div> </div>
<script> <script>
// Template variables passed from server - must be defined before users.js loads
const permissions = {{ permissions | tojson | safe }}; const permissions = {{ permissions | tojson | safe }};
const rolePermissions = {{ role_permissions | tojson | safe }}; const rolePermissions = {{ role_permissions | tojson | safe }};
function showTab(tab) {
document.getElementById('users-tab').classList.add('hidden');
document.getElementById('roles-tab').classList.add('hidden');
document.getElementById('tab-users').classList.remove('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
document.getElementById('tab-users').classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
document.getElementById('tab-roles').classList.remove('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
document.getElementById('tab-roles').classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
if (tab === 'users') {
document.getElementById('users-tab').classList.remove('hidden');
document.getElementById('tab-users').classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
document.getElementById('tab-users').classList.add('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
} else {
document.getElementById('roles-tab').classList.remove('hidden');
document.getElementById('tab-roles').classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
document.getElementById('tab-roles').classList.add('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
}
}
function editUser(userId, name, email, roleId, apiKey) {
document.getElementById('edit-user-id').value = userId;
document.getElementById('edit-user-name').value = name;
document.getElementById('edit-user-email').value = email;
document.getElementById('edit-user-password').value = '';
document.getElementById('edit-user-role').value = (roleId === null || roleId === 'null') ? '' : roleId;
document.getElementById('edit-user-api-key').textContent = apiKey || 'No API Key';
document.getElementById('edit-user-modal').classList.remove('hidden');
}
function closeEditUserModal() {
document.getElementById('edit-user-modal').classList.add('hidden');
}
function showAddRoleModal() {
// Make sure edit modal is closed first
document.getElementById('edit-role-modal').classList.add('hidden');
// Clear any form data
const addForm = document.querySelector('#add-role-modal form');
if (addForm) {
addForm.reset();
}
// Show add modal
document.getElementById('add-role-modal').classList.remove('hidden');
}
function closeAddRoleModal() {
document.getElementById('add-role-modal').classList.add('hidden');
}
function editRole(roleId, roleName, roleDescription) {
// Make sure add modal is closed first
document.getElementById('add-role-modal').classList.add('hidden');
document.getElementById('edit-role-id').value = roleId;
document.getElementById('edit-role-name').value = roleName;
document.getElementById('edit-role-description').value = roleDescription || '';
const permissionsDiv = document.getElementById('edit-role-permissions');
permissionsDiv.innerHTML = '';
const rolePerms = rolePermissions[roleId] || [];
// Group permissions by merged categories
const viewPerms = permissions.filter(p => p[3] === 'View');
const devicePerms = permissions.filter(p => p[3] === 'Device');
const deviceTypePerms = permissions.filter(p => p[3] === 'Device Type');
const subnetPerms = permissions.filter(p => p[3] === 'Subnet');
const dhcpPerms = permissions.filter(p => p[3] === 'DHCP');
const rackPerms = permissions.filter(p => p[3] === 'Rack');
const adminPerms = permissions.filter(p => p[3] === 'Admin');
let html = '';
// View Permissions
html += ' <!-- View Permissions -->\n';
html += ' <div class="col-span-full">\n';
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">View Permissions</h4>\n';
html += ' <div class="grid grid-cols-1 md:grid-cols-2 gap-2">\n';
viewPerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
html += ' </div>\n';
html += ' </div>\n';
html += ' \n';
// Device Management
html += ' <!-- Device Management -->\n';
html += ' <div>\n';
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Device Management</h4>\n';
devicePerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
deviceTypePerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
html += ' </div>\n';
html += ' \n';
// Network Management
html += ' <!-- Network Management -->\n';
html += ' <div>\n';
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Network Management</h4>\n';
subnetPerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
dhcpPerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
html += ' </div>\n';
html += ' \n';
// Rack Management
html += ' <!-- Rack Management -->\n';
html += ' <div>\n';
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Rack Management</h4>\n';
rackPerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
html += ' </div>\n';
html += ' \n';
// Admin
html += ' <!-- Admin -->\n';
html += ' <div>\n';
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Administration</h4>\n';
adminPerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
html += ' </div>\n';
permissionsDiv.innerHTML = html;
document.getElementById('edit-role-modal').classList.remove('hidden');
}
function closeEditRoleModal() {
document.getElementById('edit-role-modal').classList.add('hidden');
}
function deleteRole(roleId, roleName) {
if (confirm(`Are you sure you want to delete the role "${roleName}"?`)) {
const form = document.createElement('form');
form.method = 'POST';
form.action = '/users';
form.innerHTML = `
<input type="hidden" name="action" value="delete_role">
<input type="hidden" name="role_id" value="${roleId}">
`;
document.body.appendChild(form);
form.submit();
}
}
// Close modals when clicking outside
window.onclick = function(event) {
const editUserModal = document.getElementById('edit-user-modal');
const editRoleModal = document.getElementById('edit-role-modal');
const addRoleModal = document.getElementById('add-role-modal');
if (event.target === editUserModal) {
closeEditUserModal();
}
if (event.target === editRoleModal) {
closeEditRoleModal();
}
if (event.target === addRoleModal) {
closeAddRoleModal();
}
}
</script> </script>
<script src="/static/js/users.min.js"></script>
</body> </body>
</html> </html>
+120
View File
@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verify Two-Factor Authentication - {{ 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">
<i class="fas fa-shield-alt mr-2"></i>
Two-Factor Authentication
</h1>
<div class="space-y-4">
<p class="text-center text-gray-700 dark:text-gray-300">
Enter the 6-digit code from your authenticator app:
</p>
<form method="POST" class="space-y-4">
<div>
<input type="text" name="code" id="code" maxlength="10"
class="w-full p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border border-gray-600 text-center text-2xl font-mono tracking-widest"
placeholder="000000" required autofocus>
</div>
{% if error %}
<div class="bg-red-200 dark:bg-red-900 text-red-800 dark:text-red-200 p-3 rounded-lg">
<i class="fas fa-exclamation-circle mr-2"></i>{{ error }}
</div>
{% endif %}
<button type="submit" name="use_backup" value="false" class="w-full bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
<i class="fas fa-check"></i>
<span>Verify Code</span>
</button>
<div class="text-center">
<button type="button" onclick="toggleBackupMode()" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:underline hover:cursor-pointer">
Use backup code instead
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
let backupMode = false;
function toggleBackupMode() {
backupMode = !backupMode;
const codeInput = document.getElementById('code');
const form = codeInput.closest('form');
const submitBtn = form.querySelector('button[type="submit"]');
if (backupMode) {
codeInput.placeholder = "Enter backup code";
codeInput.maxLength = 20;
codeInput.pattern = "";
submitBtn.innerHTML = '<i class="fas fa-key"></i><span>Verify Backup Code</span>';
submitBtn.setAttribute('name', 'use_backup');
submitBtn.setAttribute('value', 'true');
} else {
codeInput.placeholder = "000000";
codeInput.maxLength = 10;
codeInput.pattern = "[0-9]{6}";
submitBtn.innerHTML = '<i class="fas fa-check"></i><span>Verify Code</span>';
submitBtn.removeAttribute('name');
submitBtn.removeAttribute('value');
}
codeInput.value = '';
codeInput.focus();
}
// Auto-submit on 6 digits for TOTP codes
const codeInput = document.getElementById('code');
let isSubmitting = false;
codeInput.addEventListener('input', function(e) {
if (!backupMode && !isSubmitting) {
this.value = this.value.replace(/[^0-9]/g, '');
if (this.value.length === 6 && this.value.trim() !== '') {
isSubmitting = true;
// Small delay to ensure value is set
setTimeout(() => {
if (this.value.length === 6 && this.value.trim() !== '') {
this.form.submit();
} else {
isSubmitting = false;
}
}, 100);
}
}
});
// Prevent form submission if code is empty
const form = codeInput.closest('form');
form.addEventListener('submit', function(e) {
const code = codeInput.value.trim();
if (!code) {
e.preventDefault();
alert('Please enter a verification code.');
return false;
}
if (!backupMode && (code.length !== 6 || !/^\d{6}$/.test(code))) {
e.preventDefault();
alert('Please enter a valid 6-digit code.');
return false;
}
});
</script>
</body>
</html>
+70
View File
@@ -0,0 +1,70 @@
import pyotp
import qrcode
import secrets
import json
import base64
from io import BytesIO
from flask import current_app
def generate_totp_secret():
"""Generate a new TOTP secret"""
return pyotp.random_base32()
def get_totp_uri(secret, email, issuer_name="IPAM"):
"""Generate TOTP URI for QR code"""
totp = pyotp.TOTP(secret)
return totp.provisioning_uri(
name=email,
issuer_name=issuer_name
)
def generate_qr_code(uri):
"""Generate QR code image from URI"""
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = BytesIO()
img.save(buffer, format='PNG')
buffer.seek(0)
return base64.b64encode(buffer.getvalue()).decode('utf-8')
def verify_totp(secret, code):
"""Verify a TOTP code"""
if not secret or not code:
return False
try:
totp = pyotp.TOTP(secret)
return totp.verify(code, valid_window=1) # Allow 1 time step window for clock skew
except Exception:
return False
def generate_backup_codes(count=10):
"""Generate backup codes for 2FA"""
return [secrets.token_urlsafe(8).upper() for _ in range(count)]
def verify_backup_code(backup_codes_json, code):
"""Verify a backup code and remove it if valid"""
if not backup_codes_json or not code:
return False, None
try:
codes = json.loads(backup_codes_json)
code_upper = code.upper().strip()
if code_upper in codes:
codes.remove(code_upper)
return True, json.dumps(codes) if codes else None
return False, None
except (json.JSONDecodeError, AttributeError):
return False, None
def format_backup_codes(codes):
"""Format backup codes for display (group in pairs)"""
formatted = []
for i in range(0, len(codes), 2):
if i + 1 < len(codes):
formatted.append(f"{codes[i]} {codes[i+1]}")
else:
formatted.append(codes[i])
return formatted