33 Commits

Author SHA1 Message Date
Jamie b5fa9ef6ae Merge pull request #30 from JDB-NET/release-please--branches--main
chore(main): release 1.8.0
2025-12-23 01:07:23 +00:00
github-actions[bot] 19e7e978aa chore(main): release 1.8.0 2025-12-23 01:06:45 +00:00
jamie 64ae4be6d5 feat: get next available ip by api 2025-12-23 01:06:25 +00:00
jamie d7fcffd4b5 build: 🚀 redeploy 2025-12-20 11:20:25 +00:00
jamie 283c445263 fix: 🐛 global search missing from devices 2025-12-05 12:07:12 +00:00
Jamie 2af3584d80 Merge pull request #28 from JDB-NET/release-please--branches--main
chore(main): release 1.7.0
2025-12-05 01:38:23 +00:00
github-actions[bot] 59ded14858 chore(main): release 1.7.0 2025-12-05 01:38:06 +00:00
jamie 9c0e6d035c feat: add devices by tag page 2025-12-05 01:37:45 +00:00
jamie 8242e9d758 fix: 🐛 invalidate linked cache 2025-12-05 01:33:47 +00:00
jamie 47208b31ee fix: 🐛 invalidate cache when device type is added 2025-12-05 01:24:05 +00:00
Jamie f44b5327e4 Merge pull request #27 from JDB-NET/release-please--branches--main
chore(main): release 1.6.1
2025-12-05 01:19:59 +00:00
github-actions[bot] f1fb8bc7e9 chore(main): release 1.6.1 2025-12-05 01:18:45 +00:00
jamie 286bf4b665 fix: 🐛 invalidate subnet cache when device is deleted 2025-12-05 01:18:21 +00:00
Jamie fb6a3445a7 Merge pull request #25 from JDB-NET/release-please--branches--main
chore(main): release 1.6.0
2025-12-05 01:07:11 +00:00
github-actions[bot] 28267989b0 chore(main): release 1.6.0 2025-12-05 01:00:09 +00:00
jamie 47f68fd27c refactor: 🎨 database indexing and optimisation 2025-12-05 00:59:43 +00:00
jamie 3a9250f5b0 feat: in memory cache 2025-12-05 00:51:02 +00:00
jamie 3e8965de6f feat: global search 2025-12-05 00:01:58 +00:00
jamie 707846bb3c feat: backup and restore 2025-12-04 23:23:33 +00:00
jamie 69588d6518 refactor: 🎨 tidy nav bar 2025-12-04 23:01:19 +00:00
jamie 1d9209a714 refactor: 🎨 js 2025-12-04 22:59:32 +00:00
jamie 730b8701db feat: update available notification 2025-12-04 22:47:43 +00:00
jamie f0165985fc refactor: 🎨 improved audit log filtering 2025-12-04 22:32:15 +00:00
jamie f6795f5281 ci: 🚀 include all commit types 2025-12-04 22:18:58 +00:00
jamie 2163be8f79 feat: bulk operations 2025-12-04 22:15:57 +00:00
jamie f98e92da06 feat: subnet utilisation stats 2025-12-04 20:01:52 +00:00
jamie 61e3200207 refactor: 🎨 header link to github releases 2025-12-04 19:26:40 +00:00
Jamie 6eb5000c27 Merge pull request #11 from JDB-NET/release-please--branches--main
chore(main): release 1.5.1
2025-12-04 19:15:10 +00:00
github-actions[bot] 9ecd4f3977 chore(main): release 1.5.1 2025-12-04 19:14:46 +00:00
jamie 6f01c9956f fix: 🐛 audit log on mobile 2025-12-04 19:14:19 +00:00
Jamie 671b750bc4 Merge pull request #9 from JDB-NET/release-please--branches--main
chore(main): release 1.5.0
2025-11-21 20:43:15 +00:00
github-actions[bot] bc1078f673 chore(main): release 1.5.0 2025-11-21 20:42:49 +00:00
jamie ad1e576da4 feat: device tags 2025-11-21 20:42:20 +00:00
43 changed files with 4921 additions and 685 deletions
+5 -1
View File
@@ -6,7 +6,11 @@
"settings": {}, "settings": {},
"customizations": { "customizations": {
"vscode": { "vscode": {
"extensions": ["ms-python.python"] "extensions": [
"ms-python.python",
"vivaxy.vscode-conventional-commits",
"esbenp.prettier-vscode"
]
} }
}, },
"postCreateCommand": "pip install -r requirements.txt; curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64; chmod +x tailwindcss-linux-x64; mv tailwindcss-linux-x64 tailwindcss", "postCreateCommand": "pip install -r requirements.txt; curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64; chmod +x tailwindcss-linux-x64; mv tailwindcss-linux-x64 tailwindcss",
+1 -1
View File
@@ -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
+1
View File
@@ -2,3 +2,4 @@ __pycache__
tailwindcss tailwindcss
static/css/output.css static/css/output.css
.env .env
backups/
+47 -3
View File
@@ -2,9 +2,53 @@
"packages": { "packages": {
".": { ".": {
"release-type": "simple", "release-type": "simple",
"version-file": "VERSION" "version-file": "VERSION",
"extra-files": [
"CHANGELOG.md"
],
"changelog-sections": [
{
"type": "feat",
"section": "Features"
},
{
"type": "fix",
"section": "Bug Fixes"
},
{
"type": "refactor",
"section": "Refactoring"
},
{
"type": "style",
"section": "Style Changes"
},
{
"type": "perf",
"section": "Performance Improvements"
},
{
"type": "docs",
"section": "Documentation"
},
{
"type": "test",
"section": "Tests"
},
{
"type": "build",
"section": "Build System"
},
{
"type": "ci",
"section": "CI/CD"
},
{
"type": "chore",
"section": "Chores"
}
]
} }
}, },
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
} }
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
".": "1.4.2" ".": "1.8.0"
} }
+77
View File
@@ -1,5 +1,82 @@
# 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) ## [1.4.2](https://github.com/JDB-NET/ipam/compare/v1.4.1...v1.4.2) (2025-11-08)
+1 -1
View File
@@ -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 \
+39 -9
View File
@@ -11,10 +11,11 @@ A Flask-based web application for comprehensive IP Address Management (IPAM). Ma
- **Subnet Management**: Create and manage IP subnets with CIDR notation (supports /24 to /32) - **Subnet Management**: Create and manage IP subnets with CIDR notation (supports /24 to /32)
- **IP Address Tracking**: Automatic IP address generation and tracking for each subnet - **IP Address Tracking**: Automatic IP address generation and tracking for each subnet
- **Device Management**: Track devices with types (Server, VM, Switch, Firewall, WiFi AP, Printer, Other) - **Device Management**: Track devices with types (Server, VM, Switch, Firewall, WiFi AP, Printer, Other)
- **Device Tagging**: Organize devices with customizable tags featuring colors and descriptions
- **IP Assignment**: Assign IP addresses to devices with automatic hostname updates - **IP Assignment**: Assign IP addresses to devices with automatic hostname updates
- **DHCP Pool Configuration**: Configure DHCP pools with start/end IP ranges and excluded IPs - **DHCP Pool Configuration**: Configure DHCP pools with start/end IP ranges and excluded IPs
- **Rack Management**: Physical infrastructure tracking with U positions and front/back sides - **Rack Management**: Physical infrastructure tracking with U positions and front/back sides
- **Site Organization**: Organize subnets and devices by site/location - **Site Organisation**: Organize subnets and devices by site/location
- **Audit Logging**: Complete audit trail of all changes with user, action, details, and timestamps - **Audit Logging**: Complete audit trail of all changes with user, action, details, and timestamps
- **User Management**: Multi-user support with secure password authentication - **User Management**: Multi-user support with secure password authentication
- **Role-Based Access Control (RBAC)**: Granular permission system with default roles (admin, user, view_only) and custom role creation - **Role-Based Access Control (RBAC)**: Granular permission system with default roles (admin, user, view_only) and custom role creation
@@ -31,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
``` ```
@@ -44,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
@@ -72,8 +74,8 @@ services:
- `MYSQL_PASSWORD`: Database password (default: password) - `MYSQL_PASSWORD`: Database password (default: password)
- `MYSQL_DATABASE`: Database name (default: ipam) - `MYSQL_DATABASE`: Database name (default: ipam)
- `SECRET_KEY`: Flask secret key for sessions (**REQUIRED in production!**) - `SECRET_KEY`: Flask secret key for sessions (**REQUIRED in production!**)
- `NAME`: Organization name displayed in header (default: JDB-NET) - `NAME`: Organisation name displayed in header (default: JDB-NET)
- `LOGO_PNG`: URL or path to organization logo (default: JDB-NET logo) - `LOGO_PNG`: URL or path to organisation logo (default: JDB-NET logo)
### Database Setup ### Database Setup
@@ -135,6 +137,22 @@ FLUSH PRIVILEGES;
- **Height**: Rack height in U units - **Height**: Rack height in U units
3. Open a rack to assign devices to specific U positions (front or back) 3. Open a rack to assign devices to specific U positions (front or back)
### Device Tagging
1. **Managing Tags** (Admin only):
- Navigate to "Admin" > "Tag Management"
- Click "Add Tag" to create new tags with custom colors and descriptions
- Edit or delete existing tags as needed
2. **Assigning Tags to Devices**:
- Open any device from the Devices page
- Use the tag assignment dropdown to add multiple tags
- Remove tags by clicking the × button next to the tag name
3. **Filtering by Tags**:
- Use the tag filter dropdown on the Devices page to view devices with specific tags
- Tags appear as colored badges throughout the interface for easy identification
### Audit Log ### Audit Log
View all changes and actions in the "Audit Log" section, with filtering by user, subnet, action type, or device name. View all changes and actions in the "Audit Log" section, with filtering by user, subnet, action type, or device name.
@@ -182,6 +200,9 @@ The application includes a comprehensive REST API for programmatic access:
3. **Available Endpoints**: 3. **Available Endpoints**:
- **Devices**: `GET`, `POST`, `PUT`, `DELETE /api/v1/devices` - **Devices**: `GET`, `POST`, `PUT`, `DELETE /api/v1/devices`
- **Device Tags**: `GET /api/v1/devices/by-tag/{tag}` (filter devices by tag)
- **Tags**: `GET`, `POST`, `PUT`, `DELETE /api/v1/tags` (with `?format=simple` option)
- **Tag Assignment**: `GET`, `POST /api/v1/devices/{id}/tags`, `DELETE /api/v1/devices/{id}/tags/{tag_id}`
- **Subnets**: `GET`, `POST`, `PUT`, `DELETE /api/v1/subnets` - **Subnets**: `GET`, `POST`, `PUT`, `DELETE /api/v1/subnets`
- **Racks**: `GET`, `POST`, `DELETE /api/v1/racks` - **Racks**: `GET`, `POST`, `DELETE /api/v1/racks`
- **Device Types**: `GET /api/v1/device-types` - **Device Types**: `GET /api/v1/device-types`
@@ -193,10 +214,19 @@ The application includes a comprehensive REST API for programmatic access:
5. **Documentation**: Full API documentation is available in the Help page of the web interface. 5. **Documentation**: Full API documentation is available in the Help page of the web interface.
**Example API Request**: **Example API Requests**:
```bash ```bash
# List all devices
curl -H "X-API-Key: your_api_key" \ curl -H "X-API-Key: your_api_key" \
https://your-server:5000/api/v1/devices https://your-server:5000/api/v1/devices
# Get devices with a specific tag
curl -H "X-API-Key: your_api_key" \
https://your-server:5000/api/v1/devices/by-tag/production
# List all tags in simple format
curl -H "X-API-Key: your_api_key" \
https://your-server:5000/api/v1/tags?format=simple
``` ```
## Kubernetes Deployment ## Kubernetes Deployment
+1 -1
View File
@@ -1 +1 @@
1.4.2 1.8.0
+4
View File
@@ -39,5 +39,9 @@ def inject_env_vars():
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__': if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True) app.run(host='0.0.0.0', port=5000, debug=True)
+191
View File
@@ -0,0 +1,191 @@
"""
In-memory caching module with TTL support and cache invalidation
"""
import time
import sys
from threading import Lock
from functools import wraps
class Cache:
"""Simple in-memory cache with TTL support and size limiting"""
def __init__(self, max_size_mb=50):
self._cache = {}
self._lock = Lock()
self._max_size_bytes = max_size_mb * 1024 * 1024 # Convert MB to bytes
self._access_order = [] # Track access order for LRU eviction
def _get_size(self, obj):
"""Estimate size of an object in bytes"""
size = sys.getsizeof(obj)
if isinstance(obj, dict):
size += sum(self._get_size(k) + self._get_size(v) for k, v in obj.items())
elif isinstance(obj, (list, tuple)):
size += sum(self._get_size(item) for item in obj)
elif isinstance(obj, str):
size += sys.getsizeof(obj) - sys.getsizeof('')
return size
def _get_cache_size(self):
"""Get approximate total size of cache in bytes"""
total_size = sys.getsizeof(self._cache)
for key, (value, expiry) in self._cache.items():
total_size += self._get_size(key) + self._get_size(value) + sys.getsizeof(expiry)
return total_size
def _evict_if_needed(self):
"""Evict entries if cache exceeds size limit"""
current_size = self._get_cache_size()
if current_size <= self._max_size_bytes:
return
# First, remove expired entries
current_time = time.time()
expired_keys = []
for key in list(self._cache.keys()):
_, expiry = self._cache[key]
if expiry is not None and current_time >= expiry:
expired_keys.append(key)
for key in expired_keys:
if key in self._cache:
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
# If still over limit, remove oldest entries (LRU)
current_size = self._get_cache_size()
while current_size > self._max_size_bytes and self._access_order:
oldest_key = self._access_order.pop(0)
if oldest_key in self._cache:
del self._cache[oldest_key]
current_size = self._get_cache_size()
def get(self, key):
"""Get value from cache if it exists and hasn't expired"""
with self._lock:
if key in self._cache:
value, expiry = self._cache[key]
if expiry is None or time.time() < expiry:
# Update access order (move to end for LRU)
if key in self._access_order:
self._access_order.remove(key)
self._access_order.append(key)
return value
else:
# Expired, remove it
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
return None
def set(self, key, value, ttl=None):
"""Set value in cache with optional TTL (time to live in seconds)"""
with self._lock:
# Remove old entry if it exists
if key in self._cache:
if key in self._access_order:
self._access_order.remove(key)
expiry = None if ttl is None else time.time() + ttl
self._cache[key] = (value, expiry)
self._access_order.append(key)
# Evict if needed to stay under size limit
self._evict_if_needed()
def delete(self, key):
"""Delete a key from cache"""
with self._lock:
if key in self._cache:
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
def clear(self, pattern=None):
"""Clear cache entries. If pattern is provided, only clear keys matching the pattern."""
with self._lock:
if pattern is None:
self._cache.clear()
self._access_order.clear()
else:
keys_to_delete = [key for key in self._cache.keys() if pattern in key]
for key in keys_to_delete:
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
def invalidate_subnet(self, subnet_id):
"""Invalidate all cache entries related to a specific subnet"""
patterns = [
f'subnet:{subnet_id}',
f'subnet_list',
f'index',
f'admin',
f'utilization:{subnet_id}'
]
with self._lock:
keys_to_delete = []
for key in self._cache.keys():
for pattern in patterns:
if pattern in key:
keys_to_delete.append(key)
break
for key in keys_to_delete:
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
def invalidate_device(self, device_id):
"""Invalidate all cache entries related to a specific device"""
patterns = [
f'device:{device_id}',
f'device_list',
f'devices',
f'device_types'
]
with self._lock:
keys_to_delete = []
for key in self._cache.keys():
for pattern in patterns:
if pattern in key:
keys_to_delete.append(key)
break
for key in keys_to_delete:
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
def invalidate_all(self):
"""Invalidate all cache entries"""
self.clear()
# Global cache instance
cache = Cache()
def cached(ttl=None, key_prefix=''):
"""
Decorator to cache function results
Args:
ttl: Time to live in seconds (None = no expiration)
key_prefix: Prefix for cache key
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
# Create cache key from function name, args, and kwargs
cache_key = f"{key_prefix}{func.__name__}:{str(args)}:{str(sorted(kwargs.items()))}"
# Try to get from cache
cached_value = cache.get(cache_key)
if cached_value is not None:
return cached_value
# Call function and cache result
result = func(*args, **kwargs)
cache.set(cache_key, result, ttl)
return result
return wrapper
return decorator
+158 -7
View File
@@ -3,6 +3,7 @@ import hashlib
import base64 import base64
import secrets import secrets
import mysql.connector import mysql.connector
import logging
from flask import current_app from flask import current_app
def hash_password(password, salt=None): def hash_password(password, salt=None):
@@ -64,8 +65,8 @@ def init_db(app=None):
details TEXT, details TEXT,
subnet_id INTEGER, subnet_id INTEGER,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES User(id), FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE SET NULL,
FOREIGN KEY (subnet_id) REFERENCES Subnet(id) FOREIGN KEY (subnet_id) REFERENCES Subnet(id) ON DELETE SET NULL
) )
''') ''')
cursor.execute(''' cursor.execute('''
@@ -132,6 +133,7 @@ def init_db(app=None):
FOREIGN KEY (device_id) REFERENCES Device(id) ON DELETE CASCADE FOREIGN KEY (device_id) REFERENCES Device(id) ON DELETE CASCADE
) )
''') ''')
# Initialize default device types only if table is empty
cursor.execute('SELECT COUNT(*) FROM DeviceType') cursor.execute('SELECT COUNT(*) FROM DeviceType')
if cursor.fetchone()[0] == 0: if cursor.fetchone()[0] == 0:
cursor.executemany('INSERT INTO DeviceType (name, icon_class) VALUES (%s, %s)', [ cursor.executemany('INSERT INTO DeviceType (name, icon_class) VALUES (%s, %s)', [
@@ -143,12 +145,20 @@ def init_db(app=None):
('Printer', 'fa-print'), ('Printer', 'fa-print'),
('Other', 'fa-question') ('Other', 'fa-question')
]) ])
conn.commit() # Commit the inserts before querying
# Add device_type_id column if it doesn't exist
cursor.execute("SHOW COLUMNS FROM Device LIKE 'device_type_id'") cursor.execute("SHOW COLUMNS FROM Device LIKE 'device_type_id'")
if not cursor.fetchone(): if not cursor.fetchone():
cursor.execute('ALTER TABLE Device ADD COLUMN device_type_id INTEGER DEFAULT NULL') cursor.execute('ALTER TABLE Device ADD COLUMN device_type_id INTEGER DEFAULT NULL')
cursor.execute("SELECT id FROM DeviceType WHERE name='Other'")
other_id = cursor.fetchone()[0] # Set default device_type_id for devices that don't have one
cursor.execute('UPDATE Device SET device_type_id = %s WHERE device_type_id IS NULL', (other_id,)) # Use the first available device type, or leave NULL if no types exist
cursor.execute('SELECT id FROM DeviceType ORDER BY id LIMIT 1')
first_type_result = cursor.fetchone()
if first_type_result:
first_type_id = first_type_result[0]
cursor.execute('UPDATE Device SET device_type_id = %s WHERE device_type_id IS NULL', (first_type_id,))
try: try:
cursor.execute('ALTER TABLE Device ADD CONSTRAINT fk_device_type FOREIGN KEY (device_type_id) REFERENCES DeviceType(id)') cursor.execute('ALTER TABLE Device ADD CONSTRAINT fk_device_type FOREIGN KEY (device_type_id) REFERENCES DeviceType(id)')
except mysql.connector.Error as e: except mysql.connector.Error as e:
@@ -199,6 +209,74 @@ def init_db(app=None):
if not cursor.fetchone(): if not cursor.fetchone():
cursor.execute('ALTER TABLE User ADD COLUMN api_key VARCHAR(255) DEFAULT NULL UNIQUE') cursor.execute('ALTER TABLE User ADD COLUMN api_key VARCHAR(255) DEFAULT NULL UNIQUE')
# 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 # Define all permissions with categories
permissions = [ permissions = [
# View permissions # View permissions
@@ -246,6 +324,14 @@ def init_db(app=None):
('edit_device_type', 'Edit device type', 'Device Type'), ('edit_device_type', 'Edit device type', 'Device Type'),
('delete_device_type', 'Delete device type', 'Device Type'), ('delete_device_type', 'Delete device type', 'Device Type'),
# Tag permissions
('view_tags', 'View tags', 'Tag'),
('add_tag', 'Add new tag', 'Tag'),
('edit_tag', 'Edit tag', 'Tag'),
('delete_tag', 'Delete tag', 'Tag'),
('assign_device_tag', 'Assign tag to device', 'Tag'),
('remove_device_tag', 'Remove tag from device', 'Tag'),
# Admin permissions # Admin permissions
('manage_users', 'Manage users (add, edit, delete)', 'Admin'), ('manage_users', 'Manage users (add, edit, delete)', 'Admin'),
('manage_roles', 'Manage roles and permissions', 'Admin'), ('manage_roles', 'Manage roles and permissions', 'Admin'),
@@ -306,7 +392,8 @@ def init_db(app=None):
'add_rack', 'delete_rack', 'add_device_to_rack', 'remove_device_from_rack', 'add_rack', 'delete_rack', 'add_device_to_rack', 'remove_device_from_rack',
'add_nonnet_device_to_rack', 'export_rack_csv', 'add_nonnet_device_to_rack', 'export_rack_csv',
'configure_dhcp', 'configure_dhcp',
'add_device_type', 'edit_device_type', 'delete_device_type' 'add_device_type', 'edit_device_type', 'delete_device_type',
'view_tags', 'add_tag', 'edit_tag', 'delete_tag', 'assign_device_tag', 'remove_device_tag'
] ]
for perm_name in non_admin_permissions: for perm_name in non_admin_permissions:
@@ -325,7 +412,7 @@ def init_db(app=None):
view_only_permissions = [ view_only_permissions = [
'view_index', 'view_devices', 'view_device', 'view_subnet', 'view_racks', 'view_rack', 'view_index', 'view_devices', 'view_device', 'view_subnet', 'view_racks', 'view_rack',
'view_audit', 'view_device_types', 'view_device_type_stats', 'view_devices_by_type', 'view_audit', 'view_device_types', 'view_device_type_stats', 'view_devices_by_type',
'view_dhcp', 'view_help' 'view_dhcp', 'view_help', 'view_tags'
] ]
for perm_name in view_only_permissions: for perm_name in view_only_permissions:
@@ -355,5 +442,69 @@ def init_db(app=None):
api_key = generate_api_key() api_key = generate_api_key()
cursor.execute('''INSERT INTO User (name, email, password, role_id, api_key) VALUES (%s, %s, %s, %s, %s)''', cursor.execute('''INSERT INTO User (name, email, password, role_id, api_key) VALUES (%s, %s, %s, %s, %s)''',
('admin', 'admin@example.com', hash_password('password'), admin_role_id, api_key)) ('admin', 'admin@example.com', hash_password('password'), admin_role_id, api_key))
# Create indexes for performance optimization
logging.info("Creating database indexes for performance...")
def create_index_if_not_exists(cursor, index_name, table_name, columns):
"""Helper function to create index if it doesn't exist"""
try:
# Check if index exists
cursor.execute('''
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = %s
AND index_name = %s
''', (table_name, index_name))
if cursor.fetchone()[0] == 0:
cursor.execute(f'CREATE INDEX {index_name} ON {table_name}({columns})')
logging.info(f"Created index {index_name}")
else:
logging.debug(f"Index {index_name} already exists")
except mysql.connector.Error as e:
logging.warning(f"Could not create index {index_name}: {e}")
# IPAddress table indexes
create_index_if_not_exists(cursor, 'idx_ipaddress_subnet_id', 'IPAddress', 'subnet_id')
create_index_if_not_exists(cursor, 'idx_ipaddress_hostname', 'IPAddress', 'hostname')
create_index_if_not_exists(cursor, 'idx_ipaddress_ip', 'IPAddress', 'ip')
create_index_if_not_exists(cursor, 'idx_ipaddress_subnet_hostname', 'IPAddress', 'subnet_id, hostname')
# 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
View File
@@ -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
+1
View File
@@ -2,3 +2,4 @@ Flask
mysql-connector-python mysql-connector-python
dotenv dotenv
gunicorn gunicorn
requests
+1924 -64
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
h2 { h2 {
cursor: pointer; cursor: pointer;
} }
form:not(.mb-6), .mt-4 { .container form:not(.mb-6), .mt-4 {
display: none; display: none;
} }
.allocated-ips { .allocated-ips {
+65
View File
@@ -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();
}
}
+79
View File
@@ -0,0 +1,79 @@
// API Documentation Interactive Functions
function getApiKey() {
return document.getElementById('apiKey').value;
}
function showStatus(message, isError = false) {
const status = document.getElementById('connectionStatus');
status.textContent = message;
status.className = `mt-2 text-sm ${isError ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}`;
}
async function testConnection() {
const apiKey = getApiKey();
if (!apiKey) {
showStatus('Please enter your API key', true);
return;
}
try {
const response = await axios.get('/api/v1/devices', {
headers: { 'X-API-Key': apiKey }
});
showStatus('✓ Connection successful');
} catch (error) {
if (error.response?.status === 401) {
showStatus('✗ Invalid API key', true);
} else if (error.response?.status === 403) {
showStatus('✗ Insufficient permissions', true);
} else {
showStatus('✗ Connection failed', true);
}
}
}
async function tryEndpoint(method, url, data, responseId) {
const apiKey = getApiKey();
if (!apiKey) {
showStatus('Please enter your API key first', true);
return;
}
try {
const config = {
method: method,
url: url,
headers: { 'X-API-Key': apiKey }
};
if (data) {
config.data = data;
}
const response = await axios(config);
document.getElementById(responseId + '-response').classList.remove('hidden');
document.getElementById(responseId).textContent = JSON.stringify(response.data, null, 2);
} catch (error) {
document.getElementById(responseId + '-response').classList.remove('hidden');
const errorMessage = error.response?.data?.error || error.message;
document.getElementById(responseId).textContent = `Error (${error.response?.status || 'Network'}): ${errorMessage}`;
}
}
async function tryEndpointWithId(method, baseUrl, inputId, responseId) {
const id = document.getElementById(inputId).value;
if (!id) {
alert('Please enter an ID');
return;
}
await tryEndpoint(method, baseUrl + encodeURIComponent(id), null, responseId);
}
// Auto-populate API key if user is logged in
document.addEventListener('DOMContentLoaded', function() {
const apiKeyInput = document.getElementById('apiKey');
if (apiKeyInput && apiKeyInput.value) {
testConnection();
}
});
+122
View File
@@ -0,0 +1,122 @@
document.addEventListener('DOMContentLoaded', function() {
// Filter toggle functionality
const filterToggle = document.getElementById('filter-toggle');
const filterForm = document.getElementById('audit-filter-form');
const filterArrow = document.getElementById('filter-arrow');
if (filterToggle && filterForm && filterArrow) {
filterToggle.addEventListener('click', function() {
filterForm.classList.toggle('hidden');
// Toggle rotation using inline style for better compatibility
if (filterForm.classList.contains('hidden')) {
filterArrow.style.transform = 'rotate(0deg)';
} else {
filterArrow.style.transform = 'rotate(180deg)';
}
});
// Set initial arrow rotation if form is visible (has active filters or expand_filters param)
if (!filterForm.classList.contains('hidden')) {
filterArrow.style.transform = 'rotate(180deg)';
}
}
// Format timestamps
document.querySelectorAll('td[data-utc]').forEach(function(td) {
const utc = td.getAttribute('data-utc');
if (utc) {
const date = new Date(utc + 'Z');
td.textContent = date.toLocaleString();
}
});
// Parse and display visual diffs
document.querySelectorAll('.diff-container').forEach(function(container) {
const details = container.getAttribute('data-details');
if (!details) return;
// Try to parse common change patterns
let html = details;
// Pattern 1: "Changed X from 'old' to 'new'"
html = html.replace(/Changed (.+?) from ['"](.+?)['"] to ['"](.+?)['"]/gi, function(match, field, oldVal, newVal) {
return `Changed ${field} from <span class="diff-removed">${oldVal}</span> to <span class="diff-added">${newVal}</span>`;
});
// Pattern 2: "Renamed X to Y"
html = html.replace(/Renamed (.+?) to ['"](.+?)['"]/gi, function(match, oldVal, newVal) {
return `Renamed <span class="diff-removed">${oldVal}</span> to <span class="diff-added">${newVal}</span>`;
});
// Pattern 3: "Updated X: old -> new"
html = html.replace(/Updated (.+?):\s*(.+?)\s*->\s*(.+?)(?:\s|$)/gi, function(match, field, oldVal, newVal) {
return `Updated ${field}: <span class="diff-removed">${oldVal}</span> → <span class="diff-added">${newVal}</span>`;
});
// Pattern 4: "Set X to Y" (when it was previously something else, look for context)
html = html.replace(/Set (.+?) to ['"](.+?)['"]/gi, function(match, field, newVal) {
return `Set ${field} to <span class="diff-added">${newVal}</span>`;
});
// Pattern 5: "Removed X" or "Deleted X"
html = html.replace(/(Removed|Deleted) ['"](.+?)['"]/gi, function(match, action, val) {
return `${action} <span class="diff-removed">${val}</span>`;
});
// Pattern 6: "Added X"
html = html.replace(/Added ['"](.+?)['"]/gi, function(match, val) {
return `Added <span class="diff-added">${val}</span>`;
});
// Pattern 7: "Assigned X to Y" or "Unassigned X from Y"
// Capture everything after "to " or "from " to preserve all spaces in target
html = html.replace(/(Assigned|Unassigned) (.+?) (to|from) (.+)$/gi, function(match, action, item, prep, target) {
const actionClass = action === 'Assigned' ? 'diff-added' : 'diff-removed';
// Preserve the space between prep and target
return `${action} <span class="${actionClass}">${item}</span> ${prep} ${target}`;
});
// Pattern 8: Generic "from X to Y" pattern
html = html.replace(/from ['"](.+?)['"] to ['"](.+?)['"]/gi, function(match, oldVal, newVal) {
return `from <span class="diff-removed">${oldVal}</span> to <span class="diff-added">${newVal}</span>`;
});
container.innerHTML = html || details;
});
// Export button handler
const exportBtn = document.getElementById('export-btn');
if (exportBtn) {
exportBtn.addEventListener('click', function() {
const form = document.getElementById('audit-filter-form');
const formData = new FormData(form);
const params = new URLSearchParams();
// Add all form fields to params
for (const [key, value] of formData.entries()) {
if (value) {
if (key === 'user_ids') {
// Handle multiple user_ids
params.append('user_ids', value);
} else {
params.append(key, value);
}
}
}
// Handle multiple user_ids separately
const userSelect = form.querySelector('select[name="user_ids"]');
if (userSelect) {
const selectedUsers = Array.from(userSelect.selectedOptions).map(opt => opt.value);
params.delete('user_ids');
selectedUsers.forEach(userId => {
params.append('user_ids', userId);
});
}
// Redirect to export endpoint
window.location.href = '/audit/export_csv?' + params.toString();
});
}
});
+143
View File
@@ -0,0 +1,143 @@
document.addEventListener('DOMContentLoaded', function() {
const messageDiv = document.getElementById('message');
function showMessage(text, isError = false) {
messageDiv.textContent = text;
messageDiv.className = isError
? 'mb-4 p-4 rounded-lg bg-red-200 dark:bg-red-800 text-red-800 dark:text-red-200'
: 'mb-4 p-4 rounded-lg bg-green-200 dark:bg-green-800 text-green-800 dark:text-green-200';
messageDiv.classList.remove('hidden');
setTimeout(() => {
messageDiv.classList.add('hidden');
}, 5000);
}
// Create backup button
const createBackupBtn = document.getElementById('create-backup-btn');
if (createBackupBtn) {
createBackupBtn.addEventListener('click', function() {
createBackupBtn.disabled = true;
createBackupBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Creating...';
fetch('/backup/create', {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage(`Backup created successfully: ${data.filename}`);
setTimeout(() => window.location.reload(), 1500);
} else {
showMessage(data.error || 'Failed to create backup', true);
createBackupBtn.disabled = false;
createBackupBtn.innerHTML = '<i class="fas fa-database"></i> <span>Create Backup</span>';
}
})
.catch(error => {
showMessage('Error creating backup: ' + error.message, true);
createBackupBtn.disabled = false;
createBackupBtn.innerHTML = '<i class="fas fa-database"></i> <span>Create Backup</span>';
});
});
}
// Upload and restore form
const uploadRestoreForm = document.getElementById('upload-restore-form');
if (uploadRestoreForm) {
uploadRestoreForm.addEventListener('submit', function(e) {
e.preventDefault();
if (!confirm('WARNING: This will replace all current database data with the backup. Are you sure you want to continue?')) {
return;
}
const formData = new FormData(this);
const submitBtn = this.querySelector('button[type="submit"]');
const originalText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Restoring...';
fetch('/backup/restore', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage('Database restored successfully. Page will reload...');
setTimeout(() => window.location.reload(), 2000);
} else {
showMessage(data.error || 'Failed to restore backup', true);
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
})
.catch(error => {
showMessage('Error restoring backup: ' + error.message, true);
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
});
});
}
// Existing backup restore form
const existingRestoreForm = document.getElementById('existing-restore-form');
if (existingRestoreForm) {
existingRestoreForm.addEventListener('submit', function(e) {
e.preventDefault();
if (!confirm('WARNING: This will replace all current database data with the backup. Are you sure you want to continue?')) {
return;
}
const formData = new FormData(this);
const submitBtn = this.querySelector('button[type="submit"]');
const originalText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Restoring...';
fetch('/backup/restore', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage('Database restored successfully. Page will reload...');
setTimeout(() => window.location.reload(), 2000);
} else {
showMessage(data.error || 'Failed to restore backup', true);
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
})
.catch(error => {
showMessage('Error restoring backup: ' + error.message, true);
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
});
});
}
});
function deleteBackup(filename) {
if (!confirm(`Are you sure you want to delete backup "${filename}"?`)) {
return;
}
fetch(`/backup/delete/${filename}`, {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
window.location.reload();
} else {
alert('Error: ' + (data.error || 'Failed to delete backup'));
}
})
.catch(error => {
alert('Error: ' + error.message);
});
}
+175
View File
@@ -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>`;
});
});
});
+13 -50
View File
@@ -1,5 +1,18 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Tag filter functionality
const tagFilter = document.getElementById('tag-filter');
if (tagFilter) {
tagFilter.addEventListener('change', function() {
const selectedTag = this.value;
if (selectedTag) {
window.location.href = '/devices?tag=' + encodeURIComponent(selectedTag);
} else {
window.location.href = '/devices';
}
});
}
// Expand/collapse site groups // Expand/collapse site groups
document.querySelectorAll('.site-header').forEach(header => { document.querySelectorAll('.site-header').forEach(header => {
header.addEventListener('click', function(e) { header.addEventListener('click', function(e) {
@@ -17,56 +30,6 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}); });
// Search functionality
const searchInput = document.getElementById('search');
searchInput.addEventListener('keypress', function(event) {
if (event.key === 'Enter') {
event.preventDefault();
const query = this.value.toLowerCase();
document.querySelectorAll('.site-group').forEach(siteGroup => {
let anyVisible = false;
siteGroup.querySelectorAll('.device-list li').forEach(li => {
const deviceName = li.querySelector('span').textContent.toLowerCase();
const ipSpans = li.querySelectorAll('span.inline-block');
let match = deviceName.includes(query);
if (!match) {
ipSpans.forEach(ipSpan => {
if (ipSpan.textContent.toLowerCase().includes(query)) {
match = true;
}
});
}
li.style.display = match ? '' : 'none';
const card = li.querySelector('a');
if (match) {
anyVisible = true;
siteGroup.querySelector('.device-list').classList.remove('hidden');
const icon = siteGroup.querySelector('.expand-btn i');
if (icon && icon.classList.contains('fa-chevron-down')) {
icon.classList.remove('fa-chevron-down');
icon.classList.add('fa-chevron-up');
}
if (card) {
card.style.transition = 'background-color 0.3s';
card.style.backgroundColor = '#2563eb';
card.style.color = '#fff';
setTimeout(() => {
card.style.backgroundColor = '';
card.style.color = '';
}, 2000);
}
} else {
if (card) {
card.style.backgroundColor = '';
card.style.color = '';
}
}
});
siteGroup.style.display = anyVisible ? '' : 'none';
});
}
});
// Scroll to Top Button // Scroll to Top Button
const scrollToTopButton = document.createElement('button'); const scrollToTopButton = document.createElement('button');
scrollToTopButton.innerHTML = '<i class="fas fa-arrow-up"></i>'; scrollToTopButton.innerHTML = '<i class="fas fa-arrow-up"></i>';
+41
View File
@@ -0,0 +1,41 @@
document.addEventListener('DOMContentLoaded', function() {
// Export CSV button
const exportBtn = document.getElementById('export-csv');
if (exportBtn) {
exportBtn.addEventListener('click', function() {
const rackId = exportBtn.getAttribute('data-rack-id');
if (rackId) {
window.location = '/rack/' + rackId + '/export_csv';
}
});
}
// Form toggle functionality
function showBothAddButtons() {
document.getElementById('show-add-device-form').classList.remove('hidden');
document.getElementById('show-nonnet-form').classList.remove('hidden');
}
showBothAddButtons();
document.getElementById('show-nonnet-form').onclick = function() {
document.getElementById('nonnet-form').classList.remove('hidden');
this.classList.add('hidden');
};
document.getElementById('hide-nonnet-form').onclick = function() {
document.getElementById('nonnet-form').classList.add('hidden');
showBothAddButtons();
};
document.getElementById('show-add-device-form').onclick = function() {
document.getElementById('add-device-form').classList.remove('hidden');
this.classList.add('hidden');
};
document.getElementById('hide-add-device-form').onclick = function() {
document.getElementById('add-device-form').classList.add('hidden');
showBothAddButtons();
};
});
+57 -1
View File
@@ -1,6 +1,17 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const form = document.querySelector('form'); // Only target the form on the subnet page, not the header search form
// Look for a form that's not in the header (header forms have action="/search")
const allForms = document.querySelectorAll('form');
let form = null;
for (let f of allForms) {
if (f.action !== '/search' && f.method === 'POST') {
form = f;
break;
}
}
if (form) { if (form) {
// Check if search input already exists to prevent duplicates
if (!document.querySelector('input[placeholder="Search by IP or Hostname"]')) {
form.addEventListener('submit', (event) => { form.addEventListener('submit', (event) => {
event.preventDefault(); event.preventDefault();
}); });
@@ -35,6 +46,21 @@ document.addEventListener('DOMContentLoaded', () => {
} }
}); });
} }
}
// Description toggle functionality
const toggleBtn = document.getElementById('toggle-desc');
const descCols = document.querySelectorAll('.desc-col');
const descHeader = document.getElementById('desc-col-header');
let shown = false;
if (toggleBtn) {
toggleBtn.addEventListener('click', function() {
shown = !shown;
descCols.forEach(col => col.classList.toggle('hidden', !shown));
if (descHeader) descHeader.classList.toggle('hidden', !shown);
toggleBtn.textContent = shown ? 'Hide Descriptions' : 'Show Descriptions';
});
}
// Scroll to Top Button // Scroll to Top Button
const scrollToTopButton = document.createElement('button'); const scrollToTopButton = document.createElement('button');
@@ -76,4 +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);
}
}
}); });
+69
View File
@@ -0,0 +1,69 @@
// Tag Management JavaScript
function showAddTagModal() {
document.getElementById('add-tag-modal').classList.remove('hidden');
document.getElementById('add-tag-name').value = '';
document.getElementById('add-tag-color').value = '#6B7280';
document.getElementById('add-tag-description').value = '';
updateColorPreview('add');
}
function closeAddTagModal() {
document.getElementById('add-tag-modal').classList.add('hidden');
}
function editTag(tagId, name, color, description) {
document.getElementById('edit-tag-id').value = tagId;
document.getElementById('edit-tag-name').value = name;
document.getElementById('edit-tag-color').value = color;
document.getElementById('edit-tag-description').value = description || '';
updateColorPreview('edit');
document.getElementById('edit-tag-modal').classList.remove('hidden');
}
function closeEditTagModal() {
document.getElementById('edit-tag-modal').classList.add('hidden');
}
function updateColorPreview(mode) {
const colorInput = document.getElementById(`${mode}-tag-color`);
const preview = document.getElementById(`${mode}-color-preview`);
preview.textContent = colorInput.value.toUpperCase();
}
// Event listeners
document.addEventListener('DOMContentLoaded', function() {
const addColorInput = document.getElementById('add-tag-color');
const editColorInput = document.getElementById('edit-tag-color');
if (addColorInput) {
addColorInput.addEventListener('input', () => updateColorPreview('add'));
}
if (editColorInput) {
editColorInput.addEventListener('input', () => updateColorPreview('edit'));
}
// Handle edit tag button clicks
document.querySelectorAll('.edit-tag-btn').forEach(button => {
button.addEventListener('click', function() {
const tagId = this.dataset.tagId;
const tagName = this.dataset.tagName;
const tagColor = this.dataset.tagColor;
const tagDescription = this.dataset.tagDescription;
editTag(tagId, tagName, tagColor, tagDescription);
});
});
});
// Close modals when clicking outside
window.onclick = function(event) {
const addModal = document.getElementById('add-tag-modal');
const editModal = document.getElementById('edit-tag-modal');
if (event.target === addModal) {
closeAddTagModal();
}
if (event.target === editModal) {
closeEditTagModal();
}
}
+40
View File
@@ -0,0 +1,40 @@
document.addEventListener('DOMContentLoaded', function() {
// Check if toast was dismissed in this session
const toastDismissed = sessionStorage.getItem('update-toast-dismissed');
if (toastDismissed) {
return;
}
// Check for updates
fetch('/check_update')
.then(response => response.json())
.then(data => {
if (data.update_available) {
const toast = document.getElementById('update-toast');
const currentVersionEl = document.getElementById('toast-current-version');
const latestVersionEl = document.getElementById('toast-latest-version');
const compareLink = document.getElementById('toast-compare-link');
const closeBtn = document.getElementById('toast-close');
// Set versions
currentVersionEl.textContent = 'v' + data.current_version;
latestVersionEl.textContent = 'v' + data.latest_version;
// Set compare link (current version to latest version)
compareLink.href = `https://github.com/JDB-NET/ipam/compare/v${data.current_version}...v${data.latest_version}`;
// Show toast
toast.classList.remove('hidden');
// Close button handler
closeBtn.addEventListener('click', function() {
toast.classList.add('hidden');
sessionStorage.setItem('update-toast-dismissed', 'true');
});
}
})
.catch(error => {
console.error('Error checking for updates:', error);
});
});
+199
View File
@@ -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();
}
}
+1 -1
View File
@@ -13,7 +13,7 @@
<div class="flex-1 flex items-center justify-center mx-4"> <div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-md pt-20"> <div class="container py-8 max-w-md pt-20">
<div class="flex items-center mb-6 relative"> <div class="flex items-center mb-6 relative">
<a href="javascript:window.history.back()" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a> <a href="/devices" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
<h1 class="text-3xl font-bold text-center w-full">Add Device</h1> <h1 class="text-3xl font-bold text-center w-full">Add Device</h1>
</div> </div>
<form action="/add_device" method="POST" class="flex flex-col space-y-4"> <form action="/add_device" method="POST" class="flex flex-col space-y-4">
+43 -67
View File
@@ -21,7 +21,7 @@
{% endif %} {% endif %}
<!-- Quick Links --> <!-- Quick Links -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
<a href="/audit" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors"> <a href="/audit" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<i class="fas fa-clipboard-list text-3xl text-gray-600 dark:text-gray-400"></i> <i class="fas fa-clipboard-list text-3xl text-gray-600 dark:text-gray-400"></i>
@@ -42,6 +42,38 @@
</div> </div>
<i class="fas fa-chevron-right text-gray-400"></i> <i class="fas fa-chevron-right text-gray-400"></i>
</a> </a>
{% if has_permission('view_tags') %}
<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> </div>
<!-- Subnet Management Section --> <!-- Subnet Management Section -->
@@ -63,6 +95,7 @@
<th class="text-center p-3">Name</th> <th class="text-center p-3">Name</th>
<th class="text-center p-3">CIDR</th> <th class="text-center p-3">CIDR</th>
<th class="text-center p-3">Site</th> <th class="text-center p-3">Site</th>
<th class="text-center p-3">Utilisation</th>
<th class="text-center p-3">Actions</th> <th class="text-center p-3">Actions</th>
</tr> </tr>
</thead> </thead>
@@ -74,6 +107,14 @@
<td class="p-3 text-center"> <td class="p-3 text-center">
<span class="px-2 py-1 bg-gray-300 dark:bg-zinc-700 rounded text-sm">{{ subnet.site }}</span> <span class="px-2 py-1 bg-gray-300 dark:bg-zinc-700 rounded text-sm">{{ subnet.site }}</span>
</td> </td>
<td class="p-3 text-center">
{% if subnet.utilization %}
<span class="text-sm text-gray-600 dark:text-gray-400">{{ subnet.utilization.percent }}%</span>
<span class="text-xs text-gray-500 dark:text-gray-500 ml-1">({{ subnet.utilization.used }}/{{ subnet.utilization.total }})</span>
{% else %}
<span class="text-sm text-gray-500"></span>
{% endif %}
</td>
<td class="p-3 text-center"> <td class="p-3 text-center">
<div class="flex items-center justify-center space-x-2"> <div class="flex items-center justify-center space-x-2">
<a href="/subnet/{{ subnet.id }}" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300" title="View Subnet"> <a href="/subnet/{{ subnet.id }}" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300" title="View Subnet">
@@ -159,71 +200,6 @@
</div> </div>
<script src="/static/js/add_subnet.js"></script> <script src="/static/js/add_subnet.js"></script>
<script> <script src="/static/js/admin.js"></script>
function showAddSubnetModal() {
document.getElementById('add-subnet-modal').classList.remove('hidden');
document.getElementById('add-subnet-name').value = '';
document.getElementById('add-subnet-cidr').value = '';
document.getElementById('add-subnet-site').value = '';
}
function closeAddSubnetModal() {
document.getElementById('add-subnet-modal').classList.add('hidden');
document.getElementById('cidr-error').classList.add('hidden');
}
function editSubnet(subnetId, name, cidr, site) {
document.getElementById('edit-subnet-id').value = subnetId;
document.getElementById('edit-subnet-name').value = name;
document.getElementById('edit-subnet-cidr').value = cidr;
document.getElementById('edit-subnet-site').value = site;
document.getElementById('edit-subnet-modal').classList.remove('hidden');
}
function closeEditSubnetModal() {
document.getElementById('edit-subnet-modal').classList.add('hidden');
document.getElementById('edit-cidr-error').classList.add('hidden');
}
function validateEditSubnetForm() {
const cidrInput = document.getElementById('edit-subnet-cidr');
const cidrError = document.getElementById('edit-cidr-error');
const cidr = cidrInput.value.trim();
// Basic CIDR validation
const cidrPattern = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/;
if (!cidrPattern.test(cidr)) {
cidrError.textContent = 'Invalid CIDR format. Use format like 192.168.1.0/24';
cidrError.classList.remove('hidden');
return false;
}
// Check prefix length
const parts = cidr.split('/');
if (parts.length === 2) {
const prefixLen = parseInt(parts[1]);
if (prefixLen < 24 || prefixLen > 32) {
cidrError.textContent = 'Subnet must be /24 or smaller (e.g., /24, /25, ... /32)';
cidrError.classList.remove('hidden');
return false;
}
}
cidrError.classList.add('hidden');
return true;
}
// Close modals when clicking outside
window.onclick = function(event) {
const addModal = document.getElementById('add-subnet-modal');
const editModal = document.getElementById('edit-subnet-modal');
if (event.target === addModal) {
closeAddSubnetModal();
}
if (event.target === editModal) {
closeEditSubnetModal();
}
}
</script>
</body> </body>
</html> </html>
+331
View File
@@ -0,0 +1,331 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Documentation - IPAM</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 mx-4 py-8 pt-20">
<div class="container max-w-7xl mx-auto">
<h1 class="text-3xl font-bold mb-8 text-center">API Documentation</h1>
<!-- Authentication Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8 mb-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">Authentication</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<p class="mb-4">All API requests require authentication using an API key. You can provide the API key in one of three ways:</p>
<ul class="list-disc list-inside space-y-2 ml-4">
<li><strong>Header:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">X-API-Key: your_api_key</code></li>
<li><strong>Authorization Header:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">Authorization: Bearer your_api_key</code></li>
<li><strong>Query Parameter:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">?api_key=your_api_key</code></li>
</ul>
<p class="mt-4"><strong>Base URL:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">/api/v1</code></p>
</div>
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h3 class="font-semibold mb-2">Your API Key</h3>
<div class="flex items-center space-x-2">
<input type="text" id="apiKey" value="{{ api_key or '' }}" readonly
class="flex-1 px-3 py-2 bg-gray-100 dark:bg-zinc-600 border border-gray-400 dark:border-zinc-500 rounded text-sm font-mono"
placeholder="API key not found">
<button onclick="testConnection()" class="px-4 py-2 bg-gray-500 hover:bg-gray-600 dark:bg-zinc-600 dark:hover:bg-zinc-500 text-white rounded text-sm transition-colors">
<i class="fas fa-plug mr-2"></i>Test
</button>
</div>
<div id="connectionStatus" class="mt-2 text-sm"></div>
</div>
</div>
</div>
<!-- Interactive Endpoints -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8 mb-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
<i class="fas fa-play-circle mr-2"></i>Interactive Testing
</h2>
<p class="text-gray-600 dark:text-gray-300 mb-6">Test GET endpoints directly in your browser. Other methods are documented below.</p>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- GET /devices -->
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-2">
<span class="px-2 py-1 bg-green-600 text-white rounded text-xs font-mono">GET</span>
<code class="text-sm">/api/v1/devices</code>
</div>
<button onclick="tryEndpoint('GET', '/api/v1/devices', null, 'devices-list')"
class="px-3 py-1 bg-gray-500 hover:bg-gray-600 dark:bg-zinc-600 dark:hover:bg-zinc-500 text-white rounded text-xs transition-colors">
<i class="fas fa-play mr-1"></i>Try
</button>
</div>
<p class="text-xs text-gray-600 dark:text-gray-300 mb-2">List all devices</p>
<div id="devices-list-response" class="hidden">
<pre class="bg-gray-100 dark:bg-zinc-600 p-2 rounded text-xs overflow-x-auto max-h-32" id="devices-list"></pre>
</div>
</div>
<!-- GET /devices/{id} -->
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-2">
<span class="px-2 py-1 bg-green-600 text-white rounded text-xs font-mono">GET</span>
<code class="text-sm">/api/v1/devices/{id}</code>
</div>
<div class="flex items-center space-x-1">
<input type="number" id="device-id" placeholder="ID" class="px-2 py-1 border rounded text-xs w-16">
<button onclick="tryEndpointWithId('GET', '/api/v1/devices/', 'device-id', 'device-detail')"
class="px-3 py-1 bg-gray-500 hover:bg-gray-600 dark:bg-zinc-600 dark:hover:bg-zinc-500 text-white rounded text-xs transition-colors">
<i class="fas fa-play mr-1"></i>Try
</button>
</div>
</div>
<p class="text-xs text-gray-600 dark:text-gray-300 mb-2">Get device by ID</p>
<div id="device-detail-response" class="hidden">
<pre class="bg-gray-100 dark:bg-zinc-600 p-2 rounded text-xs overflow-x-auto max-h-32" id="device-detail"></pre>
</div>
</div>
<!-- GET /devices/by-tag/{tag} -->
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-2">
<span class="px-2 py-1 bg-green-600 text-white rounded text-xs font-mono">GET</span>
<code class="text-sm">/api/v1/devices/by-tag/{tag}</code>
</div>
<div class="flex items-center space-x-1">
<input type="text" id="tag-name" placeholder="Tag" class="px-2 py-1 border rounded text-xs w-20">
<button onclick="tryEndpointWithId('GET', '/api/v1/devices/by-tag/', 'tag-name', 'devices-by-tag')"
class="px-3 py-1 bg-gray-500 hover:bg-gray-600 dark:bg-zinc-600 dark:hover:bg-zinc-500 text-white rounded text-xs transition-colors">
<i class="fas fa-play mr-1"></i>Try
</button>
</div>
</div>
<p class="text-xs text-gray-600 dark:text-gray-300 mb-2">Filter devices by tag</p>
<div id="devices-by-tag-response" class="hidden">
<pre class="bg-gray-100 dark:bg-zinc-600 p-2 rounded text-xs overflow-x-auto max-h-32" id="devices-by-tag"></pre>
</div>
</div>
<!-- GET /tags -->
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-2">
<span class="px-2 py-1 bg-green-600 text-white rounded text-xs font-mono">GET</span>
<code class="text-sm">/api/v1/tags</code>
</div>
<button onclick="tryEndpoint('GET', '/api/v1/tags', null, 'tags-list')"
class="px-3 py-1 bg-gray-500 hover:bg-gray-600 dark:bg-zinc-600 dark:hover:bg-zinc-500 text-white rounded text-xs transition-colors">
<i class="fas fa-play mr-1"></i>Try
</button>
</div>
<p class="text-xs text-gray-600 dark:text-gray-300 mb-2">List all tags</p>
<div id="tags-list-response" class="hidden">
<pre class="bg-gray-100 dark:bg-zinc-600 p-2 rounded text-xs overflow-x-auto max-h-32" id="tags-list"></pre>
</div>
</div>
</div>
</div>
<!-- Complete API Documentation -->
<div class="space-y-6">
<!-- Devices Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
<i class="fas fa-server mr-2"></i>Devices
</h2>
<div class="space-y-4">
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<h4 class="font-semibold mb-2">Read Operations</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/devices</code> - List all devices</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/devices/{id}</code> - Get device details</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/devices/by-tag/{tag}</code> - Get devices by tag</li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-2">Write Operations</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/devices</code> - Create device</li>
<li><code class="bg-blue-200 dark:bg-blue-800 px-2 py-1 rounded text-xs">PUT /api/v1/devices/{id}</code> - Update device</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/devices/{id}</code> - Delete device</li>
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/devices/{id}/ips</code> - Add IP to device</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/devices/{id}/ips/{ip_id}</code> - Remove IP</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Subnets Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
<i class="fas fa-network-wired mr-2"></i>Subnets
</h2>
<div class="space-y-4">
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<h4 class="font-semibold mb-2">Read Operations</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/subnets</code> - List all subnets</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/subnets/{id}</code> - Get subnet details</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/subnets/{id}/next_free_ip</code> - Get next free IP address</li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-2">Write Operations</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/subnets</code> - Create subnet</li>
<li><code class="bg-blue-200 dark:bg-blue-800 px-2 py-1 rounded text-xs">PUT /api/v1/subnets/{id}</code> - Update subnet</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/subnets/{id}</code> - Delete subnet</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Racks Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
<i class="fas fa-building mr-2"></i>Racks
</h2>
<div class="space-y-4">
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<h4 class="font-semibold mb-2">Read Operations</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/racks</code> - List all racks</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/racks/{id}</code> - Get rack details</li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-2">Write Operations</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/racks</code> - Create rack</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/racks/{id}</code> - Delete rack</li>
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/racks/{id}/devices</code> - Add device to rack</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/racks/{id}/devices/{device_id}</code> - Remove device</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Tags Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
<i class="fas fa-tags mr-2"></i>Tags
</h2>
<div class="space-y-4">
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<h4 class="font-semibold mb-2">Read Operations</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/tags</code> - List all tags</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/tags?format=simple</code> - List tags in simple format</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/tags/{id}</code> - Get tag details</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/devices/{id}/tags</code> - Get device tags</li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-2">Write Operations</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/tags</code> - Create tag</li>
<li><code class="bg-blue-200 dark:bg-blue-800 px-2 py-1 rounded text-xs">PUT /api/v1/tags/{id}</code> - Update tag</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/tags/{id}</code> - Delete tag</li>
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/devices/{id}/tags</code> - Assign tag to device</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">DELETE /api/v1/devices/{id}/tags/{tag_id}</code> - Remove tag</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Additional Endpoints -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
<i class="fas fa-cogs mr-2"></i>Additional Endpoints
</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h4 class="font-semibold mb-3"><i class="fas fa-info-circle mr-2"></i>System Information</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/info</code> - System information</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/device-types</code> - List device types</li>
</ul>
</div>
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h4 class="font-semibold mb-3"><i class="fas fa-dharmachakra mr-2"></i>DHCP Management</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/subnets/{id}/dhcp</code> - Get DHCP config</li>
<li><code class="bg-orange-200 dark:bg-orange-800 px-2 py-1 rounded text-xs">POST /api/v1/subnets/{id}/dhcp</code> - Generate DHCP config</li>
</ul>
</div>
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h4 class="font-semibold mb-3"><i class="fas fa-users mr-2"></i>User & Role Management</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/users</code> - List users</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/roles</code> - List roles</li>
</ul>
</div>
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h4 class="font-semibold mb-3"><i class="fas fa-clipboard-list mr-2"></i>Audit Log</h4>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/audit</code> - List audit entries</li>
</ul>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-2">Supports filtering with query parameters</p>
</div>
</div>
</div>
<!-- Response Format & Permissions -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-300 dark:border-zinc-600 pb-2">
<i class="fas fa-info-circle mr-2"></i>Response Format & Permissions
</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-3">Success Responses</h3>
<p class="mb-3 text-sm">All API responses are in JSON format. Successful requests return:</p>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-green-200 dark:bg-green-800 px-2 py-1 rounded text-xs">200 OK</code> - Request successful</li>
<li><code class="bg-green-200 dark:bg-green-800 px-2 py-1 rounded text-xs">201 Created</code> - Resource created</li>
<li><code class="bg-green-200 dark:bg-green-800 px-2 py-1 rounded text-xs">204 No Content</code> - Success with no response body</li>
</ul>
</div>
<div class="bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-3">Error Responses</h3>
<p class="mb-3 text-sm">Error responses include descriptive messages:</p>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">400 Bad Request</code> - Invalid request data</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">401 Unauthorized</code> - Missing or invalid API key</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">403 Forbidden</code> - Insufficient permissions</li>
<li><code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">404 Not Found</code> - Resource not found</li>
</ul>
</div>
</div>
<div class="mt-6 bg-gray-300 dark:bg-zinc-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-3"><i class="fas fa-shield-alt mr-2"></i>Permissions</h3>
<p class="text-sm">API endpoints respect the same role-based permissions as the web interface. Users can only perform actions that their role allows. If a user lacks the required permission, the API will return a <code class="bg-red-200 dark:bg-red-800 px-2 py-1 rounded text-xs">403 Forbidden</code> error with details about the missing permission.</p>
</div>
</div>
</div>
</div>
</div>
<script src="/static/js/api_docs.js"></script>
</body>
</html>
+82 -25
View File
@@ -13,42 +13,105 @@
<div class="flex-1 flex items-center justify-center mx-4"> <div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-8xl pt-20"> <div class="container py-8 max-w-8xl pt-20">
<h1 class="text-3xl font-bold mb-6 text-center">Audit Log</h1> <h1 class="text-3xl font-bold mb-6 text-center">Audit Log</h1>
<form method="GET" class="flex flex-wrap gap-4 mb-6 justify-center">
<select name="user_id" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600"> <!-- Collapsible Filter Section -->
<option value="">All Users</option> <div class="bg-gray-200 dark:bg-zinc-800 rounded-lg mb-6">
<button type="button" id="filter-toggle" class="w-full flex items-center justify-between p-4 hover:bg-gray-300 dark:hover:bg-zinc-700 rounded-lg transition-colors hover:cursor-pointer">
<h2 class="text-lg font-semibold">Filters</h2>
<i class="fas fa-chevron-down transition-transform duration-200 transform" id="filter-arrow"></i>
</button>
<!-- Advanced Filter Form -->
<form method="GET" id="audit-filter-form" class="px-4 pb-4 {% if not (search_query or selected_user_ids or request.args.get('subnet_id') or request.args.get('action') or request.args.get('device_name') or date_from or date_to or request.args.get('expand_filters')) %}hidden{% endif %}">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4">
<!-- Search -->
<div class="lg:col-span-3">
<label class="block text-sm font-medium mb-1">Search</label>
<input type="text" name="search" value="{{ search_query or '' }}" placeholder="Search in details, user, action, subnet..." class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
</div>
<!-- Multiple Users -->
<div>
<label class="block text-sm font-medium mb-1">Users</label>
<select name="user_ids" multiple size="5" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
{% for user in users %} {% for user in users %}
<option value="{{ user[0] }}" {% if request.args.get('user_id') == user[0]|string %}selected{% endif %}>{{ user[1] }}</option> <option value="{{ user[0] }}" {% if selected_user_ids and user[0]|string in selected_user_ids %}selected{% endif %}>{{ user[1] }}</option>
{% endfor %} {% endfor %}
</select> </select>
<select name="subnet_id" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600"> <p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Hold Ctrl/Cmd to select multiple</p>
</div>
<!-- Subnet -->
<div>
<label class="block text-sm font-medium mb-1">Subnet</label>
<select name="subnet_id" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
<option value="">All Subnets</option> <option value="">All Subnets</option>
{% for subnet in subnets %} {% for subnet in subnets %}
<option value="{{ subnet[0] }}" {% if request.args.get('subnet_id') == subnet[0]|string %}selected{% endif %}>{{ subnet[1] }}</option> <option value="{{ subnet[0] }}" {% if request.args.get('subnet_id') == subnet[0]|string %}selected{% endif %}>{{ subnet[1] }}</option>
{% endfor %} {% endfor %}
</select> </select>
<select name="action" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600"> </div>
<!-- Action -->
<div>
<label class="block text-sm font-medium mb-1">Action</label>
<select name="action" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
<option value="">All Actions</option> <option value="">All Actions</option>
{% for a in actions %} {% for a in actions %}
<option value="{{ a }}" {% if request.args.get('action') == a %}selected{% endif %}>{{ a }}</option> <option value="{{ a }}" {% if request.args.get('action') == a %}selected{% endif %}>{{ a }}</option>
{% endfor %} {% endfor %}
</select> </select>
<select name="device_name" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600"> </div>
<!-- Device Name -->
<div>
<label class="block text-sm font-medium mb-1">Device</label>
<select name="device_name" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
<option value="">All Devices</option> <option value="">All Devices</option>
{% for device in devices %} {% for device in devices %}
<option value="{{ device[0] }}" {% if request.args.get('device_name') == device[0] %}selected{% endif %}>{{ device[0] }}</option> <option value="{{ device[0] }}" {% if request.args.get('device_name') == device[0] %}selected{% endif %}>{{ device[0] }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div>
<!-- Date From -->
<div>
<label class="block text-sm font-medium mb-1">Date From</label>
<input type="date" name="date_from" value="{{ date_from or '' }}" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
</div>
<!-- Date To -->
<div>
<label class="block text-sm font-medium mb-1">Date To</label>
<input type="date" name="date_to" value="{{ date_to or '' }}" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
</div>
</div>
<div class="flex gap-2 justify-center">
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2"> <button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
<i class="fas fa-search"></i> <i class="fas fa-search"></i>
<span>Filter</span> <span>Filter</span>
</button> </button>
<a href="/audit?expand_filters=1" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
<i class="fas fa-times"></i>
<span>Clear</span>
</a>
<button type="button" id="export-btn" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
<i class="fas fa-file-csv"></i>
<span>Export CSV</span>
</button>
</div>
</form> </form>
</div>
<!-- Audit Log Table -->
<div class="overflow-x-auto">
<table class="w-full table-auto bg-gray-200 dark:bg-zinc-800 rounded-lg overflow-hidden"> <table class="w-full table-auto bg-gray-200 dark:bg-zinc-800 rounded-lg overflow-hidden">
<thead> <thead>
<tr class="bg-gray-400 dark:bg-zinc-700"> <tr class="bg-gray-400 dark:bg-zinc-700">
<th class="px-4 py-2 text-center">User</th> <th class="px-4 py-2 text-center">User</th>
<th class="px-4 py-2 text-center">Action</th> <th class="px-4 py-2 text-center">Action</th>
<th class="px-4 py-2 text-center">Details</th> <th class="px-4 py-2 text-center details-cell">Details</th>
<th class="px-4 py-2 text-center">Subnet</th> <th class="px-4 py-2 text-center">Subnet</th>
<th class="px-4 py-2 text-center">Timestamp</th> <th class="px-4 py-2 text-center">Timestamp</th>
</tr> </tr>
@@ -58,19 +121,23 @@
<tr class="border-b border-gray-700"> <tr class="border-b border-gray-700">
<td class="px-4 py-2 text-center">{{ log[1] or 'Unknown' }}</td> <td class="px-4 py-2 text-center">{{ log[1] or 'Unknown' }}</td>
<td class="px-4 py-2 text-center">{{ log[2] }}</td> <td class="px-4 py-2 text-center">{{ log[2] }}</td>
<td class="px-4 py-2 text-center truncate" title="{{ log[3] }}">{{ log[3][:100] ~ ('…' if log[3]|length > 100 else '') }}</td> <td class="px-4 py-2 text-center details-cell">
<div class="diff-container" data-details="{{ log[3]|e }}"></div>
</td>
<td class="px-4 py-2 text-center">{{ log[4] or 'N/A' }}</td> <td class="px-4 py-2 text-center">{{ log[4] or 'N/A' }}</td>
<td class="px-4 py-2 text-center" data-utc="{{ log[5] }}">{{ log[5] }}</td> <td class="px-4 py-2 text-center" data-utc="{{ log[5] }}">{{ log[5] }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
{% if total_pages > 1 %} {% if total_pages > 1 %}
<div class="flex justify-center mt-6 space-x-2"> <div class="flex justify-center mt-6 space-x-2">
{% if page > 1 %} {% if page > 1 %}
{% set prev_args = query_args.copy() %} {% set prev_args = query_args.copy() %}
{% set _ = prev_args.update({'page': page-1}) %} {% set _ = prev_args.update({'page': page-1}) %}
<a href="{{ url_for('audit', **prev_args) }}" class="px-3 py-1 rounded bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600 flex items-center gap-2"> <a href="{{ url_for('audit', **prev_args) }}" class="px-3 py-1 rounded bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer flex items-center gap-2">
<i class="fa fa-angle-left"></i> <i class="fa fa-angle-left"></i>
<span class="hidden sm:inline">Prev</span> <span class="hidden sm:inline">Prev</span>
</a> </a>
@@ -85,7 +152,7 @@
{% if start_page > 1 %} {% if start_page > 1 %}
{% set page_args = query_args.copy() %} {% set page_args = query_args.copy() %}
{% set _ = page_args.update({'page': 1}) %} {% set _ = page_args.update({'page': 1}) %}
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded {{ 'bg-gray-200 dark:bg-gray-500' if 1 == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">1</a> <a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded hover:cursor-pointer {{ 'bg-gray-200 dark:bg-gray-500' if 1 == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">1</a>
{% if start_page > 2 %} {% if start_page > 2 %}
<span class="px-3 py-1 text-gray-600 dark:text-gray-400"></span> <span class="px-3 py-1 text-gray-600 dark:text-gray-400"></span>
{% endif %} {% endif %}
@@ -95,7 +162,7 @@
{% for p in range(start_page, end_page + 1) %} {% for p in range(start_page, end_page + 1) %}
{% set page_args = query_args.copy() %} {% set page_args = query_args.copy() %}
{% set _ = page_args.update({'page': p}) %} {% set _ = page_args.update({'page': p}) %}
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded {{ 'bg-gray-200 dark:bg-gray-500' if p == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">{{ p }}</a> <a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded hover:cursor-pointer {{ 'bg-gray-200 dark:bg-gray-500' if p == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">{{ p }}</a>
{% endfor %} {% endfor %}
{# Show last page if we're not near the end #} {# Show last page if we're not near the end #}
@@ -105,13 +172,13 @@
{% endif %} {% endif %}
{% set page_args = query_args.copy() %} {% set page_args = query_args.copy() %}
{% set _ = page_args.update({'page': total_pages}) %} {% set _ = page_args.update({'page': total_pages}) %}
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded {{ 'bg-gray-200 dark:bg-gray-500' if total_pages == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">{{ total_pages }}</a> <a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded hover:cursor-pointer {{ 'bg-gray-200 dark:bg-gray-500' if total_pages == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">{{ total_pages }}</a>
{% endif %} {% endif %}
{% if page < total_pages %} {% if page < total_pages %}
{% set next_args = query_args.copy() %} {% set next_args = query_args.copy() %}
{% set _ = next_args.update({'page': page+1}) %} {% set _ = next_args.update({'page': page+1}) %}
<a href="{{ url_for('audit', **next_args) }}" class="px-3 py-1 rounded bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600 flex items-center gap-2"> <a href="{{ url_for('audit', **next_args) }}" class="px-3 py-1 rounded bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer flex items-center gap-2">
<span class="hidden sm:inline">Next</span> <span class="hidden sm:inline">Next</span>
<i class="fa fa-angle-right"></i> <i class="fa fa-angle-right"></i>
</a> </a>
@@ -120,16 +187,6 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<script> <script src="/static/js/audit.js"></script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('td[data-utc]').forEach(function(td) {
const utc = td.getAttribute('data-utc');
if (utc) {
const date = new Date(utc + 'Z');
td.textContent = date.toLocaleString();
}
});
});
</script>
</body> </body>
</html> </html>
+126
View File
@@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Backup & Restore</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-4xl pt-20">
<div class="flex items-center mb-6 relative">
<a href="/admin" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-full w-11 h-11 hover:cursor-pointer"><i class="fas fa-arrow-left"></i></a>
<h1 class="text-3xl font-bold text-center w-full">Backup & Restore</h1>
</div>
<div id="message" class="hidden mb-4 p-4 rounded-lg"></div>
<!-- Create Backup Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg p-6 mb-6">
<h2 class="text-xl font-bold mb-4">Create Backup</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Create a new database backup. This will export the entire database to a SQL file.</p>
<button id="create-backup-btn" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
<i class="fas fa-database"></i>
<span>Create Backup</span>
</button>
</div>
<!-- Restore Backup Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg p-6 mb-6">
<h2 class="text-xl font-bold mb-4">Restore Backup</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Restore the database from a backup file. <strong class="text-red-600 dark:text-red-400">Warning: This will replace all current data!</strong></p>
<div class="space-y-4">
<!-- Upload Backup File -->
<div>
<label class="block text-sm font-medium mb-2">Upload Backup File</label>
<form id="upload-restore-form" enctype="multipart/form-data" class="flex gap-2">
<label class="flex-1 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 border border-gray-600 rounded-lg px-4 py-2 cursor-pointer flex items-center justify-center hover:cursor-pointer">
<input type="file" name="backup_file" accept=".sql" required class="hidden" onchange="updateFileLabel(this)">
<span id="file-label" class="text-sm">Choose File</span>
</label>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">
<i class="fas fa-upload"></i> Upload & Restore
</button>
</form>
</div>
<!-- Or Select Existing Backup -->
{% if backups %}
<div>
<label class="block text-sm font-medium mb-2">Or Restore from Existing Backup</label>
<form id="existing-restore-form" class="flex gap-2">
<select name="backup_filename" required class="flex-1 border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
<option value="">Select a backup...</option>
{% for backup in backups %}
<option value="{{ backup.filename }}">{{ backup.filename }} ({{ (backup.size / 1024 / 1024)|round(2) }} MB, {{ backup.created }})</option>
{% endfor %}
</select>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">
<i class="fas fa-undo"></i> Restore
</button>
</form>
</div>
{% endif %}
</div>
</div>
<!-- Available Backups Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg p-6">
<h2 class="text-xl font-bold mb-4">Available Backups</h2>
{% if backups %}
<div class="overflow-x-auto">
<table class="w-full table-auto">
<thead>
<tr class="bg-gray-400 dark:bg-zinc-700">
<th class="px-4 py-2 text-left">Filename</th>
<th class="px-4 py-2 text-left">Size</th>
<th class="px-4 py-2 text-left">Created</th>
<th class="px-4 py-2 text-center">Actions</th>
</tr>
</thead>
<tbody>
{% for backup in backups %}
<tr class="border-b border-gray-700">
<td class="px-4 py-2">{{ backup.filename }}</td>
<td class="px-4 py-2">{{ (backup.size / 1024 / 1024)|round(2) }} MB</td>
<td class="px-4 py-2">{{ backup.created }}</td>
<td class="px-4 py-2 text-center">
<div class="flex gap-2 justify-center">
<a href="/backup/download/{{ backup.filename }}" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-3 py-1 rounded text-sm" title="Download">
<i class="fas fa-download"></i>
</a>
<button onclick="deleteBackup('{{ backup.filename }}')" class="bg-red-300 hover:bg-red-400 dark:bg-red-700 dark:hover:bg-red-600 hover:cursor-pointer px-3 py-1 rounded text-sm" title="Delete">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-gray-600 dark:text-gray-400">No backups available. Create your first backup above.</p>
{% endif %}
</div>
</div>
</div>
<script src="/static/js/backup.js"></script>
<script>
function updateFileLabel(input) {
const label = document.getElementById('file-label');
if (input.files && input.files[0]) {
label.textContent = input.files[0].name;
} else {
label.textContent = 'Choose File';
}
}
</script>
</body>
</html>
+160
View File
@@ -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&#10;Device 2&#10;Device 3" required></textarea>
<p class="text-sm text-gray-500 mt-1">Enter device names, one per line</p>
</div>
<div>
<label class="block mb-2 font-medium">Device Type:</label>
<select name="device_type" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
{% for dtype in device_types %}
<option value="{{ dtype[0] }}">{{ dtype[1] }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg">Create Devices</button>
</form>
<div id="create-devices-result" class="mt-4 hidden"></div>
{% else %}
<p class="text-gray-500">You don't have permission to create devices.</p>
{% endif %}
</div>
<!-- Bulk Tag Assignment -->
<div id="panel-assign-tags" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
{% if can_assign_device_tag %}
<h2 class="text-2xl font-bold mb-4">Bulk Tag Assignment</h2>
<p class="text-gray-600 dark:text-gray-400 mb-4">Select multiple devices and assign one or more tags to them.</p>
<form id="bulk-assign-tags-form" class="space-y-4">
<div>
<label class="block mb-2 font-medium">Select Devices (hold Ctrl/Cmd to select multiple):</label>
<select id="bulk-tag-device-select" name="device_ids[]" multiple size="10" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full">
{% for device in devices %}
<option value="{{ device[0] }}">{{ device[1] }}</option>
{% endfor %}
</select>
<p class="text-sm text-gray-500 mt-1">Selected: <span id="selected-tag-device-count">0</span> devices</p>
</div>
<div>
<label class="block mb-2 font-medium">Select Tags (hold Ctrl/Cmd to select multiple):</label>
<select name="tag_ids[]" multiple size="5" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
{% for tag in tags %}
<option value="{{ tag[0] }}">{{ tag[1] }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg">Assign Tags</button>
</form>
<div id="assign-tags-result" class="mt-4 hidden"></div>
{% else %}
<p class="text-gray-500">You don't have permission to assign tags to devices.</p>
{% endif %}
</div>
<!-- Bulk Export -->
<div id="panel-export" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
{% if can_export_subnet_csv %}
<h2 class="text-2xl font-bold mb-4">Bulk Subnet Export</h2>
<p class="text-gray-600 dark:text-gray-400 mb-4">Select multiple subnets and export them to a single CSV file.</p>
<form id="bulk-export-form" method="POST" action="/bulk/export_subnets" class="space-y-4">
<div>
<label class="block mb-2 font-medium">Select Subnets (hold Ctrl/Cmd to select multiple):</label>
<select name="subnet_ids[]" multiple size="10" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
{% for subnet in subnets %}
<option value="{{ subnet[0] }}">{{ subnet[1] }} ({{ subnet[2] }}) - {{ subnet[3] or 'Unassigned' }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg">Export to CSV</button>
</form>
{% else %}
<p class="text-gray-500">You don't have permission to export subnets.</p>
{% endif %}
</div>
</div>
</div>
<script src="/static/js/bulk_operations.js"></script>
<style>
.tab-btn.active {
background-color: rgb(156 163 175);
color: white;
}
.dark .tab-btn.active {
background-color: rgb(63 63 70);
}
</style>
</body>
</html>
+45 -2
View File
@@ -11,7 +11,7 @@
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col"> <body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %} {% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4"> <div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 w-auto min-w-[20rem] max-w-2xl pt-20"> <div class="container py-8 max-w-2xl pt-20">
<div class="flex items-center mb-8 relative justify-between gap-4"> <div class="flex items-center mb-8 relative justify-between gap-4">
<a href="/devices" class="hidden sm:flex bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 flex items-center justify-center rounded-full w-11 h-11 shrink-0"><i class="fas fa-arrow-left"></i></a> <a href="/devices" class="hidden sm:flex bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 flex items-center justify-center rounded-full w-11 h-11 shrink-0"><i class="fas fa-arrow-left"></i></a>
<h1 class="text-3xl font-bold text-center flex-1 min-w-0 truncate">{{ device.name }}</h1> <h1 class="text-3xl font-bold text-center flex-1 min-w-0 truncate">{{ device.name }}</h1>
@@ -60,7 +60,7 @@
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg w-full">Add IP</button> <button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg w-full">Add IP</button>
</div> </div>
</form> </form>
<div class="allocated-ips"> <div class="allocated-ips mb-6">
<h3 class="text-lg font-bold mb-2">Allocated IPs:</h3> <h3 class="text-lg font-bold mb-2">Allocated IPs:</h3>
<ul class="space-y-2"> <ul class="space-y-2">
{% for ip in device_ips %} {% for ip in device_ips %}
@@ -74,6 +74,49 @@
{% 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>
+37 -4
View File
@@ -14,12 +14,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 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() %}
@@ -33,7 +52,8 @@
<ul class="device-list hidden px-6 pb-4"> <ul class="device-list hidden px-6 pb-4">
{% for device in devices %} {% for device in devices %}
<li class="my-2"> <li class="my-2">
<a href="/device/{{ device.id }}" class="flex items-center justify-between bg-gray-300 hover:bg-gray-100 dark:bg-zinc-900 dark:hover:bg-gray-700 dark:text-gray-200 font-semibold rounded-lg px-4 py-2 shadow transition-colors duration-150"> <a href="/device/{{ device.id }}" class="block bg-gray-300 hover:bg-gray-100 dark:bg-zinc-900 dark:hover:bg-gray-700 dark:text-gray-200 font-semibold rounded-lg px-4 py-2 shadow transition-colors duration-150">
<div class="flex items-center justify-between">
<span><i class="fas {{ device.icon_class or 'fa-server' }} mr-2"></i>{{ device.name }}</span> <span><i class="fas {{ device.icon_class or 'fa-server' }} mr-2"></i>{{ device.name }}</span>
{% set ips = device_ips.get(device.id, []) %} {% set ips = device_ips.get(device.id, []) %}
<span class="flex flex-row flex-wrap justify-end items-center ml-4 text-xs text-blue-300 font-normal align-middle"> <span class="flex flex-row flex-wrap justify-end items-center ml-4 text-xs text-blue-300 font-normal align-middle">
@@ -45,6 +65,19 @@
<span class="text-gray-400">No IPs</span> <span class="text-gray-400">No IPs</span>
{% endif %} {% endif %}
</span> </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 %}
+64
View File
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ tag_name }} - Tagged Devices</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-3xl pt-20">
<div class="flex items-center mb-6 relative">
<a href="/tags" class="absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 flex items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
<h1 class="text-3xl font-bold text-center w-full">
<div class="flex items-center justify-center space-x-2">
<div class="w-5 h-5 rounded-full border border-gray-600" style="background-color: {{ tag_color }}"></div>
<span>{{ tag_name }} - Tagged Devices</span>
</div>
</h1>
</div>
{% if site_devices %}
{% for site, devices in site_devices.items() %}
<div class="mb-8">
<h2 class="text-xl font-bold mb-4 dark:text-white">{{ site }}</h2>
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-4">
<table class="w-full table-auto mb-2">
<thead>
<tr class="bg-gray-200 dark:bg-zinc-700">
<th class="px-4 py-2 text-left">Device Name</th>
<th class="px-4 py-2 text-left">Description</th>
</tr>
</thead>
<tbody>
{% for device in devices %}
<tr class="border-b border-gray-700">
<td class="px-4 py-3">
<a href="/device/{{ device.id }}" class="hover:underline dark:text-white hover:cursor-pointer">{{ device.name }}</a>
</td>
<td class="px-4 py-3">{{ device.description or '' }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="bg-gray-200 dark:bg-zinc-900 font-bold">
<td class="px-4 py-2 text-right" colspan="2">Total: {{ devices|length }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
{% endfor %}
{% else %}
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-8 text-center">
<p class="text-gray-500">No devices found with this tag.</p>
</div>
{% endif %}
</div>
</div>
</body>
</html>
+67 -18
View File
@@ -1,12 +1,22 @@
<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">
<div class="flex items-center space-x-3 flex-shrink-0">
<a href="/" class="flex items-center space-x-3"> <a href="/" class="flex items-center space-x-3">
<img src="{{ LOGO_PNG }}" alt="Logo" class="h-8 rounded"> <img src="{{ LOGO_PNG }}" alt="Logo" class="h-8 rounded">
<span class="text-2xl font-bold text-white">{{ NAME }} IPAM <span class="text-sm font-normal text-gray-300">v{{ VERSION }}</span></span> <span class="text-2xl font-bold text-white whitespace-nowrap">{{ NAME }} IPAM</span>
</a> </a>
<div class="hidden lg:block absolute left-1/2 transform -translate-x-1/2 text-white text-lg font-medium whitespace-nowrap"> <a href="https://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>
{% if current_user_name %}Hello, {{ current_user_name }}{% endif %}
</div> </div>
<nav class="hidden md:flex items-center space-x-6" id="main-nav"> <div class="hidden lg:flex items-center 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') %} {% 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 %} {% endif %}
@@ -19,12 +29,6 @@
{% if has_permission('view_admin') %} {% if has_permission('view_admin') %}
<a href="/admin" class="text-gray-200 hover:text-gray-400 font-medium">Admin</a> <a href="/admin" class="text-gray-200 hover:text-gray-400 font-medium">Admin</a>
{% endif %} {% endif %}
{% if has_permission('view_users') %}
<a href="/users" class="text-gray-200 hover:text-gray-400 font-medium">Users</a>
{% endif %}
{% if has_permission('view_audit') %}
<a href="/audit" class="text-gray-200 hover:text-gray-400 font-medium">Audit Log</a>
{% endif %}
{% if has_permission('view_help') %} {% if has_permission('view_help') %}
<a href="/help" class="text-gray-200 hover:text-gray-400 font-medium">Help</a> <a href="/help" class="text-gray-200 hover:text-gray-400 font-medium">Help</a>
{% endif %} {% endif %}
@@ -32,12 +36,22 @@
<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 hover:cursor-pointer focus:outline-none" id="nav-toggle" aria-label="Open navigation menu"> <button class="lg:hidden flex items-center text-gray-200 hover:cursor-pointer focus:outline-none flex-shrink-0" id="nav-toggle" aria-label="Open navigation menu">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg> </svg>
</button> </button>
<div class="md:hidden absolute top-16 right-6 bg-zinc-800 rounded-lg shadow-lg z-50 w-48 hidden flex-col py-2" id="mobile-nav"> <div class="lg:hidden fixed top-13 left-0 right-0 bg-zinc-800 shadow-lg z-50 w-full hidden flex-col py-2" id="mobile-nav">
<form action="/search" method="GET" class="px-4 py-2 border-b border-zinc-700">
<div class="flex items-center space-x-2">
<input type="text" name="q" placeholder="Search..."
class="bg-zinc-700 text-white placeholder-gray-400 px-3 py-2.5 rounded text-base focus:outline-none focus:ring-2 focus:ring-gray-500 flex-1"
value="{{ request.args.get('q', '') }}">
<button type="submit" class="text-gray-200 hover:text-gray-400 hover:cursor-pointer flex-shrink-0">
<i class="fas fa-search"></i>
</button>
</div>
</form>
{% if has_permission('view_index') %} {% if has_permission('view_index') %}
<a href="/" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Home</a> <a href="/" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Home</a>
{% endif %} {% endif %}
@@ -50,12 +64,6 @@
{% if has_permission('view_admin') %} {% if has_permission('view_admin') %}
<a href="/admin" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Admin</a> <a href="/admin" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Admin</a>
{% endif %} {% endif %}
{% if has_permission('view_users') %}
<a href="/users" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Users</a>
{% endif %}
{% if has_permission('view_audit') %}
<a href="/audit" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Audit Log</a>
{% endif %}
{% if has_permission('view_help') %} {% if has_permission('view_help') %}
<a href="/help" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Help</a> <a href="/help" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Help</a>
{% endif %} {% endif %}
@@ -64,4 +72,45 @@
{% 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>
+33 -79
View File
@@ -10,10 +10,11 @@
</head> </head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col"> <body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %} {% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4"> <div class="flex-1 mx-4 py-8 pt-20">
<div class="container py-8 max-w-2xl pt-20"> <div class="container max-w-full mx-auto lg:px-32">
<h1 class="text-3xl font-bold mb-8 text-center">Help & User Guide</h1> <h1 class="text-3xl font-bold mb-8 text-center">Help & User Guide</h1>
<div class="space-y-10 text-lg">
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 mb-8">
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8"> <div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">Subnets & Devices</h2> <h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">Subnets & Devices</h2>
<div class="space-y-4"> <div class="space-y-4">
@@ -52,6 +53,35 @@
</div> </div>
</div> </div>
</div> </div>
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">Device Tags</h2>
<div class="space-y-4">
<div>
<h3 class="text-xl font-semibold mb-1">Managing Tags</h3>
<p>Tags help you organize and categorize your devices. Administrators can manage tags from the <a href="/tags" class="text-blue-600 dark:text-blue-400 hover:underline">Tag Management</a> page accessible from the Admin panel. Each tag has a name, customizable colour, and optional description.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Creating Tags</h3>
<p>Click <span class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded">Add Tag</span> to create a new tag. Choose a descriptive name (e.g., "Production", "Development", "Critical") and select a colour to visually distinguish the tag. Tags can be used to group devices by environment, importance, location, or any other criteria.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Assigning Tags to Devices</h3>
<p>From any device's detail page, you can assign multiple tags using the tag assignment dropdown. Each device can have unlimited tags, and removing a tag is as simple as clicking the × button next to the tag name.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Filtering Devices by Tags</h3>
<p>On the <a href="/devices" class="text-blue-600 dark:text-blue-400 hover:underline">Devices</a> page, use the tag filter dropdown to view only devices with a specific tag. This makes it easy to focus on devices in a particular environment or category.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Tag Colours and Visual Organisation</h3>
<p>Tags appear as coloured badges throughout the interface. Use consistent colour schemes (e.g., red for production, blue for development) to create visual patterns that help users quickly identify device categories.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Tag Permissions</h3>
<p>Tag management respects role-based permissions. Users need appropriate permissions to view, create, edit, delete tags, or assign/remove tags from devices. View-only users can see tags but cannot modify them.</p>
</div>
</div>
</div>
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8"> <div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">User Management & Audit</h2> <h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">User Management & Audit</h2>
<div class="space-y-4"> <div class="space-y-4">
@@ -86,82 +116,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-semibold mb-4 border-b border-gray-700 pb-2">API Documentation</h2>
<div class="space-y-4">
<div>
<h3 class="text-xl font-semibold mb-1">Authentication</h3>
<p>All API requests require authentication using an API key. You can provide the API key in one of three ways:</p>
<ul class="list-disc list-inside mt-2 space-y-1 ml-4">
<li><strong>Header:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">X-API-Key: your_api_key</code></li>
<li><strong>Authorization Header:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">Authorization: Bearer your_api_key</code></li>
<li><strong>Query Parameter:</strong> <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">?api_key=your_api_key</code></li>
</ul>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Base URL</h3>
<p>All API endpoints are prefixed with <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">/api/v1</code></p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Available Endpoints</h3>
<div class="space-y-3 mt-2">
<div>
<h4 class="font-semibold">Devices</h4>
<ul class="list-disc list-inside ml-4 text-sm space-y-1">
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/devices</code> - List all devices</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/devices/{id}</code> - Get device details</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/devices</code> - Create device</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">PUT /api/v1/devices/{id}</code> - Update device</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">DELETE /api/v1/devices/{id}</code> - Delete device</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/devices/{id}/ips</code> - Add IP to device</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">DELETE /api/v1/devices/{id}/ips/{ip_id}</code> - Remove IP from device</li>
</ul>
</div>
<div>
<h4 class="font-semibold">Subnets</h4>
<ul class="list-disc list-inside ml-4 text-sm space-y-1">
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/subnets</code> - List all subnets</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/subnets/{id}</code> - Get subnet details</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/subnets</code> - Create subnet</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">PUT /api/v1/subnets/{id}</code> - Update subnet</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">DELETE /api/v1/subnets/{id}</code> - Delete subnet</li>
</ul>
</div>
<div>
<h4 class="font-semibold">Racks</h4>
<ul class="list-disc list-inside ml-4 text-sm space-y-1">
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/racks</code> - List all racks</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/racks/{id}</code> - Get rack details</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/racks</code> - Create rack</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">DELETE /api/v1/racks/{id}</code> - Delete rack</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/racks/{id}/devices</code> - Add device to rack</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">DELETE /api/v1/racks/{id}/devices/{rack_device_id}</code> - Remove device from rack</li>
</ul>
</div>
<div>
<h4 class="font-semibold">Other</h4>
<ul class="list-disc list-inside ml-4 text-sm space-y-1">
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/info</code> - Get API info and user details</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/device-types</code> - List device types</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/subnets/{id}/dhcp</code> - Get DHCP pools</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">POST /api/v1/subnets/{id}/dhcp</code> - Configure DHCP pools</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/audit</code> - Get audit log</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/users</code> - List users (admin only)</li>
<li><code class="bg-gray-300 dark:bg-zinc-700 px-1 rounded">GET /api/v1/roles</code> - List roles (admin only)</li>
</ul>
</div>
</div>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Permissions</h3>
<p>API endpoints respect the same role-based permissions as the web interface. Users can only perform actions that their role allows. If a user lacks the required permission, the API will return a <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">403 Forbidden</code> error.</p>
</div>
<div>
<h3 class="text-xl font-semibold mb-1">Response Format</h3>
<p>All API responses are in JSON format. Successful requests return <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">200 OK</code> or <code class="bg-gray-300 dark:bg-zinc-700 px-2 py-1 rounded text-sm">201 Created</code> with the requested data. Errors return appropriate HTTP status codes with an error message in the response body.</p>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
+1 -35
View File
@@ -24,16 +24,6 @@
<button type="button" id="export-csv" class="hidden sm:flex absolute right-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer items-center justify-center rounded-full w-11 h-11 export-csv-btn" title="Export as CSV" data-rack-id="{{ rack.id }}"> <button type="button" id="export-csv" class="hidden sm:flex absolute right-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer items-center justify-center rounded-full w-11 h-11 export-csv-btn" title="Export as CSV" data-rack-id="{{ rack.id }}">
<i class="fas fa-file-csv fa-lg"></i> <i class="fas fa-file-csv fa-lg"></i>
</button> </button>
<script>
document.addEventListener('DOMContentLoaded', function() {
var btn = document.getElementById('export-csv');
if (btn) {
btn.addEventListener('click', function() {
window.location = '/rack/' + btn.getAttribute('data-rack-id') + '/export_csv';
});
}
});
</script>
</div> </div>
<div class="flex flex-col gap-4 mb-6 items-stretch"> <div class="flex flex-col gap-4 mb-6 items-stretch">
<div class="flex gap-4 w-full justify-center"> <div class="flex gap-4 w-full justify-center">
@@ -78,31 +68,7 @@
</div> </div>
<div class="text-xs dark:text-gray-400 mt-2">Add a non-networked device.</div> <div class="text-xs dark:text-gray-400 mt-2">Add a non-networked device.</div>
</form> </form>
<script> <script src="/static/js/rack.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 %}
+179
View File
@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Search - {{ NAME }} IPAM</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 container mx-auto px-4 py-8 pt-20">
<div class="max-w-6xl mx-auto">
<h1 class="text-3xl font-bold mb-6">Search Results</h1>
{% if query %}
<p class="text-lg mb-6">Search results for: <span class="font-semibold">"{{ query }}"</span></p>
{% else %}
<p class="text-lg mb-6 text-gray-600 dark:text-gray-400">Enter a search query to find IPs, devices, subnets, tags, racks, and sites.</p>
{% endif %}
{% if query %}
{% set total_results = results.subnets|length + results.ips|length + results.devices|length + results.tags|length + results.racks|length + results.sites|length %}
{% if total_results == 0 %}
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-8 text-center">
<i class="fas fa-search text-4xl text-gray-400 mb-4"></i>
<p class="text-xl font-semibold mb-2">No results found</p>
<p class="text-gray-600 dark:text-gray-400">Try a different search term or check your spelling.</p>
</div>
{% else %}
<div class="space-y-6">
<!-- Subnets -->
{% if results.subnets %}
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold mb-4 flex items-center">
<i class="fas fa-network-wired mr-2"></i>
Subnets ({{ results.subnets|length }})
</h2>
<div class="space-y-2">
{% for subnet in results.subnets %}
<a href="/subnet/{{ subnet.id }}" class="block p-3 bg-gray-300 dark:bg-zinc-900 hover:bg-gray-100 dark:hover:bg-zinc-700 rounded-lg transition-colors">
<div class="flex items-center justify-between">
<div>
<p class="font-semibold text-lg">{{ subnet.name }}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">{{ subnet.cidr }}</p>
</div>
<div class="text-right">
<p class="text-sm text-gray-600 dark:text-gray-400">{{ subnet.site }}</p>
</div>
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- IP Addresses -->
{% if results.ips %}
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold mb-4 flex items-center">
<i class="fas fa-map-marker-alt mr-2"></i>
IP Addresses ({{ results.ips|length }})
</h2>
<div class="space-y-2">
{% for ip in results.ips %}
<a href="/subnet/{{ ip.subnet_id }}#ip-{{ ip.id }}" class="block p-3 bg-gray-300 dark:bg-zinc-900 hover:bg-gray-100 dark:hover:bg-zinc-700 rounded-lg transition-colors">
<div class="flex items-center justify-between">
<div>
<p class="font-semibold text-lg">{{ ip.ip }}</p>
{% if ip.hostname %}
<p class="text-sm text-gray-600 dark:text-gray-400">{{ ip.hostname }}</p>
{% endif %}
<p class="text-sm text-gray-600 dark:text-gray-400">{{ ip.subnet_name }} ({{ ip.subnet_cidr }})</p>
</div>
<div class="text-right">
<p class="text-sm text-gray-600 dark:text-gray-400">{{ ip.site }}</p>
</div>
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Devices -->
{% if results.devices %}
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold mb-4 flex items-center">
<i class="fas fa-server mr-2"></i>
Devices ({{ results.devices|length }})
</h2>
<div class="space-y-2">
{% for device in results.devices %}
<a href="/device/{{ device.id }}" class="block p-3 bg-gray-300 dark:bg-zinc-900 hover:bg-gray-100 dark:hover:bg-zinc-700 rounded-lg transition-colors">
<div>
<p class="font-semibold text-lg">{{ device.name }}</p>
{% if device.description %}
<p class="text-sm text-gray-600 dark:text-gray-400">{{ device.description }}</p>
{% endif %}
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Tags -->
{% if results.tags %}
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold mb-4 flex items-center">
<i class="fas fa-tags mr-2"></i>
Tags ({{ results.tags|length }})
</h2>
<div class="space-y-2">
{% for tag in results.tags %}
<a href="/tags" class="block p-3 bg-gray-300 dark:bg-zinc-900 hover:bg-gray-100 dark:hover:bg-zinc-700 rounded-lg transition-colors">
<div>
<p class="font-semibold text-lg">{{ tag.name }}</p>
{% if tag.description %}
<p class="text-sm text-gray-600 dark:text-gray-400">{{ tag.description }}</p>
{% endif %}
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Racks -->
{% if results.racks %}
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold mb-4 flex items-center">
<i class="fas fa-th mr-2"></i>
Racks ({{ results.racks|length }})
</h2>
<div class="space-y-2">
{% for rack in results.racks %}
<a href="/rack/{{ rack.id }}" class="block p-3 bg-gray-300 dark:bg-zinc-900 hover:bg-gray-100 dark:hover:bg-zinc-700 rounded-lg transition-colors">
<div class="flex items-center justify-between">
<div>
<p class="font-semibold text-lg">{{ rack.name }}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">{{ rack.height_u }}U</p>
</div>
<div class="text-right">
<p class="text-sm text-gray-600 dark:text-gray-400">{{ rack.site }}</p>
</div>
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Sites -->
{% if results.sites %}
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold mb-4 flex items-center">
<i class="fas fa-map-marker-alt mr-2"></i>
Sites ({{ results.sites|length }})
</h2>
<div class="space-y-2">
{% for site in results.sites %}
<div class="p-3 bg-gray-300 dark:bg-zinc-900 rounded-lg">
<p class="font-semibold text-lg">{{ site }}</p>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endif %}
{% endif %}
</div>
</div>
</body>
</html>
+16 -19
View File
@@ -6,7 +6,6 @@
<title>{{ subnet.name }} - Subnet Details</title> <title>{{ subnet.name }} - Subnet Details</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}"> <link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet"> <link href="/static/css/output.css" rel="stylesheet">
<script src="/static/js/subnet.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head> </head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col"> <body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
@@ -14,12 +13,25 @@
<div class="flex-1 flex items-center justify-center mx-4"> <div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 w-full sm:max-w-3/4 pt-20"> <div class="container py-8 w-full sm:max-w-3/4 pt-20">
<div class="flex items-center mb-6 relative"> <div class="flex items-center mb-6 relative">
<a href="javascript:window.history.back()" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a> <a href="/" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
<h1 class="text-3xl font-bold text-center w-full">{{ subnet.name }} ({{ subnet.cidr }})</h1> <h1 class="text-3xl font-bold text-center w-full">{{ subnet.name }} ({{ subnet.cidr }})</h1>
<button type="button" id="export-csv" class="hidden sm:flex absolute right-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer items-center justify-center rounded-full w-11 h-11 export-csv-btn" title="Export as CSV" data-subnet-id="{{ subnet.id }}"> <button type="button" id="export-csv" class="hidden sm:flex absolute right-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer items-center justify-center rounded-full w-11 h-11 export-csv-btn" title="Export as CSV" data-subnet-id="{{ subnet.id }}">
<i class="fas fa-file-csv fa-lg"></i> <i class="fas fa-file-csv fa-lg"></i>
</button> </button>
</div> </div>
{% 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
@@ -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>
+180
View File
@@ -0,0 +1,180 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tag Management</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 mx-4 py-8 pt-20">
<div class="container max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Tag Management</h1>
{% if can_add_tag %}
<button onclick="showAddTagModal()" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg font-medium">
<i class="fas fa-plus mr-2"></i>Add Tag
</button>
{% endif %}
</div>
{% if error %}
<div class="bg-red-200 dark:bg-red-800 text-red-800 dark:text-red-200 p-4 rounded-lg mb-6">
{{ error }}
</div>
{% endif %}
{% if tags %}
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-gray-600">
<th class="text-left p-3">Name</th>
<th class="text-left p-3">Colour</th>
<th class="text-left p-3">Description</th>
<th class="text-center p-3">Devices</th>
<th class="text-center p-3">Created</th>
<th class="text-center p-3">Actions</th>
</tr>
</thead>
<tbody>
{% for tag in tags %}
<tr class="border-b border-gray-600 hover:bg-gray-300 dark:hover:bg-zinc-700">
<td class="p-3">
<div class="flex items-center space-x-2">
<div class="w-4 h-4 rounded-full border border-gray-600" style="background-color: {{ tag.color }}"></div>
<span class="font-medium">{{ tag.name }}</span>
</div>
</td>
<td class="p-3">
<span class="font-mono text-sm">{{ tag.color }}</span>
</td>
<td class="p-3">
<span class="text-sm">{{ tag.description or '-' }}</span>
</td>
<td class="p-3 text-center">
{% if tag.device_count > 0 %}
<a href="/devices/tag/{{ tag.id }}" class="text-blue-400 hover:text-blue-600 hover:cursor-pointer">
{{ tag.device_count }}
</a>
{% else %}
<span class="text-gray-500">0</span>
{% endif %}
</td>
<td class="p-3 text-center text-sm">
{{ tag.created_at.strftime('%Y-%m-%d') if tag.created_at else '-' }}
</td>
<td class="p-3 text-center">
<div class="flex items-center justify-center space-x-2">
{% if can_edit_tag %}
<button class="edit-tag-btn text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer"
title="Edit Tag"
data-tag-id="{{ tag.id }}"
data-tag-name="{{ tag.name }}"
data-tag-color="{{ tag.color }}"
data-tag-description="{{ tag.description or '' }}">
<i class="fas fa-edit"></i>
</button>
{% endif %}
{% if can_delete_tag %}
<form action="/tags" method="POST" onsubmit="return confirm('Are you sure you want to delete this tag? This will remove it from all devices.');" class="inline">
<input type="hidden" name="action" value="delete_tag">
<input type="hidden" name="tag_id" value="{{ tag.id }}">
<button type="submit" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Delete Tag">
<i class="fas fa-trash"></i>
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
<div class="text-center py-8 text-gray-500">
<i class="fas fa-tags text-4xl mb-4"></i>
<p>No tags found. Add your first tag to get started.</p>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Add Tag Modal -->
<div id="add-tag-modal" class="hidden fixed inset-0 bg-black/30 backdrop-blur-md flex items-center justify-center z-50">
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-lg max-w-md w-full mx-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">Add New Tag</h2>
<button onclick="closeAddTagModal()" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-times"></i>
</button>
</div>
<form action="/tags" method="POST">
<input type="hidden" name="action" value="add_tag">
<div class="space-y-4">
<input type="text" name="name" id="add-tag-name" placeholder="Tag Name"
class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
<div class="flex items-center space-x-2">
<label class="text-sm font-medium">Colour:</label>
<input type="color" name="color" id="add-tag-color" value="#6B7280"
class="w-12 h-10 border border-gray-600 rounded cursor-pointer">
<span id="add-color-preview" class="text-sm font-mono">#6B7280</span>
</div>
<textarea name="description" id="add-tag-description" placeholder="Description (optional)" rows="3"
class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full resize-y"></textarea>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button type="button" onclick="closeAddTagModal()"
class="px-4 py-2 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
<button type="submit"
class="px-4 py-2 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Add Tag</button>
</div>
</form>
</div>
</div>
<!-- Edit Tag Modal -->
<div id="edit-tag-modal" class="hidden fixed inset-0 bg-black/30 backdrop-blur-md flex items-center justify-center z-50">
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-lg max-w-md w-full mx-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">Edit Tag</h2>
<button onclick="closeEditTagModal()" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-times"></i>
</button>
</div>
<form action="/tags" method="POST">
<input type="hidden" name="action" value="edit_tag">
<input type="hidden" name="tag_id" id="edit-tag-id">
<div class="space-y-4">
<input type="text" name="name" id="edit-tag-name" placeholder="Tag Name"
class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
<div class="flex items-center space-x-2">
<label class="text-sm font-medium">Colour:</label>
<input type="color" name="color" id="edit-tag-color"
class="w-12 h-10 border border-gray-600 rounded cursor-pointer">
<span id="edit-color-preview" class="text-sm font-mono"></span>
</div>
<textarea name="description" id="edit-tag-description" placeholder="Description (optional)" rows="3"
class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full resize-y"></textarea>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button type="button" onclick="closeEditTagModal()"
class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
<button type="submit"
class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Save Changes</button>
</div>
</form>
</div>
</div>
<script src="/static/js/tags.js"></script>
</body>
</html>
+2 -196
View File
@@ -337,204 +337,10 @@
</div> </div>
<script> <script>
// Template variables passed from server - must be defined before users.js loads
const permissions = {{ permissions | tojson | safe }}; const permissions = {{ permissions | tojson | safe }};
const rolePermissions = {{ role_permissions | tojson | safe }}; const rolePermissions = {{ role_permissions | tojson | safe }};
function showTab(tab) {
document.getElementById('users-tab').classList.add('hidden');
document.getElementById('roles-tab').classList.add('hidden');
document.getElementById('tab-users').classList.remove('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
document.getElementById('tab-users').classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
document.getElementById('tab-roles').classList.remove('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
document.getElementById('tab-roles').classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
if (tab === 'users') {
document.getElementById('users-tab').classList.remove('hidden');
document.getElementById('tab-users').classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
document.getElementById('tab-users').classList.add('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
} else {
document.getElementById('roles-tab').classList.remove('hidden');
document.getElementById('tab-roles').classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
document.getElementById('tab-roles').classList.add('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
}
}
function editUser(userId, name, email, roleId, apiKey) {
document.getElementById('edit-user-id').value = userId;
document.getElementById('edit-user-name').value = name;
document.getElementById('edit-user-email').value = email;
document.getElementById('edit-user-password').value = '';
document.getElementById('edit-user-role').value = (roleId === null || roleId === 'null') ? '' : roleId;
document.getElementById('edit-user-api-key').textContent = apiKey || 'No API Key';
document.getElementById('edit-user-modal').classList.remove('hidden');
}
function closeEditUserModal() {
document.getElementById('edit-user-modal').classList.add('hidden');
}
function showAddRoleModal() {
// Make sure edit modal is closed first
document.getElementById('edit-role-modal').classList.add('hidden');
// Clear any form data
const addForm = document.querySelector('#add-role-modal form');
if (addForm) {
addForm.reset();
}
// Show add modal
document.getElementById('add-role-modal').classList.remove('hidden');
}
function closeAddRoleModal() {
document.getElementById('add-role-modal').classList.add('hidden');
}
function editRole(roleId, roleName, roleDescription) {
// Make sure add modal is closed first
document.getElementById('add-role-modal').classList.add('hidden');
document.getElementById('edit-role-id').value = roleId;
document.getElementById('edit-role-name').value = roleName;
document.getElementById('edit-role-description').value = roleDescription || '';
const permissionsDiv = document.getElementById('edit-role-permissions');
permissionsDiv.innerHTML = '';
const rolePerms = rolePermissions[roleId] || [];
// Group permissions by merged categories
const viewPerms = permissions.filter(p => p[3] === 'View');
const devicePerms = permissions.filter(p => p[3] === 'Device');
const deviceTypePerms = permissions.filter(p => p[3] === 'Device Type');
const subnetPerms = permissions.filter(p => p[3] === 'Subnet');
const dhcpPerms = permissions.filter(p => p[3] === 'DHCP');
const rackPerms = permissions.filter(p => p[3] === 'Rack');
const adminPerms = permissions.filter(p => p[3] === 'Admin');
let html = '';
// View Permissions
html += ' <!-- View Permissions -->\n';
html += ' <div class="col-span-full">\n';
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">View Permissions</h4>\n';
html += ' <div class="grid grid-cols-1 md:grid-cols-2 gap-2">\n';
viewPerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
html += ' </div>\n';
html += ' </div>\n';
html += ' \n';
// Device Management
html += ' <!-- Device Management -->\n';
html += ' <div>\n';
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Device Management</h4>\n';
devicePerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
deviceTypePerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
html += ' </div>\n';
html += ' \n';
// Network Management
html += ' <!-- Network Management -->\n';
html += ' <div>\n';
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Network Management</h4>\n';
subnetPerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
dhcpPerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
html += ' </div>\n';
html += ' \n';
// Rack Management
html += ' <!-- Rack Management -->\n';
html += ' <div>\n';
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Rack Management</h4>\n';
rackPerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
html += ' </div>\n';
html += ' \n';
// Admin
html += ' <!-- Admin -->\n';
html += ' <div>\n';
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Administration</h4>\n';
adminPerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
html += ' </div>\n';
permissionsDiv.innerHTML = html;
document.getElementById('edit-role-modal').classList.remove('hidden');
}
function closeEditRoleModal() {
document.getElementById('edit-role-modal').classList.add('hidden');
}
function deleteRole(roleId, roleName) {
if (confirm(`Are you sure you want to delete the role "${roleName}"?`)) {
const form = document.createElement('form');
form.method = 'POST';
form.action = '/users';
form.innerHTML = `
<input type="hidden" name="action" value="delete_role">
<input type="hidden" name="role_id" value="${roleId}">
`;
document.body.appendChild(form);
form.submit();
}
}
// Close modals when clicking outside
window.onclick = function(event) {
const editUserModal = document.getElementById('edit-user-modal');
const editRoleModal = document.getElementById('edit-role-modal');
const addRoleModal = document.getElementById('add-role-modal');
if (event.target === editUserModal) {
closeEditUserModal();
}
if (event.target === editRoleModal) {
closeEditRoleModal();
}
if (event.target === addRoleModal) {
closeAddRoleModal();
}
}
</script> </script>
<script src="/static/js/users.js"></script>
</body> </body>
</html> </html>