Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 6eb5000c27 | |||
| 9ecd4f3977 | |||
| 6f01c9956f | |||
| 671b750bc4 | |||
| bc1078f673 | |||
| ad1e576da4 | |||
| 0029abb8cd | |||
| ee72a89287 | |||
| 5c1ad03990 | |||
| 4b21fdc5cf | |||
| b381195200 | |||
| 80b6de395f | |||
| d56e0647f7 | |||
| 8909834a19 | |||
| 59662ec4d8 | |||
| e0b6c22c1f | |||
| c53472c5d7 | |||
| fdd8b36fbf | |||
| 0efa310d50 | |||
| 3bf2697010 | |||
| 73a94943cf | |||
| d35873c04f | |||
| f93fa155eb | |||
| d68eefcf0c |
@@ -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",
|
||||||
|
|||||||
@@ -1,2 +1,51 @@
|
|||||||
|
# Documentation
|
||||||
|
README.md
|
||||||
|
CHANGELOG.md
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# Deployment files
|
||||||
deployment.yml
|
deployment.yml
|
||||||
run.sh
|
run.sh
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
|
||||||
|
# Python cache
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Build tools
|
||||||
|
tailwindcss
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
name: Deploy to Kubernetes
|
name: Deploy to Kubernetes
|
||||||
needs: release-please
|
needs: release-please
|
||||||
if: ${{ needs.release-please.outputs.release_created }}
|
if: ${{ needs.release-please.outputs.release_created }}
|
||||||
runs-on: [ k3s-lan-01 ]
|
runs-on: [ k3s-internal-htz-01 ]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ __pycache__
|
|||||||
tailwindcss
|
tailwindcss
|
||||||
static/css/output.css
|
static/css/output.css
|
||||||
.env
|
.env
|
||||||
|
backups/
|
||||||
@@ -1,10 +1,54 @@
|
|||||||
{
|
{
|
||||||
"packages": {
|
"packages": {
|
||||||
".": {
|
".": {
|
||||||
"release-type": "simple",
|
"release-type": "simple",
|
||||||
"version-file": "VERSION"
|
"version-file": "VERSION",
|
||||||
}
|
"extra-files": [
|
||||||
},
|
"CHANGELOG.md"
|
||||||
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
|
],
|
||||||
}
|
"changelog-sections": [
|
||||||
|
{
|
||||||
|
"type": "feat",
|
||||||
|
"section": "Features"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "fix",
|
||||||
|
"section": "Bug Fixes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "refactor",
|
||||||
|
"section": "Refactoring"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "style",
|
||||||
|
"section": "Style Changes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "perf",
|
||||||
|
"section": "Performance Improvements"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "docs",
|
||||||
|
"section": "Documentation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "test",
|
||||||
|
"section": "Tests"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "build",
|
||||||
|
"section": "Build System"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "ci",
|
||||||
|
"section": "CI/CD"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "chore",
|
||||||
|
"section": "Chores"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "1.1.1"
|
".": "1.8.0"
|
||||||
}
|
}
|
||||||
+118
@@ -1,5 +1,123 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.8.0](https://github.com/JDB-NET/ipam/compare/v1.7.0...v1.8.0) (2025-12-23)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* :sparkles: get next available ip by api ([64ae4be](https://github.com/JDB-NET/ipam/commit/64ae4be6d5997ff0b16ff5232237d38f2fec5b64))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* :bug: global search missing from devices ([283c445](https://github.com/JDB-NET/ipam/commit/283c445263b7dc992448d907e682e53b7720b610))
|
||||||
|
|
||||||
|
|
||||||
|
### Build System
|
||||||
|
|
||||||
|
* :rocket: redeploy ([d7fcffd](https://github.com/JDB-NET/ipam/commit/d7fcffd4b5598b682dede864ba526b1257584f6a))
|
||||||
|
|
||||||
|
## [1.7.0](https://github.com/JDB-NET/ipam/compare/v1.6.1...v1.7.0) (2025-12-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* :sparkles: add devices by tag page ([9c0e6d0](https://github.com/JDB-NET/ipam/commit/9c0e6d035c8dda68281b2bfe2b7a61802353f7a7))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* :bug: invalidate cache when device type is added ([47208b3](https://github.com/JDB-NET/ipam/commit/47208b31eed51f0cf0d7c8c411093bda1c84cf1b))
|
||||||
|
* :bug: invalidate linked cache ([8242e9d](https://github.com/JDB-NET/ipam/commit/8242e9d758ef19030b516e4a51f0cfb556f4e5ba))
|
||||||
|
|
||||||
|
## [1.6.1](https://github.com/JDB-NET/ipam/compare/v1.6.0...v1.6.1) (2025-12-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* :bug: invalidate subnet cache when device is deleted ([286bf4b](https://github.com/JDB-NET/ipam/commit/286bf4b665e6352dea7b14753f080fa5cabb7926))
|
||||||
|
|
||||||
|
## [1.6.0](https://github.com/JDB-NET/ipam/compare/v1.5.1...v1.6.0) (2025-12-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* :sparkles: backup and restore ([707846b](https://github.com/JDB-NET/ipam/commit/707846bb3c717df9223ea7103e29efc6e671e16d))
|
||||||
|
* :sparkles: bulk operations ([2163be8](https://github.com/JDB-NET/ipam/commit/2163be8f79b579e38944a689915a18d5c35f8d3a))
|
||||||
|
* :sparkles: global search ([3e8965d](https://github.com/JDB-NET/ipam/commit/3e8965de6f19b3b382e236b08df685401205f356))
|
||||||
|
* :sparkles: in memory cache ([3a9250f](https://github.com/JDB-NET/ipam/commit/3a9250f5b0c14bfc6a807fe2948bbc852a652047))
|
||||||
|
* :sparkles: subnet utilisation stats ([f98e92d](https://github.com/JDB-NET/ipam/commit/f98e92da062640d47bec3516def0efde3aebd058))
|
||||||
|
* :sparkles: update available notification ([730b870](https://github.com/JDB-NET/ipam/commit/730b8701db81f5e03760a25209baeab2f81116fa))
|
||||||
|
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
* :art: database indexing and optimisation ([47f68fd](https://github.com/JDB-NET/ipam/commit/47f68fd27cf62d0e0d2af55089bc0556043c12ff))
|
||||||
|
* :art: header link to github releases ([61e3200](https://github.com/JDB-NET/ipam/commit/61e320020724e437d8a607e7341b12b2fe6f794d))
|
||||||
|
* :art: improved audit log filtering ([f016598](https://github.com/JDB-NET/ipam/commit/f0165985fc194fd3a3e460b52447a5511908ed91))
|
||||||
|
* :art: js ([1d9209a](https://github.com/JDB-NET/ipam/commit/1d9209a714a6d0b7d1901b6e3470f5265e0171a6))
|
||||||
|
* :art: tidy nav bar ([69588d6](https://github.com/JDB-NET/ipam/commit/69588d6518571d8de55c718c14176bb78cb19ee1))
|
||||||
|
|
||||||
|
|
||||||
|
### CI/CD
|
||||||
|
|
||||||
|
* :rocket: include all commit types ([f6795f5](https://github.com/JDB-NET/ipam/commit/f6795f52815a2d599840c8ed83c99ad690a046c8))
|
||||||
|
|
||||||
|
## [1.5.1](https://github.com/JDB-NET/ipam/compare/v1.5.0...v1.5.1) (2025-12-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* :bug: audit log on mobile ([6f01c99](https://github.com/JDB-NET/ipam/commit/6f01c9956f4a31414a082a779eb493735df0b8e6))
|
||||||
|
|
||||||
|
## [1.5.0](https://github.com/JDB-NET/ipam/compare/v1.4.2...v1.5.0) (2025-11-21)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* :sparkles: device tags ([ad1e576](https://github.com/JDB-NET/ipam/commit/ad1e576da42bf90c59347f7f7a4cce13c6842204))
|
||||||
|
|
||||||
|
## [1.4.2](https://github.com/JDB-NET/ipam/compare/v1.4.1...v1.4.2) (2025-11-08)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* :bug: ensure all fields are updated by api ([5c1ad03](https://github.com/JDB-NET/ipam/commit/5c1ad039904b2c8c8629242b5558b03da5ad782c))
|
||||||
|
|
||||||
|
## [1.4.1](https://github.com/JDB-NET/ipam/compare/v1.4.0...v1.4.1) (2025-11-06)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* :bug: pagination no longer gets out of control ([80b6de3](https://github.com/JDB-NET/ipam/commit/80b6de395fc4ddb4e7cd3ece89b423af2667d298))
|
||||||
|
* :bug: styling of admin and users pages ([d56e064](https://github.com/JDB-NET/ipam/commit/d56e0647f74fba1db1f504e02364406691ede9f3))
|
||||||
|
|
||||||
|
## [1.4.0](https://github.com/JDB-NET/ipam/compare/v1.3.0...v1.4.0) (2025-11-06)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* :sparkles: full api integration ([c53472c](https://github.com/JDB-NET/ipam/commit/c53472c5d760e28e53a737cb0546e85c9a422d15))
|
||||||
|
|
||||||
|
## [1.3.0](https://github.com/JDB-NET/ipam/compare/v1.2.0...v1.3.0) (2025-11-06)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* :sparkles: role based access control ([3bf2697](https://github.com/JDB-NET/ipam/commit/3bf269701030bc1f14a48c5af488286c424dbfa7))
|
||||||
|
|
||||||
|
## [1.2.0](https://github.com/JDB-NET/ipam/compare/v1.1.1...v1.2.0) (2025-11-06)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* :sparkles: added the ability to create/edit/remove device types ([d68eefc](https://github.com/JDB-NET/ipam/commit/d68eefcf0cc4a59cda9cedb3e126d974ee45d2ad))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* :bug: missing button classes ([f93fa15](https://github.com/JDB-NET/ipam/commit/f93fa155eb5d6c9ff4ed19f332c3ad6fff328d31))
|
||||||
|
|
||||||
## [1.1.1](https://github.com/JDB-NET/ipam/compare/v1.1.0...v1.1.1) (2025-11-01)
|
## [1.1.1](https://github.com/JDB-NET/ipam/compare/v1.1.0...v1.1.1) (2025-11-01)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ FROM python:3.13-slim
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . /app
|
COPY . /app
|
||||||
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 \
|
||||||
|
|||||||
@@ -11,12 +11,15 @@ A Flask-based web application for comprehensive IP Address Management (IPAM). Ma
|
|||||||
- **Subnet Management**: Create and manage IP subnets with CIDR notation (supports /24 to /32)
|
- **Subnet Management**: Create and manage IP subnets with CIDR notation (supports /24 to /32)
|
||||||
- **IP Address Tracking**: Automatic IP address generation and tracking for each subnet
|
- **IP Address Tracking**: Automatic IP address generation and tracking for each subnet
|
||||||
- **Device Management**: Track devices with types (Server, VM, Switch, Firewall, WiFi AP, Printer, Other)
|
- **Device Management**: Track devices with types (Server, VM, Switch, Firewall, WiFi AP, Printer, Other)
|
||||||
|
- **Device Tagging**: Organize devices with customizable tags featuring colors and descriptions
|
||||||
- **IP Assignment**: Assign IP addresses to devices with automatic hostname updates
|
- **IP Assignment**: Assign IP addresses to devices with automatic hostname updates
|
||||||
- **DHCP Pool Configuration**: Configure DHCP pools with start/end IP ranges and excluded IPs
|
- **DHCP Pool Configuration**: Configure DHCP pools with start/end IP ranges and excluded IPs
|
||||||
- **Rack Management**: Physical infrastructure tracking with U positions and front/back sides
|
- **Rack Management**: Physical infrastructure tracking with U positions and front/back sides
|
||||||
- **Site Organization**: Organize subnets and devices by site/location
|
- **Site Organisation**: Organize subnets and devices by site/location
|
||||||
- **Audit Logging**: Complete audit trail of all changes with user, action, details, and timestamps
|
- **Audit Logging**: Complete audit trail of all changes with user, action, details, and timestamps
|
||||||
- **User Management**: Multi-user support with secure password authentication
|
- **User Management**: Multi-user support with secure password authentication
|
||||||
|
- **Role-Based Access Control (RBAC)**: Granular permission system with default roles (admin, user, view_only) and custom role creation
|
||||||
|
- **REST API**: Full-featured REST API with API key authentication for programmatic access
|
||||||
- **CSV Export**: Export subnet and rack data to CSV files
|
- **CSV Export**: Export subnet and rack data to CSV files
|
||||||
- **Device Statistics**: View device counts by type
|
- **Device Statistics**: View device counts by type
|
||||||
- **Web Interface**: Modern, responsive web GUI built with Tailwind CSS and dark mode support
|
- **Web Interface**: Modern, responsive web GUI built with Tailwind CSS and dark mode support
|
||||||
@@ -29,12 +32,13 @@ A Flask-based web application for comprehensive IP Address Management (IPAM). Ma
|
|||||||
docker run -d \
|
docker run -d \
|
||||||
--name ipam \
|
--name ipam \
|
||||||
-p 5000:5000 \
|
-p 5000:5000 \
|
||||||
|
-v ./backups:/app/backups \
|
||||||
-e MYSQL_HOST=10.10.2.27 \
|
-e MYSQL_HOST=10.10.2.27 \
|
||||||
-e MYSQL_USER=ipam \
|
-e MYSQL_USER=ipam \
|
||||||
-e MYSQL_PASSWORD=your_password \
|
-e MYSQL_PASSWORD=your_password \
|
||||||
-e MYSQL_DATABASE=ipam \
|
-e MYSQL_DATABASE=ipam \
|
||||||
-e SECRET_KEY=your_secret_key \
|
-e SECRET_KEY=your_secret_key \
|
||||||
-e NAME="Your Organization" \
|
-e NAME="Your Organisation" \
|
||||||
-e LOGO_PNG="https://example.com/logo.png" \
|
-e LOGO_PNG="https://example.com/logo.png" \
|
||||||
ghcr.io/jdb-net/ipam:latest
|
ghcr.io/jdb-net/ipam:latest
|
||||||
```
|
```
|
||||||
@@ -42,23 +46,23 @@ docker run -d \
|
|||||||
### Docker Compose
|
### Docker Compose
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
ipam:
|
ipam:
|
||||||
image: ghcr.io/jdb-net/ipam:latest
|
image: ghcr.io/jdb-net/ipam:latest
|
||||||
container_name: ipam
|
container_name: ipam
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000" # Web interface
|
- "5000:5000"
|
||||||
environment:
|
environment:
|
||||||
- MYSQL_HOST=10.10.2.27
|
- MYSQL_HOST=10.10.2.27
|
||||||
- MYSQL_USER=ipam
|
- MYSQL_USER=ipam
|
||||||
- MYSQL_PASSWORD=your_password
|
- MYSQL_PASSWORD=your_password
|
||||||
- MYSQL_DATABASE=ipam
|
- MYSQL_DATABASE=ipam
|
||||||
- SECRET_KEY=your_secret_key
|
- SECRET_KEY=your_secret_key
|
||||||
- NAME=Your Organization
|
- NAME=Your Organisation
|
||||||
- LOGO_PNG=https://example.com/logo.png
|
- LOGO_PNG=https://example.com/logo.png
|
||||||
|
volumes:
|
||||||
|
- ./backups:/app/backups
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
@@ -70,8 +74,8 @@ services:
|
|||||||
- `MYSQL_PASSWORD`: Database password (default: password)
|
- `MYSQL_PASSWORD`: Database password (default: password)
|
||||||
- `MYSQL_DATABASE`: Database name (default: ipam)
|
- `MYSQL_DATABASE`: Database name (default: ipam)
|
||||||
- `SECRET_KEY`: Flask secret key for sessions (**REQUIRED in production!**)
|
- `SECRET_KEY`: Flask secret key for sessions (**REQUIRED in production!**)
|
||||||
- `NAME`: Organization name displayed in header (default: JDB-NET)
|
- `NAME`: Organisation name displayed in header (default: JDB-NET)
|
||||||
- `LOGO_PNG`: URL or path to organization logo (default: JDB-NET logo)
|
- `LOGO_PNG`: URL or path to organisation logo (default: JDB-NET logo)
|
||||||
|
|
||||||
### Database Setup
|
### Database Setup
|
||||||
|
|
||||||
@@ -133,6 +137,22 @@ FLUSH PRIVILEGES;
|
|||||||
- **Height**: Rack height in U units
|
- **Height**: Rack height in U units
|
||||||
3. Open a rack to assign devices to specific U positions (front or back)
|
3. Open a rack to assign devices to specific U positions (front or back)
|
||||||
|
|
||||||
|
### Device Tagging
|
||||||
|
|
||||||
|
1. **Managing Tags** (Admin only):
|
||||||
|
- Navigate to "Admin" > "Tag Management"
|
||||||
|
- Click "Add Tag" to create new tags with custom colors and descriptions
|
||||||
|
- Edit or delete existing tags as needed
|
||||||
|
|
||||||
|
2. **Assigning Tags to Devices**:
|
||||||
|
- Open any device from the Devices page
|
||||||
|
- Use the tag assignment dropdown to add multiple tags
|
||||||
|
- Remove tags by clicking the × button next to the tag name
|
||||||
|
|
||||||
|
3. **Filtering by Tags**:
|
||||||
|
- Use the tag filter dropdown on the Devices page to view devices with specific tags
|
||||||
|
- Tags appear as colored badges throughout the interface for easy identification
|
||||||
|
|
||||||
### Audit Log
|
### Audit Log
|
||||||
|
|
||||||
View all changes and actions in the "Audit Log" section, with filtering by user, subnet, action type, or device name.
|
View all changes and actions in the "Audit Log" section, with filtering by user, subnet, action type, or device name.
|
||||||
@@ -142,6 +162,73 @@ View all changes and actions in the "Audit Log" section, with filtering by user,
|
|||||||
- **Subnet CSV**: Click "Export CSV" on any subnet page to download IP addresses with hostnames
|
- **Subnet CSV**: Click "Export CSV" on any subnet page to download IP addresses with hostnames
|
||||||
- **Rack CSV**: Click "Export CSV" on any rack page to download rack layout information
|
- **Rack CSV**: Click "Export CSV" on any rack page to download rack layout information
|
||||||
|
|
||||||
|
### Role-Based Access Control
|
||||||
|
|
||||||
|
The system uses a granular role-based access control (RBAC) system to manage user permissions:
|
||||||
|
|
||||||
|
1. **Default Roles**:
|
||||||
|
- **Admin**: Full access to all features including user and role management
|
||||||
|
- **User**: Can view and manage most features (devices, subnets, racks, etc.) but cannot manage users or roles
|
||||||
|
- **View Only**: Read-only access to view pages but cannot make any changes
|
||||||
|
|
||||||
|
2. **Custom Roles**: Administrators can create custom roles with specific permission sets from the Users page
|
||||||
|
|
||||||
|
3. **Permission Granularity**: Permissions are organized into categories:
|
||||||
|
- View permissions (access to pages)
|
||||||
|
- Device Management (add, edit, delete devices)
|
||||||
|
- Network Management (subnet operations)
|
||||||
|
- Rack Management (rack operations)
|
||||||
|
- DHCP Configuration
|
||||||
|
- Administration (user and role management)
|
||||||
|
|
||||||
|
4. **User Management**: Navigate to the Users page to:
|
||||||
|
- Create and manage users
|
||||||
|
- Assign roles to users
|
||||||
|
- Create custom roles with specific permissions
|
||||||
|
- View and regenerate API keys
|
||||||
|
|
||||||
|
### REST API
|
||||||
|
|
||||||
|
The application includes a comprehensive REST API for programmatic access:
|
||||||
|
|
||||||
|
1. **Authentication**: All API requests require an API key, which can be provided via:
|
||||||
|
- `X-API-Key` header
|
||||||
|
- `Authorization: Bearer <api_key>` header
|
||||||
|
- `?api_key=<api_key>` query parameter
|
||||||
|
|
||||||
|
2. **Base URL**: All API endpoints are prefixed with `/api/v1`
|
||||||
|
|
||||||
|
3. **Available Endpoints**:
|
||||||
|
- **Devices**: `GET`, `POST`, `PUT`, `DELETE /api/v1/devices`
|
||||||
|
- **Device Tags**: `GET /api/v1/devices/by-tag/{tag}` (filter devices by tag)
|
||||||
|
- **Tags**: `GET`, `POST`, `PUT`, `DELETE /api/v1/tags` (with `?format=simple` option)
|
||||||
|
- **Tag Assignment**: `GET`, `POST /api/v1/devices/{id}/tags`, `DELETE /api/v1/devices/{id}/tags/{tag_id}`
|
||||||
|
- **Subnets**: `GET`, `POST`, `PUT`, `DELETE /api/v1/subnets`
|
||||||
|
- **Racks**: `GET`, `POST`, `DELETE /api/v1/racks`
|
||||||
|
- **Device Types**: `GET /api/v1/device-types`
|
||||||
|
- **DHCP**: `GET`, `POST /api/v1/subnets/{id}/dhcp`
|
||||||
|
- **Audit Log**: `GET /api/v1/audit`
|
||||||
|
- **Users & Roles**: `GET /api/v1/users`, `GET /api/v1/roles` (admin only)
|
||||||
|
|
||||||
|
4. **API Keys**: Each user has a unique API key that can be viewed and regenerated from the Users page. API keys respect the same role-based permissions as the web interface.
|
||||||
|
|
||||||
|
5. **Documentation**: Full API documentation is available in the Help page of the web interface.
|
||||||
|
|
||||||
|
**Example API Requests**:
|
||||||
|
```bash
|
||||||
|
# List all devices
|
||||||
|
curl -H "X-API-Key: your_api_key" \
|
||||||
|
https://your-server:5000/api/v1/devices
|
||||||
|
|
||||||
|
# Get devices with a specific tag
|
||||||
|
curl -H "X-API-Key: your_api_key" \
|
||||||
|
https://your-server:5000/api/v1/devices/by-tag/production
|
||||||
|
|
||||||
|
# List all tags in simple format
|
||||||
|
curl -H "X-API-Key: your_api_key" \
|
||||||
|
https://your-server:5000/api/v1/tags?format=simple
|
||||||
|
```
|
||||||
|
|
||||||
## Kubernetes Deployment
|
## Kubernetes Deployment
|
||||||
|
|
||||||
The project includes a Kubernetes deployment manifest. See `deployment.yml` for details.
|
The project includes a Kubernetes deployment manifest. See `deployment.yml` for details.
|
||||||
@@ -190,6 +277,9 @@ spec:
|
|||||||
- Ensure database connections are secured (consider SSL/TLS for MySQL connections)
|
- Ensure database connections are secured (consider SSL/TLS for MySQL connections)
|
||||||
- Review audit logs regularly for unauthorized changes
|
- Review audit logs regularly for unauthorized changes
|
||||||
- Limit database user permissions if possible (though CREATE/ALTER may be needed for schema initialization)
|
- Limit database user permissions if possible (though CREATE/ALTER may be needed for schema initialization)
|
||||||
|
- **API Keys**: Keep API keys secure and never share them publicly. Regenerate keys if they may have been compromised
|
||||||
|
- **Role-Based Access**: Use the RBAC system to grant users only the permissions they need (principle of least privilege)
|
||||||
|
- **HTTPS**: Use HTTPS in production to protect API keys and session data in transit
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from flask import Flask
|
from flask import Flask, session
|
||||||
from db import init_db, hash_password
|
from db import init_db, hash_password, get_db_connection
|
||||||
from routes import register_routes
|
from routes import register_routes
|
||||||
import os
|
import os
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
@@ -26,11 +26,22 @@ def inject_env_vars():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Import has_permission from routes after routes are registered
|
||||||
|
from routes import has_permission
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'NAME': os.environ.get('NAME', 'JDB-NET'),
|
'NAME': os.environ.get('NAME', 'JDB-NET'),
|
||||||
'LOGO_PNG': os.environ.get('LOGO_PNG', 'https://assets.s3.jdbnet.co.uk/logo/128x128.png'),
|
'LOGO_PNG': os.environ.get('LOGO_PNG', 'https://assets.s3.jdbnet.co.uk/logo/128x128.png'),
|
||||||
'VERSION': version
|
'VERSION': version,
|
||||||
|
'has_permission': has_permission
|
||||||
}
|
}
|
||||||
|
|
||||||
register_routes(app)
|
register_routes(app)
|
||||||
init_db(app)
|
init_db(app)
|
||||||
|
|
||||||
|
# Start cache pre-warming in background
|
||||||
|
from routes import prewarm_cache
|
||||||
|
prewarm_cache(app)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
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
|
||||||
|
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import os
|
import os
|
||||||
import hashlib
|
import hashlib
|
||||||
import base64
|
import base64
|
||||||
|
import secrets
|
||||||
import mysql.connector
|
import mysql.connector
|
||||||
|
import logging
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
def hash_password(password, salt=None):
|
def hash_password(password, salt=None):
|
||||||
@@ -18,6 +20,10 @@ def verify_password(password, hashed):
|
|||||||
return False
|
return False
|
||||||
return hash_password(password, salt) == hashed
|
return hash_password(password, salt) == hashed
|
||||||
|
|
||||||
|
def generate_api_key():
|
||||||
|
"""Generate a secure API key"""
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
def get_db_connection(app=None):
|
def get_db_connection(app=None):
|
||||||
if app is None:
|
if app is None:
|
||||||
app = current_app
|
app = current_app
|
||||||
@@ -59,8 +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('''
|
||||||
@@ -127,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)', [
|
||||||
@@ -138,20 +145,366 @@ 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:
|
||||||
if e.errno != 1061 and e.errno != 1826 and 'Duplicate' not in str(e):
|
if e.errno != 1061 and e.errno != 1826 and 'Duplicate' not in str(e):
|
||||||
raise
|
raise
|
||||||
|
# Create Role table
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS Role (
|
||||||
|
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
name VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
description TEXT
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Create Permission table
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS Permission (
|
||||||
|
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
name VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
category VARCHAR(255)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Create RolePermission junction table
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS RolePermission (
|
||||||
|
role_id INTEGER NOT NULL,
|
||||||
|
permission_id INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (role_id, permission_id),
|
||||||
|
FOREIGN KEY (role_id) REFERENCES Role(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (permission_id) REFERENCES Permission(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Add role_id column to User table if it doesn't exist
|
||||||
|
cursor.execute("SHOW COLUMNS FROM User LIKE 'role_id'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute('ALTER TABLE User ADD COLUMN role_id INTEGER DEFAULT NULL')
|
||||||
|
try:
|
||||||
|
cursor.execute('ALTER TABLE User ADD CONSTRAINT fk_user_role FOREIGN KEY (role_id) REFERENCES Role(id)')
|
||||||
|
except mysql.connector.Error as e:
|
||||||
|
if e.errno != 1061 and e.errno != 1826 and 'Duplicate' not in str(e):
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Add api_key column to User table if it doesn't exist
|
||||||
|
cursor.execute("SHOW COLUMNS FROM User LIKE 'api_key'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute('ALTER TABLE User ADD COLUMN api_key VARCHAR(255) DEFAULT NULL UNIQUE')
|
||||||
|
|
||||||
|
# Ensure AuditLog foreign keys have ON DELETE SET NULL to preserve audit logs
|
||||||
|
# This is critical - audit logs should NEVER be deleted, even when referenced entities are deleted
|
||||||
|
try:
|
||||||
|
# Check and update user_id foreign key
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT CONSTRAINT_NAME
|
||||||
|
FROM information_schema.KEY_COLUMN_USAGE
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'AuditLog'
|
||||||
|
AND COLUMN_NAME = 'user_id'
|
||||||
|
AND REFERENCED_TABLE_NAME = 'User'
|
||||||
|
''')
|
||||||
|
fk_user = cursor.fetchone()
|
||||||
|
if fk_user:
|
||||||
|
fk_name = fk_user[0]
|
||||||
|
# Drop and recreate with ON DELETE SET NULL
|
||||||
|
cursor.execute(f'ALTER TABLE AuditLog DROP FOREIGN KEY {fk_name}')
|
||||||
|
cursor.execute('ALTER TABLE AuditLog ADD CONSTRAINT fk_auditlog_user FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE SET NULL')
|
||||||
|
except mysql.connector.Error as e:
|
||||||
|
# Foreign key might not exist or already be correct, continue
|
||||||
|
if e.errno != 1025 and e.errno != 1091: # Not "Cannot drop foreign key" or "Unknown key"
|
||||||
|
logging.warning(f"Could not update AuditLog user_id foreign key: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check and update subnet_id foreign key
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT CONSTRAINT_NAME
|
||||||
|
FROM information_schema.KEY_COLUMN_USAGE
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'AuditLog'
|
||||||
|
AND COLUMN_NAME = 'subnet_id'
|
||||||
|
AND REFERENCED_TABLE_NAME = 'Subnet'
|
||||||
|
''')
|
||||||
|
fk_subnet = cursor.fetchone()
|
||||||
|
if fk_subnet:
|
||||||
|
fk_name = fk_subnet[0]
|
||||||
|
# Drop and recreate with ON DELETE SET NULL
|
||||||
|
cursor.execute(f'ALTER TABLE AuditLog DROP FOREIGN KEY {fk_name}')
|
||||||
|
cursor.execute('ALTER TABLE AuditLog ADD CONSTRAINT fk_auditlog_subnet FOREIGN KEY (subnet_id) REFERENCES Subnet(id) ON DELETE SET NULL')
|
||||||
|
except mysql.connector.Error as e:
|
||||||
|
# Foreign key might not exist or already be correct, continue
|
||||||
|
if e.errno != 1025 and e.errno != 1091: # Not "Cannot drop foreign key" or "Unknown key"
|
||||||
|
logging.warning(f"Could not update AuditLog subnet_id foreign key: {e}")
|
||||||
|
|
||||||
|
# Create Tag table
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS Tag (
|
||||||
|
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
name VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
color VARCHAR(7) DEFAULT '#6B7280',
|
||||||
|
description TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Create DeviceTag junction table
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS DeviceTag (
|
||||||
|
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
device_id INTEGER NOT NULL,
|
||||||
|
tag_id INTEGER NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY unique_device_tag (device_id, tag_id),
|
||||||
|
FOREIGN KEY (device_id) REFERENCES Device(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (tag_id) REFERENCES Tag(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Define all permissions with categories
|
||||||
|
permissions = [
|
||||||
|
# View permissions
|
||||||
|
('view_index', 'View Home/Index page', 'View'),
|
||||||
|
('view_devices', 'View Devices page', 'View'),
|
||||||
|
('view_device', 'View Device details', 'View'),
|
||||||
|
('view_subnet', 'View Subnet details', 'View'),
|
||||||
|
('view_racks', 'View Racks page', 'View'),
|
||||||
|
('view_rack', 'View Rack details', 'View'),
|
||||||
|
('view_audit', 'View Audit Log', 'View'),
|
||||||
|
('view_admin', 'View Admin panel', 'View'),
|
||||||
|
('view_users', 'View Users page', 'View'),
|
||||||
|
('view_device_types', 'View Device Types page', 'View'),
|
||||||
|
('view_device_type_stats', 'View Device Type Statistics', 'View'),
|
||||||
|
('view_devices_by_type', 'View Devices by Type', 'View'),
|
||||||
|
('view_dhcp', 'View DHCP configuration', 'View'),
|
||||||
|
('view_help', 'View Help page', 'View'),
|
||||||
|
|
||||||
|
# Device permissions
|
||||||
|
('add_device', 'Add new device', 'Device'),
|
||||||
|
('edit_device', 'Edit device (rename, description, type)', 'Device'),
|
||||||
|
('delete_device', 'Delete device', 'Device'),
|
||||||
|
('add_device_ip', 'Add IP address to device', 'Device'),
|
||||||
|
('remove_device_ip', 'Remove IP address from device', 'Device'),
|
||||||
|
|
||||||
|
# Subnet permissions
|
||||||
|
('add_subnet', 'Add new subnet', 'Subnet'),
|
||||||
|
('edit_subnet', 'Edit subnet (name, CIDR, site)', 'Subnet'),
|
||||||
|
('delete_subnet', 'Delete subnet', 'Subnet'),
|
||||||
|
('export_subnet_csv', 'Export subnet as CSV', 'Subnet'),
|
||||||
|
|
||||||
|
# Rack permissions
|
||||||
|
('add_rack', 'Add new rack', 'Rack'),
|
||||||
|
('delete_rack', 'Delete rack', 'Rack'),
|
||||||
|
('add_device_to_rack', 'Add device to rack', 'Rack'),
|
||||||
|
('remove_device_from_rack', 'Remove device from rack', 'Rack'),
|
||||||
|
('add_nonnet_device_to_rack', 'Add non-networked device to rack', 'Rack'),
|
||||||
|
('export_rack_csv', 'Export rack as CSV', 'Rack'),
|
||||||
|
|
||||||
|
# DHCP permissions
|
||||||
|
('configure_dhcp', 'Configure DHCP pools', 'DHCP'),
|
||||||
|
|
||||||
|
# Device Type permissions
|
||||||
|
('add_device_type', 'Add device type', 'Device Type'),
|
||||||
|
('edit_device_type', 'Edit device type', 'Device Type'),
|
||||||
|
('delete_device_type', 'Delete device type', 'Device Type'),
|
||||||
|
|
||||||
|
# Tag permissions
|
||||||
|
('view_tags', 'View tags', 'Tag'),
|
||||||
|
('add_tag', 'Add new tag', 'Tag'),
|
||||||
|
('edit_tag', 'Edit tag', 'Tag'),
|
||||||
|
('delete_tag', 'Delete tag', 'Tag'),
|
||||||
|
('assign_device_tag', 'Assign tag to device', 'Tag'),
|
||||||
|
('remove_device_tag', 'Remove tag from device', 'Tag'),
|
||||||
|
|
||||||
|
# Admin permissions
|
||||||
|
('manage_users', 'Manage users (add, edit, delete)', 'Admin'),
|
||||||
|
('manage_roles', 'Manage roles and permissions', 'Admin'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Insert permissions
|
||||||
|
for perm_name, perm_desc, perm_category in permissions:
|
||||||
|
cursor.execute('SELECT id FROM Permission WHERE name = %s', (perm_name,))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute('INSERT INTO Permission (name, description, category) VALUES (%s, %s, %s)',
|
||||||
|
(perm_name, perm_desc, perm_category))
|
||||||
|
|
||||||
|
# Create default roles if they don't exist
|
||||||
|
cursor.execute('SELECT id FROM Role WHERE name = %s', ('admin',))
|
||||||
|
admin_role = cursor.fetchone()
|
||||||
|
if not admin_role:
|
||||||
|
cursor.execute('INSERT INTO Role (name, description) VALUES (%s, %s)',
|
||||||
|
('admin', 'Administrator with full access to all features'))
|
||||||
|
admin_role_id = cursor.lastrowid
|
||||||
|
else:
|
||||||
|
admin_role_id = admin_role[0]
|
||||||
|
|
||||||
|
cursor.execute('SELECT id FROM Role WHERE name = %s', ('user',))
|
||||||
|
user_role = cursor.fetchone()
|
||||||
|
if not user_role:
|
||||||
|
cursor.execute('INSERT INTO Role (name, description) VALUES (%s, %s)',
|
||||||
|
('user', 'Standard user with access to most features except admin functions'))
|
||||||
|
user_role_id = cursor.lastrowid
|
||||||
|
else:
|
||||||
|
user_role_id = user_role[0]
|
||||||
|
|
||||||
|
cursor.execute('SELECT id FROM Role WHERE name = %s', ('view_only',))
|
||||||
|
view_only_role = cursor.fetchone()
|
||||||
|
if not view_only_role:
|
||||||
|
cursor.execute('INSERT INTO Role (name, description) VALUES (%s, %s)',
|
||||||
|
('view_only', 'View-only user with read-only access to all pages'))
|
||||||
|
view_only_role_id = cursor.lastrowid
|
||||||
|
else:
|
||||||
|
view_only_role_id = view_only_role[0]
|
||||||
|
|
||||||
|
# Assign all permissions to admin role
|
||||||
|
cursor.execute('SELECT id FROM Permission')
|
||||||
|
all_permission_ids = [row[0] for row in cursor.fetchall()]
|
||||||
|
for perm_id in all_permission_ids:
|
||||||
|
cursor.execute('SELECT role_id FROM RolePermission WHERE role_id = %s AND permission_id = %s',
|
||||||
|
(admin_role_id, perm_id))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute('INSERT INTO RolePermission (role_id, permission_id) VALUES (%s, %s)',
|
||||||
|
(admin_role_id, perm_id))
|
||||||
|
|
||||||
|
# Assign non-admin permissions to user role
|
||||||
|
non_admin_permissions = [
|
||||||
|
'view_index', 'view_devices', 'view_device', 'view_subnet', 'view_racks', 'view_rack',
|
||||||
|
'view_audit', 'view_device_types', 'view_device_type_stats', 'view_devices_by_type',
|
||||||
|
'view_dhcp', 'view_help',
|
||||||
|
'add_device', 'edit_device', 'delete_device', 'add_device_ip', 'remove_device_ip',
|
||||||
|
'add_subnet', 'edit_subnet', 'delete_subnet', 'export_subnet_csv',
|
||||||
|
'add_rack', 'delete_rack', 'add_device_to_rack', 'remove_device_from_rack',
|
||||||
|
'add_nonnet_device_to_rack', 'export_rack_csv',
|
||||||
|
'configure_dhcp',
|
||||||
|
'add_device_type', 'edit_device_type', 'delete_device_type',
|
||||||
|
'view_tags', 'add_tag', 'edit_tag', 'delete_tag', 'assign_device_tag', 'remove_device_tag'
|
||||||
|
]
|
||||||
|
|
||||||
|
for perm_name in non_admin_permissions:
|
||||||
|
cursor.execute('SELECT id FROM Permission WHERE name = %s', (perm_name,))
|
||||||
|
perm_result = cursor.fetchone()
|
||||||
|
if perm_result:
|
||||||
|
perm_id = perm_result[0]
|
||||||
|
cursor.execute('SELECT role_id FROM RolePermission WHERE role_id = %s AND permission_id = %s',
|
||||||
|
(user_role_id, perm_id))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute('INSERT INTO RolePermission (role_id, permission_id) VALUES (%s, %s)',
|
||||||
|
(user_role_id, perm_id))
|
||||||
|
|
||||||
|
# Assign view-only permissions to view_only role
|
||||||
|
# Same view permissions as user role, but excluding admin views (view_admin, view_users)
|
||||||
|
view_only_permissions = [
|
||||||
|
'view_index', 'view_devices', 'view_device', 'view_subnet', 'view_racks', 'view_rack',
|
||||||
|
'view_audit', 'view_device_types', 'view_device_type_stats', 'view_devices_by_type',
|
||||||
|
'view_dhcp', 'view_help', 'view_tags'
|
||||||
|
]
|
||||||
|
|
||||||
|
for perm_name in view_only_permissions:
|
||||||
|
cursor.execute('SELECT id FROM Permission WHERE name = %s', (perm_name,))
|
||||||
|
perm_result = cursor.fetchone()
|
||||||
|
if perm_result:
|
||||||
|
perm_id = perm_result[0]
|
||||||
|
cursor.execute('SELECT role_id FROM RolePermission WHERE role_id = %s AND permission_id = %s',
|
||||||
|
(view_only_role_id, perm_id))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute('INSERT INTO RolePermission (role_id, permission_id) VALUES (%s, %s)',
|
||||||
|
(view_only_role_id, perm_id))
|
||||||
|
|
||||||
|
# Assign existing users to 'admin' role if they don't have a role
|
||||||
|
# This ensures existing users maintain admin access
|
||||||
|
cursor.execute('UPDATE User SET role_id = %s WHERE role_id IS NULL', (admin_role_id,))
|
||||||
|
|
||||||
|
# Generate API keys for users that don't have one
|
||||||
|
cursor.execute('SELECT id FROM User WHERE api_key IS NULL')
|
||||||
|
users_without_api_key = cursor.fetchall()
|
||||||
|
for (user_id,) in users_without_api_key:
|
||||||
|
api_key = generate_api_key()
|
||||||
|
cursor.execute('UPDATE User SET api_key = %s WHERE id = %s', (api_key, user_id))
|
||||||
|
|
||||||
cursor.execute('SELECT COUNT(*) FROM User')
|
cursor.execute('SELECT COUNT(*) FROM User')
|
||||||
if cursor.fetchone()[0] == 0:
|
if cursor.fetchone()[0] == 0:
|
||||||
cursor.execute('''INSERT INTO User (name, email, password) VALUES (%s, %s, %s)''',
|
api_key = generate_api_key()
|
||||||
('admin', 'admin@example.com', hash_password('password')))
|
cursor.execute('''INSERT INTO User (name, email, password, role_id, api_key) VALUES (%s, %s, %s, %s, %s)''',
|
||||||
|
('admin', 'admin@example.com', hash_password('password'), admin_role_id, api_key))
|
||||||
|
|
||||||
|
# Create indexes for performance optimization
|
||||||
|
logging.info("Creating database indexes for performance...")
|
||||||
|
|
||||||
|
def create_index_if_not_exists(cursor, index_name, table_name, columns):
|
||||||
|
"""Helper function to create index if it doesn't exist"""
|
||||||
|
try:
|
||||||
|
# Check if index exists
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT COUNT(*) FROM information_schema.statistics
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = %s
|
||||||
|
AND index_name = %s
|
||||||
|
''', (table_name, index_name))
|
||||||
|
if cursor.fetchone()[0] == 0:
|
||||||
|
cursor.execute(f'CREATE INDEX {index_name} ON {table_name}({columns})')
|
||||||
|
logging.info(f"Created index {index_name}")
|
||||||
|
else:
|
||||||
|
logging.debug(f"Index {index_name} already exists")
|
||||||
|
except mysql.connector.Error as e:
|
||||||
|
logging.warning(f"Could not create index {index_name}: {e}")
|
||||||
|
|
||||||
|
# IPAddress table indexes
|
||||||
|
create_index_if_not_exists(cursor, 'idx_ipaddress_subnet_id', 'IPAddress', 'subnet_id')
|
||||||
|
create_index_if_not_exists(cursor, 'idx_ipaddress_hostname', 'IPAddress', 'hostname')
|
||||||
|
create_index_if_not_exists(cursor, 'idx_ipaddress_ip', 'IPAddress', 'ip')
|
||||||
|
create_index_if_not_exists(cursor, 'idx_ipaddress_subnet_hostname', 'IPAddress', 'subnet_id, hostname')
|
||||||
|
|
||||||
|
# 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')
|
||||||
|
|
||||||
|
logging.info("Database indexes created successfully")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ spec:
|
|||||||
- name: SECRET_KEY
|
- name: SECRET_KEY
|
||||||
value: "41TbN7v5peFLZPrdwSCc64J3mjmiUk5fkVWsmb2m"
|
value: "41TbN7v5peFLZPrdwSCc64J3mjmiUk5fkVWsmb2m"
|
||||||
- name: MYSQL_HOST
|
- name: MYSQL_HOST
|
||||||
value: "10.10.2.27"
|
value: "10.10.25.4"
|
||||||
- name: MYSQL_USER
|
- name: MYSQL_USER
|
||||||
value: "ipam"
|
value: "ipam"
|
||||||
- name: MYSQL_PASSWORD
|
- name: MYSQL_PASSWORD
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ Flask
|
|||||||
mysql-connector-python
|
mysql-connector-python
|
||||||
dotenv
|
dotenv
|
||||||
gunicorn
|
gunicorn
|
||||||
|
requests
|
||||||
@@ -4,4 +4,4 @@ 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/*.js" --minify
|
||||||
|
|
||||||
echo "Starting app..."
|
echo "Starting app..."
|
||||||
gunicorn --bind 0.0.0.0:5000 app:app --log-level debug
|
python app.py
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/* Icon search suggestions styling */
|
||||||
|
.icon-suggestions {
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-suggestion-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease-in-out;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-suggestion-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-suggestion-item:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .icon-suggestion-item {
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .icon-suggestion-item:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-suggestion-item i {
|
||||||
|
width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .icon-suggestion-item i {
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-suggestion-item span {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .icon-suggestion-item span {
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon preview styling */
|
||||||
|
.icon-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling for suggestions */
|
||||||
|
.icon-suggestions::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-suggestions::-webkit-scrollbar-track {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .icon-suggestions::-webkit-scrollbar-track {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-suggestions::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .icon-suggestions::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-suggestions::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .icon-suggestions::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,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 {
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// API Documentation Interactive Functions
|
||||||
|
|
||||||
|
function getApiKey() {
|
||||||
|
return document.getElementById('apiKey').value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatus(message, isError = false) {
|
||||||
|
const status = document.getElementById('connectionStatus');
|
||||||
|
status.textContent = message;
|
||||||
|
status.className = `mt-2 text-sm ${isError ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testConnection() {
|
||||||
|
const apiKey = getApiKey();
|
||||||
|
if (!apiKey) {
|
||||||
|
showStatus('Please enter your API key', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/v1/devices', {
|
||||||
|
headers: { 'X-API-Key': apiKey }
|
||||||
|
});
|
||||||
|
showStatus('✓ Connection successful');
|
||||||
|
} catch (error) {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
showStatus('✗ Invalid API key', true);
|
||||||
|
} else if (error.response?.status === 403) {
|
||||||
|
showStatus('✗ Insufficient permissions', true);
|
||||||
|
} else {
|
||||||
|
showStatus('✗ Connection failed', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryEndpoint(method, url, data, responseId) {
|
||||||
|
const apiKey = getApiKey();
|
||||||
|
if (!apiKey) {
|
||||||
|
showStatus('Please enter your API key first', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = {
|
||||||
|
method: method,
|
||||||
|
url: url,
|
||||||
|
headers: { 'X-API-Key': apiKey }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
config.data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios(config);
|
||||||
|
document.getElementById(responseId + '-response').classList.remove('hidden');
|
||||||
|
document.getElementById(responseId).textContent = JSON.stringify(response.data, null, 2);
|
||||||
|
} catch (error) {
|
||||||
|
document.getElementById(responseId + '-response').classList.remove('hidden');
|
||||||
|
const errorMessage = error.response?.data?.error || error.message;
|
||||||
|
document.getElementById(responseId).textContent = `Error (${error.response?.status || 'Network'}): ${errorMessage}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryEndpointWithId(method, baseUrl, inputId, responseId) {
|
||||||
|
const id = document.getElementById(inputId).value;
|
||||||
|
if (!id) {
|
||||||
|
alert('Please enter an ID');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await tryEndpoint(method, baseUrl + encodeURIComponent(id), null, responseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-populate API key if user is logged in
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const apiKeyInput = document.getElementById('apiKey');
|
||||||
|
if (apiKeyInput && apiKeyInput.value) {
|
||||||
|
testConnection();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
function showTab(tabName) {
|
||||||
|
// Hide all panels
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.add('hidden'));
|
||||||
|
// Remove active class from all tabs
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
||||||
|
// Show selected panel
|
||||||
|
document.getElementById('panel-' + tabName).classList.remove('hidden');
|
||||||
|
// Add active class to selected tab
|
||||||
|
document.getElementById('tab-' + tabName).classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Update selected IP count
|
||||||
|
document.getElementById('bulk-ip-select')?.addEventListener('change', function() {
|
||||||
|
document.getElementById('selected-ip-count').textContent = this.selectedOptions.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('bulk-tag-device-select')?.addEventListener('change', function() {
|
||||||
|
document.getElementById('selected-tag-device-count').textContent = this.selectedOptions.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load available IPs when subnet changes
|
||||||
|
document.getElementById('bulk-subnet-select')?.addEventListener('change', function() {
|
||||||
|
const subnetId = this.value;
|
||||||
|
const ipSelect = document.getElementById('bulk-ip-select');
|
||||||
|
if (!subnetId) {
|
||||||
|
ipSelect.innerHTML = '<option value="" disabled>Select a subnet first...</option>';
|
||||||
|
document.getElementById('selected-ip-count').textContent = '0';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ipSelect.innerHTML = '<option value="" disabled>Loading...</option>';
|
||||||
|
fetch(`/get_available_ips?subnet_id=${subnetId}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
ipSelect.innerHTML = '';
|
||||||
|
if (data.available_ips.length === 0) {
|
||||||
|
ipSelect.innerHTML = '<option value="" disabled>No available IPs in this subnet</option>';
|
||||||
|
} else {
|
||||||
|
data.available_ips.forEach(ip => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = ip.id;
|
||||||
|
option.textContent = ip.ip;
|
||||||
|
ipSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.getElementById('selected-ip-count').textContent = '0';
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
ipSelect.innerHTML = '<option value="" disabled>Error loading IPs</option>';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bulk IP Assignment
|
||||||
|
document.getElementById('bulk-assign-ips-form')?.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(this);
|
||||||
|
const resultDiv = document.getElementById('assign-ips-result');
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
resultDiv.innerHTML = '<p class="text-blue-500">Processing...</p>';
|
||||||
|
|
||||||
|
fetch('/bulk/assign_ips', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
let html = '<div class="space-y-2">';
|
||||||
|
if (data.success.length > 0) {
|
||||||
|
html += `<div class="text-green-600 dark:text-green-400"><strong>Successfully assigned ${data.success.length} IP(s):</strong><ul class="list-disc list-inside mt-2">`;
|
||||||
|
data.success.forEach(item => {
|
||||||
|
html += `<li>${item.ip}</li>`;
|
||||||
|
});
|
||||||
|
html += '</ul></div>';
|
||||||
|
}
|
||||||
|
if (data.failed.length > 0) {
|
||||||
|
html += `<div class="text-red-600 dark:text-red-400"><strong>Failed ${data.failed.length} assignment(s):</strong><ul class="list-disc list-inside mt-2">`;
|
||||||
|
data.failed.forEach(item => {
|
||||||
|
const ipDisplay = item.ip ? ` (${item.ip})` : '';
|
||||||
|
html += `<li>IP ID ${item.ip_id}${ipDisplay}: ${item.reason}</li>`;
|
||||||
|
});
|
||||||
|
html += '</ul></div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
resultDiv.innerHTML = html;
|
||||||
|
// Reload IP list if successful
|
||||||
|
if (data.success.length > 0) {
|
||||||
|
const subnetSelect = document.getElementById('bulk-subnet-select');
|
||||||
|
if (subnetSelect.value) {
|
||||||
|
subnetSelect.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
resultDiv.innerHTML = `<p class="text-red-600">Error: ${error.message}</p>`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bulk Device Creation
|
||||||
|
document.getElementById('bulk-create-devices-form')?.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(this);
|
||||||
|
const resultDiv = document.getElementById('create-devices-result');
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
resultDiv.innerHTML = '<p class="text-blue-500">Processing...</p>';
|
||||||
|
|
||||||
|
fetch('/bulk/create_devices', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
let html = '<div class="space-y-2">';
|
||||||
|
if (data.success.length > 0) {
|
||||||
|
html += `<div class="text-green-600 dark:text-green-400"><strong>Successfully created ${data.success.length} device(s):</strong><ul class="list-disc list-inside mt-2">`;
|
||||||
|
data.success.forEach(item => {
|
||||||
|
html += `<li>${item.name}</li>`;
|
||||||
|
});
|
||||||
|
html += '</ul></div>';
|
||||||
|
}
|
||||||
|
if (data.failed.length > 0) {
|
||||||
|
html += `<div class="text-red-600 dark:text-red-400"><strong>Failed ${data.failed.length} creation(s):</strong><ul class="list-disc list-inside mt-2">`;
|
||||||
|
data.failed.forEach(item => {
|
||||||
|
html += `<li>${item.name}: ${item.reason}</li>`;
|
||||||
|
});
|
||||||
|
html += '</ul></div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
resultDiv.innerHTML = html;
|
||||||
|
if (data.success.length > 0) {
|
||||||
|
setTimeout(() => window.location.reload(), 2000);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
resultDiv.innerHTML = `<p class="text-red-600">Error: ${error.message}</p>`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bulk Tag Assignment
|
||||||
|
document.getElementById('bulk-assign-tags-form')?.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(this);
|
||||||
|
const resultDiv = document.getElementById('assign-tags-result');
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
resultDiv.innerHTML = '<p class="text-blue-500">Processing...</p>';
|
||||||
|
|
||||||
|
fetch('/bulk/assign_tags', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
let html = '<div class="space-y-2">';
|
||||||
|
if (data.success.length > 0) {
|
||||||
|
html += `<div class="text-green-600 dark:text-green-400"><strong>Successfully assigned ${data.success.length} tag(s):</strong><ul class="list-disc list-inside mt-2">`;
|
||||||
|
data.success.forEach(item => {
|
||||||
|
html += `<li>${item.device_name}: ${item.tag_name}</li>`;
|
||||||
|
});
|
||||||
|
html += '</ul></div>';
|
||||||
|
}
|
||||||
|
if (data.failed.length > 0) {
|
||||||
|
html += `<div class="text-red-600 dark:text-red-400"><strong>Failed ${data.failed.length} assignment(s):</strong><ul class="list-disc list-inside mt-2">`;
|
||||||
|
data.failed.forEach(item => {
|
||||||
|
html += `<li>Device ID ${item.device_id}, Tag ID ${item.tag_id}: ${item.reason}</li>`;
|
||||||
|
});
|
||||||
|
html += '</ul></div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
resultDiv.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
resultDiv.innerHTML = `<p class="text-red-600">Error: ${error.message}</p>`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// Font Awesome icon search functionality
|
||||||
|
// Common Font Awesome icons for device types
|
||||||
|
const fontAwesomeIcons = [
|
||||||
|
// Network & Server
|
||||||
|
'fa-server', 'fa-router', 'fa-network-wired', 'fa-switch', 'fa-hub', 'fa-ethernet',
|
||||||
|
'fa-satellite-dish', 'fa-broadcast-tower', 'fa-tower-cell', 'fa-wifi', 'fa-network',
|
||||||
|
'fa-project-diagram', 'fa-sitemap', 'fa-diagram-project', 'fa-cloud',
|
||||||
|
|
||||||
|
// Security
|
||||||
|
'fa-shield-halved', 'fa-shield', 'fa-shield-alt', 'fa-firewall', 'fa-lock', 'fa-unlock',
|
||||||
|
'fa-key', 'fa-fingerprint', 'fa-user-shield', 'fa-user-lock',
|
||||||
|
|
||||||
|
// Hardware
|
||||||
|
'fa-print', 'fa-boxes-stacked', 'fa-database', 'fa-hard-drive', 'fa-memory', 'fa-microchip',
|
||||||
|
'fa-cpu', 'fa-usb', 'fa-fan', 'fa-battery-full', 'fa-power-off', 'fa-plug', 'fa-bolt',
|
||||||
|
'fa-lightbulb', 'fa-monitor', 'fa-display', 'fa-tv', 'fa-camera', 'fa-video',
|
||||||
|
|
||||||
|
// Computing
|
||||||
|
'fa-laptop', 'fa-desktop', 'fa-tablet', 'fa-mobile-alt', 'fa-phone', 'fa-keyboard',
|
||||||
|
'fa-mouse', 'fa-microphone', 'fa-headphones', 'fa-speaker',
|
||||||
|
|
||||||
|
// Storage & Files
|
||||||
|
'fa-box', 'fa-package', 'fa-archive', 'fa-folder', 'fa-file', 'fa-hdd', 'fa-ssd',
|
||||||
|
'fa-floppy-disk', 'fa-disk', 'fa-save', 'fa-folder-open', 'fa-folder-plus',
|
||||||
|
|
||||||
|
// Data & Analytics
|
||||||
|
'fa-chart-line', 'fa-chart-bar', 'fa-chart-pie', 'fa-graph', 'fa-analytics',
|
||||||
|
'fa-database', 'fa-file-database', 'fa-file-chart-line', 'fa-file-chart-pie',
|
||||||
|
|
||||||
|
// Location & Infrastructure
|
||||||
|
'fa-globe', 'fa-earth', 'fa-map', 'fa-location', 'fa-map-marker', 'fa-building',
|
||||||
|
'fa-warehouse', 'fa-home', 'fa-office', 'fa-industry',
|
||||||
|
|
||||||
|
// Tools & Utilities
|
||||||
|
'fa-robot', 'fa-cog', 'fa-gear', 'fa-wrench', 'fa-tools', 'fa-question',
|
||||||
|
'fa-code', 'fa-terminal', 'fa-console', 'fa-bug', 'fa-bug-slash',
|
||||||
|
|
||||||
|
// Identification
|
||||||
|
'fa-id-card', 'fa-credit-card', 'fa-qrcode', 'fa-barcode', 'fa-rfid',
|
||||||
|
|
||||||
|
// Transport & Logistics
|
||||||
|
'fa-truck', 'fa-shipping-fast', 'fa-conveyor-belt', 'fa-pallet', 'fa-dolly',
|
||||||
|
'fa-cube', 'fa-cubes', 'fa-layer-group', 'fa-stack',
|
||||||
|
|
||||||
|
// UI & Display
|
||||||
|
'fa-th', 'fa-th-large', 'fa-th-list', 'fa-list', 'fa-list-ul', 'fa-list-ol',
|
||||||
|
'fa-table', 'fa-columns', 'fa-grid', 'fa-window-maximize', 'fa-window-restore',
|
||||||
|
'fa-window-minimize', 'fa-window-close', 'fa-expand', 'fa-compress',
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
'fa-sync', 'fa-sync-alt', 'fa-redo', 'fa-undo', 'fa-refresh', 'fa-download',
|
||||||
|
'fa-upload', 'fa-exchange-alt', 'fa-share', 'fa-link', 'fa-unlink', 'fa-chain',
|
||||||
|
'fa-chain-broken', 'fa-arrows-alt', 'fa-arrows', 'fa-move',
|
||||||
|
|
||||||
|
// Time & Calendar
|
||||||
|
'fa-clock', 'fa-hourglass', 'fa-stopwatch', 'fa-timer', 'fa-calendar',
|
||||||
|
'fa-calendar-alt', 'fa-calendar-check', 'fa-calendar-times', 'fa-history',
|
||||||
|
|
||||||
|
// Media
|
||||||
|
'fa-play', 'fa-pause', 'fa-stop', 'fa-step-backward', 'fa-step-forward',
|
||||||
|
'fa-fast-backward', 'fa-fast-forward', 'fa-eject', 'fa-record-vinyl',
|
||||||
|
'fa-compact-disc', 'fa-cd', 'fa-dvd',
|
||||||
|
|
||||||
|
// Users
|
||||||
|
'fa-user-shield', 'fa-user-lock', 'fa-user-secret', 'fa-user-cog', 'fa-user-gear',
|
||||||
|
'fa-user-tie', 'fa-user-ninja', 'fa-users', 'fa-users-cog', 'fa-user-group',
|
||||||
|
'fa-user-friends', 'fa-user-plus', 'fa-user-minus', 'fa-user-times', 'fa-user-check',
|
||||||
|
'fa-user-xmark', 'fa-user-slash'
|
||||||
|
];
|
||||||
|
|
||||||
|
function initIconSearch() {
|
||||||
|
const iconInputs = document.querySelectorAll('.icon-search-input');
|
||||||
|
|
||||||
|
iconInputs.forEach(input => {
|
||||||
|
const container = input.closest('.icon-search-container');
|
||||||
|
const preview = container.querySelector('.icon-preview');
|
||||||
|
const suggestions = container.querySelector('.icon-suggestions');
|
||||||
|
|
||||||
|
if (!preview || !suggestions) return;
|
||||||
|
|
||||||
|
// Initialize preview if input already has a value
|
||||||
|
if (input.value && input.value.trim()) {
|
||||||
|
const iconClass = input.value.trim().startsWith('fa-') ? input.value.trim() : `fa-${input.value.trim()}`;
|
||||||
|
preview.innerHTML = `<i class="fas ${iconClass}"></i>`;
|
||||||
|
preview.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('input', (e) => {
|
||||||
|
const query = e.target.value.toLowerCase().trim();
|
||||||
|
|
||||||
|
// Update preview
|
||||||
|
if (query) {
|
||||||
|
const iconClass = query.startsWith('fa-') ? query : `fa-${query}`;
|
||||||
|
preview.innerHTML = `<i class="fas ${iconClass}"></i>`;
|
||||||
|
preview.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
preview.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter and display suggestions
|
||||||
|
if (query.length > 0) {
|
||||||
|
const filtered = fontAwesomeIcons.filter(icon =>
|
||||||
|
icon.includes(query) || icon.replace('fa-', '').includes(query)
|
||||||
|
).slice(0, 10); // Show top 10 matches
|
||||||
|
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
suggestions.innerHTML = filtered.map(icon => `
|
||||||
|
<div class="icon-suggestion-item" data-icon="${icon}">
|
||||||
|
<i class="fas ${icon}"></i>
|
||||||
|
<span>${icon}</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
suggestions.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Add click handlers
|
||||||
|
suggestions.querySelectorAll('.icon-suggestion-item').forEach(item => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
input.value = item.dataset.icon;
|
||||||
|
preview.innerHTML = `<i class="fas ${item.dataset.icon}"></i>`;
|
||||||
|
preview.classList.remove('hidden');
|
||||||
|
suggestions.classList.add('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
suggestions.classList.add('hidden');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
suggestions.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide suggestions when clicking outside
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!container.contains(e.target)) {
|
||||||
|
suggestions.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update preview on blur if value exists
|
||||||
|
input.addEventListener('blur', () => {
|
||||||
|
const value = input.value.trim();
|
||||||
|
if (value && preview) {
|
||||||
|
const iconClass = value.startsWith('fa-') ? value : `fa-${value}`;
|
||||||
|
preview.innerHTML = `<i class="fas ${iconClass}"></i>`;
|
||||||
|
preview.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize when DOM is ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initIconSearch);
|
||||||
|
} else {
|
||||||
|
initIconSearch();
|
||||||
|
}
|
||||||
|
|
||||||
+13
-50
@@ -1,5 +1,18 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
|
||||||
|
// Tag filter functionality
|
||||||
|
const tagFilter = document.getElementById('tag-filter');
|
||||||
|
if (tagFilter) {
|
||||||
|
tagFilter.addEventListener('change', function() {
|
||||||
|
const selectedTag = this.value;
|
||||||
|
if (selectedTag) {
|
||||||
|
window.location.href = '/devices?tag=' + encodeURIComponent(selectedTag);
|
||||||
|
} else {
|
||||||
|
window.location.href = '/devices';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Expand/collapse site groups
|
// Expand/collapse site groups
|
||||||
document.querySelectorAll('.site-header').forEach(header => {
|
document.querySelectorAll('.site-header').forEach(header => {
|
||||||
header.addEventListener('click', function(e) {
|
header.addEventListener('click', function(e) {
|
||||||
@@ -17,56 +30,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Search functionality
|
|
||||||
const searchInput = document.getElementById('search');
|
|
||||||
searchInput.addEventListener('keypress', function(event) {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
event.preventDefault();
|
|
||||||
const query = this.value.toLowerCase();
|
|
||||||
document.querySelectorAll('.site-group').forEach(siteGroup => {
|
|
||||||
let anyVisible = false;
|
|
||||||
siteGroup.querySelectorAll('.device-list li').forEach(li => {
|
|
||||||
const deviceName = li.querySelector('span').textContent.toLowerCase();
|
|
||||||
const ipSpans = li.querySelectorAll('span.inline-block');
|
|
||||||
let match = deviceName.includes(query);
|
|
||||||
if (!match) {
|
|
||||||
ipSpans.forEach(ipSpan => {
|
|
||||||
if (ipSpan.textContent.toLowerCase().includes(query)) {
|
|
||||||
match = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
li.style.display = match ? '' : 'none';
|
|
||||||
const card = li.querySelector('a');
|
|
||||||
if (match) {
|
|
||||||
anyVisible = true;
|
|
||||||
siteGroup.querySelector('.device-list').classList.remove('hidden');
|
|
||||||
const icon = siteGroup.querySelector('.expand-btn i');
|
|
||||||
if (icon && icon.classList.contains('fa-chevron-down')) {
|
|
||||||
icon.classList.remove('fa-chevron-down');
|
|
||||||
icon.classList.add('fa-chevron-up');
|
|
||||||
}
|
|
||||||
if (card) {
|
|
||||||
card.style.transition = 'background-color 0.3s';
|
|
||||||
card.style.backgroundColor = '#2563eb';
|
|
||||||
card.style.color = '#fff';
|
|
||||||
setTimeout(() => {
|
|
||||||
card.style.backgroundColor = '';
|
|
||||||
card.style.color = '';
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (card) {
|
|
||||||
card.style.backgroundColor = '';
|
|
||||||
card.style.color = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
siteGroup.style.display = anyVisible ? '' : 'none';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Scroll to Top Button
|
// Scroll to Top Button
|
||||||
const scrollToTopButton = document.createElement('button');
|
const scrollToTopButton = document.createElement('button');
|
||||||
scrollToTopButton.innerHTML = '<i class="fas fa-arrow-up"></i>';
|
scrollToTopButton.innerHTML = '<i class="fas fa-arrow-up"></i>';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
+84
-28
@@ -1,38 +1,64 @@
|
|||||||
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();
|
||||||
|
|
||||||
|
if (ipCell.includes(searchTerm) || hostnameCell.includes(searchTerm)) {
|
||||||
|
row.style.backgroundColor = 'rgba(59, 130, 246, 0.5)';
|
||||||
|
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
row.style.backgroundColor = '';
|
||||||
|
}, 3000);
|
||||||
|
} else {
|
||||||
row.style.backgroundColor = '';
|
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 +102,34 @@ 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Tag Management JavaScript
|
||||||
|
|
||||||
|
function showAddTagModal() {
|
||||||
|
document.getElementById('add-tag-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('add-tag-name').value = '';
|
||||||
|
document.getElementById('add-tag-color').value = '#6B7280';
|
||||||
|
document.getElementById('add-tag-description').value = '';
|
||||||
|
updateColorPreview('add');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAddTagModal() {
|
||||||
|
document.getElementById('add-tag-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function editTag(tagId, name, color, description) {
|
||||||
|
document.getElementById('edit-tag-id').value = tagId;
|
||||||
|
document.getElementById('edit-tag-name').value = name;
|
||||||
|
document.getElementById('edit-tag-color').value = color;
|
||||||
|
document.getElementById('edit-tag-description').value = description || '';
|
||||||
|
updateColorPreview('edit');
|
||||||
|
document.getElementById('edit-tag-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditTagModal() {
|
||||||
|
document.getElementById('edit-tag-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateColorPreview(mode) {
|
||||||
|
const colorInput = document.getElementById(`${mode}-tag-color`);
|
||||||
|
const preview = document.getElementById(`${mode}-color-preview`);
|
||||||
|
preview.textContent = colorInput.value.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const addColorInput = document.getElementById('add-tag-color');
|
||||||
|
const editColorInput = document.getElementById('edit-tag-color');
|
||||||
|
|
||||||
|
if (addColorInput) {
|
||||||
|
addColorInput.addEventListener('input', () => updateColorPreview('add'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editColorInput) {
|
||||||
|
editColorInput.addEventListener('input', () => updateColorPreview('edit'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle edit tag button clicks
|
||||||
|
document.querySelectorAll('.edit-tag-btn').forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
const tagId = this.dataset.tagId;
|
||||||
|
const tagName = this.dataset.tagName;
|
||||||
|
const tagColor = this.dataset.tagColor;
|
||||||
|
const tagDescription = this.dataset.tagDescription;
|
||||||
|
editTag(tagId, tagName, tagColor, tagDescription);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modals when clicking outside
|
||||||
|
window.onclick = function(event) {
|
||||||
|
const addModal = document.getElementById('add-tag-modal');
|
||||||
|
const editModal = document.getElementById('edit-tag-modal');
|
||||||
|
if (event.target === addModal) {
|
||||||
|
closeAddTagModal();
|
||||||
|
}
|
||||||
|
if (event.target === editModal) {
|
||||||
|
closeEditTagModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
currentVersionEl.textContent = 'v' + data.current_version;
|
||||||
|
latestVersionEl.textContent = 'v' + data.latest_version;
|
||||||
|
|
||||||
|
// Set compare link (current version to latest version)
|
||||||
|
compareLink.href = `https://github.com/JDB-NET/ipam/compare/v${data.current_version}...v${data.latest_version}`;
|
||||||
|
|
||||||
|
// Show toast
|
||||||
|
toast.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Close button handler
|
||||||
|
closeBtn.addEventListener('click', function() {
|
||||||
|
toast.classList.add('hidden');
|
||||||
|
sessionStorage.setItem('update-toast-dismissed', 'true');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error checking for updates:', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
// These variables are set inline in the template from server data
|
||||||
|
// permissions and rolePermissions are passed from the template
|
||||||
|
|
||||||
|
function showTab(tab) {
|
||||||
|
document.getElementById('users-tab').classList.add('hidden');
|
||||||
|
document.getElementById('roles-tab').classList.add('hidden');
|
||||||
|
document.getElementById('tab-users').classList.remove('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
|
||||||
|
document.getElementById('tab-users').classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
||||||
|
document.getElementById('tab-roles').classList.remove('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
|
||||||
|
document.getElementById('tab-roles').classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
||||||
|
|
||||||
|
if (tab === 'users') {
|
||||||
|
document.getElementById('users-tab').classList.remove('hidden');
|
||||||
|
document.getElementById('tab-users').classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
||||||
|
document.getElementById('tab-users').classList.add('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
|
||||||
|
} else {
|
||||||
|
document.getElementById('roles-tab').classList.remove('hidden');
|
||||||
|
document.getElementById('tab-roles').classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
||||||
|
document.getElementById('tab-roles').classList.add('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editUser(userId, name, email, roleId, apiKey) {
|
||||||
|
document.getElementById('edit-user-id').value = userId;
|
||||||
|
document.getElementById('edit-user-name').value = name;
|
||||||
|
document.getElementById('edit-user-email').value = email;
|
||||||
|
document.getElementById('edit-user-password').value = '';
|
||||||
|
document.getElementById('edit-user-role').value = (roleId === null || roleId === 'null') ? '' : roleId;
|
||||||
|
document.getElementById('edit-user-api-key').textContent = apiKey || 'No API Key';
|
||||||
|
document.getElementById('edit-user-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditUserModal() {
|
||||||
|
document.getElementById('edit-user-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAddRoleModal() {
|
||||||
|
// Make sure edit modal is closed first
|
||||||
|
document.getElementById('edit-role-modal').classList.add('hidden');
|
||||||
|
// Clear any form data
|
||||||
|
const addForm = document.querySelector('#add-role-modal form');
|
||||||
|
if (addForm) {
|
||||||
|
addForm.reset();
|
||||||
|
}
|
||||||
|
// Show add modal
|
||||||
|
document.getElementById('add-role-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAddRoleModal() {
|
||||||
|
document.getElementById('add-role-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function editRole(roleId, roleName, roleDescription) {
|
||||||
|
// Make sure add modal is closed first
|
||||||
|
document.getElementById('add-role-modal').classList.add('hidden');
|
||||||
|
document.getElementById('edit-role-id').value = roleId;
|
||||||
|
document.getElementById('edit-role-name').value = roleName;
|
||||||
|
document.getElementById('edit-role-description').value = roleDescription || '';
|
||||||
|
|
||||||
|
const permissionsDiv = document.getElementById('edit-role-permissions');
|
||||||
|
permissionsDiv.innerHTML = '';
|
||||||
|
|
||||||
|
const rolePerms = rolePermissions[roleId] || [];
|
||||||
|
|
||||||
|
// Group permissions by merged categories
|
||||||
|
const viewPerms = permissions.filter(p => p[3] === 'View');
|
||||||
|
const devicePerms = permissions.filter(p => p[3] === 'Device');
|
||||||
|
const deviceTypePerms = permissions.filter(p => p[3] === 'Device Type');
|
||||||
|
const subnetPerms = permissions.filter(p => p[3] === 'Subnet');
|
||||||
|
const dhcpPerms = permissions.filter(p => p[3] === 'DHCP');
|
||||||
|
const rackPerms = permissions.filter(p => p[3] === 'Rack');
|
||||||
|
const adminPerms = permissions.filter(p => p[3] === 'Admin');
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
// View Permissions
|
||||||
|
html += ' <!-- View Permissions -->\n';
|
||||||
|
html += ' <div class="col-span-full">\n';
|
||||||
|
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">View Permissions</h4>\n';
|
||||||
|
html += ' <div class="grid grid-cols-1 md:grid-cols-2 gap-2">\n';
|
||||||
|
viewPerms.forEach(perm => {
|
||||||
|
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
|
||||||
|
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
|
||||||
|
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
|
||||||
|
<span class="text-sm">${perm[2]}</span>
|
||||||
|
</label>\n`;
|
||||||
|
});
|
||||||
|
html += ' </div>\n';
|
||||||
|
html += ' </div>\n';
|
||||||
|
html += ' \n';
|
||||||
|
|
||||||
|
// Device Management
|
||||||
|
html += ' <!-- Device Management -->\n';
|
||||||
|
html += ' <div>\n';
|
||||||
|
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Device Management</h4>\n';
|
||||||
|
devicePerms.forEach(perm => {
|
||||||
|
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
|
||||||
|
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
|
||||||
|
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
|
||||||
|
<span class="text-sm">${perm[2]}</span>
|
||||||
|
</label>\n`;
|
||||||
|
});
|
||||||
|
deviceTypePerms.forEach(perm => {
|
||||||
|
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
|
||||||
|
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
|
||||||
|
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
|
||||||
|
<span class="text-sm">${perm[2]}</span>
|
||||||
|
</label>\n`;
|
||||||
|
});
|
||||||
|
html += ' </div>\n';
|
||||||
|
html += ' \n';
|
||||||
|
|
||||||
|
// Network Management
|
||||||
|
html += ' <!-- Network Management -->\n';
|
||||||
|
html += ' <div>\n';
|
||||||
|
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Network Management</h4>\n';
|
||||||
|
subnetPerms.forEach(perm => {
|
||||||
|
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
|
||||||
|
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
|
||||||
|
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
|
||||||
|
<span class="text-sm">${perm[2]}</span>
|
||||||
|
</label>\n`;
|
||||||
|
});
|
||||||
|
dhcpPerms.forEach(perm => {
|
||||||
|
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
|
||||||
|
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
|
||||||
|
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
|
||||||
|
<span class="text-sm">${perm[2]}</span>
|
||||||
|
</label>\n`;
|
||||||
|
});
|
||||||
|
html += ' </div>\n';
|
||||||
|
html += ' \n';
|
||||||
|
|
||||||
|
// Rack Management
|
||||||
|
html += ' <!-- Rack Management -->\n';
|
||||||
|
html += ' <div>\n';
|
||||||
|
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Rack Management</h4>\n';
|
||||||
|
rackPerms.forEach(perm => {
|
||||||
|
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
|
||||||
|
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
|
||||||
|
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
|
||||||
|
<span class="text-sm">${perm[2]}</span>
|
||||||
|
</label>\n`;
|
||||||
|
});
|
||||||
|
html += ' </div>\n';
|
||||||
|
html += ' \n';
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
html += ' <!-- Admin -->\n';
|
||||||
|
html += ' <div>\n';
|
||||||
|
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Administration</h4>\n';
|
||||||
|
adminPerms.forEach(perm => {
|
||||||
|
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
|
||||||
|
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
|
||||||
|
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
|
||||||
|
<span class="text-sm">${perm[2]}</span>
|
||||||
|
</label>\n`;
|
||||||
|
});
|
||||||
|
html += ' </div>\n';
|
||||||
|
|
||||||
|
permissionsDiv.innerHTML = html;
|
||||||
|
|
||||||
|
document.getElementById('edit-role-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditRoleModal() {
|
||||||
|
document.getElementById('edit-role-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteRole(roleId, roleName) {
|
||||||
|
if (confirm(`Are you sure you want to delete the role "${roleName}"?`)) {
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = '/users';
|
||||||
|
form.innerHTML = `
|
||||||
|
<input type="hidden" name="action" value="delete_role">
|
||||||
|
<input type="hidden" name="role_id" value="${roleId}">
|
||||||
|
`;
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modals when clicking outside
|
||||||
|
window.onclick = function(event) {
|
||||||
|
const editUserModal = document.getElementById('edit-user-modal');
|
||||||
|
const editRoleModal = document.getElementById('edit-role-modal');
|
||||||
|
const addRoleModal = document.getElementById('add-role-modal');
|
||||||
|
if (event.target === editUserModal) {
|
||||||
|
closeEditUserModal();
|
||||||
|
}
|
||||||
|
if (event.target === editRoleModal) {
|
||||||
|
closeEditRoleModal();
|
||||||
|
}
|
||||||
|
if (event.target === addRoleModal) {
|
||||||
|
closeAddRoleModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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">
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<option value="{{ dtype[0] }}">{{ dtype[1] }}</option>
|
<option value="{{ dtype[0] }}">{{ dtype[1] }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">Add Device</button>
|
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">Add Device</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<label for="height_u" class="block font-medium mb-1">Height (U)</label>
|
<label for="height_u" class="block font-medium mb-1">Height (U)</label>
|
||||||
<input type="number" id="height_u" name="height_u" min="1" max="60" class="p-3 rounded-lg bg-gray-200 dark:bg-zinc-800 border border-gray-600 w-full" required>
|
<input type="number" id="height_u" name="height_u" min="1" max="60" class="p-3 rounded-lg bg-gray-200 dark:bg-zinc-800 border border-gray-600 w-full" required>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg w-full">Add Rack</button>
|
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg w-full">Add Rack</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+185
-33
@@ -10,44 +10,196 @@
|
|||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
||||||
{% include 'header.html' %}
|
{% include 'header.html' %}
|
||||||
<div class="flex-1 flex items-center justify-center mx-4">
|
<div class="flex-1 mx-4 py-8 pt-20">
|
||||||
<div class="container py-8 max-w-md pt-20">
|
<div class="container max-w-7xl mx-auto">
|
||||||
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
|
<h1 class="text-3xl font-bold mb-6 text-center">Admin Panel</h1>
|
||||||
<h1 class="text-3xl font-bold mb-6 text-center">Admin</h1>
|
|
||||||
<div class="flex justify-center gap-4 mb-6">
|
|
||||||
<a href="/audit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg shadow text-center w-40">Audit Log</a>
|
|
||||||
<a href="/users" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg shadow text-center w-40">Users</a>
|
|
||||||
</div>
|
|
||||||
<hr class="border-t-2 border-gray-600 rounded-lg mb-4">
|
|
||||||
<h1 class="text-2xl font-bold mb-6 text-center">Add Subnet</h1>
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="text-red-500 text-center mb-4">{{ error }}</div>
|
<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 %}
|
{% endif %}
|
||||||
<form action="/add_subnet" method="POST" class="mb-6" onsubmit="return validateSubnetForm();">
|
|
||||||
<div class="flex flex-col space-y-4">
|
<!-- Quick Links -->
|
||||||
<input type="text" name="name" placeholder="Subnet Name" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600" required>
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
||||||
<input type="text" name="cidr" id="cidr-input" placeholder="CIDR (e.g., 192.168.1.0/24)" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600" required>
|
<a href="/audit" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
|
||||||
<input type="text" name="site" placeholder="Site/Location" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600" required>
|
<div class="flex items-center space-x-4">
|
||||||
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">Add Subnet</button>
|
<i class="fas fa-clipboard-list text-3xl text-gray-600 dark:text-gray-400"></i>
|
||||||
<span id="cidr-error" class="text-red-500 text-sm hidden"></span>
|
<div>
|
||||||
|
<h3 class="text-lg font-bold">Audit Log</h3>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">View system activity</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-chevron-right text-gray-400"></i>
|
||||||
|
</a>
|
||||||
|
<a href="/users" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<i class="fas fa-users text-3xl text-gray-600 dark:text-gray-400"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-bold">User Management</h3>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Manage users & roles</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-chevron-right text-gray-400"></i>
|
||||||
|
</a>
|
||||||
|
{% if has_permission('view_tags') %}
|
||||||
|
<a href="/tags" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<i class="fas fa-tags text-3xl text-gray-600 dark:text-gray-400"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-bold">Tag Management</h3>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Manage device tags</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-chevron-right text-gray-400"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="/api-docs" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<i class="fas fa-code text-3xl text-gray-600 dark:text-gray-400"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-bold">API Documentation</h3>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Interactive API reference</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-chevron-right text-gray-400"></i>
|
||||||
|
</a>
|
||||||
|
<a href="/backup" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<i class="fas fa-database text-3xl text-gray-600 dark:text-gray-400"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-bold">Backup & Restore</h3>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Database backup and restore</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-chevron-right text-gray-400"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subnet Management Section -->
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h2 class="text-2xl font-bold">Subnet Management</h2>
|
||||||
|
{% if can_add_subnet %}
|
||||||
|
<button onclick="showAddSubnetModal()" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg font-medium">
|
||||||
|
<i class="fas fa-plus mr-2"></i>Add Subnet
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
<hr class="border-t-2 border-gray-600 rounded-lg mb-4">
|
{% if subnets %}
|
||||||
<h1 class="text-2xl font-bold mb-6 text-center">Delete Subnet</h1>
|
<div class="overflow-x-auto">
|
||||||
<form action="/delete_subnet" method="POST" class="mb-6 flex items-center space-x-4 justify-center" onsubmit="return confirm('Are you sure you want to delete this subnet and all its IPs? This action is irreversible.');">
|
<table class="w-full">
|
||||||
<select name="subnet_id" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600" required>
|
<thead>
|
||||||
<option value="" disabled selected>Select Subnet</option>
|
<tr class="border-b border-gray-600">
|
||||||
{% for subnet in subnets %}
|
<th class="text-center p-3">Name</th>
|
||||||
<option value="{{ subnet.id }}">{{ subnet.name }} ({{ subnet.cidr }})</option>
|
<th class="text-center p-3">CIDR</th>
|
||||||
{% endfor %}
|
<th class="text-center p-3">Site</th>
|
||||||
</select>
|
<th class="text-center p-3">Utilisation</th>
|
||||||
<button type="submit" class="text-red-500 hover:text-red-700 rounded-full p-3" title="Delete Subnet">
|
<th class="text-center p-3">Actions</th>
|
||||||
<i class="fas fa-trash fa-lg"></i>
|
</tr>
|
||||||
</button>
|
</thead>
|
||||||
</form>
|
<tbody>
|
||||||
</div>
|
{% for subnet in subnets %}
|
||||||
|
<tr class="border-b border-gray-600 hover:bg-gray-300 dark:hover:bg-zinc-700">
|
||||||
|
<td class="p-3 font-medium text-center">{{ subnet.name }}</td>
|
||||||
|
<td class="p-3 font-mono text-sm text-center">{{ subnet.cidr }}</td>
|
||||||
|
<td class="p-3 text-center">
|
||||||
|
<span class="px-2 py-1 bg-gray-300 dark:bg-zinc-700 rounded text-sm">{{ subnet.site }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 text-center">
|
||||||
|
{% if subnet.utilization %}
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{ subnet.utilization.percent }}%</span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-500 ml-1">({{ subnet.utilization.used }}/{{ subnet.utilization.total }})</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-sm text-gray-500">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="p-3 text-center">
|
||||||
|
<div class="flex items-center justify-center space-x-2">
|
||||||
|
<a href="/subnet/{{ subnet.id }}" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300" title="View Subnet">
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
</a>
|
||||||
|
{% if can_edit_subnet %}
|
||||||
|
<button onclick="editSubnet({{ subnet.id }}, '{{ subnet.name|replace("'", "\\'") }}', '{{ subnet.cidr|replace("'", "\\'") }}', '{{ subnet.site|replace("'", "\\'") }}')" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Edit Subnet">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if can_delete_subnet %}
|
||||||
|
<form action="/delete_subnet" method="POST" onsubmit="return confirm('Are you sure you want to delete this subnet and all its IPs? This action is irreversible.');" class="inline">
|
||||||
|
<input type="hidden" name="subnet_id" value="{{ subnet.id }}">
|
||||||
|
<button type="submit" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Delete Subnet">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-8 text-gray-500">
|
||||||
|
<i class="fas fa-network-wired text-4xl mb-4"></i>
|
||||||
|
<p>No subnets found. Add your first subnet to get started.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Subnet Modal -->
|
||||||
|
<div id="add-subnet-modal" class="hidden fixed inset-0 bg-black/30 backdrop-blur-md flex items-center justify-center z-50">
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-lg max-w-md w-full mx-4">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-bold">Add New Subnet</h2>
|
||||||
|
<button onclick="closeAddSubnetModal()" class="text-gray-500 hover:text-gray-700">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form action="/add_subnet" method="POST" onsubmit="return validateSubnetForm();">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<input type="text" name="name" id="add-subnet-name" placeholder="Subnet Name" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
|
||||||
|
<input type="text" name="cidr" id="add-subnet-cidr" placeholder="CIDR (e.g., 192.168.1.0/24)" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
|
||||||
|
<input type="text" name="site" id="add-subnet-site" placeholder="Site/Location" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
|
||||||
|
<span id="cidr-error" class="text-red-500 text-sm hidden"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end space-x-2 mt-6">
|
||||||
|
<button type="button" onclick="closeAddSubnetModal()" class="px-4 py-2 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Add Subnet</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Subnet Modal -->
|
||||||
|
<div id="edit-subnet-modal" class="hidden fixed inset-0 bg-black/30 backdrop-blur-md flex items-center justify-center z-50">
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-lg max-w-md w-full mx-4">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-bold">Edit Subnet</h2>
|
||||||
|
<button onclick="closeEditSubnetModal()" class="text-gray-500 hover:text-gray-700">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form action="/edit_subnet" method="POST" onsubmit="return validateEditSubnetForm();">
|
||||||
|
<input type="hidden" name="subnet_id" id="edit-subnet-id">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<input type="text" name="name" id="edit-subnet-name" placeholder="Subnet Name" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
|
||||||
|
<input type="text" name="cidr" id="edit-subnet-cidr" placeholder="CIDR (e.g., 192.168.1.0/24)" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
|
||||||
|
<input type="text" name="site" id="edit-subnet-site" placeholder="Site/Location" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
|
||||||
|
<span id="edit-cidr-error" class="text-red-500 text-sm hidden"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end space-x-2 mt-6">
|
||||||
|
<button type="button" onclick="closeEditSubnetModal()" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/add_subnet.js"></script>
|
<script src="/static/js/add_subnet.js"></script>
|
||||||
|
<script src="/static/js/admin.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>API Documentation - IPAM</title>
|
||||||
|
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
|
||||||
|
<link href="/static/css/output.css" rel="stylesheet">
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
||||||
|
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
||||||
|
{% include 'header.html' %}
|
||||||
|
<div class="flex-1 mx-4 py-8 pt-20">
|
||||||
|
<div class="container max-w-7xl mx-auto">
|
||||||
|
<h1 class="text-3xl font-bold mb-8 text-center">API Documentation</h1>
|
||||||
|
|
||||||
|
<!-- Authentication Section -->
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8 mb-8">
|
||||||
|
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">Authentication</h2>
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<p class="mb-4">All API requests require authentication using an API key. You can provide the API key in one of three ways:</p>
|
||||||
|
<ul class="list-disc list-inside space-y-2 ml-4">
|
||||||
|
<li><strong>Header:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">X-API-Key: your_api_key</code></li>
|
||||||
|
<li><strong>Authorization Header:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">Authorization: Bearer your_api_key</code></li>
|
||||||
|
<li><strong>Query Parameter:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">?api_key=your_api_key</code></li>
|
||||||
|
</ul>
|
||||||
|
<p class="mt-4"><strong>Base URL:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">/api/v1</code></p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
|
||||||
|
<h3 class="font-semibold mb-2">Your API Key</h3>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<input type="text" id="apiKey" value="{{ api_key or '' }}" readonly
|
||||||
|
class="flex-1 px-3 py-2 bg-gray-100 dark:bg-zinc-600 border border-gray-400 dark:border-zinc-500 rounded text-sm font-mono"
|
||||||
|
placeholder="API key not found">
|
||||||
|
<button onclick="testConnection()" class="px-4 py-2 bg-gray-500 hover:bg-gray-600 dark:bg-zinc-600 dark:hover:bg-zinc-500 text-white rounded text-sm transition-colors">
|
||||||
|
<i class="fas fa-plug mr-2"></i>Test
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="connectionStatus" class="mt-2 text-sm"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Interactive Endpoints -->
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8 mb-8">
|
||||||
|
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
|
||||||
|
<i class="fas fa-play-circle mr-2"></i>Interactive Testing
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-300 mb-6">Test GET endpoints directly in your browser. Other methods are documented below.</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<!-- GET /devices -->
|
||||||
|
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="px-2 py-1 bg-green-600 text-white rounded text-xs font-mono">GET</span>
|
||||||
|
<code class="text-sm">/api/v1/devices</code>
|
||||||
|
</div>
|
||||||
|
<button onclick="tryEndpoint('GET', '/api/v1/devices', null, 'devices-list')"
|
||||||
|
class="px-3 py-1 bg-gray-500 hover:bg-gray-600 dark:bg-zinc-600 dark:hover:bg-zinc-500 text-white rounded text-xs transition-colors">
|
||||||
|
<i class="fas fa-play mr-1"></i>Try
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-300 mb-2">List all devices</p>
|
||||||
|
<div id="devices-list-response" class="hidden">
|
||||||
|
<pre class="bg-gray-100 dark:bg-zinc-600 p-2 rounded text-xs overflow-x-auto max-h-32" id="devices-list"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GET /devices/{id} -->
|
||||||
|
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="px-2 py-1 bg-green-600 text-white rounded text-xs font-mono">GET</span>
|
||||||
|
<code class="text-sm">/api/v1/devices/{id}</code>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-1">
|
||||||
|
<input type="number" id="device-id" placeholder="ID" class="px-2 py-1 border rounded text-xs w-16">
|
||||||
|
<button onclick="tryEndpointWithId('GET', '/api/v1/devices/', 'device-id', 'device-detail')"
|
||||||
|
class="px-3 py-1 bg-gray-500 hover:bg-gray-600 dark:bg-zinc-600 dark:hover:bg-zinc-500 text-white rounded text-xs transition-colors">
|
||||||
|
<i class="fas fa-play mr-1"></i>Try
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-300 mb-2">Get device by ID</p>
|
||||||
|
<div id="device-detail-response" class="hidden">
|
||||||
|
<pre class="bg-gray-100 dark:bg-zinc-600 p-2 rounded text-xs overflow-x-auto max-h-32" id="device-detail"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GET /devices/by-tag/{tag} -->
|
||||||
|
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="px-2 py-1 bg-green-600 text-white rounded text-xs font-mono">GET</span>
|
||||||
|
<code class="text-sm">/api/v1/devices/by-tag/{tag}</code>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-1">
|
||||||
|
<input type="text" id="tag-name" placeholder="Tag" class="px-2 py-1 border rounded text-xs w-20">
|
||||||
|
<button onclick="tryEndpointWithId('GET', '/api/v1/devices/by-tag/', 'tag-name', 'devices-by-tag')"
|
||||||
|
class="px-3 py-1 bg-gray-500 hover:bg-gray-600 dark:bg-zinc-600 dark:hover:bg-zinc-500 text-white rounded text-xs transition-colors">
|
||||||
|
<i class="fas fa-play mr-1"></i>Try
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-300 mb-2">Filter devices by tag</p>
|
||||||
|
<div id="devices-by-tag-response" class="hidden">
|
||||||
|
<pre class="bg-gray-100 dark:bg-zinc-600 p-2 rounded text-xs overflow-x-auto max-h-32" id="devices-by-tag"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GET /tags -->
|
||||||
|
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="px-2 py-1 bg-green-600 text-white rounded text-xs font-mono">GET</span>
|
||||||
|
<code class="text-sm">/api/v1/tags</code>
|
||||||
|
</div>
|
||||||
|
<button onclick="tryEndpoint('GET', '/api/v1/tags', null, 'tags-list')"
|
||||||
|
class="px-3 py-1 bg-gray-500 hover:bg-gray-600 dark:bg-zinc-600 dark:hover:bg-zinc-500 text-white rounded text-xs transition-colors">
|
||||||
|
<i class="fas fa-play mr-1"></i>Try
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-300 mb-2">List all tags</p>
|
||||||
|
<div id="tags-list-response" class="hidden">
|
||||||
|
<pre class="bg-gray-100 dark:bg-zinc-600 p-2 rounded text-xs overflow-x-auto max-h-32" id="tags-list"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Complete API Documentation -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Devices Section -->
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
|
||||||
|
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
|
||||||
|
<i class="fas fa-server mr-2"></i>Devices
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold mb-2">Read Operations</h4>
|
||||||
|
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||||
|
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/devices</code> - List all devices</li>
|
||||||
|
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/devices/{id}</code> - Get device details</li>
|
||||||
|
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/devices/by-tag/{tag}</code> - Get devices by tag</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold mb-2">Write Operations</h4>
|
||||||
|
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||||
|
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/devices</code> - Create device</li>
|
||||||
|
<li><code class="bg-blue-200 dark:bg-blue-800 px-2 py-1 rounded text-xs">PUT /api/v1/devices/{id}</code> - Update device</li>
|
||||||
|
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/devices/{id}</code> - Delete device</li>
|
||||||
|
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/devices/{id}/ips</code> - Add IP to device</li>
|
||||||
|
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/devices/{id}/ips/{ip_id}</code> - Remove IP</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subnets Section -->
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
|
||||||
|
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
|
||||||
|
<i class="fas fa-network-wired mr-2"></i>Subnets
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold mb-2">Read Operations</h4>
|
||||||
|
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||||
|
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/subnets</code> - List all subnets</li>
|
||||||
|
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/subnets/{id}</code> - Get subnet details</li>
|
||||||
|
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/subnets/{id}/next_free_ip</code> - Get next free IP address</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold mb-2">Write Operations</h4>
|
||||||
|
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||||
|
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/subnets</code> - Create subnet</li>
|
||||||
|
<li><code class="bg-blue-200 dark:bg-blue-800 px-2 py-1 rounded text-xs">PUT /api/v1/subnets/{id}</code> - Update subnet</li>
|
||||||
|
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/subnets/{id}</code> - Delete subnet</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Racks Section -->
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
|
||||||
|
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
|
||||||
|
<i class="fas fa-building mr-2"></i>Racks
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold mb-2">Read Operations</h4>
|
||||||
|
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||||
|
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/racks</code> - List all racks</li>
|
||||||
|
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/racks/{id}</code> - Get rack details</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold mb-2">Write Operations</h4>
|
||||||
|
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||||
|
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/racks</code> - Create rack</li>
|
||||||
|
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/racks/{id}</code> - Delete rack</li>
|
||||||
|
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/racks/{id}/devices</code> - Add device to rack</li>
|
||||||
|
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/racks/{id}/devices/{device_id}</code> - Remove device</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags Section -->
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
|
||||||
|
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
|
||||||
|
<i class="fas fa-tags mr-2"></i>Tags
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold mb-2">Read Operations</h4>
|
||||||
|
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||||
|
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/tags</code> - List all tags</li>
|
||||||
|
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/tags?format=simple</code> - List tags in simple format</li>
|
||||||
|
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/tags/{id}</code> - Get tag details</li>
|
||||||
|
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/devices/{id}/tags</code> - Get device tags</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold mb-2">Write Operations</h4>
|
||||||
|
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||||
|
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/tags</code> - Create tag</li>
|
||||||
|
<li><code class="bg-blue-200 dark:bg-blue-800 px-2 py-1 rounded text-xs">PUT /api/v1/tags/{id}</code> - Update tag</li>
|
||||||
|
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/tags/{id}</code> - Delete tag</li>
|
||||||
|
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/devices/{id}/tags</code> - Assign tag to device</li>
|
||||||
|
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/devices/{id}/tags/{tag_id}</code> - Remove tag</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Additional Endpoints -->
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
|
||||||
|
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
|
||||||
|
<i class="fas fa-cogs mr-2"></i>Additional Endpoints
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
|
||||||
|
<h4 class="font-semibold mb-3"><i class="fas fa-info-circle mr-2"></i>System Information</h4>
|
||||||
|
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||||
|
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/info</code> - System information</li>
|
||||||
|
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/device-types</code> - List device types</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
|
||||||
|
<h4 class="font-semibold mb-3"><i class="fas fa-dharmachakra mr-2"></i>DHCP Management</h4>
|
||||||
|
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||||
|
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/subnets/{id}/dhcp</code> - Get DHCP config</li>
|
||||||
|
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/subnets/{id}/dhcp</code> - Generate DHCP config</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
|
||||||
|
<h4 class="font-semibold mb-3"><i class="fas fa-users mr-2"></i>User & Role Management</h4>
|
||||||
|
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||||
|
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/users</code> - List users</li>
|
||||||
|
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/roles</code> - List roles</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
|
||||||
|
<h4 class="font-semibold mb-3"><i class="fas fa-clipboard-list mr-2"></i>Audit Log</h4>
|
||||||
|
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||||
|
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/audit</code> - List audit entries</li>
|
||||||
|
</ul>
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400 mt-2">Supports filtering with query parameters</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Response Format & Permissions -->
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
|
||||||
|
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
|
||||||
|
<i class="fas fa-info-circle mr-2"></i>Response Format & Permissions
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
|
||||||
|
<h3 class="text-lg font-semibold mb-3">Success Responses</h3>
|
||||||
|
<p class="mb-3 text-sm">All API responses are in JSON format. Successful requests return:</p>
|
||||||
|
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||||
|
<li><code class="bg-green-200 dark:bg-green-800 px-2 py-1 rounded text-xs">200 OK</code> - Request successful</li>
|
||||||
|
<li><code class="bg-green-200 dark:bg-green-800 px-2 py-1 rounded text-xs">201 Created</code> - Resource created</li>
|
||||||
|
<li><code class="bg-green-200 dark:bg-green-800 px-2 py-1 rounded text-xs">204 No Content</code> - Success with no response body</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
|
||||||
|
<h3 class="text-lg font-semibold mb-3">Error Responses</h3>
|
||||||
|
<p class="mb-3 text-sm">Error responses include descriptive messages:</p>
|
||||||
|
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||||
|
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">400 Bad Request</code> - Invalid request data</li>
|
||||||
|
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">401 Unauthorized</code> - Missing or invalid API key</li>
|
||||||
|
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">403 Forbidden</code> - Insufficient permissions</li>
|
||||||
|
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">404 Not Found</code> - Resource not found</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
|
||||||
|
<h3 class="text-lg font-semibold mb-3"><i class="fas fa-shield-alt mr-2"></i>Permissions</h3>
|
||||||
|
<p class="text-sm">API endpoints respect the same role-based permissions as the web interface. Users can only perform actions that their role allows. If a user lacks the required permission, the API will return a <code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">403 Forbidden</code> error with details about the missing permission.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/api_docs.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+153
-68
@@ -13,77 +13,172 @@
|
|||||||
<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 px-4 py-2 rounded-lg flex items-center gap-2">
|
|
||||||
<i class="fas fa-search"></i>
|
|
||||||
<span>Filter</span>
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
|
||||||
<table class="w-full table-auto bg-gray-200 dark:bg-zinc-800 rounded-lg overflow-hidden">
|
<!-- Advanced Filter Form -->
|
||||||
<thead>
|
<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 %}">
|
||||||
<tr class="bg-gray-400 dark:bg-zinc-700">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4">
|
||||||
<th class="px-4 py-2 text-center">User</th>
|
<!-- Search -->
|
||||||
<th class="px-4 py-2 text-center">Action</th>
|
<div class="lg:col-span-3">
|
||||||
<th class="px-4 py-2 text-center">Details</th>
|
<label class="block text-sm font-medium mb-1">Search</label>
|
||||||
<th class="px-4 py-2 text-center">Subnet</th>
|
<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">
|
||||||
<th class="px-4 py-2 text-center">Timestamp</th>
|
</div>
|
||||||
</tr>
|
|
||||||
</thead>
|
<!-- Multiple Users -->
|
||||||
<tbody>
|
<div>
|
||||||
{% for log in logs %}
|
<label class="block text-sm font-medium mb-1">Users</label>
|
||||||
<tr class="border-b border-gray-700">
|
<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">
|
||||||
<td class="px-4 py-2 text-center">{{ log[1] or 'Unknown' }}</td>
|
{% for user in users %}
|
||||||
<td class="px-4 py-2 text-center">{{ log[2] }}</td>
|
<option value="{{ user[0] }}" {% if selected_user_ids and user[0]|string in selected_user_ids %}selected{% endif %}>{{ user[1] }}</option>
|
||||||
<td class="px-4 py-2 text-center truncate" title="{{ log[3] }}">{{ log[3][:100] ~ ('…' if log[3]|length > 100 else '') }}</td>
|
{% endfor %}
|
||||||
<td class="px-4 py-2 text-center">{{ log[4] or 'N/A' }}</td>
|
</select>
|
||||||
<td class="px-4 py-2 text-center" data-utc="{{ log[5] }}">{{ log[5] }}</td>
|
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Hold Ctrl/Cmd to select multiple</p>
|
||||||
</tr>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
<!-- Subnet -->
|
||||||
</table>
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">Subnet</label>
|
||||||
|
<select name="subnet_id" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
|
||||||
|
<option value="">All Subnets</option>
|
||||||
|
{% for subnet in subnets %}
|
||||||
|
<option value="{{ subnet[0] }}" {% if request.args.get('subnet_id') == subnet[0]|string %}selected{% endif %}>{{ subnet[1] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">Action</label>
|
||||||
|
<select name="action" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
|
||||||
|
<option value="">All Actions</option>
|
||||||
|
{% for a in actions %}
|
||||||
|
<option value="{{ a }}" {% if request.args.get('action') == a %}selected{% endif %}>{{ a }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Device Name -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">Device</label>
|
||||||
|
<select name="device_name" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
|
||||||
|
<option value="">All Devices</option>
|
||||||
|
{% for device in devices %}
|
||||||
|
<option value="{{ device[0] }}" {% if request.args.get('device_name') == device[0] %}selected{% endif %}>{{ device[0] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date From -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">Date From</label>
|
||||||
|
<input type="date" name="date_from" value="{{ date_from or '' }}" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date To -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">Date To</label>
|
||||||
|
<input type="date" name="date_to" value="{{ date_to or '' }}" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 justify-center">
|
||||||
|
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
<span>Filter</span>
|
||||||
|
</button>
|
||||||
|
<a href="/audit?expand_filters=1" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
<span>Clear</span>
|
||||||
|
</a>
|
||||||
|
<button type="button" id="export-btn" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
|
||||||
|
<i class="fas fa-file-csv"></i>
|
||||||
|
<span>Export CSV</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Audit Log Table -->
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full table-auto bg-gray-200 dark:bg-zinc-800 rounded-lg overflow-hidden">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-400 dark:bg-zinc-700">
|
||||||
|
<th class="px-4 py-2 text-center">User</th>
|
||||||
|
<th class="px-4 py-2 text-center">Action</th>
|
||||||
|
<th class="px-4 py-2 text-center details-cell">Details</th>
|
||||||
|
<th class="px-4 py-2 text-center">Subnet</th>
|
||||||
|
<th class="px-4 py-2 text-center">Timestamp</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for log in logs %}
|
||||||
|
<tr class="border-b border-gray-700">
|
||||||
|
<td class="px-4 py-2 text-center">{{ log[1] or 'Unknown' }}</td>
|
||||||
|
<td class="px-4 py-2 text-center">{{ log[2] }}</td>
|
||||||
|
<td class="px-4 py-2 text-center details-cell">
|
||||||
|
<div class="diff-container" data-details="{{ log[3]|e }}"></div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 text-center">{{ log[4] or 'N/A' }}</td>
|
||||||
|
<td class="px-4 py-2 text-center" data-utc="{{ log[5] }}">{{ log[5] }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if total_pages > 1 %}
|
{% 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>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for p in range(1, total_pages+1) %}
|
|
||||||
{% set page_args = query_args.copy() %}
|
{# Smart pagination logic #}
|
||||||
{% set _ = page_args.update({'page': p}) %}
|
{% set delta = 2 %}
|
||||||
<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>
|
{% set start_page = [1, page - delta]|max %}
|
||||||
|
{% set end_page = [total_pages, page + delta]|min %}
|
||||||
|
|
||||||
|
{# Show first page if we're not near the start #}
|
||||||
|
{% if start_page > 1 %}
|
||||||
|
{% set page_args = query_args.copy() %}
|
||||||
|
{% set _ = page_args.update({'page': 1}) %}
|
||||||
|
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded hover:cursor-pointer {{ 'bg-gray-200 dark:bg-gray-500' if 1 == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">1</a>
|
||||||
|
{% if start_page > 2 %}
|
||||||
|
<span class="px-3 py-1 text-gray-600 dark:text-gray-400">…</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Show pages around current page #}
|
||||||
|
{% for p in range(start_page, end_page + 1) %}
|
||||||
|
{% set page_args = query_args.copy() %}
|
||||||
|
{% set _ = page_args.update({'page': p}) %}
|
||||||
|
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded hover:cursor-pointer {{ 'bg-gray-200 dark:bg-gray-500' if p == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">{{ p }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
{# Show last page if we're not near the end #}
|
||||||
|
{% if end_page < total_pages %}
|
||||||
|
{% if end_page < total_pages - 1 %}
|
||||||
|
<span class="px-3 py-1 text-gray-600 dark:text-gray-400">…</span>
|
||||||
|
{% endif %}
|
||||||
|
{% set page_args = query_args.copy() %}
|
||||||
|
{% set _ = page_args.update({'page': total_pages}) %}
|
||||||
|
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded hover:cursor-pointer {{ 'bg-gray-200 dark:bg-gray-500' if total_pages == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">{{ total_pages }}</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if page < total_pages %}
|
{% 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>
|
||||||
@@ -92,16 +187,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script src="/static/js/audit.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.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,160 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Bulk Operations</title>
|
||||||
|
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
|
||||||
|
<link href="/static/css/output.css" rel="stylesheet">
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
||||||
|
{% include 'header.html' %}
|
||||||
|
<div class="flex-1 mx-4 py-8 pt-20">
|
||||||
|
<div class="container max-w-6xl mx-auto">
|
||||||
|
<div class="flex items-center mb-6 relative">
|
||||||
|
<a href="/devices" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
|
||||||
|
<h1 class="text-3xl font-bold text-center w-full">Bulk Operations</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="flex flex-wrap gap-2 mb-6 justify-center border-b border-gray-600">
|
||||||
|
<button onclick="showTab('assign-ips')" id="tab-assign-ips" class="tab-btn px-4 py-2 rounded-t-lg bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 hover:cursor-pointer active">Bulk IP Assignment</button>
|
||||||
|
<button onclick="showTab('create-devices')" id="tab-create-devices" class="tab-btn px-4 py-2 rounded-t-lg bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 hover:cursor-pointer">Bulk Device Creation</button>
|
||||||
|
<button onclick="showTab('assign-tags')" id="tab-assign-tags" class="tab-btn px-4 py-2 rounded-t-lg bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 hover:cursor-pointer">Bulk Tag Assignment</button>
|
||||||
|
<button onclick="showTab('export')" id="tab-export" class="tab-btn px-4 py-2 rounded-t-lg bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 hover:cursor-pointer">Bulk Export</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk IP Assignment -->
|
||||||
|
<div id="panel-assign-ips" class="tab-panel bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
|
||||||
|
{% if can_add_device_ip %}
|
||||||
|
<h2 class="text-2xl font-bold mb-4">Bulk IP Assignment</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-4">Select a device and assign multiple IPs from a subnet. Hold Ctrl/Cmd to select multiple IPs.</p>
|
||||||
|
<form id="bulk-assign-ips-form" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block mb-2 font-medium">Select Device:</label>
|
||||||
|
<select id="bulk-device-select" name="device_id" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
|
||||||
|
<option value="">Select a device...</option>
|
||||||
|
{% for device in devices %}
|
||||||
|
<option value="{{ device[0] }}">{{ device[1] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block mb-2 font-medium">Select Subnet:</label>
|
||||||
|
<select id="bulk-subnet-select" name="subnet_id" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
|
||||||
|
<option value="">Select a subnet...</option>
|
||||||
|
{% for subnet in subnets %}
|
||||||
|
<option value="{{ subnet[0] }}">{{ subnet[1] }} ({{ subnet[2] }}) - {{ subnet[3] or 'Unassigned' }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block mb-2 font-medium">Select IPs (hold Ctrl/Cmd to select multiple):</label>
|
||||||
|
<select id="bulk-ip-select" name="ip_ids[]" multiple size="15" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
|
||||||
|
<option value="" disabled>Select a subnet first...</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">Selected: <span id="selected-ip-count">0</span> IPs</p>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg">Assign IPs</button>
|
||||||
|
</form>
|
||||||
|
<div id="assign-ips-result" class="mt-4 hidden"></div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-500">You don't have permission to assign IPs to devices.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk Device Creation -->
|
||||||
|
<div id="panel-create-devices" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
|
||||||
|
{% if can_add_device %}
|
||||||
|
<h2 class="text-2xl font-bold mb-4">Bulk Device Creation</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-4">Create multiple devices at once. Enter one device name per line.</p>
|
||||||
|
<form id="bulk-create-devices-form" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block mb-2 font-medium">Device Names (one per line):</label>
|
||||||
|
<textarea id="device-names" name="device_names" rows="10" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" placeholder="Device 1 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 -->
|
||||||
|
<div id="panel-assign-tags" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
|
||||||
|
{% if can_assign_device_tag %}
|
||||||
|
<h2 class="text-2xl font-bold mb-4">Bulk Tag Assignment</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-4">Select multiple devices and assign one or more tags to them.</p>
|
||||||
|
<form id="bulk-assign-tags-form" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block mb-2 font-medium">Select Devices (hold Ctrl/Cmd to select multiple):</label>
|
||||||
|
<select id="bulk-tag-device-select" name="device_ids[]" multiple size="10" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full">
|
||||||
|
{% for device in devices %}
|
||||||
|
<option value="{{ device[0] }}">{{ device[1] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">Selected: <span id="selected-tag-device-count">0</span> devices</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block mb-2 font-medium">Select Tags (hold Ctrl/Cmd to select multiple):</label>
|
||||||
|
<select name="tag_ids[]" multiple size="5" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
|
||||||
|
{% for tag in tags %}
|
||||||
|
<option value="{{ tag[0] }}">{{ tag[1] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg">Assign Tags</button>
|
||||||
|
</form>
|
||||||
|
<div id="assign-tags-result" class="mt-4 hidden"></div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-500">You don't have permission to assign tags to devices.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk Export -->
|
||||||
|
<div id="panel-export" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
|
||||||
|
{% if can_export_subnet_csv %}
|
||||||
|
<h2 class="text-2xl font-bold mb-4">Bulk Subnet Export</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-4">Select multiple subnets and export them to a single CSV file.</p>
|
||||||
|
<form id="bulk-export-form" method="POST" action="/bulk/export_subnets" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block mb-2 font-medium">Select Subnets (hold Ctrl/Cmd to select multiple):</label>
|
||||||
|
<select name="subnet_ids[]" multiple size="10" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
|
||||||
|
{% for subnet in subnets %}
|
||||||
|
<option value="{{ subnet[0] }}">{{ subnet[1] }} ({{ subnet[2] }}) - {{ subnet[3] or 'Unassigned' }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg">Export to CSV</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-500">You don't have permission to export subnets.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/bulk_operations.js"></script>
|
||||||
|
<style>
|
||||||
|
.tab-btn.active {
|
||||||
|
background-color: rgb(156 163 175);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.dark .tab-btn.active {
|
||||||
|
background-color: rgb(63 63 70);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
+52
-9
@@ -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>
|
||||||
@@ -27,13 +27,13 @@
|
|||||||
<form action="/rename_device" method="POST" class="inline">
|
<form action="/rename_device" method="POST" class="inline">
|
||||||
<input type="hidden" name="device_id" value="{{ device.id }}">
|
<input type="hidden" name="device_id" value="{{ device.id }}">
|
||||||
<input type="text" name="new_name" value="{{ device.name }}" class="hidden border p-1 rounded bg-gray-200 dark:bg-zinc-800 border-gray-600 w-32 mr-2" style="vertical-align: middle;" required>
|
<input type="text" name="new_name" value="{{ device.name }}" class="hidden border p-1 rounded bg-gray-200 dark:bg-zinc-800 border-gray-600 w-32 mr-2" style="vertical-align: middle;" required>
|
||||||
<button type="button" class="text-blue-400 hover:text-blue-600 ml-2 rename-btn" title="Rename Device"><i class="fas fa-pencil-alt"></i></button>
|
<button type="button" class="text-blue-400 hover:text-blue-600 hover:cursor-pointer ml-2 rename-btn" title="Rename Device"><i class="fas fa-pencil-alt"></i></button>
|
||||||
<button type="submit" class="text-green-400 hover:text-green-600 ml-2 save-btn hidden" title="Save Name"><i class="fas fa-check"></i></button>
|
<button type="submit" class="text-green-400 hover:text-green-600 hover:cursor-pointer ml-2 save-btn hidden" title="Save Name"><i class="fas fa-check"></i></button>
|
||||||
<button type="button" class="text-gray-400 hover:text-gray-600 ml-2 cancel-btn hidden" title="Cancel"><i class="fas fa-times"></i></button>
|
<button type="button" class="text-gray-400 hover:text-gray-600 hover:cursor-pointer ml-2 cancel-btn hidden" title="Cancel"><i class="fas fa-times"></i></button>
|
||||||
</form>
|
</form>
|
||||||
<form action="/delete_device" method="POST" onsubmit="return confirm('Are you sure you want to delete this device?');">
|
<form action="/delete_device" method="POST" onsubmit="return confirm('Are you sure you want to delete this device?');">
|
||||||
<input type="hidden" name="device_id" value="{{ device.id }}">
|
<input type="hidden" name="device_id" value="{{ device.id }}">
|
||||||
<button type="submit" class="ml-4 text-red-500 hover:text-red-700" title="Delete Device">
|
<button type="submit" class="ml-4 text-red-500 hover:text-red-700 hover:cursor-pointer" title="Delete Device">
|
||||||
<i class="fas fa-trash fa-lg"></i>
|
<i class="fas fa-trash fa-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -57,10 +57,10 @@
|
|||||||
<select name="ip_id" id="ip-select" class="border p-3 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full" required>
|
<select name="ip_id" id="ip-select" class="border p-3 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full" required>
|
||||||
<option value="" disabled selected>Select IP...</option>
|
<option value="" disabled selected>Select IP...</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg w-full">Add IP</button>
|
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg w-full">Add IP</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div class="allocated-ips">
|
<div class="allocated-ips mb-6">
|
||||||
<h3 class="text-lg font-bold mb-2">Allocated IPs:</h3>
|
<h3 class="text-lg font-bold mb-2">Allocated IPs:</h3>
|
||||||
<ul class="space-y-2">
|
<ul class="space-y-2">
|
||||||
{% for ip in device_ips %}
|
{% for ip in device_ips %}
|
||||||
@@ -68,17 +68,60 @@
|
|||||||
<span class="allocated-ip">{{ ip.ip }}</span>
|
<span class="allocated-ip">{{ ip.ip }}</span>
|
||||||
<form action="/device/{{ device.id }}/delete_ip" method="POST" class="inline">
|
<form action="/device/{{ device.id }}/delete_ip" method="POST" class="inline">
|
||||||
<input type="hidden" name="device_ip_id" value="{{ ip.device_ip_id }}">
|
<input type="hidden" name="device_ip_id" value="{{ ip.device_ip_id }}">
|
||||||
<button type="submit" class="text-red-500 hover:text-red-600 py-1 mr-2 text-lg"><i class="fas fa-trash"></i></button>
|
<button type="submit" class="text-red-500 hover:text-red-600 hover:cursor-pointer py-1 mr-2 text-lg"><i class="fas fa-trash"></i></button>
|
||||||
</form>
|
</form>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags Section -->
|
||||||
|
<div class="tags-section mb-6">
|
||||||
|
<h3 class="text-lg font-bold mb-2">Tags:</h3>
|
||||||
|
<div class="flex flex-wrap gap-2 mb-4">
|
||||||
|
{% if device_tags %}
|
||||||
|
{% for tag in device_tags %}
|
||||||
|
<div class="flex items-center space-x-1 px-3 py-1 rounded-full text-sm" style="background-color: {{ tag.color }}20; border: 1px solid {{ tag.color }}">
|
||||||
|
<div class="w-2 h-2 rounded-full" style="background-color: {{ tag.color }}"></div>
|
||||||
|
<span>{{ tag.name }}</span>
|
||||||
|
{% if can_remove_device_tag %}
|
||||||
|
<form action="/device/{{ device.id }}/remove_tag" method="POST" class="inline">
|
||||||
|
<input type="hidden" name="tag_id" value="{{ tag.id }}">
|
||||||
|
<button type="submit" class="ml-1 text-red-500 hover:text-red-700 text-xs" title="Remove tag">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-500">No tags assigned</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if can_assign_device_tag and all_tags %}
|
||||||
|
<form action="/device/{{ device.id }}/assign_tag" method="POST" class="flex gap-2">
|
||||||
|
<select name="tag_id" class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 flex-1" required>
|
||||||
|
<option value="" disabled selected>Select a tag to assign...</option>
|
||||||
|
{% for tag in all_tags %}
|
||||||
|
{% set already_assigned = device_tags|selectattr('id', 'equalto', tag.id)|list|length > 0 %}
|
||||||
|
{% if not already_assigned %}
|
||||||
|
<option value="{{ tag.id }}">{{ tag.name }}</option>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">
|
||||||
|
<i class="fas fa-plus mr-1"></i>Assign Tag
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<form action="/update_device_description" method="POST" class="mb-6 mt-4">
|
<form action="/update_device_description" method="POST" class="mb-6 mt-4">
|
||||||
<input type="hidden" name="device_id" value="{{ device.id }}">
|
<input type="hidden" name="device_id" value="{{ device.id }}">
|
||||||
<label for="description" class="block mb-2 text-lg font-bold">Description</label>
|
<label for="description" class="block mb-2 text-lg font-bold">Description</label>
|
||||||
<textarea id="description" name="description" rows="3" class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full resize-y" placeholder="Enter device description...">{{ device.description or '' }}</textarea>
|
<textarea id="description" name="description" rows="3" class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full resize-y" placeholder="Enter device description...">{{ device.description or '' }}</textarea>
|
||||||
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<div class="flex items-center mb-6 relative">
|
<div class="flex items-center mb-6 relative">
|
||||||
<a href="/devices" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
|
<a href="/devices" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
|
||||||
<h1 class="text-3xl font-bold text-center w-full">Device Stats</h1>
|
<h1 class="text-3xl font-bold text-center w-full">Device Stats</h1>
|
||||||
|
<a href="/device_types" class="hidden sm:flex absolute right-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-lg px-4 py-2 text-sm"><i class="fas fa-cog mr-2"></i>Manage Types</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
|
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
|
||||||
<table class="w-full table-auto">
|
<table class="w-full table-auto">
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Device Type Management</title>
|
||||||
|
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
|
||||||
|
<link href="/static/css/output.css" rel="stylesheet">
|
||||||
|
<link href="/static/css/device_types.css" rel="stylesheet">
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
||||||
|
{% include 'header.html' %}
|
||||||
|
<div class="flex-1 flex items-center justify-center mx-4">
|
||||||
|
<div class="container py-8 max-w-6xl pt-20">
|
||||||
|
<div class="flex items-center mb-6 relative">
|
||||||
|
<a href="/device_type_stats" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
|
||||||
|
<h1 class="text-3xl font-bold text-center w-full">Device Type Management</h1>
|
||||||
|
</div>
|
||||||
|
{% if error %}
|
||||||
|
<div class="mb-4 bg-red-200 dark:bg-red-800 text-red-900 dark:text-red-100 p-4 rounded-lg">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<form action="/device_types" method="POST" class="mb-8 bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
|
||||||
|
<input type="hidden" name="action" value="add">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Add New Device Type</h2>
|
||||||
|
<div class="flex flex-col space-y-4">
|
||||||
|
<div class="flex flex-col md:flex-row gap-4">
|
||||||
|
<input type="text" name="name" placeholder="Device Type Name (e.g., Router, Load Balancer)" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 flex-1" required>
|
||||||
|
<div class="icon-search-container relative flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="icon-preview hidden text-2xl text-gray-600 dark:text-gray-400 flex-shrink-0"></div>
|
||||||
|
<input type="text" name="icon_class" placeholder="Icon Class (e.g., fa-server, fa-router)" class="icon-search-input border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 flex-1" required>
|
||||||
|
</div>
|
||||||
|
<div class="icon-suggestions hidden absolute z-10 w-full mt-1 bg-gray-300 dark:bg-zinc-900 border border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg whitespace-nowrap">Add Device Type</button>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<p><strong>Icon Class Format:</strong> Start typing to see icon suggestions. Use Font Awesome icon classes (e.g., <code>fa-server</code>, <code>fa-router</code>, <code>fa-database</code>).</p>
|
||||||
|
<p class="mt-1">Common icons: <code>fa-server</code>, <code>fa-network-wired</code>, <code>fa-shield-halved</code>, <code>fa-wifi</code>, <code>fa-print</code>, <code>fa-boxes-stacked</code>, <code>fa-question</code></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<h2 class="text-xl font-bold mb-4">Existing Device Types</h2>
|
||||||
|
{% if device_types %}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{% for device_type in device_types %}
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md hover:shadow-lg transition-shadow">
|
||||||
|
<form id="edit-form-{{ device_type[0] }}" action="/device_types" method="POST" class="space-y-4">
|
||||||
|
<input type="hidden" name="action" value="edit">
|
||||||
|
<input type="hidden" name="device_type_id" value="{{ device_type[0] }}">
|
||||||
|
<div class="flex flex-col space-y-3">
|
||||||
|
<div class="flex items-center justify-center mb-2">
|
||||||
|
<i class="fas {{ device_type[2] }} text-4xl text-gray-700 dark:text-gray-300"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
|
||||||
|
<input type="text" name="name" value="{{ device_type[1] }}" class="border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
|
||||||
|
</div>
|
||||||
|
<div class="icon-search-container relative">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Icon</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="icon-preview text-xl text-gray-600 dark:text-gray-400 flex-shrink-0">
|
||||||
|
<i class="fas {{ device_type[2] }}"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" name="icon_class" value="{{ device_type[2] }}" class="icon-search-input border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 flex-1" required>
|
||||||
|
</div>
|
||||||
|
<div class="icon-suggestions hidden absolute z-10 w-full mt-1 bg-gray-300 dark:bg-zinc-900 border border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="flex gap-2 pt-2">
|
||||||
|
<button type="submit" form="edit-form-{{ device_type[0] }}" class="flex-1 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-3 py-2 rounded-lg text-sm font-medium transition-colors">
|
||||||
|
<i class="fas fa-save mr-1"></i> Save
|
||||||
|
</button>
|
||||||
|
<form action="/device_types" method="POST" onsubmit="return confirm('Are you sure you want to delete this device type?');" class="inline">
|
||||||
|
<input type="hidden" name="action" value="delete">
|
||||||
|
<input type="hidden" name="device_type_id" value="{{ device_type[0] }}">
|
||||||
|
<button type="submit" class="bg-red-500 hover:bg-red-600 dark:bg-red-600 dark:hover:bg-red-700 hover:cursor-pointer text-white px-3 py-2 rounded-lg text-sm font-medium transition-colors" title="Delete Device Type">
|
||||||
|
<i class="fas fa-trash mr-1"></i> Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg text-center text-gray-600 dark:text-gray-400">
|
||||||
|
<p>No device types found. Add your first device type above.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/static/js/device_types.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
+49
-16
@@ -14,37 +14,70 @@
|
|||||||
<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>
|
||||||
|
<a href="/bulk" class="text-center bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">Bulk Operations</a>
|
||||||
<a href="/device_type_stats" class="text-center bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">View Device Stats</a>
|
<a href="/device_type_stats" class="text-center bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">View Device Stats</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6">
|
|
||||||
<input type="text" id="search" placeholder="Search devices or IPs..." class="border p-3 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full">
|
<!-- Filters Section -->
|
||||||
|
<div class="mb-6 space-y-4">
|
||||||
|
<!-- Tag Filter -->
|
||||||
|
{% if all_tag_names %}
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<label class="text-sm font-medium">Filter by tag:</label>
|
||||||
|
<select id="tag-filter" class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600">
|
||||||
|
<option value="">All devices</option>
|
||||||
|
{% for tag_name in all_tag_names %}
|
||||||
|
<option value="{{ tag_name }}" {% if current_tag_filter == tag_name %}selected{% endif %}>{{ tag_name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% if current_tag_filter %}
|
||||||
|
<a href="/devices" class="text-red-500 hover:text-red-700 text-sm">
|
||||||
|
<i class="fas fa-times"></i> Clear filter
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div id="site-list" class="space-y-6">
|
<div id="site-list" class="space-y-6">
|
||||||
{% for site, devices in sites_devices.items() %}
|
{% for site, devices in sites_devices.items() %}
|
||||||
<div class="site-group bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md">
|
<div class="site-group bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md">
|
||||||
<div class="flex flex-row items-center justify-between p-4 cursor-pointer site-header">
|
<div class="flex flex-row items-center justify-between p-4 cursor-pointer site-header">
|
||||||
<h2 class="text-xl font-bold mb-0 dark:text-white">{{ site }}</h2>
|
<h2 class="text-xl font-bold mb-0 dark:text-white">{{ site }}</h2>
|
||||||
<button type="button" class="expand-btn text-gray-400 hover:text-gray-200 ml-2 flex items-center" aria-label="Expand site">
|
<button type="button" class="expand-btn text-gray-400 hover:text-gray-200 hover:cursor-pointer ml-2 flex items-center" aria-label="Expand site">
|
||||||
<i class="fas fa-chevron-down"></i>
|
<i class="fas fa-chevron-down"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<ul class="device-list hidden px-6 pb-4">
|
<ul class="device-list hidden px-6 pb-4">
|
||||||
{% for device in devices %}
|
{% for device in devices %}
|
||||||
<li class="my-2">
|
<li class="my-2">
|
||||||
<a href="/device/{{ device.id }}" class="flex items-center justify-between bg-gray-300 hover:bg-gray-100 dark:bg-zinc-900 dark:hover:bg-gray-700 dark:text-gray-200 font-semibold rounded-lg px-4 py-2 shadow transition-colors duration-150">
|
<a href="/device/{{ device.id }}" class="block bg-gray-300 hover:bg-gray-100 dark:bg-zinc-900 dark:hover:bg-gray-700 dark:text-gray-200 font-semibold rounded-lg px-4 py-2 shadow transition-colors duration-150">
|
||||||
<span><i class="fas {{ device.icon_class or 'fa-server' }} mr-2"></i>{{ device.name }}</span>
|
<div class="flex items-center justify-between">
|
||||||
{% set ips = device_ips.get(device.id, []) %}
|
<span><i class="fas {{ device.icon_class or 'fa-server' }} mr-2"></i>{{ device.name }}</span>
|
||||||
<span class="flex flex-row flex-wrap justify-end items-center ml-4 text-xs text-blue-300 font-normal align-middle">
|
{% set ips = device_ips.get(device.id, []) %}
|
||||||
{% if ips|length > 0 %}
|
<span class="flex flex-row flex-wrap justify-end items-center ml-4 text-xs text-blue-300 font-normal align-middle">
|
||||||
{% for ip in ips %}
|
{% if ips|length > 0 %}
|
||||||
<span class="inline-block bg-gray-200 dark:bg-zinc-800 text-gray-900 dark:text-white rounded px-2 py-1 ml-1 font-mono">{{ ip[1] }}</span>
|
{% for ip in ips %}
|
||||||
{% endfor %}
|
<span class="inline-block bg-gray-200 dark:bg-zinc-800 text-gray-900 dark:text-white rounded px-2 py-1 ml-1 font-mono">{{ ip[1] }}</span>
|
||||||
{% else %}
|
{% endfor %}
|
||||||
<span class="text-gray-400">No IPs</span>
|
{% else %}
|
||||||
{% endif %}
|
<span class="text-gray-400">No IPs</span>
|
||||||
</span>
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Tags -->
|
||||||
|
{% set tags = device_tags.get(device.id, []) %}
|
||||||
|
{% if tags %}
|
||||||
|
<div class="flex flex-wrap gap-1 mt-2">
|
||||||
|
{% for tag in tags %}
|
||||||
|
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs" style="background-color: {{ tag.color }}20; border: 1px solid {{ tag.color }}; color: {{ tag.color }}">
|
||||||
|
<div class="w-1.5 h-1.5 rounded-full mr-1" style="background-color: {{ tag.color }}"></div>
|
||||||
|
{{ tag.name }}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
+2
-2
@@ -27,9 +27,9 @@
|
|||||||
<label for="excluded_ips" class="font-medium">Exclude IPs (comma separated)</label>
|
<label for="excluded_ips" class="font-medium">Exclude IPs (comma separated)</label>
|
||||||
<input type="text" id="excluded_ips" name="excluded_ips" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600" placeholder="e.g. 192.168.1.105,192.168.1.110" value="{{ dhcp_pool.excluded_ips if dhcp_pool and dhcp_pool.excluded_ips else '' }}">
|
<input type="text" id="excluded_ips" name="excluded_ips" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600" placeholder="e.g. 192.168.1.105,192.168.1.110" value="{{ dhcp_pool.excluded_ips if dhcp_pool and dhcp_pool.excluded_ips else '' }}">
|
||||||
<div class="flex gap-4 mt-4">
|
<div class="flex gap-4 mt-4">
|
||||||
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">Save DHCP Pool</button>
|
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">Save DHCP Pool</button>
|
||||||
{% if dhcp_pool %}
|
{% if dhcp_pool %}
|
||||||
<button type="submit" name="remove" value="1" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">Remove DHCP Pool</button>
|
<button type="submit" name="remove" value="1" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">Remove DHCP Pool</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
+90
-11
@@ -1,37 +1,116 @@
|
|||||||
<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://github.com/JDB-NET/ipam/releases" target="_blank" rel="noopener noreferrer" class="text-sm font-normal text-gray-300 hover:text-gray-100 -ml-1 mt-3">v{{ VERSION }}</a>
|
||||||
</div>
|
</div>
|
||||||
<nav class="hidden md:flex items-center space-x-6" id="main-nav">
|
<div class="hidden lg:flex items-center justify-center absolute left-1/2" style="transform: translateX(calc(-50% + 1.5rem));">
|
||||||
|
<form action="/search" method="GET" class="flex items-center space-x-2">
|
||||||
|
<input type="text" name="q" id="search-input" placeholder="Search..."
|
||||||
|
class="bg-zinc-700 text-white placeholder-gray-400 px-4 py-2 rounded-md text-base focus:outline-none focus:ring-2 focus:ring-gray-500 w-100"
|
||||||
|
value="{{ request.args.get('q', '') }}">
|
||||||
|
<button type="submit" class="text-gray-200 hover:text-gray-400 hover:cursor-pointer flex-shrink-0">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<nav class="hidden lg:flex items-center space-x-6 flex-shrink-0" id="main-nav">
|
||||||
|
{% if has_permission('view_index') %}
|
||||||
<a href="/" class="text-gray-200 hover:text-gray-400 font-medium">Home</a>
|
<a href="/" class="text-gray-200 hover:text-gray-400 font-medium">Home</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if has_permission('view_devices') %}
|
||||||
<a href="/devices" class="text-gray-200 hover:text-gray-400 font-medium">Devices</a>
|
<a href="/devices" class="text-gray-200 hover:text-gray-400 font-medium">Devices</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if has_permission('view_racks') %}
|
||||||
<a href="/racks" class="text-gray-200 hover:text-gray-400 font-medium">Racks</a>
|
<a href="/racks" class="text-gray-200 hover:text-gray-400 font-medium">Racks</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if has_permission('view_admin') %}
|
||||||
<a href="/admin" class="text-gray-200 hover:text-gray-400 font-medium">Admin</a>
|
<a href="/admin" class="text-gray-200 hover:text-gray-400 font-medium">Admin</a>
|
||||||
<a href="/audit" class="text-gray-200 hover:text-gray-400 font-medium">Audit Log</a>
|
{% endif %}
|
||||||
|
{% 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">Help</a>
|
||||||
|
{% 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="/logout" class="text-gray-200 hover:text-gray-400 font-medium">Logout</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
<button class="md:hidden flex items-center text-gray-200 focus:outline-none" id="nav-toggle" aria-label="Open navigation menu">
|
<button class="lg:hidden flex items-center text-gray-200 hover:cursor-pointer focus:outline-none flex-shrink-0" id="nav-toggle" aria-label="Open navigation menu">
|
||||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="md:hidden absolute top-16 right-6 bg-zinc-800 rounded-lg shadow-lg z-50 w-48 hidden flex-col py-2" id="mobile-nav">
|
<div class="lg:hidden fixed top-13 left-0 right-0 bg-zinc-800 shadow-lg z-50 w-full hidden flex-col py-2" id="mobile-nav">
|
||||||
|
<form action="/search" method="GET" class="px-4 py-2 border-b border-zinc-700">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<input type="text" name="q" placeholder="Search..."
|
||||||
|
class="bg-zinc-700 text-white placeholder-gray-400 px-3 py-2.5 rounded text-base focus:outline-none focus:ring-2 focus:ring-gray-500 flex-1"
|
||||||
|
value="{{ request.args.get('q', '') }}">
|
||||||
|
<button type="submit" class="text-gray-200 hover:text-gray-400 hover:cursor-pointer flex-shrink-0">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% if has_permission('view_index') %}
|
||||||
<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">Home</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if has_permission('view_devices') %}
|
||||||
<a href="/devices" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Devices</a>
|
<a href="/devices" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Devices</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if has_permission('view_racks') %}
|
||||||
<a href="/racks" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Racks</a>
|
<a href="/racks" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Racks</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if has_permission('view_admin') %}
|
||||||
<a href="/admin" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Admin</a>
|
<a href="/admin" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Admin</a>
|
||||||
<a href="/audit" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Audit Log</a>
|
{% endif %}
|
||||||
|
{% if has_permission('view_help') %}
|
||||||
<a href="/help" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Help</a>
|
<a href="/help" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Help</a>
|
||||||
|
{% endif %}
|
||||||
{% if current_user_name %}
|
{% 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="/logout" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Logout</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/js/header.js"></script>
|
<script src="/static/js/header.js"></script>
|
||||||
|
|
||||||
|
<!-- Update Available Toast -->
|
||||||
|
<div id="update-toast" class="hidden fixed bottom-4 right-4 bg-gray-200 dark:bg-zinc-800 border border-gray-400 dark:border-zinc-600 rounded-lg shadow-lg max-w-md z-50">
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex items-start justify-between mb-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="fas fa-bell text-gray-900 dark:text-gray-100"></i>
|
||||||
|
<h3 class="font-semibold text-lg text-gray-900 dark:text-gray-100">Update Available</h3>
|
||||||
|
</div>
|
||||||
|
<button id="toast-close" class="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:cursor-pointer">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
||||||
|
Version <span id="toast-latest-version" class="font-semibold"></span> is now available. You're currently on <span id="toast-current-version" class="font-semibold"></span>.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2 mt-3">
|
||||||
|
<a id="toast-compare-link" href="#" target="_blank" rel="noopener noreferrer" class="flex-1 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 text-gray-900 dark:text-gray-100 px-3 py-2 rounded text-center text-sm hover:cursor-pointer">
|
||||||
|
View Changes
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#update-toast {
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script src="/static/js/update_toast.js"></script>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
+58
-7
@@ -10,16 +10,17 @@
|
|||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
||||||
{% include 'header.html' %}
|
{% include 'header.html' %}
|
||||||
<div class="flex-1 flex items-center justify-center mx-4">
|
<div class="flex-1 mx-4 py-8 pt-20">
|
||||||
<div class="container py-8 max-w-2xl pt-20">
|
<div class="container max-w-full mx-auto lg:px-32">
|
||||||
<h1 class="text-3xl font-bold mb-8 text-center">Help & User Guide</h1>
|
<h1 class="text-3xl font-bold mb-8 text-center">Help & User Guide</h1>
|
||||||
<div class="space-y-10 text-lg">
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 mb-8">
|
||||||
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
|
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
|
||||||
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">Subnets & Devices</h2>
|
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">Subnets & Devices</h2>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-xl font-semibold mb-1">Managing Subnets</h3>
|
<h3 class="text-xl font-semibold mb-1">Managing Subnets</h3>
|
||||||
<p>To add or remove subnets, go to the <a href="/admin" class="text-blue-600 dark:text-blue-400 hover:underline">Admin</a> page. Click <span class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded">Add Subnet</span> to create a new subnet. Subnets are associated with sites.</p>
|
<p>To add or edit subnets, go to the <a href="/admin" class="text-blue-600 dark:text-blue-400 hover:underline">Admin</a> page. Click <span class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded">Add Subnet</span> to create a new subnet, or use the edit button to modify existing subnets. Subnets are associated with sites and can be organised by location.</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-xl font-semibold mb-1">Adding a Device</h3>
|
<h3 class="text-xl font-semibold mb-1">Adding a Device</h3>
|
||||||
@@ -52,16 +53,66 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
|
||||||
|
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">Device Tags</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold mb-1">Managing Tags</h3>
|
||||||
|
<p>Tags help you organize and categorize your devices. Administrators can manage tags from the <a href="/tags" class="text-blue-600 dark:text-blue-400 hover:underline">Tag Management</a> page accessible from the Admin panel. Each tag has a name, customizable colour, and optional description.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold mb-1">Creating Tags</h3>
|
||||||
|
<p>Click <span class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded">Add Tag</span> to create a new tag. Choose a descriptive name (e.g., "Production", "Development", "Critical") and select a colour to visually distinguish the tag. Tags can be used to group devices by environment, importance, location, or any other criteria.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold mb-1">Assigning Tags to Devices</h3>
|
||||||
|
<p>From any device's detail page, you can assign multiple tags using the tag assignment dropdown. Each device can have unlimited tags, and removing a tag is as simple as clicking the × button next to the tag name.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold mb-1">Filtering Devices by Tags</h3>
|
||||||
|
<p>On the <a href="/devices" class="text-blue-600 dark:text-blue-400 hover:underline">Devices</a> page, use the tag filter dropdown to view only devices with a specific tag. This makes it easy to focus on devices in a particular environment or category.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold mb-1">Tag Colours and Visual Organisation</h3>
|
||||||
|
<p>Tags appear as coloured badges throughout the interface. Use consistent colour schemes (e.g., red for production, blue for development) to create visual patterns that help users quickly identify device categories.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold mb-1">Tag Permissions</h3>
|
||||||
|
<p>Tag management respects role-based permissions. Users need appropriate permissions to view, create, edit, delete tags, or assign/remove tags from devices. View-only users can see tags but cannot modify them.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
|
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
|
||||||
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">User Management & Audit</h2>
|
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">User Management & Audit</h2>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-xl font-semibold mb-1">User Management & Admin</h3>
|
<h3 class="text-xl font-semibold mb-1">User & Role Management</h3>
|
||||||
<p>Users can manage themselves and other users from the <a href="/users" class="text-blue-600 dark:text-blue-400 hover:underline">Users</a> page. Use this area to add, remove, or update user accounts.</p>
|
<p>Administrators can manage users and roles from the <a href="/users" class="text-blue-600 dark:text-blue-400 hover:underline">Users</a> page. This includes creating users, assigning roles, and managing custom roles with specific permission sets. Only users with the appropriate permissions can access this page.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold mb-1">Understanding Roles</h3>
|
||||||
|
<p>The system uses role-based access control to manage what users can do. There are three default roles:</p>
|
||||||
|
<ul class="list-disc list-inside mt-2 space-y-1 ml-4">
|
||||||
|
<li><strong>Admin:</strong> Full access to all features including user and role management</li>
|
||||||
|
<li><strong>User:</strong> Can view and manage most features (devices, subnets, racks, etc.) but cannot manage users or roles</li>
|
||||||
|
<li><strong>View Only:</strong> Read-only access to view pages but cannot make any changes</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold mb-1">Custom Roles</h3>
|
||||||
|
<p>Administrators can create custom roles with specific permission sets. Go to the <a href="/users" class="text-blue-600 dark:text-blue-400 hover:underline">Users</a> page and click the "Roles & Permissions" tab to create and manage roles.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold mb-1">Permission Granularity</h3>
|
||||||
|
<p>Permissions are very granular, allowing fine-grained control over what each role can do. Permissions are organised into categories like View, Device Management, Network Management, Rack Management, and Administration.</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-xl font-semibold mb-1">Audit & History</h3>
|
<h3 class="text-xl font-semibold mb-1">Audit & History</h3>
|
||||||
<p>All changes are logged and can be reviewed on the <a href="/audit" class="text-blue-600 dark:text-blue-400 hover:underline">Audit</a> page for accountability and troubleshooting.</p>
|
<p>All changes are logged and can be reviewed on the <a href="/audit" class="text-blue-600 dark:text-blue-400 hover:underline">Audit</a> page for accountability and troubleshooting. The audit log shows who made changes, what was changed, and when.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold mb-1">API Keys</h3>
|
||||||
|
<p>Each user has a unique API key that can be used to authenticate API requests. API keys can be viewed and regenerated from the <a href="/users" class="text-blue-600 dark:text-blue-400 hover:underline">Users</a> page. Keep your API key secure and never share it publicly.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<li class="site-group bg-gray-200 dark:bg-zinc-800 rounded-xl shadow-lg">
|
<li class="site-group bg-gray-200 dark:bg-zinc-800 rounded-xl shadow-lg">
|
||||||
<div class="flex flex-row items-center justify-between p-4 cursor-pointer site-header">
|
<div class="flex flex-row items-center justify-between p-4 cursor-pointer site-header">
|
||||||
<h2 class="text-xl font-bold mb-0 text-gray-900 dark:text-white">{{ site }}</h2>
|
<h2 class="text-xl font-bold mb-0 text-gray-900 dark:text-white">{{ site }}</h2>
|
||||||
<button type="button" class="expand-btn ml-2 flex items-center" aria-label="Expand site">
|
<button type="button" class="expand-btn ml-2 flex items-center hover:cursor-pointer" aria-label="Expand site">
|
||||||
<i class="fas fa-chevron-down"></i>
|
<i class="fas fa-chevron-down"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
<p class="text-gray-900 dark:text-white text-lg font-medium">{{ subnet.name }}</p>
|
<p class="text-gray-900 dark:text-white text-lg font-medium">{{ subnet.name }}</p>
|
||||||
<p class="text-sm text-gray-800 dark:text-gray-400">{{ subnet.cidr }}</p>
|
<p class="text-sm text-gray-800 dark:text-gray-400">{{ subnet.cidr }}</p>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="export-csv-btn ml-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-600 dark:hover:bg-zinc-500 flex items-center justify-center rounded-full w-9 h-9" title="Export as CSV" data-subnet-id="{{ subnet.id }}">
|
<button type="button" class="export-csv-btn ml-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-600 dark:hover:bg-zinc-500 hover:cursor-pointer flex items-center justify-center rounded-full w-9 h-9" title="Export as CSV" data-subnet-id="{{ subnet.id }}">
|
||||||
<i class="fas fa-file-csv"></i>
|
<i class="fas fa-file-csv"></i>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<form action="/login" method="POST" class="flex flex-col space-y-4">
|
<form action="/login" method="POST" class="flex flex-col space-y-4">
|
||||||
<input type="email" name="email" placeholder="Email Address" class="p-3 rounded-lg bg-gray-200 dark:bg-zinc-800 border border-gray-600" required>
|
<input type="email" name="email" placeholder="Email Address" class="p-3 rounded-lg bg-gray-200 dark:bg-zinc-800 border border-gray-600" required>
|
||||||
<input type="password" name="password" placeholder="Password" class="p-3 rounded-lg bg-gray-200 dark:bg-zinc-800 border border-gray-600" required>
|
<input type="password" name="password" placeholder="Password" class="p-3 rounded-lg bg-gray-200 dark:bg-zinc-800 border border-gray-600" required>
|
||||||
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg flex items-center gap-2 justify-center w-full">
|
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2 justify-center w-full">
|
||||||
<i class="fas fa-sign-in-alt"></i>
|
<i class="fas fa-sign-in-alt"></i>
|
||||||
<span>Login</span>
|
<span>Login</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+10
-44
@@ -17,23 +17,13 @@
|
|||||||
<h1 class="text-3xl font-bold text-center w-full mb-0">{{ rack.name }}</h1>
|
<h1 class="text-3xl font-bold text-center w-full mb-0">{{ rack.name }}</h1>
|
||||||
<span class="text-base mt-1">({{ rack.height_u }}U, {{ rack.site }})</span>
|
<span class="text-base mt-1">({{ rack.height_u }}U, {{ rack.site }})</span>
|
||||||
<form action="/rack/{{ rack.id }}/delete" method="POST" onsubmit="return confirm('Delete this rack?');" class="hidden sm:flex absolute right-0 mr-14">
|
<form action="/rack/{{ rack.id }}/delete" method="POST" onsubmit="return confirm('Delete this rack?');" class="hidden sm:flex absolute right-0 mr-14">
|
||||||
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-full w-11 h-11 flex" title="Delete Rack">
|
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer items-center justify-center rounded-full w-11 h-11 flex" title="Delete Rack">
|
||||||
<i class="fas fa-times fa-lg"></i>
|
<i class="fas fa-times fa-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<button type="button" id="export-csv" class="hidden sm:flex absolute right-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-full w-11 h-11 export-csv-btn" title="Export as CSV" data-rack-id="{{ rack.id }}">
|
<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">
|
||||||
@@ -41,8 +31,8 @@
|
|||||||
<a href="?side=back" id="back-btn" class="rack-side-btn px-4 py-2 rounded-lg font-semibold bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 {% if current_side == 'back' %}ring-2 ring-gray-400{% endif %}">Back</a>
|
<a href="?side=back" id="back-btn" class="rack-side-btn px-4 py-2 rounded-lg font-semibold bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 {% if current_side == 'back' %}ring-2 ring-gray-400{% endif %}">Back</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-4 w-full justify-center">
|
<div class="flex flex-wrap gap-4 w-full justify-center">
|
||||||
<button id="show-add-device-form" type="button" class="flex-1 min-w-[12rem] max-w-[16rem] bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg flex-shrink-0 whitespace-nowrap"> <i class="fas fa-plus"></i> Add Device</button>
|
<button id="show-add-device-form" type="button" class="flex-1 min-w-[12rem] max-w-[16rem] bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex-shrink-0 whitespace-nowrap"> <i class="fas fa-plus"></i> Add Device</button>
|
||||||
<button id="show-nonnet-form" type="button" class="flex-[1.5_1.5_0%] min-w-[16rem] max-w-[28rem] bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg flex-shrink-0 whitespace-nowrap"> <i class="fas fa-plus"></i> Add Non-Networked Device</button>
|
<button id="show-nonnet-form" type="button" class="flex-[1.5_1.5_0%] min-w-[16rem] max-w-[28rem] bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex-shrink-0 whitespace-nowrap"> <i class="fas fa-plus"></i> Add Non-Networked Device</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form id="add-device-form" action="/rack/{{ rack.id }}/add_device" method="POST" class="hidden mb-6 bg-gray-200 dark:bg-zinc-800 rounded-lg p-4">
|
<form id="add-device-form" action="/rack/{{ rack.id }}/add_device" method="POST" class="hidden mb-6 bg-gray-200 dark:bg-zinc-800 rounded-lg p-4">
|
||||||
@@ -60,8 +50,8 @@
|
|||||||
<option value="front">Front</option>
|
<option value="front">Front</option>
|
||||||
<option value="back">Back</option>
|
<option value="back">Back</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="submit" class="w-full md:w-auto bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">Add Device</button>
|
<button type="submit" class="w-full md:w-auto bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">Add Device</button>
|
||||||
<button id="hide-add-device-form" type="button" class="w-full md:w-auto bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg md:ml-0.5 mt-2 md:mt-0 flex-shrink-0"><i class="fas fa-times"></i></button>
|
<button id="hide-add-device-form" type="button" class="w-full md:w-auto bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg md:ml-0.5 mt-2 md:mt-0 flex-shrink-0"><i class="fas fa-times"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs dark:text-gray-400">To add a multi-U device, repeat for each U position.</div>
|
<div class="text-xs dark:text-gray-400">To add a multi-U device, repeat for each U position.</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -73,36 +63,12 @@
|
|||||||
<option value="front">Front</option>
|
<option value="front">Front</option>
|
||||||
<option value="back">Back</option>
|
<option value="back">Back</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="submit" class="w-full md:w-auto bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">Add</button>
|
<button type="submit" class="w-full md:w-auto bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">Add</button>
|
||||||
<button id="hide-nonnet-form" type="button" class="w-full md:w-auto bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg md:ml-0.5 mt-2 md:mt-0 flex-shrink-0"><i class="fas fa-times"></i></button>
|
<button id="hide-nonnet-form" type="button" class="w-full md:w-auto bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg md:ml-0.5 mt-2 md:mt-0 flex-shrink-0"><i class="fas fa-times"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<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.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 %}
|
||||||
@@ -123,7 +89,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<form action="/rack/{{ rack.id }}/remove_device" method="POST" style="display:inline" onsubmit="return confirm('Are you sure you want to remove this device from the rack?');">
|
<form action="/rack/{{ rack.id }}/remove_device" method="POST" style="display:inline" onsubmit="return confirm('Are you sure you want to remove this device from the rack?');">
|
||||||
<input type="hidden" name="rack_device_id" value="{{ rd.id }}">
|
<input type="hidden" name="rack_device_id" value="{{ rd.id }}">
|
||||||
<button type="submit" class="ml-3 text-red-400 hover:text-red-600"><i class="fas fa-times"></i></button>
|
<button type="submit" class="ml-3 text-red-400 hover:text-red-600 hover:cursor-pointer"><i class="fas fa-times"></i></button>
|
||||||
</form>
|
</form>
|
||||||
{% set found = true %}
|
{% set found = true %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
+18
-21
@@ -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,31 @@
|
|||||||
<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 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>
|
||||||
|
{% if utilization %}
|
||||||
|
<div class="hidden sm:flex justify-center mb-4">
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 px-4 py-2 rounded-lg text-sm">
|
||||||
|
<span class="font-medium">{{ utilization.percent }}% used</span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400 mx-2">•</span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{{ utilization.assigned }} assigned</span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400 mx-2">•</span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{{ utilization.dhcp }} DHCP</span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400 mx-2">•</span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{{ utilization.available }} available</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="flex justify-center mb-4">
|
<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">
|
<a href="/subnet/{{ subnet.id }}/dhcp" class="hidden sm:flex bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg shadow items-center gap-2">
|
||||||
<i class="fas fa-network-wired"></i> Define DHCP Pool
|
<i class="fas fa-network-wired"></i> Define 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 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>
|
||||||
<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,7 +49,7 @@
|
|||||||
</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">{{ ip[1] }}</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
{% if ip[2] == 'DHCP' %}
|
{% if ip[2] == 'DHCP' %}
|
||||||
@@ -61,21 +73,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/js/export_csv.js"></script>
|
<script src="/static/js/export_csv.js"></script>
|
||||||
<script>
|
<script src="/static/js/subnet.js"></script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const toggleBtn = document.getElementById('toggle-desc');
|
|
||||||
const descCols = document.querySelectorAll('.desc-col');
|
|
||||||
const descHeader = document.getElementById('desc-col-header');
|
|
||||||
let shown = false;
|
|
||||||
if (toggleBtn) {
|
|
||||||
toggleBtn.addEventListener('click', function() {
|
|
||||||
shown = !shown;
|
|
||||||
descCols.forEach(col => col.classList.toggle('hidden', !shown));
|
|
||||||
if (descHeader) descHeader.classList.toggle('hidden', !shown);
|
|
||||||
toggleBtn.textContent = shown ? 'Hide Descriptions' : 'Show Descriptions';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Tag Management</title>
|
||||||
|
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
|
||||||
|
<link href="/static/css/output.css" rel="stylesheet">
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
||||||
|
{% include 'header.html' %}
|
||||||
|
<div class="flex-1 mx-4 py-8 pt-20">
|
||||||
|
<div class="container max-w-7xl mx-auto">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-3xl font-bold">Tag Management</h1>
|
||||||
|
{% if can_add_tag %}
|
||||||
|
<button onclick="showAddTagModal()" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg font-medium">
|
||||||
|
<i class="fas fa-plus mr-2"></i>Add Tag
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-200 dark:bg-red-800 text-red-800 dark:text-red-200 p-4 rounded-lg mb-6">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if tags %}
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-gray-600">
|
||||||
|
<th class="text-left p-3">Name</th>
|
||||||
|
<th class="text-left p-3">Colour</th>
|
||||||
|
<th class="text-left p-3">Description</th>
|
||||||
|
<th class="text-center p-3">Devices</th>
|
||||||
|
<th class="text-center p-3">Created</th>
|
||||||
|
<th class="text-center p-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for tag in tags %}
|
||||||
|
<tr class="border-b border-gray-600 hover:bg-gray-300 dark:hover:bg-zinc-700">
|
||||||
|
<td class="p-3">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="w-4 h-4 rounded-full border border-gray-600" style="background-color: {{ tag.color }}"></div>
|
||||||
|
<span class="font-medium">{{ tag.name }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="p-3">
|
||||||
|
<span class="font-mono text-sm">{{ tag.color }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="p-3">
|
||||||
|
<span class="text-sm">{{ tag.description or '-' }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="p-3 text-center">
|
||||||
|
{% if tag.device_count > 0 %}
|
||||||
|
<a href="/devices/tag/{{ tag.id }}" class="text-blue-400 hover:text-blue-600 hover:cursor-pointer">
|
||||||
|
{{ tag.device_count }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-500">0</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="p-3 text-center text-sm">
|
||||||
|
{{ tag.created_at.strftime('%Y-%m-%d') if tag.created_at else '-' }}
|
||||||
|
</td>
|
||||||
|
<td class="p-3 text-center">
|
||||||
|
<div class="flex items-center justify-center space-x-2">
|
||||||
|
{% if can_edit_tag %}
|
||||||
|
<button class="edit-tag-btn text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer"
|
||||||
|
title="Edit Tag"
|
||||||
|
data-tag-id="{{ tag.id }}"
|
||||||
|
data-tag-name="{{ tag.name }}"
|
||||||
|
data-tag-color="{{ tag.color }}"
|
||||||
|
data-tag-description="{{ tag.description or '' }}">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if can_delete_tag %}
|
||||||
|
<form action="/tags" method="POST" onsubmit="return confirm('Are you sure you want to delete this tag? This will remove it from all devices.');" class="inline">
|
||||||
|
<input type="hidden" name="action" value="delete_tag">
|
||||||
|
<input type="hidden" name="tag_id" value="{{ tag.id }}">
|
||||||
|
<button type="submit" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Delete Tag">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
|
||||||
|
<div class="text-center py-8 text-gray-500">
|
||||||
|
<i class="fas fa-tags text-4xl mb-4"></i>
|
||||||
|
<p>No tags found. Add your first tag to get started.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Tag Modal -->
|
||||||
|
<div id="add-tag-modal" class="hidden fixed inset-0 bg-black/30 backdrop-blur-md flex items-center justify-center z-50">
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-lg max-w-md w-full mx-4">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-bold">Add New Tag</h2>
|
||||||
|
<button onclick="closeAddTagModal()" class="text-gray-500 hover:text-gray-700">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form action="/tags" method="POST">
|
||||||
|
<input type="hidden" name="action" value="add_tag">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<input type="text" name="name" id="add-tag-name" placeholder="Tag Name"
|
||||||
|
class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<label class="text-sm font-medium">Colour:</label>
|
||||||
|
<input type="color" name="color" id="add-tag-color" value="#6B7280"
|
||||||
|
class="w-12 h-10 border border-gray-600 rounded cursor-pointer">
|
||||||
|
<span id="add-color-preview" class="text-sm font-mono">#6B7280</span>
|
||||||
|
</div>
|
||||||
|
<textarea name="description" id="add-tag-description" placeholder="Description (optional)" rows="3"
|
||||||
|
class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full resize-y"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end space-x-2 mt-6">
|
||||||
|
<button type="button" onclick="closeAddTagModal()"
|
||||||
|
class="px-4 py-2 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-4 py-2 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Add Tag</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Tag Modal -->
|
||||||
|
<div id="edit-tag-modal" class="hidden fixed inset-0 bg-black/30 backdrop-blur-md flex items-center justify-center z-50">
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-lg max-w-md w-full mx-4">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-bold">Edit Tag</h2>
|
||||||
|
<button onclick="closeEditTagModal()" class="text-gray-500 hover:text-gray-700">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form action="/tags" method="POST">
|
||||||
|
<input type="hidden" name="action" value="edit_tag">
|
||||||
|
<input type="hidden" name="tag_id" id="edit-tag-id">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<input type="text" name="name" id="edit-tag-name" placeholder="Tag Name"
|
||||||
|
class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<label class="text-sm font-medium">Colour:</label>
|
||||||
|
<input type="color" name="color" id="edit-tag-color"
|
||||||
|
class="w-12 h-10 border border-gray-600 rounded cursor-pointer">
|
||||||
|
<span id="edit-color-preview" class="text-sm font-mono"></span>
|
||||||
|
</div>
|
||||||
|
<textarea name="description" id="edit-tag-description" placeholder="Description (optional)" rows="3"
|
||||||
|
class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full resize-y"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end space-x-2 mt-6">
|
||||||
|
<button type="button" onclick="closeEditTagModal()"
|
||||||
|
class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/tags.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+330
-32
@@ -3,46 +3,344 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>User Management</title>
|
<title>User & Role 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="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">
|
||||||
{% include 'header.html' %}
|
{% include 'header.html' %}
|
||||||
<div class="flex-1 flex items-center justify-center mx-4">
|
<div class="flex-1 mx-4 py-8 pt-20">
|
||||||
<div class="container py-8 max-w-6xl pt-20">
|
<div class="container max-w-7xl mx-auto">
|
||||||
<h1 class="text-3xl font-bold mb-6 text-center">User Management</h1>
|
<h1 class="text-3xl font-bold mb-6 text-center">User & Role Management</h1>
|
||||||
<form action="/users" method="POST" class="mb-8 bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
|
|
||||||
<input type="hidden" name="action" value="add">
|
{% if error %}
|
||||||
<div class="flex flex-col space-y-4 items-center w-full">
|
<div class="bg-red-200 dark:bg-red-800 text-red-800 dark:text-red-200 p-4 rounded-lg mb-6">
|
||||||
<input type="text" name="name" placeholder="Name" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
|
{{ error }}
|
||||||
<input type="email" name="email" placeholder="Email Address" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
|
</div>
|
||||||
<input type="password" name="password" placeholder="Password" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
|
{% endif %}
|
||||||
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg max-w-md w-full sm:w-auto">Add User</button>
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="mb-6 border-b border-gray-600">
|
||||||
|
<button onclick="showTab('users')" id="tab-users" class="tab-button px-6 py-3 font-medium border-b-2 border-blue-500 text-blue-600 dark:text-blue-400 hover:text-gray-800 dark:hover:text-gray-200 hover:cursor-pointer">
|
||||||
|
Users
|
||||||
|
</button>
|
||||||
|
<button onclick="showTab('roles')" id="tab-roles" class="tab-button px-6 py-3 font-medium border-b-2 border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:cursor-pointer">
|
||||||
|
Roles & Permissions
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Users Tab -->
|
||||||
|
<div id="users-tab" class="tab-content">
|
||||||
|
{% if can_manage_users %}
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md mb-8">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Add New User</h2>
|
||||||
|
<form action="/users" method="POST" class="space-y-4">
|
||||||
|
<input type="hidden" name="action" value="add_user">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<input type="text" name="name" placeholder="Name" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600" required>
|
||||||
|
<input type="email" name="email" placeholder="Email Address" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600" required>
|
||||||
|
<input type="password" name="password" placeholder="Password" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600" required>
|
||||||
|
<select name="role_id" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600">
|
||||||
|
<option value="">No Role</option>
|
||||||
|
{% for role in roles %}
|
||||||
|
<option value="{{ role[0] }}">{{ role[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-4 py-2 rounded-lg">Add User</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
{% endif %}
|
||||||
<h2 class="text-xl font-bold mb-4">Existing Users</h2>
|
|
||||||
<ul class="space-y-4">
|
<h2 class="text-xl font-bold mb-4">Existing Users</h2>
|
||||||
{% for user in users %}
|
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
|
||||||
<li class="bg-gray-200 dark:bg-zinc-800 p-4 rounded-lg flex justify-between items-center">
|
<div class="overflow-x-auto">
|
||||||
<form action="/users" method="POST" class="flex flex-row items-center space-x-2">
|
<table class="w-full">
|
||||||
<input type="hidden" name="action" value="edit">
|
<thead>
|
||||||
<input type="hidden" name="user_id" value="{{ user[0] }}">
|
<tr class="border-b border-gray-600">
|
||||||
<input type="text" name="name" value="{{ user[1] }}" class="border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-52">
|
<th class="text-left p-2">Name</th>
|
||||||
<input type="email" name="email" value="{{ user[2] }}" class="border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-80">
|
<th class="text-left p-2">Email</th>
|
||||||
<input type="password" name="password" placeholder="New Password (leave blank to keep)" class="border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-80">
|
<th class="text-center p-2">Role</th>
|
||||||
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-3 py-1 rounded-lg">Save</button>
|
{% if can_manage_users %}
|
||||||
</form>
|
<th class="text-center p-2">Actions</th>
|
||||||
<form action="/users" method="POST" onsubmit="return confirm('Are you sure you want to delete this user?');">
|
{% endif %}
|
||||||
<input type="hidden" name="action" value="delete">
|
</tr>
|
||||||
<input type="hidden" name="user_id" value="{{ user[0] }}">
|
</thead>
|
||||||
<button type="submit" class="text-red-500 hover:text-red-700 mx-4" title="Delete User"><i class="fas fa-trash"></i></button>
|
<tbody>
|
||||||
</form>
|
{% for user in users %}
|
||||||
</li>
|
<tr class="border-b border-gray-600">
|
||||||
{% endfor %}
|
<td class="p-2">{{ user[1] }}</td>
|
||||||
</ul>
|
<td class="p-2">{{ user[2] }}</td>
|
||||||
|
<td class="p-2 text-center">
|
||||||
|
<span class="px-2 py-1 bg-gray-300 dark:bg-zinc-700 rounded">{{ user[4] or 'No Role' }}</span>
|
||||||
|
</td>
|
||||||
|
{% if can_manage_users %}
|
||||||
|
<td class="p-2 text-center">
|
||||||
|
<button onclick="editUser({{ user[0] }}, '{{ user[1]|replace("'", "\\'") }}', '{{ user[2]|replace("'", "\\'") }}', {{ user[3] if user[3] else 'null' }}, '{{ user[5]|replace("'", "\\'") if user[5] else '' }}')" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 mr-2 hover:cursor-pointer" title="Edit User">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</button>
|
||||||
|
<form action="/users" method="POST" onsubmit="return confirm('Are you sure you want to regenerate the API key for this user?');" class="inline mr-2">
|
||||||
|
<input type="hidden" name="action" value="regenerate_api_key">
|
||||||
|
<input type="hidden" name="user_id" value="{{ user[0] }}">
|
||||||
|
<button type="submit" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Regenerate API Key">
|
||||||
|
<i class="fas fa-key"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form action="/users" method="POST" onsubmit="return confirm('Are you sure you want to delete this user?');" class="inline">
|
||||||
|
<input type="hidden" name="action" value="delete_user">
|
||||||
|
<input type="hidden" name="user_id" value="{{ user[0] }}">
|
||||||
|
<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 User">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Roles Tab -->
|
||||||
|
<div id="roles-tab" class="tab-content hidden">
|
||||||
|
{% if can_manage_roles %}
|
||||||
|
<div class="mb-6 flex justify-end">
|
||||||
|
<button type="button" onclick="showAddRoleModal(); return false;" 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 New Role
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h2 class="text-xl font-bold mb-4">Roles</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{% for role in roles %}
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md hover:shadow-lg transition-shadow">
|
||||||
|
<div class="flex justify-between items-start mb-3">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-lg font-bold mb-1">{{ role[1] }}</h3>
|
||||||
|
{% if role[2] %}
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">{{ role[2] }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if can_manage_roles %}
|
||||||
|
<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">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</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">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">Permissions:</p>
|
||||||
|
{% set role_perms = role_permissions.get(role[0], []) %}
|
||||||
|
{% set perm_dict = {} %}
|
||||||
|
{% for perm in permissions %}
|
||||||
|
{% set _ = perm_dict.update({perm[0]: perm[2]}) %}
|
||||||
|
{% endfor %}
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{% for perm_id in role_perms[:5] %}
|
||||||
|
<span class="px-2 py-1 bg-green-200 dark:bg-green-800 rounded text-xs">{{ perm_dict.get(perm_id, 'Unknown') }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% if role_perms|length > 5 %}
|
||||||
|
<span class="px-2 py-1 bg-gray-300 dark:bg-gray-700 rounded text-xs">+{{ role_perms|length - 5 }} more</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if not role_perms %}
|
||||||
|
<span class="text-gray-500 text-xs">No permissions</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit User Modal -->
|
||||||
|
<div id="edit-user-modal" class="hidden fixed inset-0 bg-black/30 backdrop-blur-md flex items-center justify-center z-50">
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-lg max-w-md w-full mx-4">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-bold">Edit User</h2>
|
||||||
|
<button onclick="closeEditUserModal()" class="text-gray-500 hover:text-gray-700 hover:cursor-pointer">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form action="/users" method="POST" class="space-y-4">
|
||||||
|
<input type="hidden" name="action" value="edit_user">
|
||||||
|
<input type="hidden" name="user_id" id="edit-user-id">
|
||||||
|
<input type="text" name="name" id="edit-user-name" placeholder="Name" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
|
||||||
|
<input type="email" name="email" id="edit-user-email" placeholder="Email Address" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
|
||||||
|
<input type="password" name="password" id="edit-user-password" placeholder="New Password (leave blank to keep)" 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">
|
||||||
|
<select name="role_id" id="edit-user-role" 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">
|
||||||
|
<option value="">No Role</option>
|
||||||
|
{% for role in roles %}
|
||||||
|
<option value="{{ role[0] }}">{{ role[1] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full">
|
||||||
|
<label class="text-sm font-semibold mb-2 block">API Key</label>
|
||||||
|
<code id="edit-user-api-key" class="text-xs font-mono break-all block bg-gray-200 dark:bg-zinc-800 px-2 py-1 rounded"></code>
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400 mt-2">Use this API key to authenticate API requests. Keep it secure!</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end space-x-2">
|
||||||
|
<button type="button" onclick="closeEditUserModal()" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Role Modal -->
|
||||||
|
<div id="add-role-modal" class="hidden fixed inset-0 bg-black/30 backdrop-blur-md flex items-center justify-center z-50 overflow-y-auto py-8">
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-lg max-w-3xl w-full mx-4 my-8">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-bold">Add New Role</h2>
|
||||||
|
<button onclick="closeAddRoleModal()" class="text-gray-500 hover:text-gray-700 hover:cursor-pointer">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form action="/users" method="POST" class="space-y-4">
|
||||||
|
<input type="hidden" name="action" value="add_role">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<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">
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<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">
|
||||||
|
<!-- View Permissions -->
|
||||||
|
<div class="col-span-full">
|
||||||
|
<h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">View Permissions</h4>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
|
{% for perm in permissions %}
|
||||||
|
{% if perm[3] == 'View' %}
|
||||||
|
<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] }}" class="mr-2">
|
||||||
|
<span class="text-sm">{{ perm[2] }}</span>
|
||||||
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Device Management -->
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Device Management</h4>
|
||||||
|
{% for perm in permissions %}
|
||||||
|
{% if perm[3] == 'Device' %}
|
||||||
|
<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] }}" class="mr-2">
|
||||||
|
<span class="text-sm">{{ perm[2] }}</span>
|
||||||
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% for perm in permissions %}
|
||||||
|
{% if perm[3] == 'Device Type' %}
|
||||||
|
<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] }}" class="mr-2">
|
||||||
|
<span class="text-sm">{{ perm[2] }}</span>
|
||||||
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Network Management -->
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Network Management</h4>
|
||||||
|
{% for perm in permissions %}
|
||||||
|
{% if perm[3] == 'Subnet' %}
|
||||||
|
<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] }}" class="mr-2">
|
||||||
|
<span class="text-sm">{{ perm[2] }}</span>
|
||||||
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% for perm in permissions %}
|
||||||
|
{% if perm[3] == 'DHCP' %}
|
||||||
|
<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] }}" class="mr-2">
|
||||||
|
<span class="text-sm">{{ perm[2] }}</span>
|
||||||
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rack Management -->
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Rack Management</h4>
|
||||||
|
{% for perm in permissions %}
|
||||||
|
{% if perm[3] == 'Rack' %}
|
||||||
|
<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] }}" class="mr-2">
|
||||||
|
<span class="text-sm">{{ perm[2] }}</span>
|
||||||
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin -->
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Administration</h4>
|
||||||
|
{% for perm in permissions %}
|
||||||
|
{% if perm[3] == 'Admin' %}
|
||||||
|
<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] }}" class="mr-2">
|
||||||
|
<span class="text-sm">{{ perm[2] }}</span>
|
||||||
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end space-x-2">
|
||||||
|
<button type="button" onclick="closeAddRoleModal()" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Add Role</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Role Modal -->
|
||||||
|
<div id="edit-role-modal" class="hidden fixed inset-0 bg-black/30 backdrop-blur-md flex items-center justify-center z-50 overflow-y-auto py-8">
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-lg max-w-3xl w-full mx-4 my-8">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-bold">Edit Role</h2>
|
||||||
|
<button onclick="closeEditRoleModal()" class="text-gray-500 hover:text-gray-700 hover:cursor-pointer">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form action="/users" method="POST" class="space-y-4">
|
||||||
|
<input type="hidden" name="action" value="edit_role">
|
||||||
|
<input type="hidden" name="role_id" id="edit-role-id">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<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">
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<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">
|
||||||
|
<!-- Permissions will be populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end space-x-2">
|
||||||
|
<button type="button" onclick="closeEditRoleModal()" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Template variables passed from server - must be defined before users.js loads
|
||||||
|
const permissions = {{ permissions | tojson | safe }};
|
||||||
|
const rolePermissions = {{ role_permissions | tojson | safe }};
|
||||||
|
</script>
|
||||||
|
<script src="/static/js/users.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user