Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb6a3445a7 | |||
| 28267989b0 | |||
| 47f68fd27c | |||
| 3a9250f5b0 | |||
| 3e8965de6f | |||
| 707846bb3c | |||
| 69588d6518 | |||
| 1d9209a714 | |||
| 730b8701db | |||
| f0165985fc | |||
| f6795f5281 | |||
| 2163be8f79 | |||
| f98e92da06 | |||
| 61e3200207 | |||
| 6eb5000c27 | |||
| 9ecd4f3977 | |||
| 6f01c9956f |
@@ -2,3 +2,4 @@ __pycache__
|
|||||||
tailwindcss
|
tailwindcss
|
||||||
static/css/output.css
|
static/css/output.css
|
||||||
.env
|
.env
|
||||||
|
backups/
|
||||||
@@ -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,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "1.5.0"
|
".": "1.6.0"
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,38 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [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)
|
## [1.5.0](https://github.com/JDB-NET/ipam/compare/v1.4.2...v1.5.0) (2025-11-21)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ FROM python:3.13-slim
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . /app
|
COPY . /app
|
||||||
RUN pip install -r requirements.txt
|
RUN pip install -r requirements.txt
|
||||||
RUN apt-get update && apt-get install -y curl
|
RUN apt-get update && apt-get install -y curl mariadb-client-compat
|
||||||
RUN rm -rf /var/lib/apt/lists/*
|
RUN rm -rf /var/lib/apt/lists/*
|
||||||
RUN curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 \
|
RUN curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 \
|
||||||
&& chmod +x tailwindcss-linux-x64 \
|
&& chmod +x tailwindcss-linux-x64 \
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ A Flask-based web application for comprehensive IP Address Management (IPAM). Ma
|
|||||||
docker run -d \
|
docker run -d \
|
||||||
--name ipam \
|
--name ipam \
|
||||||
-p 5000:5000 \
|
-p 5000:5000 \
|
||||||
|
-v ./backups:/app/backups \
|
||||||
-e MYSQL_HOST=10.10.2.27 \
|
-e MYSQL_HOST=10.10.2.27 \
|
||||||
-e MYSQL_USER=ipam \
|
-e MYSQL_USER=ipam \
|
||||||
-e MYSQL_PASSWORD=your_password \
|
-e MYSQL_PASSWORD=your_password \
|
||||||
@@ -45,15 +46,13 @@ 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
|
||||||
@@ -62,6 +61,8 @@ services:
|
|||||||
- SECRET_KEY=your_secret_key
|
- SECRET_KEY=your_secret_key
|
||||||
- NAME=Your Organisation
|
- NAME=Your Organisation
|
||||||
- LOGO_PNG=https://example.com/logo.png
|
- LOGO_PNG=https://example.com/logo.png
|
||||||
|
volumes:
|
||||||
|
- ./backups:/app/backups
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"""
|
||||||
|
In-memory caching module with TTL support and cache invalidation
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
import sys
|
||||||
|
from threading import Lock
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
class Cache:
|
||||||
|
"""Simple in-memory cache with TTL support and size limiting"""
|
||||||
|
|
||||||
|
def __init__(self, max_size_mb=50):
|
||||||
|
self._cache = {}
|
||||||
|
self._lock = Lock()
|
||||||
|
self._max_size_bytes = max_size_mb * 1024 * 1024 # Convert MB to bytes
|
||||||
|
self._access_order = [] # Track access order for LRU eviction
|
||||||
|
|
||||||
|
def _get_size(self, obj):
|
||||||
|
"""Estimate size of an object in bytes"""
|
||||||
|
size = sys.getsizeof(obj)
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
size += sum(self._get_size(k) + self._get_size(v) for k, v in obj.items())
|
||||||
|
elif isinstance(obj, (list, tuple)):
|
||||||
|
size += sum(self._get_size(item) for item in obj)
|
||||||
|
elif isinstance(obj, str):
|
||||||
|
size += sys.getsizeof(obj) - sys.getsizeof('')
|
||||||
|
return size
|
||||||
|
|
||||||
|
def _get_cache_size(self):
|
||||||
|
"""Get approximate total size of cache in bytes"""
|
||||||
|
total_size = sys.getsizeof(self._cache)
|
||||||
|
for key, (value, expiry) in self._cache.items():
|
||||||
|
total_size += self._get_size(key) + self._get_size(value) + sys.getsizeof(expiry)
|
||||||
|
return total_size
|
||||||
|
|
||||||
|
def _evict_if_needed(self):
|
||||||
|
"""Evict entries if cache exceeds size limit"""
|
||||||
|
current_size = self._get_cache_size()
|
||||||
|
if current_size <= self._max_size_bytes:
|
||||||
|
return
|
||||||
|
|
||||||
|
# First, remove expired entries
|
||||||
|
current_time = time.time()
|
||||||
|
expired_keys = []
|
||||||
|
for key in list(self._cache.keys()):
|
||||||
|
_, expiry = self._cache[key]
|
||||||
|
if expiry is not None and current_time >= expiry:
|
||||||
|
expired_keys.append(key)
|
||||||
|
|
||||||
|
for key in expired_keys:
|
||||||
|
if key in self._cache:
|
||||||
|
del self._cache[key]
|
||||||
|
if key in self._access_order:
|
||||||
|
self._access_order.remove(key)
|
||||||
|
|
||||||
|
# If still over limit, remove oldest entries (LRU)
|
||||||
|
current_size = self._get_cache_size()
|
||||||
|
while current_size > self._max_size_bytes and self._access_order:
|
||||||
|
oldest_key = self._access_order.pop(0)
|
||||||
|
if oldest_key in self._cache:
|
||||||
|
del self._cache[oldest_key]
|
||||||
|
current_size = self._get_cache_size()
|
||||||
|
|
||||||
|
def get(self, key):
|
||||||
|
"""Get value from cache if it exists and hasn't expired"""
|
||||||
|
with self._lock:
|
||||||
|
if key in self._cache:
|
||||||
|
value, expiry = self._cache[key]
|
||||||
|
if expiry is None or time.time() < expiry:
|
||||||
|
# Update access order (move to end for LRU)
|
||||||
|
if key in self._access_order:
|
||||||
|
self._access_order.remove(key)
|
||||||
|
self._access_order.append(key)
|
||||||
|
return value
|
||||||
|
else:
|
||||||
|
# Expired, remove it
|
||||||
|
del self._cache[key]
|
||||||
|
if key in self._access_order:
|
||||||
|
self._access_order.remove(key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set(self, key, value, ttl=None):
|
||||||
|
"""Set value in cache with optional TTL (time to live in seconds)"""
|
||||||
|
with self._lock:
|
||||||
|
# Remove old entry if it exists
|
||||||
|
if key in self._cache:
|
||||||
|
if key in self._access_order:
|
||||||
|
self._access_order.remove(key)
|
||||||
|
|
||||||
|
expiry = None if ttl is None else time.time() + ttl
|
||||||
|
self._cache[key] = (value, expiry)
|
||||||
|
self._access_order.append(key)
|
||||||
|
|
||||||
|
# Evict if needed to stay under size limit
|
||||||
|
self._evict_if_needed()
|
||||||
|
|
||||||
|
def delete(self, key):
|
||||||
|
"""Delete a key from cache"""
|
||||||
|
with self._lock:
|
||||||
|
if key in self._cache:
|
||||||
|
del self._cache[key]
|
||||||
|
if key in self._access_order:
|
||||||
|
self._access_order.remove(key)
|
||||||
|
|
||||||
|
def clear(self, pattern=None):
|
||||||
|
"""Clear cache entries. If pattern is provided, only clear keys matching the pattern."""
|
||||||
|
with self._lock:
|
||||||
|
if pattern is None:
|
||||||
|
self._cache.clear()
|
||||||
|
self._access_order.clear()
|
||||||
|
else:
|
||||||
|
keys_to_delete = [key for key in self._cache.keys() if pattern in key]
|
||||||
|
for key in keys_to_delete:
|
||||||
|
del self._cache[key]
|
||||||
|
if key in self._access_order:
|
||||||
|
self._access_order.remove(key)
|
||||||
|
|
||||||
|
def invalidate_subnet(self, subnet_id):
|
||||||
|
"""Invalidate all cache entries related to a specific subnet"""
|
||||||
|
patterns = [
|
||||||
|
f'subnet:{subnet_id}',
|
||||||
|
f'subnet_list',
|
||||||
|
f'index',
|
||||||
|
f'admin',
|
||||||
|
f'utilization:{subnet_id}'
|
||||||
|
]
|
||||||
|
with self._lock:
|
||||||
|
keys_to_delete = []
|
||||||
|
for key in self._cache.keys():
|
||||||
|
for pattern in patterns:
|
||||||
|
if pattern in key:
|
||||||
|
keys_to_delete.append(key)
|
||||||
|
break
|
||||||
|
for key in keys_to_delete:
|
||||||
|
del self._cache[key]
|
||||||
|
if key in self._access_order:
|
||||||
|
self._access_order.remove(key)
|
||||||
|
|
||||||
|
def invalidate_device(self, device_id):
|
||||||
|
"""Invalidate all cache entries related to a specific device"""
|
||||||
|
patterns = [
|
||||||
|
f'device:{device_id}',
|
||||||
|
f'device_list',
|
||||||
|
f'devices',
|
||||||
|
f'device_types'
|
||||||
|
]
|
||||||
|
with self._lock:
|
||||||
|
keys_to_delete = []
|
||||||
|
for key in self._cache.keys():
|
||||||
|
for pattern in patterns:
|
||||||
|
if pattern in key:
|
||||||
|
keys_to_delete.append(key)
|
||||||
|
break
|
||||||
|
for key in keys_to_delete:
|
||||||
|
del self._cache[key]
|
||||||
|
if key in self._access_order:
|
||||||
|
self._access_order.remove(key)
|
||||||
|
|
||||||
|
def invalidate_all(self):
|
||||||
|
"""Invalidate all cache entries"""
|
||||||
|
self.clear()
|
||||||
|
|
||||||
|
# Global cache instance
|
||||||
|
cache = Cache()
|
||||||
|
|
||||||
|
def cached(ttl=None, key_prefix=''):
|
||||||
|
"""
|
||||||
|
Decorator to cache function results
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ttl: Time to live in seconds (None = no expiration)
|
||||||
|
key_prefix: Prefix for cache key
|
||||||
|
"""
|
||||||
|
def decorator(func):
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
# Create cache key from function name, args, and kwargs
|
||||||
|
cache_key = f"{key_prefix}{func.__name__}:{str(args)}:{str(sorted(kwargs.items()))}"
|
||||||
|
|
||||||
|
# Try to get from cache
|
||||||
|
cached_value = cache.get(cache_key)
|
||||||
|
if cached_value is not None:
|
||||||
|
return cached_value
|
||||||
|
|
||||||
|
# Call function and cache result
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
cache.set(cache_key, result, ttl)
|
||||||
|
return result
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
@@ -3,6 +3,7 @@ import hashlib
|
|||||||
import base64
|
import base64
|
||||||
import secrets
|
import secrets
|
||||||
import mysql.connector
|
import mysql.connector
|
||||||
|
import logging
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
def hash_password(password, salt=None):
|
def hash_password(password, salt=None):
|
||||||
@@ -64,8 +65,8 @@ def init_db(app=None):
|
|||||||
details TEXT,
|
details TEXT,
|
||||||
subnet_id INTEGER,
|
subnet_id INTEGER,
|
||||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES User(id),
|
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE SET NULL,
|
||||||
FOREIGN KEY (subnet_id) REFERENCES Subnet(id)
|
FOREIGN KEY (subnet_id) REFERENCES Subnet(id) ON DELETE SET NULL
|
||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
@@ -199,6 +200,50 @@ 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
|
# Create Tag table
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS Tag (
|
CREATE TABLE IF NOT EXISTS Tag (
|
||||||
@@ -388,5 +433,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()
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ Flask
|
|||||||
mysql-connector-python
|
mysql-connector-python
|
||||||
dotenv
|
dotenv
|
||||||
gunicorn
|
gunicorn
|
||||||
|
requests
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
function showAddSubnetModal() {
|
||||||
|
document.getElementById('add-subnet-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('add-subnet-name').value = '';
|
||||||
|
document.getElementById('add-subnet-cidr').value = '';
|
||||||
|
document.getElementById('add-subnet-site').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAddSubnetModal() {
|
||||||
|
document.getElementById('add-subnet-modal').classList.add('hidden');
|
||||||
|
document.getElementById('cidr-error').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function editSubnet(subnetId, name, cidr, site) {
|
||||||
|
document.getElementById('edit-subnet-id').value = subnetId;
|
||||||
|
document.getElementById('edit-subnet-name').value = name;
|
||||||
|
document.getElementById('edit-subnet-cidr').value = cidr;
|
||||||
|
document.getElementById('edit-subnet-site').value = site;
|
||||||
|
document.getElementById('edit-subnet-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditSubnetModal() {
|
||||||
|
document.getElementById('edit-subnet-modal').classList.add('hidden');
|
||||||
|
document.getElementById('edit-cidr-error').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateEditSubnetForm() {
|
||||||
|
const cidrInput = document.getElementById('edit-subnet-cidr');
|
||||||
|
const cidrError = document.getElementById('edit-cidr-error');
|
||||||
|
const cidr = cidrInput.value.trim();
|
||||||
|
|
||||||
|
// Basic CIDR validation
|
||||||
|
const cidrPattern = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/;
|
||||||
|
if (!cidrPattern.test(cidr)) {
|
||||||
|
cidrError.textContent = 'Invalid CIDR format. Use format like 192.168.1.0/24';
|
||||||
|
cidrError.classList.remove('hidden');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check prefix length
|
||||||
|
const parts = cidr.split('/');
|
||||||
|
if (parts.length === 2) {
|
||||||
|
const prefixLen = parseInt(parts[1]);
|
||||||
|
if (prefixLen < 24 || prefixLen > 32) {
|
||||||
|
cidrError.textContent = 'Subnet must be /24 or smaller (e.g., /24, /25, ... /32)';
|
||||||
|
cidrError.classList.remove('hidden');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cidrError.classList.add('hidden');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modals when clicking outside
|
||||||
|
window.onclick = function(event) {
|
||||||
|
const addModal = document.getElementById('add-subnet-modal');
|
||||||
|
const editModal = document.getElementById('edit-subnet-modal');
|
||||||
|
if (event.target === addModal) {
|
||||||
|
closeAddSubnetModal();
|
||||||
|
}
|
||||||
|
if (event.target === editModal) {
|
||||||
|
closeEditSubnetModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Filter toggle functionality
|
||||||
|
const filterToggle = document.getElementById('filter-toggle');
|
||||||
|
const filterForm = document.getElementById('audit-filter-form');
|
||||||
|
const filterArrow = document.getElementById('filter-arrow');
|
||||||
|
|
||||||
|
if (filterToggle && filterForm && filterArrow) {
|
||||||
|
filterToggle.addEventListener('click', function() {
|
||||||
|
filterForm.classList.toggle('hidden');
|
||||||
|
// Toggle rotation using inline style for better compatibility
|
||||||
|
if (filterForm.classList.contains('hidden')) {
|
||||||
|
filterArrow.style.transform = 'rotate(0deg)';
|
||||||
|
} else {
|
||||||
|
filterArrow.style.transform = 'rotate(180deg)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set initial arrow rotation if form is visible (has active filters or expand_filters param)
|
||||||
|
if (!filterForm.classList.contains('hidden')) {
|
||||||
|
filterArrow.style.transform = 'rotate(180deg)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format timestamps
|
||||||
|
document.querySelectorAll('td[data-utc]').forEach(function(td) {
|
||||||
|
const utc = td.getAttribute('data-utc');
|
||||||
|
if (utc) {
|
||||||
|
const date = new Date(utc + 'Z');
|
||||||
|
td.textContent = date.toLocaleString();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse and display visual diffs
|
||||||
|
document.querySelectorAll('.diff-container').forEach(function(container) {
|
||||||
|
const details = container.getAttribute('data-details');
|
||||||
|
if (!details) return;
|
||||||
|
|
||||||
|
// Try to parse common change patterns
|
||||||
|
let html = details;
|
||||||
|
|
||||||
|
// Pattern 1: "Changed X from 'old' to 'new'"
|
||||||
|
html = html.replace(/Changed (.+?) from ['"](.+?)['"] to ['"](.+?)['"]/gi, function(match, field, oldVal, newVal) {
|
||||||
|
return `Changed ${field} from <span class="diff-removed">${oldVal}</span> to <span class="diff-added">${newVal}</span>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pattern 2: "Renamed X to Y"
|
||||||
|
html = html.replace(/Renamed (.+?) to ['"](.+?)['"]/gi, function(match, oldVal, newVal) {
|
||||||
|
return `Renamed <span class="diff-removed">${oldVal}</span> to <span class="diff-added">${newVal}</span>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pattern 3: "Updated X: old -> new"
|
||||||
|
html = html.replace(/Updated (.+?):\s*(.+?)\s*->\s*(.+?)(?:\s|$)/gi, function(match, field, oldVal, newVal) {
|
||||||
|
return `Updated ${field}: <span class="diff-removed">${oldVal}</span> → <span class="diff-added">${newVal}</span>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pattern 4: "Set X to Y" (when it was previously something else, look for context)
|
||||||
|
html = html.replace(/Set (.+?) to ['"](.+?)['"]/gi, function(match, field, newVal) {
|
||||||
|
return `Set ${field} to <span class="diff-added">${newVal}</span>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pattern 5: "Removed X" or "Deleted X"
|
||||||
|
html = html.replace(/(Removed|Deleted) ['"](.+?)['"]/gi, function(match, action, val) {
|
||||||
|
return `${action} <span class="diff-removed">${val}</span>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pattern 6: "Added X"
|
||||||
|
html = html.replace(/Added ['"](.+?)['"]/gi, function(match, val) {
|
||||||
|
return `Added <span class="diff-added">${val}</span>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pattern 7: "Assigned X to Y" or "Unassigned X from Y"
|
||||||
|
// Capture everything after "to " or "from " to preserve all spaces in target
|
||||||
|
html = html.replace(/(Assigned|Unassigned) (.+?) (to|from) (.+)$/gi, function(match, action, item, prep, target) {
|
||||||
|
const actionClass = action === 'Assigned' ? 'diff-added' : 'diff-removed';
|
||||||
|
// Preserve the space between prep and target
|
||||||
|
return `${action} <span class="${actionClass}">${item}</span> ${prep} ${target}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pattern 8: Generic "from X to Y" pattern
|
||||||
|
html = html.replace(/from ['"](.+?)['"] to ['"](.+?)['"]/gi, function(match, oldVal, newVal) {
|
||||||
|
return `from <span class="diff-removed">${oldVal}</span> to <span class="diff-added">${newVal}</span>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = html || details;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export button handler
|
||||||
|
const exportBtn = document.getElementById('export-btn');
|
||||||
|
if (exportBtn) {
|
||||||
|
exportBtn.addEventListener('click', function() {
|
||||||
|
const form = document.getElementById('audit-filter-form');
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
// Add all form fields to params
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
if (value) {
|
||||||
|
if (key === 'user_ids') {
|
||||||
|
// Handle multiple user_ids
|
||||||
|
params.append('user_ids', value);
|
||||||
|
} else {
|
||||||
|
params.append(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle multiple user_ids separately
|
||||||
|
const userSelect = form.querySelector('select[name="user_ids"]');
|
||||||
|
if (userSelect) {
|
||||||
|
const selectedUsers = Array.from(userSelect.selectedOptions).map(opt => opt.value);
|
||||||
|
params.delete('user_ids');
|
||||||
|
selectedUsers.forEach(userId => {
|
||||||
|
params.append('user_ids', userId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to export endpoint
|
||||||
|
window.location.href = '/audit/export_csv?' + params.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const messageDiv = document.getElementById('message');
|
||||||
|
|
||||||
|
function showMessage(text, isError = false) {
|
||||||
|
messageDiv.textContent = text;
|
||||||
|
messageDiv.className = isError
|
||||||
|
? 'mb-4 p-4 rounded-lg bg-red-200 dark:bg-red-800 text-red-800 dark:text-red-200'
|
||||||
|
: 'mb-4 p-4 rounded-lg bg-green-200 dark:bg-green-800 text-green-800 dark:text-green-200';
|
||||||
|
messageDiv.classList.remove('hidden');
|
||||||
|
setTimeout(() => {
|
||||||
|
messageDiv.classList.add('hidden');
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup button
|
||||||
|
const createBackupBtn = document.getElementById('create-backup-btn');
|
||||||
|
if (createBackupBtn) {
|
||||||
|
createBackupBtn.addEventListener('click', function() {
|
||||||
|
createBackupBtn.disabled = true;
|
||||||
|
createBackupBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Creating...';
|
||||||
|
|
||||||
|
fetch('/backup/create', {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
showMessage(`Backup created successfully: ${data.filename}`);
|
||||||
|
setTimeout(() => window.location.reload(), 1500);
|
||||||
|
} else {
|
||||||
|
showMessage(data.error || 'Failed to create backup', true);
|
||||||
|
createBackupBtn.disabled = false;
|
||||||
|
createBackupBtn.innerHTML = '<i class="fas fa-database"></i> <span>Create Backup</span>';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
showMessage('Error creating backup: ' + error.message, true);
|
||||||
|
createBackupBtn.disabled = false;
|
||||||
|
createBackupBtn.innerHTML = '<i class="fas fa-database"></i> <span>Create Backup</span>';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload and restore form
|
||||||
|
const uploadRestoreForm = document.getElementById('upload-restore-form');
|
||||||
|
if (uploadRestoreForm) {
|
||||||
|
uploadRestoreForm.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!confirm('WARNING: This will replace all current database data with the backup. Are you sure you want to continue?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData(this);
|
||||||
|
const submitBtn = this.querySelector('button[type="submit"]');
|
||||||
|
const originalText = submitBtn.innerHTML;
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Restoring...';
|
||||||
|
|
||||||
|
fetch('/backup/restore', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
showMessage('Database restored successfully. Page will reload...');
|
||||||
|
setTimeout(() => window.location.reload(), 2000);
|
||||||
|
} else {
|
||||||
|
showMessage(data.error || 'Failed to restore backup', true);
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = originalText;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
showMessage('Error restoring backup: ' + error.message, true);
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = originalText;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing backup restore form
|
||||||
|
const existingRestoreForm = document.getElementById('existing-restore-form');
|
||||||
|
if (existingRestoreForm) {
|
||||||
|
existingRestoreForm.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!confirm('WARNING: This will replace all current database data with the backup. Are you sure you want to continue?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData(this);
|
||||||
|
const submitBtn = this.querySelector('button[type="submit"]');
|
||||||
|
const originalText = submitBtn.innerHTML;
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Restoring...';
|
||||||
|
|
||||||
|
fetch('/backup/restore', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
showMessage('Database restored successfully. Page will reload...');
|
||||||
|
setTimeout(() => window.location.reload(), 2000);
|
||||||
|
} else {
|
||||||
|
showMessage(data.error || 'Failed to restore backup', true);
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = originalText;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
showMessage('Error restoring backup: ' + error.message, true);
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = originalText;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function deleteBackup(filename) {
|
||||||
|
if (!confirm(`Are you sure you want to delete backup "${filename}"?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`/backup/delete/${filename}`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + (data.error || 'Failed to delete backup'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert('Error: ' + error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
function showTab(tabName) {
|
||||||
|
// Hide all panels
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.add('hidden'));
|
||||||
|
// Remove active class from all tabs
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
||||||
|
// Show selected panel
|
||||||
|
document.getElementById('panel-' + tabName).classList.remove('hidden');
|
||||||
|
// Add active class to selected tab
|
||||||
|
document.getElementById('tab-' + tabName).classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Update selected IP count
|
||||||
|
document.getElementById('bulk-ip-select')?.addEventListener('change', function() {
|
||||||
|
document.getElementById('selected-ip-count').textContent = this.selectedOptions.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('bulk-tag-device-select')?.addEventListener('change', function() {
|
||||||
|
document.getElementById('selected-tag-device-count').textContent = this.selectedOptions.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load available IPs when subnet changes
|
||||||
|
document.getElementById('bulk-subnet-select')?.addEventListener('change', function() {
|
||||||
|
const subnetId = this.value;
|
||||||
|
const ipSelect = document.getElementById('bulk-ip-select');
|
||||||
|
if (!subnetId) {
|
||||||
|
ipSelect.innerHTML = '<option value="" disabled>Select a subnet first...</option>';
|
||||||
|
document.getElementById('selected-ip-count').textContent = '0';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ipSelect.innerHTML = '<option value="" disabled>Loading...</option>';
|
||||||
|
fetch(`/get_available_ips?subnet_id=${subnetId}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
ipSelect.innerHTML = '';
|
||||||
|
if (data.available_ips.length === 0) {
|
||||||
|
ipSelect.innerHTML = '<option value="" disabled>No available IPs in this subnet</option>';
|
||||||
|
} else {
|
||||||
|
data.available_ips.forEach(ip => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = ip.id;
|
||||||
|
option.textContent = ip.ip;
|
||||||
|
ipSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.getElementById('selected-ip-count').textContent = '0';
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
ipSelect.innerHTML = '<option value="" disabled>Error loading IPs</option>';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bulk IP Assignment
|
||||||
|
document.getElementById('bulk-assign-ips-form')?.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(this);
|
||||||
|
const resultDiv = document.getElementById('assign-ips-result');
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
resultDiv.innerHTML = '<p class="text-blue-500">Processing...</p>';
|
||||||
|
|
||||||
|
fetch('/bulk/assign_ips', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
let html = '<div class="space-y-2">';
|
||||||
|
if (data.success.length > 0) {
|
||||||
|
html += `<div class="text-green-600 dark:text-green-400"><strong>Successfully assigned ${data.success.length} IP(s):</strong><ul class="list-disc list-inside mt-2">`;
|
||||||
|
data.success.forEach(item => {
|
||||||
|
html += `<li>${item.ip}</li>`;
|
||||||
|
});
|
||||||
|
html += '</ul></div>';
|
||||||
|
}
|
||||||
|
if (data.failed.length > 0) {
|
||||||
|
html += `<div class="text-red-600 dark:text-red-400"><strong>Failed ${data.failed.length} assignment(s):</strong><ul class="list-disc list-inside mt-2">`;
|
||||||
|
data.failed.forEach(item => {
|
||||||
|
const ipDisplay = item.ip ? ` (${item.ip})` : '';
|
||||||
|
html += `<li>IP ID ${item.ip_id}${ipDisplay}: ${item.reason}</li>`;
|
||||||
|
});
|
||||||
|
html += '</ul></div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
resultDiv.innerHTML = html;
|
||||||
|
// Reload IP list if successful
|
||||||
|
if (data.success.length > 0) {
|
||||||
|
const subnetSelect = document.getElementById('bulk-subnet-select');
|
||||||
|
if (subnetSelect.value) {
|
||||||
|
subnetSelect.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
resultDiv.innerHTML = `<p class="text-red-600">Error: ${error.message}</p>`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bulk Device Creation
|
||||||
|
document.getElementById('bulk-create-devices-form')?.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(this);
|
||||||
|
const resultDiv = document.getElementById('create-devices-result');
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
resultDiv.innerHTML = '<p class="text-blue-500">Processing...</p>';
|
||||||
|
|
||||||
|
fetch('/bulk/create_devices', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
let html = '<div class="space-y-2">';
|
||||||
|
if (data.success.length > 0) {
|
||||||
|
html += `<div class="text-green-600 dark:text-green-400"><strong>Successfully created ${data.success.length} device(s):</strong><ul class="list-disc list-inside mt-2">`;
|
||||||
|
data.success.forEach(item => {
|
||||||
|
html += `<li>${item.name}</li>`;
|
||||||
|
});
|
||||||
|
html += '</ul></div>';
|
||||||
|
}
|
||||||
|
if (data.failed.length > 0) {
|
||||||
|
html += `<div class="text-red-600 dark:text-red-400"><strong>Failed ${data.failed.length} creation(s):</strong><ul class="list-disc list-inside mt-2">`;
|
||||||
|
data.failed.forEach(item => {
|
||||||
|
html += `<li>${item.name}: ${item.reason}</li>`;
|
||||||
|
});
|
||||||
|
html += '</ul></div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
resultDiv.innerHTML = html;
|
||||||
|
if (data.success.length > 0) {
|
||||||
|
setTimeout(() => window.location.reload(), 2000);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
resultDiv.innerHTML = `<p class="text-red-600">Error: ${error.message}</p>`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bulk Tag Assignment
|
||||||
|
document.getElementById('bulk-assign-tags-form')?.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(this);
|
||||||
|
const resultDiv = document.getElementById('assign-tags-result');
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
resultDiv.innerHTML = '<p class="text-blue-500">Processing...</p>';
|
||||||
|
|
||||||
|
fetch('/bulk/assign_tags', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
let html = '<div class="space-y-2">';
|
||||||
|
if (data.success.length > 0) {
|
||||||
|
html += `<div class="text-green-600 dark:text-green-400"><strong>Successfully assigned ${data.success.length} tag(s):</strong><ul class="list-disc list-inside mt-2">`;
|
||||||
|
data.success.forEach(item => {
|
||||||
|
html += `<li>${item.device_name}: ${item.tag_name}</li>`;
|
||||||
|
});
|
||||||
|
html += '</ul></div>';
|
||||||
|
}
|
||||||
|
if (data.failed.length > 0) {
|
||||||
|
html += `<div class="text-red-600 dark:text-red-400"><strong>Failed ${data.failed.length} assignment(s):</strong><ul class="list-disc list-inside mt-2">`;
|
||||||
|
data.failed.forEach(item => {
|
||||||
|
html += `<li>Device ID ${item.device_id}, Tag ID ${item.tag_id}: ${item.reason}</li>`;
|
||||||
|
});
|
||||||
|
html += '</ul></div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
resultDiv.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
resultDiv.innerHTML = `<p class="text-red-600">Error: ${error.message}</p>`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Check if toast was dismissed in this session
|
||||||
|
const toastDismissed = sessionStorage.getItem('update-toast-dismissed');
|
||||||
|
if (toastDismissed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for updates
|
||||||
|
fetch('/check_update')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.update_available) {
|
||||||
|
const toast = document.getElementById('update-toast');
|
||||||
|
const currentVersionEl = document.getElementById('toast-current-version');
|
||||||
|
const latestVersionEl = document.getElementById('toast-latest-version');
|
||||||
|
const compareLink = document.getElementById('toast-compare-link');
|
||||||
|
const closeBtn = document.getElementById('toast-close');
|
||||||
|
|
||||||
|
// Set versions
|
||||||
|
currentVersionEl.textContent = 'v' + data.current_version;
|
||||||
|
latestVersionEl.textContent = 'v' + data.latest_version;
|
||||||
|
|
||||||
|
// Set compare link (current version to latest version)
|
||||||
|
compareLink.href = `https://github.com/JDB-NET/ipam/compare/v${data.current_version}...v${data.latest_version}`;
|
||||||
|
|
||||||
|
// Show toast
|
||||||
|
toast.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Close button handler
|
||||||
|
closeBtn.addEventListener('click', function() {
|
||||||
|
toast.classList.add('hidden');
|
||||||
|
sessionStorage.setItem('update-toast-dismissed', 'true');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error checking for updates:', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
// These variables are set inline in the template from server data
|
||||||
|
// permissions and rolePermissions are passed from the template
|
||||||
|
|
||||||
|
function showTab(tab) {
|
||||||
|
document.getElementById('users-tab').classList.add('hidden');
|
||||||
|
document.getElementById('roles-tab').classList.add('hidden');
|
||||||
|
document.getElementById('tab-users').classList.remove('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
|
||||||
|
document.getElementById('tab-users').classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
||||||
|
document.getElementById('tab-roles').classList.remove('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
|
||||||
|
document.getElementById('tab-roles').classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
||||||
|
|
||||||
|
if (tab === 'users') {
|
||||||
|
document.getElementById('users-tab').classList.remove('hidden');
|
||||||
|
document.getElementById('tab-users').classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
||||||
|
document.getElementById('tab-users').classList.add('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
|
||||||
|
} else {
|
||||||
|
document.getElementById('roles-tab').classList.remove('hidden');
|
||||||
|
document.getElementById('tab-roles').classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
||||||
|
document.getElementById('tab-roles').classList.add('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editUser(userId, name, email, roleId, apiKey) {
|
||||||
|
document.getElementById('edit-user-id').value = userId;
|
||||||
|
document.getElementById('edit-user-name').value = name;
|
||||||
|
document.getElementById('edit-user-email').value = email;
|
||||||
|
document.getElementById('edit-user-password').value = '';
|
||||||
|
document.getElementById('edit-user-role').value = (roleId === null || roleId === 'null') ? '' : roleId;
|
||||||
|
document.getElementById('edit-user-api-key').textContent = apiKey || 'No API Key';
|
||||||
|
document.getElementById('edit-user-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditUserModal() {
|
||||||
|
document.getElementById('edit-user-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAddRoleModal() {
|
||||||
|
// Make sure edit modal is closed first
|
||||||
|
document.getElementById('edit-role-modal').classList.add('hidden');
|
||||||
|
// Clear any form data
|
||||||
|
const addForm = document.querySelector('#add-role-modal form');
|
||||||
|
if (addForm) {
|
||||||
|
addForm.reset();
|
||||||
|
}
|
||||||
|
// Show add modal
|
||||||
|
document.getElementById('add-role-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAddRoleModal() {
|
||||||
|
document.getElementById('add-role-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function editRole(roleId, roleName, roleDescription) {
|
||||||
|
// Make sure add modal is closed first
|
||||||
|
document.getElementById('add-role-modal').classList.add('hidden');
|
||||||
|
document.getElementById('edit-role-id').value = roleId;
|
||||||
|
document.getElementById('edit-role-name').value = roleName;
|
||||||
|
document.getElementById('edit-role-description').value = roleDescription || '';
|
||||||
|
|
||||||
|
const permissionsDiv = document.getElementById('edit-role-permissions');
|
||||||
|
permissionsDiv.innerHTML = '';
|
||||||
|
|
||||||
|
const rolePerms = rolePermissions[roleId] || [];
|
||||||
|
|
||||||
|
// Group permissions by merged categories
|
||||||
|
const viewPerms = permissions.filter(p => p[3] === 'View');
|
||||||
|
const devicePerms = permissions.filter(p => p[3] === 'Device');
|
||||||
|
const deviceTypePerms = permissions.filter(p => p[3] === 'Device Type');
|
||||||
|
const subnetPerms = permissions.filter(p => p[3] === 'Subnet');
|
||||||
|
const dhcpPerms = permissions.filter(p => p[3] === 'DHCP');
|
||||||
|
const rackPerms = permissions.filter(p => p[3] === 'Rack');
|
||||||
|
const adminPerms = permissions.filter(p => p[3] === 'Admin');
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
// View Permissions
|
||||||
|
html += ' <!-- View Permissions -->\n';
|
||||||
|
html += ' <div class="col-span-full">\n';
|
||||||
|
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">View Permissions</h4>\n';
|
||||||
|
html += ' <div class="grid grid-cols-1 md:grid-cols-2 gap-2">\n';
|
||||||
|
viewPerms.forEach(perm => {
|
||||||
|
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
|
||||||
|
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
|
||||||
|
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
|
||||||
|
<span class="text-sm">${perm[2]}</span>
|
||||||
|
</label>\n`;
|
||||||
|
});
|
||||||
|
html += ' </div>\n';
|
||||||
|
html += ' </div>\n';
|
||||||
|
html += ' \n';
|
||||||
|
|
||||||
|
// Device Management
|
||||||
|
html += ' <!-- Device Management -->\n';
|
||||||
|
html += ' <div>\n';
|
||||||
|
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Device Management</h4>\n';
|
||||||
|
devicePerms.forEach(perm => {
|
||||||
|
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
|
||||||
|
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
|
||||||
|
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
|
||||||
|
<span class="text-sm">${perm[2]}</span>
|
||||||
|
</label>\n`;
|
||||||
|
});
|
||||||
|
deviceTypePerms.forEach(perm => {
|
||||||
|
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
|
||||||
|
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
|
||||||
|
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
|
||||||
|
<span class="text-sm">${perm[2]}</span>
|
||||||
|
</label>\n`;
|
||||||
|
});
|
||||||
|
html += ' </div>\n';
|
||||||
|
html += ' \n';
|
||||||
|
|
||||||
|
// Network Management
|
||||||
|
html += ' <!-- Network Management -->\n';
|
||||||
|
html += ' <div>\n';
|
||||||
|
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Network Management</h4>\n';
|
||||||
|
subnetPerms.forEach(perm => {
|
||||||
|
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
|
||||||
|
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
|
||||||
|
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
|
||||||
|
<span class="text-sm">${perm[2]}</span>
|
||||||
|
</label>\n`;
|
||||||
|
});
|
||||||
|
dhcpPerms.forEach(perm => {
|
||||||
|
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
|
||||||
|
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
|
||||||
|
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
|
||||||
|
<span class="text-sm">${perm[2]}</span>
|
||||||
|
</label>\n`;
|
||||||
|
});
|
||||||
|
html += ' </div>\n';
|
||||||
|
html += ' \n';
|
||||||
|
|
||||||
|
// Rack Management
|
||||||
|
html += ' <!-- Rack Management -->\n';
|
||||||
|
html += ' <div>\n';
|
||||||
|
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Rack Management</h4>\n';
|
||||||
|
rackPerms.forEach(perm => {
|
||||||
|
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
|
||||||
|
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
|
||||||
|
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
|
||||||
|
<span class="text-sm">${perm[2]}</span>
|
||||||
|
</label>\n`;
|
||||||
|
});
|
||||||
|
html += ' </div>\n';
|
||||||
|
html += ' \n';
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
html += ' <!-- Admin -->\n';
|
||||||
|
html += ' <div>\n';
|
||||||
|
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Administration</h4>\n';
|
||||||
|
adminPerms.forEach(perm => {
|
||||||
|
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
|
||||||
|
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
|
||||||
|
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
|
||||||
|
<span class="text-sm">${perm[2]}</span>
|
||||||
|
</label>\n`;
|
||||||
|
});
|
||||||
|
html += ' </div>\n';
|
||||||
|
|
||||||
|
permissionsDiv.innerHTML = html;
|
||||||
|
|
||||||
|
document.getElementById('edit-role-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditRoleModal() {
|
||||||
|
document.getElementById('edit-role-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteRole(roleId, roleName) {
|
||||||
|
if (confirm(`Are you sure you want to delete the role "${roleName}"?`)) {
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = '/users';
|
||||||
|
form.innerHTML = `
|
||||||
|
<input type="hidden" name="action" value="delete_role">
|
||||||
|
<input type="hidden" name="role_id" value="${roleId}">
|
||||||
|
`;
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modals when clicking outside
|
||||||
|
window.onclick = function(event) {
|
||||||
|
const editUserModal = document.getElementById('edit-user-modal');
|
||||||
|
const editRoleModal = document.getElementById('edit-role-modal');
|
||||||
|
const addRoleModal = document.getElementById('add-role-modal');
|
||||||
|
if (event.target === editUserModal) {
|
||||||
|
closeEditUserModal();
|
||||||
|
}
|
||||||
|
if (event.target === editRoleModal) {
|
||||||
|
closeEditRoleModal();
|
||||||
|
}
|
||||||
|
if (event.target === addRoleModal) {
|
||||||
|
closeAddRoleModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<div class="flex-1 flex items-center justify-center mx-4">
|
<div class="flex-1 flex items-center justify-center mx-4">
|
||||||
<div class="container py-8 max-w-md pt-20">
|
<div class="container py-8 max-w-md pt-20">
|
||||||
<div class="flex items-center mb-6 relative">
|
<div class="flex items-center mb-6 relative">
|
||||||
<a href="javascript:window.history.back()" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
|
<a href="/devices" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
|
||||||
<h1 class="text-3xl font-bold text-center w-full">Add Device</h1>
|
<h1 class="text-3xl font-bold text-center w-full">Add Device</h1>
|
||||||
</div>
|
</div>
|
||||||
<form action="/add_device" method="POST" class="flex flex-col space-y-4">
|
<form action="/add_device" method="POST" class="flex flex-col space-y-4">
|
||||||
|
|||||||
+20
-66
@@ -64,6 +64,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<i class="fas fa-chevron-right text-gray-400"></i>
|
<i class="fas fa-chevron-right text-gray-400"></i>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/backup" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<i class="fas fa-database text-3xl text-gray-600 dark:text-gray-400"></i>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-bold">Backup & Restore</h3>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Database backup and restore</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-chevron-right text-gray-400"></i>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Subnet Management Section -->
|
<!-- Subnet Management Section -->
|
||||||
@@ -85,6 +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>
|
||||||
@@ -96,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">
|
||||||
@@ -181,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>
|
||||||
|
|||||||
+82
-25
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Backup & Restore</title>
|
||||||
|
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
|
||||||
|
<link href="/static/css/output.css" rel="stylesheet">
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
||||||
|
{% include 'header.html' %}
|
||||||
|
<div class="flex-1 flex items-center justify-center mx-4">
|
||||||
|
<div class="container py-8 max-w-4xl pt-20">
|
||||||
|
<div class="flex items-center mb-6 relative">
|
||||||
|
<a href="/admin" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-full w-11 h-11 hover:cursor-pointer"><i class="fas fa-arrow-left"></i></a>
|
||||||
|
<h1 class="text-3xl font-bold text-center w-full">Backup & Restore</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="message" class="hidden mb-4 p-4 rounded-lg"></div>
|
||||||
|
|
||||||
|
<!-- Create Backup Section -->
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg p-6 mb-6">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Create Backup</h2>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Create a new database backup. This will export the entire database to a SQL file.</p>
|
||||||
|
<button id="create-backup-btn" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
|
||||||
|
<i class="fas fa-database"></i>
|
||||||
|
<span>Create Backup</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Restore Backup Section -->
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg p-6 mb-6">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Restore Backup</h2>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Restore the database from a backup file. <strong class="text-red-600 dark:text-red-400">Warning: This will replace all current data!</strong></p>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Upload Backup File -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Upload Backup File</label>
|
||||||
|
<form id="upload-restore-form" enctype="multipart/form-data" class="flex gap-2">
|
||||||
|
<label class="flex-1 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 border border-gray-600 rounded-lg px-4 py-2 cursor-pointer flex items-center justify-center hover:cursor-pointer">
|
||||||
|
<input type="file" name="backup_file" accept=".sql" required class="hidden" onchange="updateFileLabel(this)">
|
||||||
|
<span id="file-label" class="text-sm">Choose File</span>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">
|
||||||
|
<i class="fas fa-upload"></i> Upload & Restore
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Or Select Existing Backup -->
|
||||||
|
{% if backups %}
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">Or Restore from Existing Backup</label>
|
||||||
|
<form id="existing-restore-form" class="flex gap-2">
|
||||||
|
<select name="backup_filename" required class="flex-1 border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
|
||||||
|
<option value="">Select a backup...</option>
|
||||||
|
{% for backup in backups %}
|
||||||
|
<option value="{{ backup.filename }}">{{ backup.filename }} ({{ (backup.size / 1024 / 1024)|round(2) }} MB, {{ backup.created }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">
|
||||||
|
<i class="fas fa-undo"></i> Restore
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Available Backups Section -->
|
||||||
|
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg p-6">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Available Backups</h2>
|
||||||
|
{% if backups %}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full table-auto">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-400 dark:bg-zinc-700">
|
||||||
|
<th class="px-4 py-2 text-left">Filename</th>
|
||||||
|
<th class="px-4 py-2 text-left">Size</th>
|
||||||
|
<th class="px-4 py-2 text-left">Created</th>
|
||||||
|
<th class="px-4 py-2 text-center">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for backup in backups %}
|
||||||
|
<tr class="border-b border-gray-700">
|
||||||
|
<td class="px-4 py-2">{{ backup.filename }}</td>
|
||||||
|
<td class="px-4 py-2">{{ (backup.size / 1024 / 1024)|round(2) }} MB</td>
|
||||||
|
<td class="px-4 py-2">{{ backup.created }}</td>
|
||||||
|
<td class="px-4 py-2 text-center">
|
||||||
|
<div class="flex gap-2 justify-center">
|
||||||
|
<a href="/backup/download/{{ backup.filename }}" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-3 py-1 rounded text-sm" title="Download">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</a>
|
||||||
|
<button onclick="deleteBackup('{{ backup.filename }}')" class="bg-red-300 hover:bg-red-400 dark:bg-red-700 dark:hover:bg-red-600 hover:cursor-pointer px-3 py-1 rounded text-sm" title="Delete">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">No backups available. Create your first backup above.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/static/js/backup.js"></script>
|
||||||
|
<script>
|
||||||
|
function updateFileLabel(input) {
|
||||||
|
const label = document.getElementById('file-label');
|
||||||
|
if (input.files && input.files[0]) {
|
||||||
|
label.textContent = input.files[0].name;
|
||||||
|
} else {
|
||||||
|
label.textContent = 'Choose File';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Bulk Operations</title>
|
||||||
|
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
|
||||||
|
<link href="/static/css/output.css" rel="stylesheet">
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
||||||
|
{% include 'header.html' %}
|
||||||
|
<div class="flex-1 mx-4 py-8 pt-20">
|
||||||
|
<div class="container max-w-6xl mx-auto">
|
||||||
|
<div class="flex items-center mb-6 relative">
|
||||||
|
<a href="/devices" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
|
||||||
|
<h1 class="text-3xl font-bold text-center w-full">Bulk Operations</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="flex flex-wrap gap-2 mb-6 justify-center border-b border-gray-600">
|
||||||
|
<button onclick="showTab('assign-ips')" id="tab-assign-ips" class="tab-btn px-4 py-2 rounded-t-lg bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 hover:cursor-pointer active">Bulk IP Assignment</button>
|
||||||
|
<button onclick="showTab('create-devices')" id="tab-create-devices" class="tab-btn px-4 py-2 rounded-t-lg bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 hover:cursor-pointer">Bulk Device Creation</button>
|
||||||
|
<button onclick="showTab('assign-tags')" id="tab-assign-tags" class="tab-btn px-4 py-2 rounded-t-lg bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 hover:cursor-pointer">Bulk Tag Assignment</button>
|
||||||
|
<button onclick="showTab('export')" id="tab-export" class="tab-btn px-4 py-2 rounded-t-lg bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 hover:cursor-pointer">Bulk Export</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk IP Assignment -->
|
||||||
|
<div id="panel-assign-ips" class="tab-panel bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
|
||||||
|
{% if can_add_device_ip %}
|
||||||
|
<h2 class="text-2xl font-bold mb-4">Bulk IP Assignment</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-4">Select a device and assign multiple IPs from a subnet. Hold Ctrl/Cmd to select multiple IPs.</p>
|
||||||
|
<form id="bulk-assign-ips-form" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block mb-2 font-medium">Select Device:</label>
|
||||||
|
<select id="bulk-device-select" name="device_id" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
|
||||||
|
<option value="">Select a device...</option>
|
||||||
|
{% for device in devices %}
|
||||||
|
<option value="{{ device[0] }}">{{ device[1] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block mb-2 font-medium">Select Subnet:</label>
|
||||||
|
<select id="bulk-subnet-select" name="subnet_id" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
|
||||||
|
<option value="">Select a subnet...</option>
|
||||||
|
{% for subnet in subnets %}
|
||||||
|
<option value="{{ subnet[0] }}">{{ subnet[1] }} ({{ subnet[2] }}) - {{ subnet[3] or 'Unassigned' }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block mb-2 font-medium">Select IPs (hold Ctrl/Cmd to select multiple):</label>
|
||||||
|
<select id="bulk-ip-select" name="ip_ids[]" multiple size="15" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
|
||||||
|
<option value="" disabled>Select a subnet first...</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">Selected: <span id="selected-ip-count">0</span> IPs</p>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg">Assign IPs</button>
|
||||||
|
</form>
|
||||||
|
<div id="assign-ips-result" class="mt-4 hidden"></div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-500">You don't have permission to assign IPs to devices.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk Device Creation -->
|
||||||
|
<div id="panel-create-devices" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
|
||||||
|
{% if can_add_device %}
|
||||||
|
<h2 class="text-2xl font-bold mb-4">Bulk Device Creation</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-4">Create multiple devices at once. Enter one device name per line.</p>
|
||||||
|
<form id="bulk-create-devices-form" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block mb-2 font-medium">Device Names (one per line):</label>
|
||||||
|
<textarea id="device-names" name="device_names" rows="10" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" placeholder="Device 1 Device 2 Device 3" required></textarea>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">Enter device names, one per line</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block mb-2 font-medium">Device Type:</label>
|
||||||
|
<select name="device_type" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
|
||||||
|
{% for dtype in device_types %}
|
||||||
|
<option value="{{ dtype[0] }}">{{ dtype[1] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg">Create Devices</button>
|
||||||
|
</form>
|
||||||
|
<div id="create-devices-result" class="mt-4 hidden"></div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-500">You don't have permission to create devices.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk Tag Assignment -->
|
||||||
|
<div id="panel-assign-tags" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
|
||||||
|
{% if can_assign_device_tag %}
|
||||||
|
<h2 class="text-2xl font-bold mb-4">Bulk Tag Assignment</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-4">Select multiple devices and assign one or more tags to them.</p>
|
||||||
|
<form id="bulk-assign-tags-form" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block mb-2 font-medium">Select Devices (hold Ctrl/Cmd to select multiple):</label>
|
||||||
|
<select id="bulk-tag-device-select" name="device_ids[]" multiple size="10" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full">
|
||||||
|
{% for device in devices %}
|
||||||
|
<option value="{{ device[0] }}">{{ device[1] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">Selected: <span id="selected-tag-device-count">0</span> devices</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block mb-2 font-medium">Select Tags (hold Ctrl/Cmd to select multiple):</label>
|
||||||
|
<select name="tag_ids[]" multiple size="5" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
|
||||||
|
{% for tag in tags %}
|
||||||
|
<option value="{{ tag[0] }}">{{ tag[1] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg">Assign Tags</button>
|
||||||
|
</form>
|
||||||
|
<div id="assign-tags-result" class="mt-4 hidden"></div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-500">You don't have permission to assign tags to devices.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk Export -->
|
||||||
|
<div id="panel-export" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
|
||||||
|
{% if can_export_subnet_csv %}
|
||||||
|
<h2 class="text-2xl font-bold mb-4">Bulk Subnet Export</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-4">Select multiple subnets and export them to a single CSV file.</p>
|
||||||
|
<form id="bulk-export-form" method="POST" action="/bulk/export_subnets" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block mb-2 font-medium">Select Subnets (hold Ctrl/Cmd to select multiple):</label>
|
||||||
|
<select name="subnet_ids[]" multiple size="10" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
|
||||||
|
{% for subnet in subnets %}
|
||||||
|
<option value="{{ subnet[0] }}">{{ subnet[1] }} ({{ subnet[2] }}) - {{ subnet[3] or 'Unassigned' }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg">Export to CSV</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-500">You don't have permission to export subnets.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/bulk_operations.js"></script>
|
||||||
|
<style>
|
||||||
|
.tab-btn.active {
|
||||||
|
background-color: rgb(156 163 175);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.dark .tab-btn.active {
|
||||||
|
background-color: rgb(63 63 70);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -14,8 +14,9 @@
|
|||||||
<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>
|
||||||
|
|
||||||
|
|||||||
+67
-18
@@ -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 transform -translate-x-1/2">
|
||||||
|
<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>
|
||||||
|
|||||||
+1
-35
@@ -24,16 +24,6 @@
|
|||||||
<button type="button" id="export-csv" class="hidden sm:flex absolute right-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer items-center justify-center rounded-full w-11 h-11 export-csv-btn" title="Export as CSV" data-rack-id="{{ rack.id }}">
|
<button type="button" id="export-csv" class="hidden sm:flex absolute right-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer items-center justify-center rounded-full w-11 h-11 export-csv-btn" title="Export as CSV" data-rack-id="{{ rack.id }}">
|
||||||
<i class="fas fa-file-csv fa-lg"></i>
|
<i class="fas fa-file-csv fa-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
var btn = document.getElementById('export-csv');
|
|
||||||
if (btn) {
|
|
||||||
btn.addEventListener('click', function() {
|
|
||||||
window.location = '/rack/' + btn.getAttribute('data-rack-id') + '/export_csv';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-4 mb-6 items-stretch">
|
<div class="flex flex-col gap-4 mb-6 items-stretch">
|
||||||
<div class="flex gap-4 w-full justify-center">
|
<div class="flex gap-4 w-full justify-center">
|
||||||
@@ -78,31 +68,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-xs dark:text-gray-400 mt-2">Add a non-networked device.</div>
|
<div class="text-xs dark:text-gray-400 mt-2">Add a non-networked device.</div>
|
||||||
</form>
|
</form>
|
||||||
<script>
|
<script src="/static/js/rack.js"></script>
|
||||||
function showBothAddButtons() {
|
|
||||||
document.getElementById('show-add-device-form').classList.remove('hidden');
|
|
||||||
document.getElementById('show-nonnet-form').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
showBothAddButtons();
|
|
||||||
document.getElementById('show-nonnet-form').onclick = function() {
|
|
||||||
document.getElementById('nonnet-form').classList.remove('hidden');
|
|
||||||
this.classList.add('hidden');
|
|
||||||
};
|
|
||||||
document.getElementById('hide-nonnet-form').onclick = function() {
|
|
||||||
document.getElementById('nonnet-form').classList.add('hidden');
|
|
||||||
showBothAddButtons();
|
|
||||||
};
|
|
||||||
document.getElementById('show-add-device-form').onclick = function() {
|
|
||||||
document.getElementById('add-device-form').classList.remove('hidden');
|
|
||||||
this.classList.add('hidden');
|
|
||||||
};
|
|
||||||
document.getElementById('hide-add-device-form').onclick = function() {
|
|
||||||
document.getElementById('add-device-form').classList.add('hidden');
|
|
||||||
showBothAddButtons();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="mb-4 p-3 bg-red-700 text-white rounded-lg text-center font-semibold">{{ error }}</div>
|
<div class="mb-4 p-3 bg-red-700 text-white rounded-lg text-center font-semibold">{{ error }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -0,0 +1,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
@@ -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>
|
||||||
+2
-196
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user