Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d1f0e38374 | |||
| 84d024f4c6 | |||
| 1fa28590b4 | |||
| 30a3ea66d5 | |||
| 6f2cfad65f | |||
| 2621d233f9 | |||
| af4997df5a | |||
| 1980fd04ba | |||
| d06d0c76c2 | |||
| 9244328da8 | |||
| 70489c3dac | |||
| 2a3ee1c8af | |||
| 8a01cb4755 | |||
| d85b409662 | |||
| 9dfea6c795 | |||
| 29cb46963c | |||
| ca7c5f77a4 | |||
| 9f28113573 | |||
| f4920cbee6 | |||
| c1b0a7084b | |||
| 9558baf84e | |||
| 5912bc6367 | |||
| 83c1b21c04 | |||
| a73ce91a2f | |||
| 71bce2989c | |||
| c7350aeb1f | |||
| 7e1c4b126e | |||
| 8b001a047b | |||
| b23cda48af | |||
| 53dc19a549 | |||
| 91067994ba | |||
| 21042b7fd7 | |||
| e028f9610c | |||
| e316a16386 | |||
| 181e2b2ca5 | |||
| 5037c1b578 | |||
| b5fa9ef6ae | |||
| 19e7e978aa | |||
| 64ae4be6d5 | |||
| d7fcffd4b5 | |||
| 283c445263 | |||
| 2af3584d80 | |||
| 59ded14858 | |||
| 9c0e6d035c | |||
| 8242e9d758 | |||
| 47208b31ee | |||
| f44b5327e4 | |||
| f1fb8bc7e9 | |||
| 286bf4b665 | |||
| fb6a3445a7 | |||
| 28267989b0 | |||
| 47f68fd27c | |||
| 3a9250f5b0 | |||
| 3e8965de6f | |||
| 707846bb3c | |||
| 69588d6518 | |||
| 1d9209a714 | |||
| 730b8701db | |||
| f0165985fc | |||
| f6795f5281 | |||
| 2163be8f79 | |||
| f98e92da06 | |||
| 61e3200207 |
@@ -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
@@ -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
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
name: Release Please
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release-please:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
release_created: ${{ steps.release.outputs.release_created }}
|
|
||||||
steps:
|
|
||||||
- uses: googleapis/release-please-action@v4
|
|
||||||
id: release
|
|
||||||
with:
|
|
||||||
manifest-file: .release-please-manifest.json
|
|
||||||
config-file: .release-please-config.json
|
|
||||||
|
|
||||||
- name: Checkout
|
|
||||||
if: ${{ steps.release.outputs.release_created }}
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
if: ${{ steps.release.outputs.release_created }}
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
if: ${{ steps.release.outputs.release_created }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Read version
|
|
||||||
if: ${{ steps.release.outputs.release_created }}
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
VERSION=$(cat VERSION)
|
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
||||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
|
||||||
if: ${{ steps.release.outputs.release_created }}
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
ghcr.io/jdb-net/ipam:${{ env.VERSION }}
|
|
||||||
ghcr.io/jdb-net/ipam:latest
|
|
||||||
build-args: |
|
|
||||||
VERSION=${{ env.VERSION }}
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
name: Deploy to Kubernetes
|
|
||||||
needs: release-please
|
|
||||||
if: ${{ needs.release-please.outputs.release_created }}
|
|
||||||
runs-on: [ k3s-lan-01 ]
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Apply manifests
|
|
||||||
run: |
|
|
||||||
sudo kubectl replace -f deployment.yml --grace-period=60 --force
|
|
||||||
@@ -2,3 +2,4 @@ __pycache__
|
|||||||
tailwindcss
|
tailwindcss
|
||||||
static/css/output.css
|
static/css/output.css
|
||||||
.env
|
.env
|
||||||
|
backups/
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"packages": {
|
|
||||||
".": {
|
|
||||||
"release-type": "simple",
|
|
||||||
"version-file": "VERSION"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
".": "1.5.1"
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
## [1.5.1](https://github.com/JDB-NET/ipam/compare/v1.5.0...v1.5.1) (2025-12-04)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* :bug: audit log on mobile ([6f01c99](https://github.com/JDB-NET/ipam/commit/6f01c9956f4a31414a082a779eb493735df0b8e6))
|
|
||||||
|
|
||||||
## [1.5.0](https://github.com/JDB-NET/ipam/compare/v1.4.2...v1.5.0) (2025-11-21)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* :sparkles: device tags ([ad1e576](https://github.com/JDB-NET/ipam/commit/ad1e576da42bf90c59347f7f7a4cce13c6842204))
|
|
||||||
|
|
||||||
## [1.4.2](https://github.com/JDB-NET/ipam/compare/v1.4.1...v1.4.2) (2025-11-08)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* :bug: ensure all fields are updated by api ([5c1ad03](https://github.com/JDB-NET/ipam/commit/5c1ad039904b2c8c8629242b5558b03da5ad782c))
|
|
||||||
|
|
||||||
## [1.4.1](https://github.com/JDB-NET/ipam/compare/v1.4.0...v1.4.1) (2025-11-06)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* :bug: pagination no longer gets out of control ([80b6de3](https://github.com/JDB-NET/ipam/commit/80b6de395fc4ddb4e7cd3ece89b423af2667d298))
|
|
||||||
* :bug: styling of admin and users pages ([d56e064](https://github.com/JDB-NET/ipam/commit/d56e0647f74fba1db1f504e02364406691ede9f3))
|
|
||||||
|
|
||||||
## [1.4.0](https://github.com/JDB-NET/ipam/compare/v1.3.0...v1.4.0) (2025-11-06)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* :sparkles: full api integration ([c53472c](https://github.com/JDB-NET/ipam/commit/c53472c5d760e28e53a737cb0546e85c9a422d15))
|
|
||||||
|
|
||||||
## [1.3.0](https://github.com/JDB-NET/ipam/compare/v1.2.0...v1.3.0) (2025-11-06)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* :sparkles: role based access control ([3bf2697](https://github.com/JDB-NET/ipam/commit/3bf269701030bc1f14a48c5af488286c424dbfa7))
|
|
||||||
|
|
||||||
## [1.2.0](https://github.com/JDB-NET/ipam/compare/v1.1.1...v1.2.0) (2025-11-06)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* :sparkles: added the ability to create/edit/remove device types ([d68eefc](https://github.com/JDB-NET/ipam/commit/d68eefcf0cc4a59cda9cedb3e126d974ee45d2ad))
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* :bug: missing button classes ([f93fa15](https://github.com/JDB-NET/ipam/commit/f93fa155eb5d6c9ff4ed19f332c3ad6fff328d31))
|
|
||||||
|
|
||||||
## [1.1.1](https://github.com/JDB-NET/ipam/compare/v1.1.0...v1.1.1) (2025-11-01)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* :bug: image name ([de123fa](https://github.com/JDB-NET/ipam/commit/de123fafd40d97ea6e545bd8dd1d3a812e2a709f))
|
|
||||||
|
|
||||||
## [1.1.0](https://github.com/JDB-NET/ipam/compare/v1.0.0...v1.1.0) (2025-11-01)
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* Added icon on login button. Closes [#1](https://github.com/JDB-NET/ipam/issues/1) ([6e068b6](https://github.com/JDB-NET/ipam/commit/6e068b672592f7d23ca66a0a6189b5763d89a698))
|
|
||||||
* Added light mode up to admin ([38c8402](https://github.com/JDB-NET/ipam/commit/38c840251f03c8f1e1a2c407efa77621df70ce2f))
|
|
||||||
* Rack stuff now complete ([5d220d3](https://github.com/JDB-NET/ipam/commit/5d220d354df83db8b2bfbf8e2c87bd78ba91f6e5))
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* Back buttons now hidden on mobile ([40a7a2f](https://github.com/JDB-NET/ipam/commit/40a7a2f2d58f6c89a7e7e74908c088e7eddf966a))
|
|
||||||
* Corrected image in deployment ([9ecd492](https://github.com/JDB-NET/ipam/commit/9ecd492065fcd226d274f8e343d401437e1c8de8))
|
|
||||||
* Fixed back button on device page ([9734e4d](https://github.com/JDB-NET/ipam/commit/9734e4df0b27461867393c132991f9e2ec907de4))
|
|
||||||
* Fixed database initialisation and dropped to 1 worker ([7cd6a0f](https://github.com/JDB-NET/ipam/commit/7cd6a0f96d8dc20743603d55498d8c1af8069690))
|
|
||||||
+5
-2
@@ -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"]
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="https://projects.jdbnet.co.uk/ipam/img/favicon.png" alt="IPAM" width="200" />
|
<img src="https://assets.jdbnet.co.uk/projects/ipam.png" alt="IPAM" width="200" />
|
||||||
|
|
||||||
# IP Address Management
|
# IP Address Management
|
||||||
</div>
|
</div>
|
||||||
@@ -32,6 +32,7 @@ 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 \
|
||||||
@@ -39,21 +40,19 @@ docker run -d \
|
|||||||
-e SECRET_KEY=your_secret_key \
|
-e SECRET_KEY=your_secret_key \
|
||||||
-e NAME="Your Organisation" \
|
-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
|
||||||
@@ -62,6 +61,8 @@ services:
|
|||||||
- SECRET_KEY=your_secret_key
|
- SECRET_KEY=your_secret_key
|
||||||
- NAME=Your Organisation
|
- NAME=Your Organisation
|
||||||
- LOGO_PNG=https://example.com/logo.png
|
- LOGO_PNG=https://example.com/logo.png
|
||||||
|
volumes:
|
||||||
|
- ./backups:/app/backups
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
@@ -246,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,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.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)
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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,72 @@ 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
|
# Create Tag table
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS Tag (
|
CREATE TABLE IF NOT EXISTS Tag (
|
||||||
@@ -223,6 +299,83 @@ def init_db(app=None):
|
|||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
|
# Create CustomFieldDefinition table
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS CustomFieldDefinition (
|
||||||
|
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
entity_type ENUM('device', 'subnet') NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
field_key VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
field_type VARCHAR(50) NOT NULL,
|
||||||
|
required BOOLEAN DEFAULT FALSE,
|
||||||
|
default_value TEXT,
|
||||||
|
help_text TEXT,
|
||||||
|
display_order INTEGER DEFAULT 0,
|
||||||
|
validation_rules TEXT,
|
||||||
|
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
|
||||||
@@ -278,6 +431,10 @@ def init_db(app=None):
|
|||||||
('assign_device_tag', 'Assign tag to device', 'Tag'),
|
('assign_device_tag', 'Assign tag to device', 'Tag'),
|
||||||
('remove_device_tag', 'Remove tag from device', 'Tag'),
|
('remove_device_tag', 'Remove tag from device', 'Tag'),
|
||||||
|
|
||||||
|
# Custom Fields permissions
|
||||||
|
('view_custom_fields', 'View custom fields', 'Custom Fields'),
|
||||||
|
('manage_custom_fields', 'Manage custom fields (add, edit, delete)', 'Custom Fields'),
|
||||||
|
|
||||||
# Admin permissions
|
# Admin permissions
|
||||||
('manage_users', 'Manage users (add, edit, delete)', 'Admin'),
|
('manage_users', 'Manage users (add, edit, delete)', 'Admin'),
|
||||||
('manage_roles', 'Manage roles and permissions', 'Admin'),
|
('manage_roles', 'Manage roles and permissions', 'Admin'),
|
||||||
@@ -339,7 +496,8 @@ def init_db(app=None):
|
|||||||
'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_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:
|
||||||
@@ -358,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_tags'
|
'view_dhcp', 'view_help', 'view_tags', 'view_custom_fields'
|
||||||
]
|
]
|
||||||
|
|
||||||
for perm_name in view_only_permissions:
|
for perm_name in view_only_permissions:
|
||||||
@@ -388,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()
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: ipam
|
|
||||||
namespace: ipam
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: ipam
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: ipam
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: ipam
|
|
||||||
image: ghcr.io/jdb-net/ipam:latest
|
|
||||||
imagePullPolicy: Always
|
|
||||||
ports:
|
|
||||||
- containerPort: 5000
|
|
||||||
name: "ipam"
|
|
||||||
env:
|
|
||||||
- name: SECRET_KEY
|
|
||||||
value: "41TbN7v5peFLZPrdwSCc64J3mjmiUk5fkVWsmb2m"
|
|
||||||
- name: MYSQL_HOST
|
|
||||||
value: "10.10.2.27"
|
|
||||||
- name: MYSQL_USER
|
|
||||||
value: "ipam"
|
|
||||||
- name: MYSQL_PASSWORD
|
|
||||||
value: "WXPmo05sGCfjGe"
|
|
||||||
- name: MYSQL_DATABASE
|
|
||||||
value: "ipam"
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: ipam-ingress-service
|
|
||||||
namespace: ipam
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: ipam
|
|
||||||
ports:
|
|
||||||
- protocol: TCP
|
|
||||||
port: 80
|
|
||||||
targetPort: 5000
|
|
||||||
---
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: ipam-ingress
|
|
||||||
namespace: ipam
|
|
||||||
spec:
|
|
||||||
rules:
|
|
||||||
- host: ipam.jdb143.uk
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- pathType: Prefix
|
|
||||||
path: "/"
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: ipam-ingress-service
|
|
||||||
port:
|
|
||||||
number: 80
|
|
||||||
@@ -2,3 +2,7 @@ Flask
|
|||||||
mysql-connector-python
|
mysql-connector-python
|
||||||
dotenv
|
dotenv
|
||||||
gunicorn
|
gunicorn
|
||||||
|
requests
|
||||||
|
pyotp
|
||||||
|
qrcode[pil]
|
||||||
|
Flask-Limiter
|
||||||
@@ -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
|
||||||
Vendored
+1
@@ -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,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 {
|
||||||
|
|||||||
Vendored
+1
@@ -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}
|
||||||
Vendored
+1
@@ -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)}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Vendored
+1
@@ -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()};
|
||||||
Vendored
+1
@@ -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()});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Vendored
+1
@@ -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()})});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Vendored
+1
@@ -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)})}
|
||||||
@@ -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>`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
Vendored
+1
@@ -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>`})})});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -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")}))});
|
||||||
Vendored
+6
@@ -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();
|
||||||
@@ -30,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>';
|
||||||
|
|||||||
Vendored
+14
@@ -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"})})});
|
||||||
Vendored
+1
@@ -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`})});
|
||||||
+88
-4
@@ -1,12 +1,96 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const navToggle = document.getElementById('nav-toggle');
|
const navToggle = document.getElementById('nav-toggle');
|
||||||
const mobileNav = document.getElementById('mobile-nav');
|
const mobileNav = document.getElementById('mobile-nav');
|
||||||
navToggle.addEventListener('click', function() {
|
const searchModal = document.getElementById('search-modal');
|
||||||
mobileNav.classList.toggle('hidden');
|
const searchModalOpen = document.getElementById('search-modal-open');
|
||||||
});
|
const searchModalOpenMobile = document.getElementById('search-modal-open-mobile');
|
||||||
|
const searchModalClose = document.getElementById('search-modal-close');
|
||||||
|
const searchModalBackdrop = document.getElementById('search-modal-backdrop');
|
||||||
|
const searchModalInput = document.getElementById('search-modal-input');
|
||||||
|
|
||||||
|
if (navToggle && mobileNav) {
|
||||||
|
navToggle.addEventListener('click', function() {
|
||||||
|
mobileNav.classList.toggle('hidden');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSearchModal() {
|
||||||
|
if (!searchModal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchModal.classList.remove('hidden');
|
||||||
|
searchModal.classList.add('flex');
|
||||||
|
document.body.classList.add('overflow-hidden');
|
||||||
|
|
||||||
|
if (mobileNav) {
|
||||||
|
mobileNav.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(function() {
|
||||||
|
if (searchModalInput) {
|
||||||
|
searchModalInput.focus();
|
||||||
|
searchModalInput.select();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSearchModal() {
|
||||||
|
if (!searchModal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchModal.classList.add('hidden');
|
||||||
|
searchModal.classList.remove('flex');
|
||||||
|
document.body.classList.remove('overflow-hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchModalOpen) {
|
||||||
|
searchModalOpen.addEventListener('click', openSearchModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchModalOpenMobile) {
|
||||||
|
searchModalOpenMobile.addEventListener('click', openSearchModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchModalClose) {
|
||||||
|
searchModalClose.addEventListener('click', closeSearchModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchModalBackdrop) {
|
||||||
|
searchModalBackdrop.addEventListener('click', closeSearchModal);
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
if (!mobileNav.contains(e.target) && !navToggle.contains(e.target)) {
|
if (mobileNav && navToggle && !mobileNav.contains(e.target) && !navToggle.contains(e.target)) {
|
||||||
mobileNav.classList.add('hidden');
|
mobileNav.classList.add('hidden');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
const target = e.target;
|
||||||
|
const isEditableTarget = target && (
|
||||||
|
target.tagName === 'INPUT' ||
|
||||||
|
target.tagName === 'TEXTAREA' ||
|
||||||
|
target.tagName === 'SELECT' ||
|
||||||
|
target.isContentEditable
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
e.key === '/' &&
|
||||||
|
!e.ctrlKey &&
|
||||||
|
!e.metaKey &&
|
||||||
|
!e.altKey &&
|
||||||
|
searchModal &&
|
||||||
|
!isEditableTarget
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
openSearchModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeSearchModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("nav-toggle"),t=document.getElementById("mobile-nav"),o=document.getElementById("search-modal"),n=document.getElementById("search-modal-open"),d=document.getElementById("search-modal-open-mobile"),c=document.getElementById("search-modal-close"),l=document.getElementById("search-modal-backdrop"),a=document.getElementById("search-modal-input");function s(){o&&(o.classList.remove("hidden"),o.classList.add("flex"),document.body.classList.add("overflow-hidden"),t&&t.classList.add("hidden"),setTimeout(function(){a&&(a.focus(),a.select())},0))}function i(){o&&(o.classList.add("hidden"),o.classList.remove("flex"),document.body.classList.remove("overflow-hidden"))}e&&t&&e.addEventListener("click",function(){t.classList.toggle("hidden")}),n&&n.addEventListener("click",s),d&&d.addEventListener("click",s),c&&c.addEventListener("click",i),l&&l.addEventListener("click",i),document.addEventListener("click",function(o){t&&e&&!t.contains(o.target)&&!e.contains(o.target)&&t.classList.add("hidden")}),document.addEventListener("keydown",function(e){let t=e.target,n=t&&("INPUT"===t.tagName||"TEXTAREA"===t.tagName||"SELECT"===t.tagName||t.isContentEditable);if("/"===e.key&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&o&&!n)return e.preventDefault(),void s();"Escape"===e.key&&i()})});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Vendored
+20
@@ -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"))})});
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
Vendored
+1
@@ -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()}});
|
||||||
Vendored
+1
@@ -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"))})})});
|
||||||
+231
-28
@@ -1,38 +1,66 @@
|
|||||||
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) {
|
||||||
form.addEventListener('submit', (event) => {
|
// Check if search input already exists to prevent duplicates
|
||||||
event.preventDefault();
|
if (!document.querySelector('input[placeholder="Search by IP or Hostname"]')) {
|
||||||
});
|
form.addEventListener('submit', (event) => {
|
||||||
|
|
||||||
const searchInput = document.createElement('input');
|
|
||||||
searchInput.type = 'text';
|
|
||||||
searchInput.placeholder = 'Search by IP or Hostname';
|
|
||||||
searchInput.className = 'p-2 w-full rounded-lg bg-gray-200 dark:bg-zinc-800 border border-gray-600 focus:outline-none focus:border-blue-400 mb-4 text-center';
|
|
||||||
form.insertAdjacentElement('beforebegin', searchInput);
|
|
||||||
|
|
||||||
searchInput.addEventListener('keypress', (event) => {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const searchTerm = searchInput.value.toLowerCase();
|
});
|
||||||
const rows = document.querySelectorAll('tbody tr');
|
|
||||||
|
|
||||||
rows.forEach(row => {
|
const searchInput = document.createElement('input');
|
||||||
const ipCell = row.querySelector('td:nth-child(1)').textContent.toLowerCase();
|
searchInput.type = 'text';
|
||||||
const hostnameCell = row.querySelector('td:nth-child(2)').textContent.toLowerCase();
|
searchInput.placeholder = 'Search by IP or Hostname';
|
||||||
|
searchInput.className = 'p-2 w-full rounded-lg bg-gray-200 dark:bg-zinc-800 border border-gray-600 focus:outline-none focus:border-blue-400 mb-4 text-center';
|
||||||
|
form.insertAdjacentElement('beforebegin', searchInput);
|
||||||
|
|
||||||
if (ipCell.includes(searchTerm) || hostnameCell.includes(searchTerm)) {
|
searchInput.addEventListener('keypress', (event) => {
|
||||||
row.style.backgroundColor = 'rgba(59, 130, 246, 0.5)';
|
if (event.key === 'Enter') {
|
||||||
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
event.preventDefault();
|
||||||
|
const searchTerm = searchInput.value.toLowerCase();
|
||||||
|
const rows = document.querySelectorAll('tbody tr');
|
||||||
|
|
||||||
setTimeout(() => {
|
rows.forEach(row => {
|
||||||
|
const ipCell = row.querySelector('td:nth-child(1)').textContent.toLowerCase();
|
||||||
|
const hostnameCell = row.querySelector('td:nth-child(2)').textContent.toLowerCase();
|
||||||
|
const descCell = row.querySelector('td:nth-child(3)');
|
||||||
|
const descText = descCell ? descCell.textContent.toLowerCase() : '';
|
||||||
|
|
||||||
|
if (ipCell.includes(searchTerm) || hostnameCell.includes(searchTerm) || descText.includes(searchTerm)) {
|
||||||
|
row.style.backgroundColor = 'rgba(59, 130, 246, 0.5)';
|
||||||
|
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
row.style.backgroundColor = '';
|
||||||
|
}, 3000);
|
||||||
|
} else {
|
||||||
row.style.backgroundColor = '';
|
row.style.backgroundColor = '';
|
||||||
}, 3000);
|
}
|
||||||
} else {
|
});
|
||||||
row.style.backgroundColor = '';
|
}
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Description toggle functionality
|
||||||
|
const toggleBtn = document.getElementById('toggle-desc');
|
||||||
|
const descCols = document.querySelectorAll('.desc-col');
|
||||||
|
const descHeader = document.getElementById('desc-col-header');
|
||||||
|
let shown = false;
|
||||||
|
if (toggleBtn) {
|
||||||
|
toggleBtn.addEventListener('click', function() {
|
||||||
|
shown = !shown;
|
||||||
|
descCols.forEach(col => col.classList.toggle('hidden', !shown));
|
||||||
|
if (descHeader) descHeader.classList.toggle('hidden', !shown);
|
||||||
|
toggleBtn.textContent = shown ? 'Hide Descriptions' : 'Show Descriptions';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Vendored
+14
@@ -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())})})});
|
||||||
@@ -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
@@ -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()})});
|
||||||
Vendored
+1
@@ -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()};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
Vendored
+1
@@ -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)})});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Vendored
+32
@@ -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()};
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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">
|
||||||
|
|||||||
+85
-69
@@ -42,7 +42,7 @@
|
|||||||
</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') %}
|
{% 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">
|
<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">
|
<div class="flex items-center space-x-4">
|
||||||
<i class="fas fa-tags text-3xl text-gray-600 dark:text-gray-400"></i>
|
<i class="fas fa-tags text-3xl text-gray-600 dark:text-gray-400"></i>
|
||||||
@@ -54,6 +54,18 @@
|
|||||||
<i class="fas fa-chevron-right text-gray-400"></i>
|
<i class="fas fa-chevron-right text-gray-400"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% 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">
|
<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">
|
<div class="flex items-center space-x-4">
|
||||||
<i class="fas fa-code text-3xl text-gray-600 dark:text-gray-400"></i>
|
<i class="fas fa-code text-3xl text-gray-600 dark:text-gray-400"></i>
|
||||||
@@ -64,6 +76,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<i class="fas fa-chevron-right text-gray-400"></i>
|
<i class="fas fa-chevron-right text-gray-400"></i>
|
||||||
</a>
|
</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 -->
|
||||||
@@ -85,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>
|
||||||
@@ -96,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 %}
|
||||||
@@ -128,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>
|
||||||
|
|
||||||
@@ -145,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>
|
||||||
@@ -170,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>
|
||||||
@@ -180,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>
|
||||||
|
|||||||
@@ -177,6 +177,7 @@
|
|||||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
<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</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}</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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -325,6 +326,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/api_docs.js"></script>
|
<script src="/static/js/api_docs.min.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+102
-47
@@ -13,43 +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">
|
||||||
{% for user in users %}
|
<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">
|
||||||
<option value="{{ user[0] }}" {% if request.args.get('user_id') == user[0]|string %}selected{% endif %}>{{ user[1] }}</option>
|
<h2 class="text-lg font-semibold">Filters</h2>
|
||||||
{% endfor %}
|
<i class="fas fa-chevron-down transition-transform duration-200 transform" id="filter-arrow"></i>
|
||||||
</select>
|
|
||||||
<select name="subnet_id" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
|
|
||||||
<option value="">All Subnets</option>
|
|
||||||
{% for subnet in subnets %}
|
|
||||||
<option value="{{ subnet[0] }}" {% if request.args.get('subnet_id') == subnet[0]|string %}selected{% endif %}>{{ subnet[1] }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<select name="action" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
|
|
||||||
<option value="">All Actions</option>
|
|
||||||
{% for a in actions %}
|
|
||||||
<option value="{{ a }}" {% if request.args.get('action') == a %}selected{% endif %}>{{ a }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<select name="device_name" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
|
|
||||||
<option value="">All Devices</option>
|
|
||||||
{% for device in devices %}
|
|
||||||
<option value="{{ device[0] }}" {% if request.args.get('device_name') == device[0] %}selected{% endif %}>{{ device[0] }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
|
|
||||||
<i class="fas fa-search"></i>
|
|
||||||
<span>Filter</span>
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
|
||||||
|
<!-- Advanced Filter Form -->
|
||||||
|
<form method="GET" id="audit-filter-form" class="px-4 pb-4 {% if not (search_query or selected_user_ids or request.args.get('subnet_id') or request.args.get('action') or request.args.get('device_name') or date_from or date_to or request.args.get('expand_filters')) %}hidden{% endif %}">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4">
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="lg:col-span-3">
|
||||||
|
<label class="block text-sm font-medium mb-1">Search</label>
|
||||||
|
<input type="text" name="search" value="{{ search_query or '' }}" placeholder="Search in details, user, action, subnet..." class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Multiple Users -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">Users</label>
|
||||||
|
<select name="user_ids" multiple size="5" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
|
||||||
|
{% for user in users %}
|
||||||
|
<option value="{{ user[0] }}" {% if selected_user_ids and user[0]|string in selected_user_ids %}selected{% endif %}>{{ user[1] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Hold Ctrl/Cmd to select multiple</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subnet -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">Subnet</label>
|
||||||
|
<select name="subnet_id" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
|
||||||
|
<option value="">All Subnets</option>
|
||||||
|
{% for subnet in subnets %}
|
||||||
|
<option value="{{ subnet[0] }}" {% if request.args.get('subnet_id') == subnet[0]|string %}selected{% endif %}>{{ subnet[1] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">Action</label>
|
||||||
|
<select name="action" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
|
||||||
|
<option value="">All Actions</option>
|
||||||
|
{% for a in actions %}
|
||||||
|
<option value="{{ a }}" {% if request.args.get('action') == a %}selected{% endif %}>{{ a }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Device Name -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">Device</label>
|
||||||
|
<select name="device_name" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
|
||||||
|
<option value="">All Devices</option>
|
||||||
|
{% for device in devices %}
|
||||||
|
<option value="{{ device[0] }}" {% if request.args.get('device_name') == device[0] %}selected{% endif %}>{{ device[0] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date From -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">Date From</label>
|
||||||
|
<input type="date" name="date_from" value="{{ date_from or '' }}" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date To -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">Date To</label>
|
||||||
|
<input type="date" name="date_to" value="{{ date_to or '' }}" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 justify-center">
|
||||||
|
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
<span>Filter</span>
|
||||||
|
</button>
|
||||||
|
<a href="/audit?expand_filters=1" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
<span>Clear</span>
|
||||||
|
</a>
|
||||||
|
<button type="button" id="export-btn" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
|
||||||
|
<i class="fas fa-file-csv"></i>
|
||||||
|
<span>Export CSV</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Audit Log Table -->
|
||||||
<div class="overflow-x-auto">
|
<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>
|
||||||
@@ -59,7 +121,9 @@
|
|||||||
<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>
|
||||||
@@ -67,12 +131,13 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
||||||
@@ -87,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 %}
|
||||||
@@ -97,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 #}
|
||||||
@@ -107,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>
|
||||||
@@ -122,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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 Device 2 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>
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
+171
-2
@@ -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>
|
||||||
@@ -75,7 +75,47 @@
|
|||||||
</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 -->
|
<!-- Tags Section -->
|
||||||
|
{% if is_feature_enabled('device_tags') %}
|
||||||
<div class="tags-section mb-6">
|
<div class="tags-section mb-6">
|
||||||
<h3 class="text-lg font-bold mb-2">Tags:</h3>
|
<h3 class="text-lg font-bold mb-2">Tags:</h3>
|
||||||
<div class="flex flex-wrap gap-2 mb-4">
|
<div class="flex flex-wrap gap-2 mb-4">
|
||||||
@@ -116,6 +156,7 @@
|
|||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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 }}">
|
||||||
@@ -123,8 +164,136 @@
|
|||||||
<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>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/js/device.js"></script>
|
<script src="/static/js/device.min.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -7,24 +7,25 @@
|
|||||||
<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>
|
||||||
|
|
||||||
<!-- Filters Section -->
|
<!-- Filters Section -->
|
||||||
<div class="mb-6 space-y-4">
|
<div class="mb-6 space-y-4">
|
||||||
<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">
|
|
||||||
|
|
||||||
<!-- Tag Filter -->
|
<!-- Tag Filter -->
|
||||||
{% if all_tag_names %}
|
{% if is_feature_enabled('device_tags') and all_tag_names %}
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<label class="text-sm font-medium">Filter by tag:</label>
|
<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">
|
<select id="tag-filter" class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600">
|
||||||
@@ -68,6 +69,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Tags -->
|
<!-- Tags -->
|
||||||
|
{% if is_feature_enabled('device_tags') %}
|
||||||
{% set tags = device_tags.get(device.id, []) %}
|
{% set tags = device_tags.get(device.id, []) %}
|
||||||
{% if tags %}
|
{% if tags %}
|
||||||
<div class="flex flex-wrap gap-1 mt-2">
|
<div class="flex flex-wrap gap-1 mt-2">
|
||||||
@@ -79,6 +81,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -88,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>
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
+151
-40
@@ -1,67 +1,178 @@
|
|||||||
<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 flex items-center justify-between relative">
|
||||||
<a href="/" class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3 flex-shrink-0">
|
||||||
<img src="{{ LOGO_PNG }}" alt="Logo" class="h-8 rounded">
|
<a href="/" class="flex items-center space-x-3">
|
||||||
<span class="text-2xl font-bold text-white">{{ NAME }} IPAM <span class="text-sm font-normal text-gray-300">v{{ VERSION }}</span></span>
|
<img src="{{ LOGO_PNG }}" alt="Logo" class="h-8 rounded">
|
||||||
</a>
|
<span class="text-2xl font-bold text-white whitespace-nowrap">{{ NAME }} IPAM</span>
|
||||||
<div class="hidden lg:block absolute left-1/2 transform -translate-x-1/2 text-white text-lg font-medium whitespace-nowrap">
|
</a>
|
||||||
{% if current_user_name %}Hello, {{ current_user_name }}{% endif %}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<nav class="hidden md:flex items-center space-x-6" id="main-nav">
|
<nav class="hidden lg:flex items-center space-x-6 flex-shrink-0" id="main-nav">
|
||||||
|
{% if current_user_name %}
|
||||||
|
<button type="button" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2 hover:cursor-pointer" id="search-modal-open" aria-label="Open search modal">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
<span>Search</span>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
{% 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">
|
<div class="lg:hidden flex items-center gap-3 flex-shrink-0">
|
||||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
{% if current_user_name %}
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
<button class="flex items-center text-gray-200 hover:cursor-pointer focus:outline-none" id="search-modal-open-mobile" aria-label="Open search modal">
|
||||||
</svg>
|
<i class="fas fa-search text-xl"></i>
|
||||||
</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">
|
{% endif %}
|
||||||
|
<button class="flex items-center text-gray-200 hover:cursor-pointer focus:outline-none" id="nav-toggle" aria-label="Open navigation menu">
|
||||||
|
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
{% 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>
|
|
||||||
|
{% if current_user_name %}
|
||||||
|
<div id="search-modal" class="hidden fixed inset-0 z-50 items-center justify-center p-4">
|
||||||
|
<div id="search-modal-backdrop" class="absolute inset-0 bg-black/60"></div>
|
||||||
|
<div class="relative w-full max-w-2xl rounded-lg bg-zinc-800 border border-zinc-700 shadow-xl">
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 border-b border-zinc-700">
|
||||||
|
<h3 class="text-white font-semibold text-lg">Search</h3>
|
||||||
|
<button type="button" id="search-modal-close" class="text-gray-300 hover:text-white hover:cursor-pointer" aria-label="Close search modal">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form action="/search" method="GET" class="p-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="text" name="q" id="search-modal-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"
|
||||||
|
value="{{ request.args.get('q', '') }}">
|
||||||
|
<button type="submit" class="text-gray-200 hover:text-gray-400 hover:cursor-pointer flex-shrink-0" aria-label="Submit search">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|||||||
@@ -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
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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
@@ -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">×</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>
|
||||||
+2
-2
@@ -58,7 +58,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="p-3 text-center">
|
<td class="p-3 text-center">
|
||||||
{% if tag.device_count > 0 %}
|
{% if tag.device_count > 0 %}
|
||||||
<a href="/api/v1/devices/by-tag/{{ tag.name }}" class="text-blue-400 hover:text-blue-600">
|
<a href="/devices/tag/{{ tag.id }}" class="text-blue-400 hover:text-blue-600 hover:cursor-pointer">
|
||||||
{{ tag.device_count }}
|
{{ tag.device_count }}
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -175,6 +175,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/tags.js"></script>
|
<script src="/static/js/tags.min.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+27
-197
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
Reference in New Issue
Block a user