27 Commits

Author SHA1 Message Date
Jamie b5fa9ef6ae Merge pull request #30 from JDB-NET/release-please--branches--main
chore(main): release 1.8.0
2025-12-23 01:07:23 +00:00
github-actions[bot] 19e7e978aa chore(main): release 1.8.0 2025-12-23 01:06:45 +00:00
jamie 64ae4be6d5 feat: get next available ip by api 2025-12-23 01:06:25 +00:00
jamie d7fcffd4b5 build: 🚀 redeploy 2025-12-20 11:20:25 +00:00
jamie 283c445263 fix: 🐛 global search missing from devices 2025-12-05 12:07:12 +00:00
Jamie 2af3584d80 Merge pull request #28 from JDB-NET/release-please--branches--main
chore(main): release 1.7.0
2025-12-05 01:38:23 +00:00
github-actions[bot] 59ded14858 chore(main): release 1.7.0 2025-12-05 01:38:06 +00:00
jamie 9c0e6d035c feat: add devices by tag page 2025-12-05 01:37:45 +00:00
jamie 8242e9d758 fix: 🐛 invalidate linked cache 2025-12-05 01:33:47 +00:00
jamie 47208b31ee fix: 🐛 invalidate cache when device type is added 2025-12-05 01:24:05 +00:00
Jamie f44b5327e4 Merge pull request #27 from JDB-NET/release-please--branches--main
chore(main): release 1.6.1
2025-12-05 01:19:59 +00:00
github-actions[bot] f1fb8bc7e9 chore(main): release 1.6.1 2025-12-05 01:18:45 +00:00
jamie 286bf4b665 fix: 🐛 invalidate subnet cache when device is deleted 2025-12-05 01:18:21 +00:00
Jamie fb6a3445a7 Merge pull request #25 from JDB-NET/release-please--branches--main
chore(main): release 1.6.0
2025-12-05 01:07:11 +00:00
github-actions[bot] 28267989b0 chore(main): release 1.6.0 2025-12-05 01:00:09 +00:00
jamie 47f68fd27c refactor: 🎨 database indexing and optimisation 2025-12-05 00:59:43 +00:00
jamie 3a9250f5b0 feat: in memory cache 2025-12-05 00:51:02 +00:00
jamie 3e8965de6f feat: global search 2025-12-05 00:01:58 +00:00
jamie 707846bb3c feat: backup and restore 2025-12-04 23:23:33 +00:00
jamie 69588d6518 refactor: 🎨 tidy nav bar 2025-12-04 23:01:19 +00:00
jamie 1d9209a714 refactor: 🎨 js 2025-12-04 22:59:32 +00:00
jamie 730b8701db feat: update available notification 2025-12-04 22:47:43 +00:00
jamie f0165985fc refactor: 🎨 improved audit log filtering 2025-12-04 22:32:15 +00:00
jamie f6795f5281 ci: 🚀 include all commit types 2025-12-04 22:18:58 +00:00
jamie 2163be8f79 feat: bulk operations 2025-12-04 22:15:57 +00:00
jamie f98e92da06 feat: subnet utilisation stats 2025-12-04 20:01:52 +00:00
jamie 61e3200207 refactor: 🎨 header link to github releases 2025-12-04 19:26:40 +00:00
40 changed files with 3434 additions and 540 deletions
+5 -1
View File
@@ -6,7 +6,11 @@
"settings": {},
"customizations": {
"vscode": {
"extensions": ["ms-python.python"]
"extensions": [
"ms-python.python",
"vivaxy.vscode-conventional-commits",
"esbenp.prettier-vscode"
]
}
},
"postCreateCommand": "pip install -r requirements.txt; curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64; chmod +x tailwindcss-linux-x64; mv tailwindcss-linux-x64 tailwindcss",
+1 -1
View File
@@ -62,7 +62,7 @@ jobs:
name: Deploy to Kubernetes
needs: release-please
if: ${{ needs.release-please.outputs.release_created }}
runs-on: [ k3s-lan-01 ]
runs-on: [ k3s-internal-htz-01 ]
steps:
- name: Checkout
uses: actions/checkout@v4
+2 -1
View File
@@ -1,4 +1,5 @@
__pycache__
tailwindcss
static/css/output.css
.env
.env
backups/
+53 -9
View File
@@ -1,10 +1,54 @@
{
"packages": {
".": {
"release-type": "simple",
"version-file": "VERSION"
}
},
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
}
"packages": {
".": {
"release-type": "simple",
"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"
}
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.5.1"
".": "1.8.0"
}
+63
View File
@@ -1,5 +1,68 @@
# Changelog
## [1.8.0](https://github.com/JDB-NET/ipam/compare/v1.7.0...v1.8.0) (2025-12-23)
### Features
* :sparkles: get next available ip by api ([64ae4be](https://github.com/JDB-NET/ipam/commit/64ae4be6d5997ff0b16ff5232237d38f2fec5b64))
### Bug Fixes
* :bug: global search missing from devices ([283c445](https://github.com/JDB-NET/ipam/commit/283c445263b7dc992448d907e682e53b7720b610))
### Build System
* :rocket: redeploy ([d7fcffd](https://github.com/JDB-NET/ipam/commit/d7fcffd4b5598b682dede864ba526b1257584f6a))
## [1.7.0](https://github.com/JDB-NET/ipam/compare/v1.6.1...v1.7.0) (2025-12-05)
### Features
* :sparkles: add devices by tag page ([9c0e6d0](https://github.com/JDB-NET/ipam/commit/9c0e6d035c8dda68281b2bfe2b7a61802353f7a7))
### Bug Fixes
* :bug: invalidate cache when device type is added ([47208b3](https://github.com/JDB-NET/ipam/commit/47208b31eed51f0cf0d7c8c411093bda1c84cf1b))
* :bug: invalidate linked cache ([8242e9d](https://github.com/JDB-NET/ipam/commit/8242e9d758ef19030b516e4a51f0cfb556f4e5ba))
## [1.6.1](https://github.com/JDB-NET/ipam/compare/v1.6.0...v1.6.1) (2025-12-05)
### Bug Fixes
* :bug: invalidate subnet cache when device is deleted ([286bf4b](https://github.com/JDB-NET/ipam/commit/286bf4b665e6352dea7b14753f080fa5cabb7926))
## [1.6.0](https://github.com/JDB-NET/ipam/compare/v1.5.1...v1.6.0) (2025-12-05)
### Features
* :sparkles: backup and restore ([707846b](https://github.com/JDB-NET/ipam/commit/707846bb3c717df9223ea7103e29efc6e671e16d))
* :sparkles: bulk operations ([2163be8](https://github.com/JDB-NET/ipam/commit/2163be8f79b579e38944a689915a18d5c35f8d3a))
* :sparkles: global search ([3e8965d](https://github.com/JDB-NET/ipam/commit/3e8965de6f19b3b382e236b08df685401205f356))
* :sparkles: in memory cache ([3a9250f](https://github.com/JDB-NET/ipam/commit/3a9250f5b0c14bfc6a807fe2948bbc852a652047))
* :sparkles: subnet utilisation stats ([f98e92d](https://github.com/JDB-NET/ipam/commit/f98e92da062640d47bec3516def0efde3aebd058))
* :sparkles: update available notification ([730b870](https://github.com/JDB-NET/ipam/commit/730b8701db81f5e03760a25209baeab2f81116fa))
### Refactoring
* :art: database indexing and optimisation ([47f68fd](https://github.com/JDB-NET/ipam/commit/47f68fd27cf62d0e0d2af55089bc0556043c12ff))
* :art: header link to github releases ([61e3200](https://github.com/JDB-NET/ipam/commit/61e320020724e437d8a607e7341b12b2fe6f794d))
* :art: improved audit log filtering ([f016598](https://github.com/JDB-NET/ipam/commit/f0165985fc194fd3a3e460b52447a5511908ed91))
* :art: js ([1d9209a](https://github.com/JDB-NET/ipam/commit/1d9209a714a6d0b7d1901b6e3470f5265e0171a6))
* :art: tidy nav bar ([69588d6](https://github.com/JDB-NET/ipam/commit/69588d6518571d8de55c718c14176bb78cb19ee1))
### CI/CD
* :rocket: include all commit types ([f6795f5](https://github.com/JDB-NET/ipam/commit/f6795f52815a2d599840c8ed83c99ad690a046c8))
## [1.5.1](https://github.com/JDB-NET/ipam/compare/v1.5.0...v1.5.1) (2025-12-04)
+1 -1
View File
@@ -2,7 +2,7 @@ FROM python:3.13-slim
WORKDIR /app
COPY . /app
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 curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 \
&& chmod +x tailwindcss-linux-x64 \
+4 -3
View File
@@ -32,6 +32,7 @@ A Flask-based web application for comprehensive IP Address Management (IPAM). Ma
docker run -d \
--name ipam \
-p 5000:5000 \
-v ./backups:/app/backups \
-e MYSQL_HOST=10.10.2.27 \
-e MYSQL_USER=ipam \
-e MYSQL_PASSWORD=your_password \
@@ -45,15 +46,13 @@ docker run -d \
### Docker Compose
```yaml
version: '3.8'
services:
ipam:
image: ghcr.io/jdb-net/ipam:latest
container_name: ipam
restart: unless-stopped
ports:
- "5000:5000" # Web interface
- "5000:5000"
environment:
- MYSQL_HOST=10.10.2.27
- MYSQL_USER=ipam
@@ -62,6 +61,8 @@ services:
- SECRET_KEY=your_secret_key
- NAME=Your Organisation
- LOGO_PNG=https://example.com/logo.png
volumes:
- ./backups:/app/backups
```
## Configuration
+1 -1
View File
@@ -1 +1 @@
1.5.1
1.8.0
+4
View File
@@ -39,5 +39,9 @@ def inject_env_vars():
register_routes(app)
init_db(app)
# Start cache pre-warming in background
from routes import prewarm_cache
prewarm_cache(app)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)
+191
View File
@@ -0,0 +1,191 @@
"""
In-memory caching module with TTL support and cache invalidation
"""
import time
import sys
from threading import Lock
from functools import wraps
class Cache:
"""Simple in-memory cache with TTL support and size limiting"""
def __init__(self, max_size_mb=50):
self._cache = {}
self._lock = Lock()
self._max_size_bytes = max_size_mb * 1024 * 1024 # Convert MB to bytes
self._access_order = [] # Track access order for LRU eviction
def _get_size(self, obj):
"""Estimate size of an object in bytes"""
size = sys.getsizeof(obj)
if isinstance(obj, dict):
size += sum(self._get_size(k) + self._get_size(v) for k, v in obj.items())
elif isinstance(obj, (list, tuple)):
size += sum(self._get_size(item) for item in obj)
elif isinstance(obj, str):
size += sys.getsizeof(obj) - sys.getsizeof('')
return size
def _get_cache_size(self):
"""Get approximate total size of cache in bytes"""
total_size = sys.getsizeof(self._cache)
for key, (value, expiry) in self._cache.items():
total_size += self._get_size(key) + self._get_size(value) + sys.getsizeof(expiry)
return total_size
def _evict_if_needed(self):
"""Evict entries if cache exceeds size limit"""
current_size = self._get_cache_size()
if current_size <= self._max_size_bytes:
return
# First, remove expired entries
current_time = time.time()
expired_keys = []
for key in list(self._cache.keys()):
_, expiry = self._cache[key]
if expiry is not None and current_time >= expiry:
expired_keys.append(key)
for key in expired_keys:
if key in self._cache:
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
# If still over limit, remove oldest entries (LRU)
current_size = self._get_cache_size()
while current_size > self._max_size_bytes and self._access_order:
oldest_key = self._access_order.pop(0)
if oldest_key in self._cache:
del self._cache[oldest_key]
current_size = self._get_cache_size()
def get(self, key):
"""Get value from cache if it exists and hasn't expired"""
with self._lock:
if key in self._cache:
value, expiry = self._cache[key]
if expiry is None or time.time() < expiry:
# Update access order (move to end for LRU)
if key in self._access_order:
self._access_order.remove(key)
self._access_order.append(key)
return value
else:
# Expired, remove it
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
return None
def set(self, key, value, ttl=None):
"""Set value in cache with optional TTL (time to live in seconds)"""
with self._lock:
# Remove old entry if it exists
if key in self._cache:
if key in self._access_order:
self._access_order.remove(key)
expiry = None if ttl is None else time.time() + ttl
self._cache[key] = (value, expiry)
self._access_order.append(key)
# Evict if needed to stay under size limit
self._evict_if_needed()
def delete(self, key):
"""Delete a key from cache"""
with self._lock:
if key in self._cache:
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
def clear(self, pattern=None):
"""Clear cache entries. If pattern is provided, only clear keys matching the pattern."""
with self._lock:
if pattern is None:
self._cache.clear()
self._access_order.clear()
else:
keys_to_delete = [key for key in self._cache.keys() if pattern in key]
for key in keys_to_delete:
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
def invalidate_subnet(self, subnet_id):
"""Invalidate all cache entries related to a specific subnet"""
patterns = [
f'subnet:{subnet_id}',
f'subnet_list',
f'index',
f'admin',
f'utilization:{subnet_id}'
]
with self._lock:
keys_to_delete = []
for key in self._cache.keys():
for pattern in patterns:
if pattern in key:
keys_to_delete.append(key)
break
for key in keys_to_delete:
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
def invalidate_device(self, device_id):
"""Invalidate all cache entries related to a specific device"""
patterns = [
f'device:{device_id}',
f'device_list',
f'devices',
f'device_types'
]
with self._lock:
keys_to_delete = []
for key in self._cache.keys():
for pattern in patterns:
if pattern in key:
keys_to_delete.append(key)
break
for key in keys_to_delete:
del self._cache[key]
if key in self._access_order:
self._access_order.remove(key)
def invalidate_all(self):
"""Invalidate all cache entries"""
self.clear()
# Global cache instance
cache = Cache()
def cached(ttl=None, key_prefix=''):
"""
Decorator to cache function results
Args:
ttl: Time to live in seconds (None = no expiration)
key_prefix: Prefix for cache key
"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
# Create cache key from function name, args, and kwargs
cache_key = f"{key_prefix}{func.__name__}:{str(args)}:{str(sorted(kwargs.items()))}"
# Try to get from cache
cached_value = cache.get(cache_key)
if cached_value is not None:
return cached_value
# Call function and cache result
result = func(*args, **kwargs)
cache.set(cache_key, result, ttl)
return result
return wrapper
return decorator
+123 -5
View File
@@ -3,6 +3,7 @@ import hashlib
import base64
import secrets
import mysql.connector
import logging
from flask import current_app
def hash_password(password, salt=None):
@@ -64,8 +65,8 @@ def init_db(app=None):
details TEXT,
subnet_id INTEGER,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES User(id),
FOREIGN KEY (subnet_id) REFERENCES Subnet(id)
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE SET NULL,
FOREIGN KEY (subnet_id) REFERENCES Subnet(id) ON DELETE SET NULL
)
''')
cursor.execute('''
@@ -132,6 +133,7 @@ def init_db(app=None):
FOREIGN KEY (device_id) REFERENCES Device(id) ON DELETE CASCADE
)
''')
# Initialize default device types only if table is empty
cursor.execute('SELECT COUNT(*) FROM DeviceType')
if cursor.fetchone()[0] == 0:
cursor.executemany('INSERT INTO DeviceType (name, icon_class) VALUES (%s, %s)', [
@@ -143,12 +145,20 @@ def init_db(app=None):
('Printer', 'fa-print'),
('Other', 'fa-question')
])
conn.commit() # Commit the inserts before querying
# Add device_type_id column if it doesn't exist
cursor.execute("SHOW COLUMNS FROM Device LIKE 'device_type_id'")
if not cursor.fetchone():
cursor.execute('ALTER TABLE Device ADD COLUMN device_type_id INTEGER DEFAULT NULL')
cursor.execute("SELECT id FROM DeviceType WHERE name='Other'")
other_id = cursor.fetchone()[0]
cursor.execute('UPDATE Device SET device_type_id = %s WHERE device_type_id IS NULL', (other_id,))
# Set default device_type_id for devices that don't have one
# Use the first available device type, or leave NULL if no types exist
cursor.execute('SELECT id FROM DeviceType ORDER BY id LIMIT 1')
first_type_result = cursor.fetchone()
if first_type_result:
first_type_id = first_type_result[0]
cursor.execute('UPDATE Device SET device_type_id = %s WHERE device_type_id IS NULL', (first_type_id,))
try:
cursor.execute('ALTER TABLE Device ADD CONSTRAINT fk_device_type FOREIGN KEY (device_type_id) REFERENCES DeviceType(id)')
except mysql.connector.Error as e:
@@ -199,6 +209,50 @@ def init_db(app=None):
if not cursor.fetchone():
cursor.execute('ALTER TABLE User ADD COLUMN api_key VARCHAR(255) DEFAULT NULL UNIQUE')
# Ensure AuditLog foreign keys have ON DELETE SET NULL to preserve audit logs
# This is critical - audit logs should NEVER be deleted, even when referenced entities are deleted
try:
# Check and update user_id foreign key
cursor.execute('''
SELECT CONSTRAINT_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'AuditLog'
AND COLUMN_NAME = 'user_id'
AND REFERENCED_TABLE_NAME = 'User'
''')
fk_user = cursor.fetchone()
if fk_user:
fk_name = fk_user[0]
# Drop and recreate with ON DELETE SET NULL
cursor.execute(f'ALTER TABLE AuditLog DROP FOREIGN KEY {fk_name}')
cursor.execute('ALTER TABLE AuditLog ADD CONSTRAINT fk_auditlog_user FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE SET NULL')
except mysql.connector.Error as e:
# Foreign key might not exist or already be correct, continue
if e.errno != 1025 and e.errno != 1091: # Not "Cannot drop foreign key" or "Unknown key"
logging.warning(f"Could not update AuditLog user_id foreign key: {e}")
try:
# Check and update subnet_id foreign key
cursor.execute('''
SELECT CONSTRAINT_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'AuditLog'
AND COLUMN_NAME = 'subnet_id'
AND REFERENCED_TABLE_NAME = 'Subnet'
''')
fk_subnet = cursor.fetchone()
if fk_subnet:
fk_name = fk_subnet[0]
# Drop and recreate with ON DELETE SET NULL
cursor.execute(f'ALTER TABLE AuditLog DROP FOREIGN KEY {fk_name}')
cursor.execute('ALTER TABLE AuditLog ADD CONSTRAINT fk_auditlog_subnet FOREIGN KEY (subnet_id) REFERENCES Subnet(id) ON DELETE SET NULL')
except mysql.connector.Error as e:
# Foreign key might not exist or already be correct, continue
if e.errno != 1025 and e.errno != 1091: # Not "Cannot drop foreign key" or "Unknown key"
logging.warning(f"Could not update AuditLog subnet_id foreign key: {e}")
# Create Tag table
cursor.execute('''
CREATE TABLE IF NOT EXISTS Tag (
@@ -388,5 +442,69 @@ def init_db(app=None):
api_key = generate_api_key()
cursor.execute('''INSERT INTO User (name, email, password, role_id, api_key) VALUES (%s, %s, %s, %s, %s)''',
('admin', 'admin@example.com', hash_password('password'), admin_role_id, api_key))
# Create indexes for performance optimization
logging.info("Creating database indexes for performance...")
def create_index_if_not_exists(cursor, index_name, table_name, columns):
"""Helper function to create index if it doesn't exist"""
try:
# Check if index exists
cursor.execute('''
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = %s
AND index_name = %s
''', (table_name, index_name))
if cursor.fetchone()[0] == 0:
cursor.execute(f'CREATE INDEX {index_name} ON {table_name}({columns})')
logging.info(f"Created index {index_name}")
else:
logging.debug(f"Index {index_name} already exists")
except mysql.connector.Error as e:
logging.warning(f"Could not create index {index_name}: {e}")
# IPAddress table indexes
create_index_if_not_exists(cursor, 'idx_ipaddress_subnet_id', 'IPAddress', 'subnet_id')
create_index_if_not_exists(cursor, 'idx_ipaddress_hostname', 'IPAddress', 'hostname')
create_index_if_not_exists(cursor, 'idx_ipaddress_ip', 'IPAddress', 'ip')
create_index_if_not_exists(cursor, 'idx_ipaddress_subnet_hostname', 'IPAddress', 'subnet_id, hostname')
# DeviceIPAddress table indexes
create_index_if_not_exists(cursor, 'idx_deviceipaddress_device_id', 'DeviceIPAddress', 'device_id')
create_index_if_not_exists(cursor, 'idx_deviceipaddress_ip_id', 'DeviceIPAddress', 'ip_id')
create_index_if_not_exists(cursor, 'idx_deviceipaddress_device_ip', 'DeviceIPAddress', 'device_id, ip_id')
# AuditLog table indexes
create_index_if_not_exists(cursor, 'idx_auditlog_timestamp', 'AuditLog', 'timestamp')
create_index_if_not_exists(cursor, 'idx_auditlog_user_id', 'AuditLog', 'user_id')
create_index_if_not_exists(cursor, 'idx_auditlog_subnet_id', 'AuditLog', 'subnet_id')
create_index_if_not_exists(cursor, 'idx_auditlog_action', 'AuditLog', 'action')
create_index_if_not_exists(cursor, 'idx_auditlog_user_timestamp', 'AuditLog', 'user_id, timestamp')
create_index_if_not_exists(cursor, 'idx_auditlog_subnet_timestamp', 'AuditLog', 'subnet_id, timestamp')
# Subnet table indexes
create_index_if_not_exists(cursor, 'idx_subnet_site', 'Subnet', 'site')
create_index_if_not_exists(cursor, 'idx_subnet_site_name', 'Subnet', 'site, name')
# DeviceTag table indexes
create_index_if_not_exists(cursor, 'idx_devicetag_device_id', 'DeviceTag', 'device_id')
create_index_if_not_exists(cursor, 'idx_devicetag_tag_id', 'DeviceTag', 'tag_id')
# DHCPPool table indexes
create_index_if_not_exists(cursor, 'idx_dhcppool_subnet_id', 'DHCPPool', 'subnet_id')
# RackDevice table indexes
create_index_if_not_exists(cursor, 'idx_rackdevice_rack_id', 'RackDevice', 'rack_id')
create_index_if_not_exists(cursor, 'idx_rackdevice_device_id', 'RackDevice', 'device_id')
create_index_if_not_exists(cursor, 'idx_rackdevice_rack_side', 'RackDevice', 'rack_id, side')
# Device table indexes
create_index_if_not_exists(cursor, 'idx_device_device_type_id', 'Device', 'device_type_id')
# User table indexes (api_key already has UNIQUE index)
create_index_if_not_exists(cursor, 'idx_user_role_id', 'User', 'role_id')
logging.info("Database indexes created successfully")
conn.commit()
conn.close()
+1 -1
View File
@@ -24,7 +24,7 @@ spec:
- name: SECRET_KEY
value: "41TbN7v5peFLZPrdwSCc64J3mjmiUk5fkVWsmb2m"
- name: MYSQL_HOST
value: "10.10.2.27"
value: "10.10.25.4"
- name: MYSQL_USER
value: "ipam"
- name: MYSQL_PASSWORD
+2 -1
View File
@@ -1,4 +1,5 @@
Flask
mysql-connector-python
dotenv
gunicorn
gunicorn
requests
+1366 -46
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
h2 {
cursor: pointer;
}
form:not(.mb-6), .mt-4 {
.container form:not(.mb-6), .mt-4 {
display: none;
}
.allocated-ips {
+65
View File
@@ -0,0 +1,65 @@
function showAddSubnetModal() {
document.getElementById('add-subnet-modal').classList.remove('hidden');
document.getElementById('add-subnet-name').value = '';
document.getElementById('add-subnet-cidr').value = '';
document.getElementById('add-subnet-site').value = '';
}
function closeAddSubnetModal() {
document.getElementById('add-subnet-modal').classList.add('hidden');
document.getElementById('cidr-error').classList.add('hidden');
}
function editSubnet(subnetId, name, cidr, site) {
document.getElementById('edit-subnet-id').value = subnetId;
document.getElementById('edit-subnet-name').value = name;
document.getElementById('edit-subnet-cidr').value = cidr;
document.getElementById('edit-subnet-site').value = site;
document.getElementById('edit-subnet-modal').classList.remove('hidden');
}
function closeEditSubnetModal() {
document.getElementById('edit-subnet-modal').classList.add('hidden');
document.getElementById('edit-cidr-error').classList.add('hidden');
}
function validateEditSubnetForm() {
const cidrInput = document.getElementById('edit-subnet-cidr');
const cidrError = document.getElementById('edit-cidr-error');
const cidr = cidrInput.value.trim();
// Basic CIDR validation
const cidrPattern = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/;
if (!cidrPattern.test(cidr)) {
cidrError.textContent = 'Invalid CIDR format. Use format like 192.168.1.0/24';
cidrError.classList.remove('hidden');
return false;
}
// Check prefix length
const parts = cidr.split('/');
if (parts.length === 2) {
const prefixLen = parseInt(parts[1]);
if (prefixLen < 24 || prefixLen > 32) {
cidrError.textContent = 'Subnet must be /24 or smaller (e.g., /24, /25, ... /32)';
cidrError.classList.remove('hidden');
return false;
}
}
cidrError.classList.add('hidden');
return true;
}
// Close modals when clicking outside
window.onclick = function(event) {
const addModal = document.getElementById('add-subnet-modal');
const editModal = document.getElementById('edit-subnet-modal');
if (event.target === addModal) {
closeAddSubnetModal();
}
if (event.target === editModal) {
closeEditSubnetModal();
}
}
+122
View File
@@ -0,0 +1,122 @@
document.addEventListener('DOMContentLoaded', function() {
// Filter toggle functionality
const filterToggle = document.getElementById('filter-toggle');
const filterForm = document.getElementById('audit-filter-form');
const filterArrow = document.getElementById('filter-arrow');
if (filterToggle && filterForm && filterArrow) {
filterToggle.addEventListener('click', function() {
filterForm.classList.toggle('hidden');
// Toggle rotation using inline style for better compatibility
if (filterForm.classList.contains('hidden')) {
filterArrow.style.transform = 'rotate(0deg)';
} else {
filterArrow.style.transform = 'rotate(180deg)';
}
});
// Set initial arrow rotation if form is visible (has active filters or expand_filters param)
if (!filterForm.classList.contains('hidden')) {
filterArrow.style.transform = 'rotate(180deg)';
}
}
// Format timestamps
document.querySelectorAll('td[data-utc]').forEach(function(td) {
const utc = td.getAttribute('data-utc');
if (utc) {
const date = new Date(utc + 'Z');
td.textContent = date.toLocaleString();
}
});
// Parse and display visual diffs
document.querySelectorAll('.diff-container').forEach(function(container) {
const details = container.getAttribute('data-details');
if (!details) return;
// Try to parse common change patterns
let html = details;
// Pattern 1: "Changed X from 'old' to 'new'"
html = html.replace(/Changed (.+?) from ['"](.+?)['"] to ['"](.+?)['"]/gi, function(match, field, oldVal, newVal) {
return `Changed ${field} from <span class="diff-removed">${oldVal}</span> to <span class="diff-added">${newVal}</span>`;
});
// Pattern 2: "Renamed X to Y"
html = html.replace(/Renamed (.+?) to ['"](.+?)['"]/gi, function(match, oldVal, newVal) {
return `Renamed <span class="diff-removed">${oldVal}</span> to <span class="diff-added">${newVal}</span>`;
});
// Pattern 3: "Updated X: old -> new"
html = html.replace(/Updated (.+?):\s*(.+?)\s*->\s*(.+?)(?:\s|$)/gi, function(match, field, oldVal, newVal) {
return `Updated ${field}: <span class="diff-removed">${oldVal}</span> → <span class="diff-added">${newVal}</span>`;
});
// Pattern 4: "Set X to Y" (when it was previously something else, look for context)
html = html.replace(/Set (.+?) to ['"](.+?)['"]/gi, function(match, field, newVal) {
return `Set ${field} to <span class="diff-added">${newVal}</span>`;
});
// Pattern 5: "Removed X" or "Deleted X"
html = html.replace(/(Removed|Deleted) ['"](.+?)['"]/gi, function(match, action, val) {
return `${action} <span class="diff-removed">${val}</span>`;
});
// Pattern 6: "Added X"
html = html.replace(/Added ['"](.+?)['"]/gi, function(match, val) {
return `Added <span class="diff-added">${val}</span>`;
});
// Pattern 7: "Assigned X to Y" or "Unassigned X from Y"
// Capture everything after "to " or "from " to preserve all spaces in target
html = html.replace(/(Assigned|Unassigned) (.+?) (to|from) (.+)$/gi, function(match, action, item, prep, target) {
const actionClass = action === 'Assigned' ? 'diff-added' : 'diff-removed';
// Preserve the space between prep and target
return `${action} <span class="${actionClass}">${item}</span> ${prep} ${target}`;
});
// Pattern 8: Generic "from X to Y" pattern
html = html.replace(/from ['"](.+?)['"] to ['"](.+?)['"]/gi, function(match, oldVal, newVal) {
return `from <span class="diff-removed">${oldVal}</span> to <span class="diff-added">${newVal}</span>`;
});
container.innerHTML = html || details;
});
// Export button handler
const exportBtn = document.getElementById('export-btn');
if (exportBtn) {
exportBtn.addEventListener('click', function() {
const form = document.getElementById('audit-filter-form');
const formData = new FormData(form);
const params = new URLSearchParams();
// Add all form fields to params
for (const [key, value] of formData.entries()) {
if (value) {
if (key === 'user_ids') {
// Handle multiple user_ids
params.append('user_ids', value);
} else {
params.append(key, value);
}
}
}
// Handle multiple user_ids separately
const userSelect = form.querySelector('select[name="user_ids"]');
if (userSelect) {
const selectedUsers = Array.from(userSelect.selectedOptions).map(opt => opt.value);
params.delete('user_ids');
selectedUsers.forEach(userId => {
params.append('user_ids', userId);
});
}
// Redirect to export endpoint
window.location.href = '/audit/export_csv?' + params.toString();
});
}
});
+143
View File
@@ -0,0 +1,143 @@
document.addEventListener('DOMContentLoaded', function() {
const messageDiv = document.getElementById('message');
function showMessage(text, isError = false) {
messageDiv.textContent = text;
messageDiv.className = isError
? 'mb-4 p-4 rounded-lg bg-red-200 dark:bg-red-800 text-red-800 dark:text-red-200'
: 'mb-4 p-4 rounded-lg bg-green-200 dark:bg-green-800 text-green-800 dark:text-green-200';
messageDiv.classList.remove('hidden');
setTimeout(() => {
messageDiv.classList.add('hidden');
}, 5000);
}
// Create backup button
const createBackupBtn = document.getElementById('create-backup-btn');
if (createBackupBtn) {
createBackupBtn.addEventListener('click', function() {
createBackupBtn.disabled = true;
createBackupBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Creating...';
fetch('/backup/create', {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage(`Backup created successfully: ${data.filename}`);
setTimeout(() => window.location.reload(), 1500);
} else {
showMessage(data.error || 'Failed to create backup', true);
createBackupBtn.disabled = false;
createBackupBtn.innerHTML = '<i class="fas fa-database"></i> <span>Create Backup</span>';
}
})
.catch(error => {
showMessage('Error creating backup: ' + error.message, true);
createBackupBtn.disabled = false;
createBackupBtn.innerHTML = '<i class="fas fa-database"></i> <span>Create Backup</span>';
});
});
}
// Upload and restore form
const uploadRestoreForm = document.getElementById('upload-restore-form');
if (uploadRestoreForm) {
uploadRestoreForm.addEventListener('submit', function(e) {
e.preventDefault();
if (!confirm('WARNING: This will replace all current database data with the backup. Are you sure you want to continue?')) {
return;
}
const formData = new FormData(this);
const submitBtn = this.querySelector('button[type="submit"]');
const originalText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Restoring...';
fetch('/backup/restore', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage('Database restored successfully. Page will reload...');
setTimeout(() => window.location.reload(), 2000);
} else {
showMessage(data.error || 'Failed to restore backup', true);
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
})
.catch(error => {
showMessage('Error restoring backup: ' + error.message, true);
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
});
});
}
// Existing backup restore form
const existingRestoreForm = document.getElementById('existing-restore-form');
if (existingRestoreForm) {
existingRestoreForm.addEventListener('submit', function(e) {
e.preventDefault();
if (!confirm('WARNING: This will replace all current database data with the backup. Are you sure you want to continue?')) {
return;
}
const formData = new FormData(this);
const submitBtn = this.querySelector('button[type="submit"]');
const originalText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Restoring...';
fetch('/backup/restore', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage('Database restored successfully. Page will reload...');
setTimeout(() => window.location.reload(), 2000);
} else {
showMessage(data.error || 'Failed to restore backup', true);
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
})
.catch(error => {
showMessage('Error restoring backup: ' + error.message, true);
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
});
});
}
});
function deleteBackup(filename) {
if (!confirm(`Are you sure you want to delete backup "${filename}"?`)) {
return;
}
fetch(`/backup/delete/${filename}`, {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
window.location.reload();
} else {
alert('Error: ' + (data.error || 'Failed to delete backup'));
}
})
.catch(error => {
alert('Error: ' + error.message);
});
}
+175
View File
@@ -0,0 +1,175 @@
function showTab(tabName) {
// Hide all panels
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.add('hidden'));
// Remove active class from all tabs
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
// Show selected panel
document.getElementById('panel-' + tabName).classList.remove('hidden');
// Add active class to selected tab
document.getElementById('tab-' + tabName).classList.add('active');
}
document.addEventListener('DOMContentLoaded', function() {
// Update selected IP count
document.getElementById('bulk-ip-select')?.addEventListener('change', function() {
document.getElementById('selected-ip-count').textContent = this.selectedOptions.length;
});
document.getElementById('bulk-tag-device-select')?.addEventListener('change', function() {
document.getElementById('selected-tag-device-count').textContent = this.selectedOptions.length;
});
// Load available IPs when subnet changes
document.getElementById('bulk-subnet-select')?.addEventListener('change', function() {
const subnetId = this.value;
const ipSelect = document.getElementById('bulk-ip-select');
if (!subnetId) {
ipSelect.innerHTML = '<option value="" disabled>Select a subnet first...</option>';
document.getElementById('selected-ip-count').textContent = '0';
return;
}
ipSelect.innerHTML = '<option value="" disabled>Loading...</option>';
fetch(`/get_available_ips?subnet_id=${subnetId}`)
.then(response => response.json())
.then(data => {
ipSelect.innerHTML = '';
if (data.available_ips.length === 0) {
ipSelect.innerHTML = '<option value="" disabled>No available IPs in this subnet</option>';
} else {
data.available_ips.forEach(ip => {
const option = document.createElement('option');
option.value = ip.id;
option.textContent = ip.ip;
ipSelect.appendChild(option);
});
}
document.getElementById('selected-ip-count').textContent = '0';
})
.catch(() => {
ipSelect.innerHTML = '<option value="" disabled>Error loading IPs</option>';
});
});
// Bulk IP Assignment
document.getElementById('bulk-assign-ips-form')?.addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const resultDiv = document.getElementById('assign-ips-result');
resultDiv.classList.remove('hidden');
resultDiv.innerHTML = '<p class="text-blue-500">Processing...</p>';
fetch('/bulk/assign_ips', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
let html = '<div class="space-y-2">';
if (data.success.length > 0) {
html += `<div class="text-green-600 dark:text-green-400"><strong>Successfully assigned ${data.success.length} IP(s):</strong><ul class="list-disc list-inside mt-2">`;
data.success.forEach(item => {
html += `<li>${item.ip}</li>`;
});
html += '</ul></div>';
}
if (data.failed.length > 0) {
html += `<div class="text-red-600 dark:text-red-400"><strong>Failed ${data.failed.length} assignment(s):</strong><ul class="list-disc list-inside mt-2">`;
data.failed.forEach(item => {
const ipDisplay = item.ip ? ` (${item.ip})` : '';
html += `<li>IP ID ${item.ip_id}${ipDisplay}: ${item.reason}</li>`;
});
html += '</ul></div>';
}
html += '</div>';
resultDiv.innerHTML = html;
// Reload IP list if successful
if (data.success.length > 0) {
const subnetSelect = document.getElementById('bulk-subnet-select');
if (subnetSelect.value) {
subnetSelect.dispatchEvent(new Event('change'));
}
}
})
.catch(error => {
resultDiv.innerHTML = `<p class="text-red-600">Error: ${error.message}</p>`;
});
});
// Bulk Device Creation
document.getElementById('bulk-create-devices-form')?.addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const resultDiv = document.getElementById('create-devices-result');
resultDiv.classList.remove('hidden');
resultDiv.innerHTML = '<p class="text-blue-500">Processing...</p>';
fetch('/bulk/create_devices', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
let html = '<div class="space-y-2">';
if (data.success.length > 0) {
html += `<div class="text-green-600 dark:text-green-400"><strong>Successfully created ${data.success.length} device(s):</strong><ul class="list-disc list-inside mt-2">`;
data.success.forEach(item => {
html += `<li>${item.name}</li>`;
});
html += '</ul></div>';
}
if (data.failed.length > 0) {
html += `<div class="text-red-600 dark:text-red-400"><strong>Failed ${data.failed.length} creation(s):</strong><ul class="list-disc list-inside mt-2">`;
data.failed.forEach(item => {
html += `<li>${item.name}: ${item.reason}</li>`;
});
html += '</ul></div>';
}
html += '</div>';
resultDiv.innerHTML = html;
if (data.success.length > 0) {
setTimeout(() => window.location.reload(), 2000);
}
})
.catch(error => {
resultDiv.innerHTML = `<p class="text-red-600">Error: ${error.message}</p>`;
});
});
// Bulk Tag Assignment
document.getElementById('bulk-assign-tags-form')?.addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const resultDiv = document.getElementById('assign-tags-result');
resultDiv.classList.remove('hidden');
resultDiv.innerHTML = '<p class="text-blue-500">Processing...</p>';
fetch('/bulk/assign_tags', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
let html = '<div class="space-y-2">';
if (data.success.length > 0) {
html += `<div class="text-green-600 dark:text-green-400"><strong>Successfully assigned ${data.success.length} tag(s):</strong><ul class="list-disc list-inside mt-2">`;
data.success.forEach(item => {
html += `<li>${item.device_name}: ${item.tag_name}</li>`;
});
html += '</ul></div>';
}
if (data.failed.length > 0) {
html += `<div class="text-red-600 dark:text-red-400"><strong>Failed ${data.failed.length} assignment(s):</strong><ul class="list-disc list-inside mt-2">`;
data.failed.forEach(item => {
html += `<li>Device ID ${item.device_id}, Tag ID ${item.tag_id}: ${item.reason}</li>`;
});
html += '</ul></div>';
}
html += '</div>';
resultDiv.innerHTML = html;
})
.catch(error => {
resultDiv.innerHTML = `<p class="text-red-600">Error: ${error.message}</p>`;
});
});
});
-50
View File
@@ -30,56 +30,6 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
// Search functionality
const searchInput = document.getElementById('search');
searchInput.addEventListener('keypress', function(event) {
if (event.key === 'Enter') {
event.preventDefault();
const query = this.value.toLowerCase();
document.querySelectorAll('.site-group').forEach(siteGroup => {
let anyVisible = false;
siteGroup.querySelectorAll('.device-list li').forEach(li => {
const deviceName = li.querySelector('span').textContent.toLowerCase();
const ipSpans = li.querySelectorAll('span.inline-block');
let match = deviceName.includes(query);
if (!match) {
ipSpans.forEach(ipSpan => {
if (ipSpan.textContent.toLowerCase().includes(query)) {
match = true;
}
});
}
li.style.display = match ? '' : 'none';
const card = li.querySelector('a');
if (match) {
anyVisible = true;
siteGroup.querySelector('.device-list').classList.remove('hidden');
const icon = siteGroup.querySelector('.expand-btn i');
if (icon && icon.classList.contains('fa-chevron-down')) {
icon.classList.remove('fa-chevron-down');
icon.classList.add('fa-chevron-up');
}
if (card) {
card.style.transition = 'background-color 0.3s';
card.style.backgroundColor = '#2563eb';
card.style.color = '#fff';
setTimeout(() => {
card.style.backgroundColor = '';
card.style.color = '';
}, 2000);
}
} else {
if (card) {
card.style.backgroundColor = '';
card.style.color = '';
}
}
});
siteGroup.style.display = anyVisible ? '' : 'none';
});
}
});
// Scroll to Top Button
const scrollToTopButton = document.createElement('button');
scrollToTopButton.innerHTML = '<i class="fas fa-arrow-up"></i>';
+41
View File
@@ -0,0 +1,41 @@
document.addEventListener('DOMContentLoaded', function() {
// Export CSV button
const exportBtn = document.getElementById('export-csv');
if (exportBtn) {
exportBtn.addEventListener('click', function() {
const rackId = exportBtn.getAttribute('data-rack-id');
if (rackId) {
window.location = '/rack/' + rackId + '/export_csv';
}
});
}
// Form toggle functionality
function showBothAddButtons() {
document.getElementById('show-add-device-form').classList.remove('hidden');
document.getElementById('show-nonnet-form').classList.remove('hidden');
}
showBothAddButtons();
document.getElementById('show-nonnet-form').onclick = function() {
document.getElementById('nonnet-form').classList.remove('hidden');
this.classList.add('hidden');
};
document.getElementById('hide-nonnet-form').onclick = function() {
document.getElementById('nonnet-form').classList.add('hidden');
showBothAddButtons();
};
document.getElementById('show-add-device-form').onclick = function() {
document.getElementById('add-device-form').classList.remove('hidden');
this.classList.add('hidden');
};
document.getElementById('hide-add-device-form').onclick = function() {
document.getElementById('add-device-form').classList.add('hidden');
showBothAddButtons();
};
});
+84 -28
View File
@@ -1,38 +1,64 @@
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) {
form.addEventListener('submit', (event) => {
event.preventDefault();
});
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.placeholder = 'Search by IP or Hostname';
searchInput.className = 'p-2 w-full rounded-lg bg-gray-200 dark:bg-zinc-800 border border-gray-600 focus:outline-none focus:border-blue-400 mb-4 text-center';
form.insertAdjacentElement('beforebegin', searchInput);
searchInput.addEventListener('keypress', (event) => {
if (event.key === 'Enter') {
// Check if search input already exists to prevent duplicates
if (!document.querySelector('input[placeholder="Search by IP or Hostname"]')) {
form.addEventListener('submit', (event) => {
event.preventDefault();
const searchTerm = searchInput.value.toLowerCase();
const rows = document.querySelectorAll('tbody tr');
});
rows.forEach(row => {
const ipCell = row.querySelector('td:nth-child(1)').textContent.toLowerCase();
const hostnameCell = row.querySelector('td:nth-child(2)').textContent.toLowerCase();
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.placeholder = 'Search by IP or Hostname';
searchInput.className = 'p-2 w-full rounded-lg bg-gray-200 dark:bg-zinc-800 border border-gray-600 focus:outline-none focus:border-blue-400 mb-4 text-center';
form.insertAdjacentElement('beforebegin', searchInput);
if (ipCell.includes(searchTerm) || hostnameCell.includes(searchTerm)) {
row.style.backgroundColor = 'rgba(59, 130, 246, 0.5)';
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
searchInput.addEventListener('keypress', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
const searchTerm = searchInput.value.toLowerCase();
const rows = document.querySelectorAll('tbody tr');
setTimeout(() => {
rows.forEach(row => {
const ipCell = row.querySelector('td:nth-child(1)').textContent.toLowerCase();
const hostnameCell = row.querySelector('td:nth-child(2)').textContent.toLowerCase();
if (ipCell.includes(searchTerm) || hostnameCell.includes(searchTerm)) {
row.style.backgroundColor = 'rgba(59, 130, 246, 0.5)';
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
setTimeout(() => {
row.style.backgroundColor = '';
}, 3000);
} else {
row.style.backgroundColor = '';
}, 3000);
} else {
row.style.backgroundColor = '';
}
});
}
}
});
}
});
}
}
// Description toggle functionality
const toggleBtn = document.getElementById('toggle-desc');
const descCols = document.querySelectorAll('.desc-col');
const descHeader = document.getElementById('desc-col-header');
let shown = false;
if (toggleBtn) {
toggleBtn.addEventListener('click', function() {
shown = !shown;
descCols.forEach(col => col.classList.toggle('hidden', !shown));
if (descHeader) descHeader.classList.toggle('hidden', !shown);
toggleBtn.textContent = shown ? 'Hide Descriptions' : 'Show Descriptions';
});
}
@@ -76,4 +102,34 @@ document.addEventListener('DOMContentLoaded', () => {
scrollToTopButton.addEventListener('click', () => {
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);
}
}
});
+40
View File
@@ -0,0 +1,40 @@
document.addEventListener('DOMContentLoaded', function() {
// Check if toast was dismissed in this session
const toastDismissed = sessionStorage.getItem('update-toast-dismissed');
if (toastDismissed) {
return;
}
// Check for updates
fetch('/check_update')
.then(response => response.json())
.then(data => {
if (data.update_available) {
const toast = document.getElementById('update-toast');
const currentVersionEl = document.getElementById('toast-current-version');
const latestVersionEl = document.getElementById('toast-latest-version');
const compareLink = document.getElementById('toast-compare-link');
const closeBtn = document.getElementById('toast-close');
// Set versions
currentVersionEl.textContent = 'v' + data.current_version;
latestVersionEl.textContent = 'v' + data.latest_version;
// Set compare link (current version to latest version)
compareLink.href = `https://github.com/JDB-NET/ipam/compare/v${data.current_version}...v${data.latest_version}`;
// Show toast
toast.classList.remove('hidden');
// Close button handler
closeBtn.addEventListener('click', function() {
toast.classList.add('hidden');
sessionStorage.setItem('update-toast-dismissed', 'true');
});
}
})
.catch(error => {
console.error('Error checking for updates:', error);
});
});
+199
View File
@@ -0,0 +1,199 @@
// These variables are set inline in the template from server data
// permissions and rolePermissions are passed from the template
function showTab(tab) {
document.getElementById('users-tab').classList.add('hidden');
document.getElementById('roles-tab').classList.add('hidden');
document.getElementById('tab-users').classList.remove('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
document.getElementById('tab-users').classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
document.getElementById('tab-roles').classList.remove('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
document.getElementById('tab-roles').classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
if (tab === 'users') {
document.getElementById('users-tab').classList.remove('hidden');
document.getElementById('tab-users').classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
document.getElementById('tab-users').classList.add('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
} else {
document.getElementById('roles-tab').classList.remove('hidden');
document.getElementById('tab-roles').classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
document.getElementById('tab-roles').classList.add('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
}
}
function editUser(userId, name, email, roleId, apiKey) {
document.getElementById('edit-user-id').value = userId;
document.getElementById('edit-user-name').value = name;
document.getElementById('edit-user-email').value = email;
document.getElementById('edit-user-password').value = '';
document.getElementById('edit-user-role').value = (roleId === null || roleId === 'null') ? '' : roleId;
document.getElementById('edit-user-api-key').textContent = apiKey || 'No API Key';
document.getElementById('edit-user-modal').classList.remove('hidden');
}
function closeEditUserModal() {
document.getElementById('edit-user-modal').classList.add('hidden');
}
function showAddRoleModal() {
// Make sure edit modal is closed first
document.getElementById('edit-role-modal').classList.add('hidden');
// Clear any form data
const addForm = document.querySelector('#add-role-modal form');
if (addForm) {
addForm.reset();
}
// Show add modal
document.getElementById('add-role-modal').classList.remove('hidden');
}
function closeAddRoleModal() {
document.getElementById('add-role-modal').classList.add('hidden');
}
function editRole(roleId, roleName, roleDescription) {
// Make sure add modal is closed first
document.getElementById('add-role-modal').classList.add('hidden');
document.getElementById('edit-role-id').value = roleId;
document.getElementById('edit-role-name').value = roleName;
document.getElementById('edit-role-description').value = roleDescription || '';
const permissionsDiv = document.getElementById('edit-role-permissions');
permissionsDiv.innerHTML = '';
const rolePerms = rolePermissions[roleId] || [];
// Group permissions by merged categories
const viewPerms = permissions.filter(p => p[3] === 'View');
const devicePerms = permissions.filter(p => p[3] === 'Device');
const deviceTypePerms = permissions.filter(p => p[3] === 'Device Type');
const subnetPerms = permissions.filter(p => p[3] === 'Subnet');
const dhcpPerms = permissions.filter(p => p[3] === 'DHCP');
const rackPerms = permissions.filter(p => p[3] === 'Rack');
const adminPerms = permissions.filter(p => p[3] === 'Admin');
let html = '';
// View Permissions
html += ' <!-- View Permissions -->\n';
html += ' <div class="col-span-full">\n';
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">View Permissions</h4>\n';
html += ' <div class="grid grid-cols-1 md:grid-cols-2 gap-2">\n';
viewPerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
html += ' </div>\n';
html += ' </div>\n';
html += ' \n';
// Device Management
html += ' <!-- Device Management -->\n';
html += ' <div>\n';
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Device Management</h4>\n';
devicePerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
deviceTypePerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
html += ' </div>\n';
html += ' \n';
// Network Management
html += ' <!-- Network Management -->\n';
html += ' <div>\n';
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Network Management</h4>\n';
subnetPerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
dhcpPerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
html += ' </div>\n';
html += ' \n';
// Rack Management
html += ' <!-- Rack Management -->\n';
html += ' <div>\n';
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Rack Management</h4>\n';
rackPerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
html += ' </div>\n';
html += ' \n';
// Admin
html += ' <!-- Admin -->\n';
html += ' <div>\n';
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Administration</h4>\n';
adminPerms.forEach(perm => {
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
<span class="text-sm">${perm[2]}</span>
</label>\n`;
});
html += ' </div>\n';
permissionsDiv.innerHTML = html;
document.getElementById('edit-role-modal').classList.remove('hidden');
}
function closeEditRoleModal() {
document.getElementById('edit-role-modal').classList.add('hidden');
}
function deleteRole(roleId, roleName) {
if (confirm(`Are you sure you want to delete the role "${roleName}"?`)) {
const form = document.createElement('form');
form.method = 'POST';
form.action = '/users';
form.innerHTML = `
<input type="hidden" name="action" value="delete_role">
<input type="hidden" name="role_id" value="${roleId}">
`;
document.body.appendChild(form);
form.submit();
}
}
// Close modals when clicking outside
window.onclick = function(event) {
const editUserModal = document.getElementById('edit-user-modal');
const editRoleModal = document.getElementById('edit-role-modal');
const addRoleModal = document.getElementById('add-role-modal');
if (event.target === editUserModal) {
closeEditUserModal();
}
if (event.target === editRoleModal) {
closeEditRoleModal();
}
if (event.target === addRoleModal) {
closeAddRoleModal();
}
}
+1 -1
View File
@@ -13,7 +13,7 @@
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-md pt-20">
<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>
</div>
<form action="/add_device" method="POST" class="flex flex-col space-y-4">
+20 -66
View File
@@ -64,6 +64,16 @@
</div>
<i class="fas fa-chevron-right text-gray-400"></i>
</a>
<a href="/backup" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
<div class="flex items-center space-x-4">
<i class="fas fa-database text-3xl text-gray-600 dark:text-gray-400"></i>
<div>
<h3 class="text-lg font-bold">Backup & Restore</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Database backup and restore</p>
</div>
</div>
<i class="fas fa-chevron-right text-gray-400"></i>
</a>
</div>
<!-- Subnet Management Section -->
@@ -85,6 +95,7 @@
<th class="text-center p-3">Name</th>
<th class="text-center p-3">CIDR</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>
</tr>
</thead>
@@ -96,6 +107,14 @@
<td class="p-3 text-center">
<span class="px-2 py-1 bg-gray-300 dark:bg-zinc-700 rounded text-sm">{{ subnet.site }}</span>
</td>
<td class="p-3 text-center">
{% if subnet.utilization %}
<span class="text-sm text-gray-600 dark:text-gray-400">{{ subnet.utilization.percent }}%</span>
<span class="text-xs text-gray-500 dark:text-gray-500 ml-1">({{ subnet.utilization.used }}/{{ subnet.utilization.total }})</span>
{% else %}
<span class="text-sm text-gray-500"></span>
{% endif %}
</td>
<td class="p-3 text-center">
<div class="flex items-center justify-center space-x-2">
<a href="/subnet/{{ subnet.id }}" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300" title="View Subnet">
@@ -181,71 +200,6 @@
</div>
<script src="/static/js/add_subnet.js"></script>
<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>
<script src="/static/js/admin.js"></script>
</body>
</html>
+1
View File
@@ -177,6 +177,7 @@
<ul class="list-disc list-inside space-y-1 text-sm">
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/subnets</code> - List all subnets</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/subnets/{id}</code> - Get subnet details</li>
<li><code class="bg-gray-400 dark:bg-zinc-600 px-2 py-1 rounded text-xs">GET /api/v1/subnets/{id}/next_free_ip</code> - Get next free IP address</li>
</ul>
</div>
<div>
+102 -47
View File
@@ -13,43 +13,105 @@
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-8xl pt-20">
<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">
<option value="">All Users</option>
{% for user in users %}
<option value="{{ user[0] }}" {% if request.args.get('user_id') == user[0]|string %}selected{% endif %}>{{ user[1] }}</option>
{% endfor %}
</select>
<select name="subnet_id" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
<option value="">All Subnets</option>
{% for subnet in subnets %}
<option value="{{ subnet[0] }}" {% if request.args.get('subnet_id') == subnet[0]|string %}selected{% endif %}>{{ subnet[1] }}</option>
{% endfor %}
</select>
<select name="action" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
<option value="">All Actions</option>
{% for a in actions %}
<option value="{{ a }}" {% if request.args.get('action') == a %}selected{% endif %}>{{ a }}</option>
{% endfor %}
</select>
<select name="device_name" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
<option value="">All Devices</option>
{% for device in devices %}
<option value="{{ device[0] }}" {% if request.args.get('device_name') == device[0] %}selected{% endif %}>{{ device[0] }}</option>
{% endfor %}
</select>
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
<i class="fas fa-search"></i>
<span>Filter</span>
<!-- Collapsible Filter Section -->
<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>
</form>
<!-- Advanced Filter Form -->
<form method="GET" id="audit-filter-form" class="px-4 pb-4 {% if not (search_query or selected_user_ids or request.args.get('subnet_id') or request.args.get('action') or request.args.get('device_name') or date_from or date_to or request.args.get('expand_filters')) %}hidden{% endif %}">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4">
<!-- Search -->
<div class="lg:col-span-3">
<label class="block text-sm font-medium mb-1">Search</label>
<input type="text" name="search" value="{{ search_query or '' }}" placeholder="Search in details, user, action, subnet..." class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
</div>
<!-- Multiple Users -->
<div>
<label class="block text-sm font-medium mb-1">Users</label>
<select name="user_ids" multiple size="5" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
{% for user in users %}
<option value="{{ user[0] }}" {% if selected_user_ids and user[0]|string in selected_user_ids %}selected{% endif %}>{{ user[1] }}</option>
{% endfor %}
</select>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Hold Ctrl/Cmd to select multiple</p>
</div>
<!-- Subnet -->
<div>
<label class="block text-sm font-medium mb-1">Subnet</label>
<select name="subnet_id" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
<option value="">All Subnets</option>
{% for subnet in subnets %}
<option value="{{ subnet[0] }}" {% if request.args.get('subnet_id') == subnet[0]|string %}selected{% endif %}>{{ subnet[1] }}</option>
{% endfor %}
</select>
</div>
<!-- Action -->
<div>
<label class="block text-sm font-medium mb-1">Action</label>
<select name="action" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
<option value="">All Actions</option>
{% for a in actions %}
<option value="{{ a }}" {% if request.args.get('action') == a %}selected{% endif %}>{{ a }}</option>
{% endfor %}
</select>
</div>
<!-- Device Name -->
<div>
<label class="block text-sm font-medium mb-1">Device</label>
<select name="device_name" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
<option value="">All Devices</option>
{% for device in devices %}
<option value="{{ device[0] }}" {% if request.args.get('device_name') == device[0] %}selected{% endif %}>{{ device[0] }}</option>
{% endfor %}
</select>
</div>
<!-- Date From -->
<div>
<label class="block text-sm font-medium mb-1">Date From</label>
<input type="date" name="date_from" value="{{ date_from or '' }}" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
</div>
<!-- Date To -->
<div>
<label class="block text-sm font-medium mb-1">Date To</label>
<input type="date" name="date_to" value="{{ date_to or '' }}" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
</div>
</div>
<div class="flex gap-2 justify-center">
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
<i class="fas fa-search"></i>
<span>Filter</span>
</button>
<a href="/audit?expand_filters=1" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
<i class="fas fa-times"></i>
<span>Clear</span>
</a>
<button type="button" id="export-btn" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
<i class="fas fa-file-csv"></i>
<span>Export CSV</span>
</button>
</div>
</form>
</div>
<!-- Audit Log Table -->
<div class="overflow-x-auto">
<table class="w-full table-auto bg-gray-200 dark:bg-zinc-800 rounded-lg overflow-hidden">
<thead>
<tr class="bg-gray-400 dark:bg-zinc-700">
<th class="px-4 py-2 text-center">User</th>
<th class="px-4 py-2 text-center">Action</th>
<th class="px-4 py-2 text-center">Details</th>
<th class="px-4 py-2 text-center details-cell">Details</th>
<th class="px-4 py-2 text-center">Subnet</th>
<th class="px-4 py-2 text-center">Timestamp</th>
</tr>
@@ -59,7 +121,9 @@
<tr class="border-b border-gray-700">
<td class="px-4 py-2 text-center">{{ log[1] or 'Unknown' }}</td>
<td class="px-4 py-2 text-center">{{ log[2] }}</td>
<td class="px-4 py-2 text-center 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" data-utc="{{ log[5] }}">{{ log[5] }}</td>
</tr>
@@ -67,12 +131,13 @@
</tbody>
</table>
</div>
{% if total_pages > 1 %}
<div class="flex justify-center mt-6 space-x-2">
{% if page > 1 %}
{% set prev_args = query_args.copy() %}
{% 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>
<span class="hidden sm:inline">Prev</span>
</a>
@@ -87,7 +152,7 @@
{% if start_page > 1 %}
{% set page_args = query_args.copy() %}
{% set _ = page_args.update({'page': 1}) %}
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded {{ '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 %}
<span class="px-3 py-1 text-gray-600 dark:text-gray-400"></span>
{% endif %}
@@ -97,7 +162,7 @@
{% for p in range(start_page, end_page + 1) %}
{% set page_args = query_args.copy() %}
{% set _ = page_args.update({'page': p}) %}
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded {{ '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 %}
{# Show last page if we're not near the end #}
@@ -107,13 +172,13 @@
{% endif %}
{% set page_args = query_args.copy() %}
{% set _ = page_args.update({'page': total_pages}) %}
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded {{ '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 %}
{% if page < total_pages %}
{% set next_args = query_args.copy() %}
{% 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>
<i class="fa fa-angle-right"></i>
</a>
@@ -122,16 +187,6 @@
{% endif %}
</div>
</div>
<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>
<script src="/static/js/audit.js"></script>
</body>
</html>
+126
View File
@@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Backup & Restore</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-4xl pt-20">
<div class="flex items-center mb-6 relative">
<a href="/admin" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-full w-11 h-11 hover:cursor-pointer"><i class="fas fa-arrow-left"></i></a>
<h1 class="text-3xl font-bold text-center w-full">Backup & Restore</h1>
</div>
<div id="message" class="hidden mb-4 p-4 rounded-lg"></div>
<!-- Create Backup Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg p-6 mb-6">
<h2 class="text-xl font-bold mb-4">Create Backup</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Create a new database backup. This will export the entire database to a SQL file.</p>
<button id="create-backup-btn" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
<i class="fas fa-database"></i>
<span>Create Backup</span>
</button>
</div>
<!-- Restore Backup Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg p-6 mb-6">
<h2 class="text-xl font-bold mb-4">Restore Backup</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Restore the database from a backup file. <strong class="text-red-600 dark:text-red-400">Warning: This will replace all current data!</strong></p>
<div class="space-y-4">
<!-- Upload Backup File -->
<div>
<label class="block text-sm font-medium mb-2">Upload Backup File</label>
<form id="upload-restore-form" enctype="multipart/form-data" class="flex gap-2">
<label class="flex-1 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 border border-gray-600 rounded-lg px-4 py-2 cursor-pointer flex items-center justify-center hover:cursor-pointer">
<input type="file" name="backup_file" accept=".sql" required class="hidden" onchange="updateFileLabel(this)">
<span id="file-label" class="text-sm">Choose File</span>
</label>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">
<i class="fas fa-upload"></i> Upload & Restore
</button>
</form>
</div>
<!-- Or Select Existing Backup -->
{% if backups %}
<div>
<label class="block text-sm font-medium mb-2">Or Restore from Existing Backup</label>
<form id="existing-restore-form" class="flex gap-2">
<select name="backup_filename" required class="flex-1 border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
<option value="">Select a backup...</option>
{% for backup in backups %}
<option value="{{ backup.filename }}">{{ backup.filename }} ({{ (backup.size / 1024 / 1024)|round(2) }} MB, {{ backup.created }})</option>
{% endfor %}
</select>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">
<i class="fas fa-undo"></i> Restore
</button>
</form>
</div>
{% endif %}
</div>
</div>
<!-- Available Backups Section -->
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg p-6">
<h2 class="text-xl font-bold mb-4">Available Backups</h2>
{% if backups %}
<div class="overflow-x-auto">
<table class="w-full table-auto">
<thead>
<tr class="bg-gray-400 dark:bg-zinc-700">
<th class="px-4 py-2 text-left">Filename</th>
<th class="px-4 py-2 text-left">Size</th>
<th class="px-4 py-2 text-left">Created</th>
<th class="px-4 py-2 text-center">Actions</th>
</tr>
</thead>
<tbody>
{% for backup in backups %}
<tr class="border-b border-gray-700">
<td class="px-4 py-2">{{ backup.filename }}</td>
<td class="px-4 py-2">{{ (backup.size / 1024 / 1024)|round(2) }} MB</td>
<td class="px-4 py-2">{{ backup.created }}</td>
<td class="px-4 py-2 text-center">
<div class="flex gap-2 justify-center">
<a href="/backup/download/{{ backup.filename }}" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-3 py-1 rounded text-sm" title="Download">
<i class="fas fa-download"></i>
</a>
<button onclick="deleteBackup('{{ backup.filename }}')" class="bg-red-300 hover:bg-red-400 dark:bg-red-700 dark:hover:bg-red-600 hover:cursor-pointer px-3 py-1 rounded text-sm" title="Delete">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-gray-600 dark:text-gray-400">No backups available. Create your first backup above.</p>
{% endif %}
</div>
</div>
</div>
<script src="/static/js/backup.js"></script>
<script>
function updateFileLabel(input) {
const label = document.getElementById('file-label');
if (input.files && input.files[0]) {
label.textContent = input.files[0].name;
} else {
label.textContent = 'Choose File';
}
}
</script>
</body>
</html>
+160
View File
@@ -0,0 +1,160 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bulk Operations</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 mx-4 py-8 pt-20">
<div class="container max-w-6xl mx-auto">
<div class="flex items-center mb-6 relative">
<a href="/devices" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
<h1 class="text-3xl font-bold text-center w-full">Bulk Operations</h1>
</div>
<!-- Tabs -->
<div class="flex flex-wrap gap-2 mb-6 justify-center border-b border-gray-600">
<button onclick="showTab('assign-ips')" id="tab-assign-ips" class="tab-btn px-4 py-2 rounded-t-lg bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 hover:cursor-pointer active">Bulk IP Assignment</button>
<button onclick="showTab('create-devices')" id="tab-create-devices" class="tab-btn px-4 py-2 rounded-t-lg bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 hover:cursor-pointer">Bulk Device Creation</button>
<button onclick="showTab('assign-tags')" id="tab-assign-tags" class="tab-btn px-4 py-2 rounded-t-lg bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 hover:cursor-pointer">Bulk Tag Assignment</button>
<button onclick="showTab('export')" id="tab-export" class="tab-btn px-4 py-2 rounded-t-lg bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 hover:cursor-pointer">Bulk Export</button>
</div>
<!-- Bulk IP Assignment -->
<div id="panel-assign-ips" class="tab-panel bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
{% if can_add_device_ip %}
<h2 class="text-2xl font-bold mb-4">Bulk IP Assignment</h2>
<p class="text-gray-600 dark:text-gray-400 mb-4">Select a device and assign multiple IPs from a subnet. Hold Ctrl/Cmd to select multiple IPs.</p>
<form id="bulk-assign-ips-form" class="space-y-4">
<div>
<label class="block mb-2 font-medium">Select Device:</label>
<select id="bulk-device-select" name="device_id" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
<option value="">Select a device...</option>
{% for device in devices %}
<option value="{{ device[0] }}">{{ device[1] }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block mb-2 font-medium">Select Subnet:</label>
<select id="bulk-subnet-select" name="subnet_id" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
<option value="">Select a subnet...</option>
{% for subnet in subnets %}
<option value="{{ subnet[0] }}">{{ subnet[1] }} ({{ subnet[2] }}) - {{ subnet[3] or 'Unassigned' }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block mb-2 font-medium">Select IPs (hold Ctrl/Cmd to select multiple):</label>
<select id="bulk-ip-select" name="ip_ids[]" multiple size="15" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
<option value="" disabled>Select a subnet first...</option>
</select>
<p class="text-sm text-gray-500 mt-1">Selected: <span id="selected-ip-count">0</span> IPs</p>
</div>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg">Assign IPs</button>
</form>
<div id="assign-ips-result" class="mt-4 hidden"></div>
{% else %}
<p class="text-gray-500">You don't have permission to assign IPs to devices.</p>
{% endif %}
</div>
<!-- Bulk Device Creation -->
<div id="panel-create-devices" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
{% if can_add_device %}
<h2 class="text-2xl font-bold mb-4">Bulk Device Creation</h2>
<p class="text-gray-600 dark:text-gray-400 mb-4">Create multiple devices at once. Enter one device name per line.</p>
<form id="bulk-create-devices-form" class="space-y-4">
<div>
<label class="block mb-2 font-medium">Device Names (one per line):</label>
<textarea id="device-names" name="device_names" rows="10" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" placeholder="Device 1&#10;Device 2&#10;Device 3" required></textarea>
<p class="text-sm text-gray-500 mt-1">Enter device names, one per line</p>
</div>
<div>
<label class="block mb-2 font-medium">Device Type:</label>
<select name="device_type" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
{% for dtype in device_types %}
<option value="{{ dtype[0] }}">{{ dtype[1] }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg">Create Devices</button>
</form>
<div id="create-devices-result" class="mt-4 hidden"></div>
{% else %}
<p class="text-gray-500">You don't have permission to create devices.</p>
{% endif %}
</div>
<!-- Bulk Tag Assignment -->
<div id="panel-assign-tags" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
{% if can_assign_device_tag %}
<h2 class="text-2xl font-bold mb-4">Bulk Tag Assignment</h2>
<p class="text-gray-600 dark:text-gray-400 mb-4">Select multiple devices and assign one or more tags to them.</p>
<form id="bulk-assign-tags-form" class="space-y-4">
<div>
<label class="block mb-2 font-medium">Select Devices (hold Ctrl/Cmd to select multiple):</label>
<select id="bulk-tag-device-select" name="device_ids[]" multiple size="10" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full">
{% for device in devices %}
<option value="{{ device[0] }}">{{ device[1] }}</option>
{% endfor %}
</select>
<p class="text-sm text-gray-500 mt-1">Selected: <span id="selected-tag-device-count">0</span> devices</p>
</div>
<div>
<label class="block mb-2 font-medium">Select Tags (hold Ctrl/Cmd to select multiple):</label>
<select name="tag_ids[]" multiple size="5" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
{% for tag in tags %}
<option value="{{ tag[0] }}">{{ tag[1] }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg">Assign Tags</button>
</form>
<div id="assign-tags-result" class="mt-4 hidden"></div>
{% else %}
<p class="text-gray-500">You don't have permission to assign tags to devices.</p>
{% endif %}
</div>
<!-- Bulk Export -->
<div id="panel-export" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
{% if can_export_subnet_csv %}
<h2 class="text-2xl font-bold mb-4">Bulk Subnet Export</h2>
<p class="text-gray-600 dark:text-gray-400 mb-4">Select multiple subnets and export them to a single CSV file.</p>
<form id="bulk-export-form" method="POST" action="/bulk/export_subnets" class="space-y-4">
<div>
<label class="block mb-2 font-medium">Select Subnets (hold Ctrl/Cmd to select multiple):</label>
<select name="subnet_ids[]" multiple size="10" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
{% for subnet in subnets %}
<option value="{{ subnet[0] }}">{{ subnet[1] }} ({{ subnet[2] }}) - {{ subnet[3] or 'Unassigned' }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg">Export to CSV</button>
</form>
{% else %}
<p class="text-gray-500">You don't have permission to export subnets.</p>
{% endif %}
</div>
</div>
</div>
<script src="/static/js/bulk_operations.js"></script>
<style>
.tab-btn.active {
background-color: rgb(156 163 175);
color: white;
}
.dark .tab-btn.active {
background-color: rgb(63 63 70);
}
</style>
</body>
</html>
+1 -1
View File
@@ -11,7 +11,7 @@
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<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">
<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>
+2 -3
View File
@@ -14,15 +14,14 @@
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-4xl pt-20">
<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="/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>
</div>
<!-- Filters Section -->
<div class="mb-6 space-y-4">
<input type="text" id="search" placeholder="Search devices or IPs..." class="border p-3 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full">
<!-- Tag Filter -->
{% if all_tag_names %}
<div class="flex items-center space-x-2">
+64
View File
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ tag_name }} - Tagged Devices</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 flex items-center justify-center mx-4">
<div class="container py-8 max-w-3xl pt-20">
<div class="flex items-center mb-6 relative">
<a href="/tags" class="absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 flex items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
<h1 class="text-3xl font-bold text-center w-full">
<div class="flex items-center justify-center space-x-2">
<div class="w-5 h-5 rounded-full border border-gray-600" style="background-color: {{ tag_color }}"></div>
<span>{{ tag_name }} - Tagged Devices</span>
</div>
</h1>
</div>
{% if site_devices %}
{% for site, devices in site_devices.items() %}
<div class="mb-8">
<h2 class="text-xl font-bold mb-4 dark:text-white">{{ site }}</h2>
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-4">
<table class="w-full table-auto mb-2">
<thead>
<tr class="bg-gray-200 dark:bg-zinc-700">
<th class="px-4 py-2 text-left">Device Name</th>
<th class="px-4 py-2 text-left">Description</th>
</tr>
</thead>
<tbody>
{% for device in devices %}
<tr class="border-b border-gray-700">
<td class="px-4 py-3">
<a href="/device/{{ device.id }}" class="hover:underline dark:text-white hover:cursor-pointer">{{ device.name }}</a>
</td>
<td class="px-4 py-3">{{ device.description or '' }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="bg-gray-200 dark:bg-zinc-900 font-bold">
<td class="px-4 py-2 text-right" colspan="2">Total: {{ devices|length }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
{% endfor %}
{% else %}
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-8 text-center">
<p class="text-gray-500">No devices found with this tag.</p>
</div>
{% endif %}
</div>
</div>
</body>
</html>
+70 -21
View File
@@ -1,12 +1,22 @@
<header class="bg-zinc-800 shadow-md py-3 px-6 flex items-center justify-between relative">
<a href="/" class="flex items-center space-x-3">
<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>
</a>
<div class="hidden lg:block absolute left-1/2 transform -translate-x-1/2 text-white text-lg font-medium whitespace-nowrap">
{% if current_user_name %}Hello, {{ current_user_name }}{% endif %}
<div class="flex items-center space-x-3 flex-shrink-0">
<a href="/" class="flex items-center space-x-3">
<img src="{{ LOGO_PNG }}" alt="Logo" class="h-8 rounded">
<span class="text-2xl font-bold text-white whitespace-nowrap">{{ NAME }} IPAM</span>
</a>
<a href="https://github.com/JDB-NET/ipam/releases" target="_blank" rel="noopener noreferrer" class="text-sm font-normal text-gray-300 hover:text-gray-100 -ml-1 mt-3">v{{ VERSION }}</a>
</div>
<nav class="hidden md:flex items-center space-x-6" id="main-nav">
<div class="hidden lg:flex items-center justify-center absolute left-1/2" style="transform: translateX(calc(-50% + 1.5rem));">
<form action="/search" method="GET" class="flex items-center space-x-2">
<input type="text" name="q" id="search-input" placeholder="Search..."
class="bg-zinc-700 text-white placeholder-gray-400 px-4 py-2 rounded-md text-base focus:outline-none focus:ring-2 focus:ring-gray-500 w-100"
value="{{ request.args.get('q', '') }}">
<button type="submit" class="text-gray-200 hover:text-gray-400 hover:cursor-pointer flex-shrink-0">
<i class="fas fa-search"></i>
</button>
</form>
</div>
<nav class="hidden lg:flex items-center space-x-6 flex-shrink-0" id="main-nav">
{% if has_permission('view_index') %}
<a href="/" class="text-gray-200 hover:text-gray-400 font-medium">Home</a>
{% endif %}
@@ -19,12 +29,6 @@
{% if has_permission('view_admin') %}
<a href="/admin" class="text-gray-200 hover:text-gray-400 font-medium">Admin</a>
{% 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') %}
<a href="/help" class="text-gray-200 hover:text-gray-400 font-medium">Help</a>
{% endif %}
@@ -32,12 +36,22 @@
<a href="/logout" class="text-gray-200 hover:text-gray-400 font-medium">Logout</a>
{% endif %}
</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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
<div class="md:hidden absolute top-16 right-6 bg-zinc-800 rounded-lg shadow-lg z-50 w-48 hidden flex-col py-2" id="mobile-nav">
<div class="lg:hidden fixed top-13 left-0 right-0 bg-zinc-800 shadow-lg z-50 w-full hidden flex-col py-2" id="mobile-nav">
<form action="/search" method="GET" class="px-4 py-2 border-b border-zinc-700">
<div class="flex items-center space-x-2">
<input type="text" name="q" placeholder="Search..."
class="bg-zinc-700 text-white placeholder-gray-400 px-3 py-2.5 rounded text-base focus:outline-none focus:ring-2 focus:ring-gray-500 flex-1"
value="{{ request.args.get('q', '') }}">
<button type="submit" class="text-gray-200 hover:text-gray-400 hover:cursor-pointer flex-shrink-0">
<i class="fas fa-search"></i>
</button>
</div>
</form>
{% if has_permission('view_index') %}
<a href="/" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Home</a>
{% endif %}
@@ -50,12 +64,6 @@
{% 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>
{% 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') %}
<a href="/help" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium">Help</a>
{% endif %}
@@ -64,4 +72,45 @@
{% endif %}
</div>
<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>
+1 -35
View File
@@ -24,16 +24,6 @@
<button type="button" id="export-csv" class="hidden sm:flex absolute right-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer items-center justify-center rounded-full w-11 h-11 export-csv-btn" title="Export as CSV" data-rack-id="{{ rack.id }}">
<i class="fas fa-file-csv fa-lg"></i>
</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 class="flex flex-col gap-4 mb-6 items-stretch">
<div class="flex gap-4 w-full justify-center">
@@ -78,31 +68,7 @@
</div>
<div class="text-xs dark:text-gray-400 mt-2">Add a non-networked device.</div>
</form>
<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>
<script src="/static/js/rack.js"></script>
{% if error %}
<div class="mb-4 p-3 bg-red-700 text-white rounded-lg text-center font-semibold">{{ error }}</div>
{% endif %}
+179
View File
@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Search - {{ NAME }} IPAM</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<link href="/static/css/output.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
{% include 'header.html' %}
<div class="flex-1 container mx-auto px-4 py-8 pt-20">
<div class="max-w-6xl mx-auto">
<h1 class="text-3xl font-bold mb-6">Search Results</h1>
{% if query %}
<p class="text-lg mb-6">Search results for: <span class="font-semibold">"{{ query }}"</span></p>
{% else %}
<p class="text-lg mb-6 text-gray-600 dark:text-gray-400">Enter a search query to find IPs, devices, subnets, tags, racks, and sites.</p>
{% endif %}
{% if query %}
{% set total_results = results.subnets|length + results.ips|length + results.devices|length + results.tags|length + results.racks|length + results.sites|length %}
{% if total_results == 0 %}
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-8 text-center">
<i class="fas fa-search text-4xl text-gray-400 mb-4"></i>
<p class="text-xl font-semibold mb-2">No results found</p>
<p class="text-gray-600 dark:text-gray-400">Try a different search term or check your spelling.</p>
</div>
{% else %}
<div class="space-y-6">
<!-- Subnets -->
{% if results.subnets %}
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold mb-4 flex items-center">
<i class="fas fa-network-wired mr-2"></i>
Subnets ({{ results.subnets|length }})
</h2>
<div class="space-y-2">
{% for subnet in results.subnets %}
<a href="/subnet/{{ subnet.id }}" class="block p-3 bg-gray-300 dark:bg-zinc-900 hover:bg-gray-100 dark:hover:bg-zinc-700 rounded-lg transition-colors">
<div class="flex items-center justify-between">
<div>
<p class="font-semibold text-lg">{{ subnet.name }}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">{{ subnet.cidr }}</p>
</div>
<div class="text-right">
<p class="text-sm text-gray-600 dark:text-gray-400">{{ subnet.site }}</p>
</div>
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- IP Addresses -->
{% if results.ips %}
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold mb-4 flex items-center">
<i class="fas fa-map-marker-alt mr-2"></i>
IP Addresses ({{ results.ips|length }})
</h2>
<div class="space-y-2">
{% for ip in results.ips %}
<a href="/subnet/{{ ip.subnet_id }}#ip-{{ ip.id }}" class="block p-3 bg-gray-300 dark:bg-zinc-900 hover:bg-gray-100 dark:hover:bg-zinc-700 rounded-lg transition-colors">
<div class="flex items-center justify-between">
<div>
<p class="font-semibold text-lg">{{ ip.ip }}</p>
{% if ip.hostname %}
<p class="text-sm text-gray-600 dark:text-gray-400">{{ ip.hostname }}</p>
{% endif %}
<p class="text-sm text-gray-600 dark:text-gray-400">{{ ip.subnet_name }} ({{ ip.subnet_cidr }})</p>
</div>
<div class="text-right">
<p class="text-sm text-gray-600 dark:text-gray-400">{{ ip.site }}</p>
</div>
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Devices -->
{% if results.devices %}
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold mb-4 flex items-center">
<i class="fas fa-server mr-2"></i>
Devices ({{ results.devices|length }})
</h2>
<div class="space-y-2">
{% for device in results.devices %}
<a href="/device/{{ device.id }}" class="block p-3 bg-gray-300 dark:bg-zinc-900 hover:bg-gray-100 dark:hover:bg-zinc-700 rounded-lg transition-colors">
<div>
<p class="font-semibold text-lg">{{ device.name }}</p>
{% if device.description %}
<p class="text-sm text-gray-600 dark:text-gray-400">{{ device.description }}</p>
{% endif %}
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Tags -->
{% if results.tags %}
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold mb-4 flex items-center">
<i class="fas fa-tags mr-2"></i>
Tags ({{ results.tags|length }})
</h2>
<div class="space-y-2">
{% for tag in results.tags %}
<a href="/tags" class="block p-3 bg-gray-300 dark:bg-zinc-900 hover:bg-gray-100 dark:hover:bg-zinc-700 rounded-lg transition-colors">
<div>
<p class="font-semibold text-lg">{{ tag.name }}</p>
{% if tag.description %}
<p class="text-sm text-gray-600 dark:text-gray-400">{{ tag.description }}</p>
{% endif %}
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Racks -->
{% if results.racks %}
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold mb-4 flex items-center">
<i class="fas fa-th mr-2"></i>
Racks ({{ results.racks|length }})
</h2>
<div class="space-y-2">
{% for rack in results.racks %}
<a href="/rack/{{ rack.id }}" class="block p-3 bg-gray-300 dark:bg-zinc-900 hover:bg-gray-100 dark:hover:bg-zinc-700 rounded-lg transition-colors">
<div class="flex items-center justify-between">
<div>
<p class="font-semibold text-lg">{{ rack.name }}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">{{ rack.height_u }}U</p>
</div>
<div class="text-right">
<p class="text-sm text-gray-600 dark:text-gray-400">{{ rack.site }}</p>
</div>
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Sites -->
{% if results.sites %}
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold mb-4 flex items-center">
<i class="fas fa-map-marker-alt mr-2"></i>
Sites ({{ results.sites|length }})
</h2>
<div class="space-y-2">
{% for site in results.sites %}
<div class="p-3 bg-gray-300 dark:bg-zinc-900 rounded-lg">
<p class="font-semibold text-lg">{{ site }}</p>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endif %}
{% endif %}
</div>
</div>
</body>
</html>
+16 -19
View File
@@ -6,7 +6,6 @@
<title>{{ subnet.name }} - Subnet Details</title>
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
<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">
</head>
<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="container py-8 w-full sm:max-w-3/4 pt-20">
<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>
<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>
</button>
</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">
<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
@@ -37,7 +49,7 @@
</thead>
<tbody class="divide-y divide-gray-700">
{% for ip in ip_addresses %}
<tr>
<tr id="ip-{{ ip[0] }}">
<td class="font-bold text-center">{{ ip[1] }}</td>
<td class="text-center">
{% if ip[2] == 'DHCP' %}
@@ -61,21 +73,6 @@
</div>
</div>
<script src="/static/js/export_csv.js"></script>
<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>
<script src="/static/js/subnet.js"></script>
</body>
</html>
+1 -1
View File
@@ -58,7 +58,7 @@
</td>
<td class="p-3 text-center">
{% if tag.device_count > 0 %}
<a href="/api/v1/devices/by-tag/{{ tag.name }}" class="text-blue-400 hover:text-blue-600">
<a href="/devices/tag/{{ tag.id }}" class="text-blue-400 hover:text-blue-600 hover:cursor-pointer">
{{ tag.device_count }}
</a>
{% else %}
+2 -196
View File
@@ -337,204 +337,10 @@
</div>
<script>
// Template variables passed from server - must be defined before users.js loads
const permissions = {{ permissions | tojson | safe }};
const rolePermissions = {{ role_permissions | tojson | safe }};
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 src="/static/js/users.js"></script>
</body>
</html>