feat: in memory cache

This commit is contained in:
2025-12-05 00:51:02 +00:00
parent 3e8965de6f
commit 3a9250f5b0
6 changed files with 615 additions and 26 deletions
+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
+47 -2
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('''
@@ -199,6 +200,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 (
+369 -22
View File
@@ -12,6 +12,7 @@ import subprocess
import shutil
from datetime import datetime
from werkzeug.utils import secure_filename
from cache import cache
app = None
@@ -142,6 +143,263 @@ def add_audit_log(user_id, action, details=None, subnet_id=None, conn=None):
conn.commit()
conn.close()
def invalidate_cache_for_device(device_id):
"""Invalidate all cache entries related to a device"""
cache.invalidate_device(device_id)
cache.clear('devices')
cache.clear('device_list')
def invalidate_cache_for_subnet(subnet_id):
"""Invalidate all cache entries related to a subnet"""
cache.invalidate_subnet(subnet_id)
cache.clear('index')
cache.clear('admin')
def prewarm_cache(app):
"""Pre-warm cache in background by loading all data"""
import threading
import time
def _prewarm():
"""Background function to pre-warm cache"""
# Wait a bit for app to fully initialize
time.sleep(2)
try:
with app.app_context():
from flask import current_app
conn = get_db_connection(current_app)
try:
cursor = conn.cursor()
# Pre-warm index page (all subnets with utilization)
logging.info("Pre-warming cache: Loading all subnets for index page...")
cursor.execute('SELECT id, name, cidr, site FROM Subnet')
subnets = cursor.fetchall()
sites_subnets = {}
for subnet in subnets:
site = subnet[3] or 'Unassigned'
if site not in sites_subnets:
sites_subnets[site] = []
subnet_id = subnet[0]
cursor.execute('SELECT COUNT(*) FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
total_ips = cursor.fetchone()[0]
cursor.execute('''
SELECT COUNT(*) FROM IPAddress ip
WHERE ip.subnet_id = %s AND ip.id IN (SELECT ip_id FROM DeviceIPAddress)
''', (subnet_id,))
assigned_ips = cursor.fetchone()[0]
cursor.execute('''
SELECT COUNT(*) FROM IPAddress ip
WHERE ip.subnet_id = %s AND ip.hostname = 'DHCP' AND ip.id NOT IN (SELECT ip_id FROM DeviceIPAddress)
''', (subnet_id,))
dhcp_ips = cursor.fetchone()[0]
used_ips = assigned_ips + dhcp_ips
utilization_percent = (used_ips / total_ips * 100) if total_ips > 0 else 0
sites_subnets[site].append({
'id': subnet[0],
'name': subnet[1],
'cidr': subnet[2],
'utilization': round(utilization_percent, 1)
})
cache.set('index', sites_subnets, ttl=10800)
logging.info(f"Pre-warmed index cache with {len(subnets)} subnets")
# Pre-warm admin page
logging.info("Pre-warming cache: Loading admin page data...")
cursor.execute('SELECT id, name, cidr, site FROM Subnet ORDER BY site, name')
subnet_rows = cursor.fetchall()
admin_subnets = []
for row in subnet_rows:
subnet_id = row[0]
cursor.execute('SELECT COUNT(*) FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
total_ips = cursor.fetchone()[0]
cursor.execute('''
SELECT COUNT(*) FROM IPAddress ip
WHERE ip.subnet_id = %s AND ip.id IN (SELECT ip_id FROM DeviceIPAddress)
''', (subnet_id,))
assigned_ips = cursor.fetchone()[0]
cursor.execute('''
SELECT COUNT(*) FROM IPAddress ip
WHERE ip.subnet_id = %s AND ip.hostname = 'DHCP' AND ip.id NOT IN (SELECT ip_id FROM DeviceIPAddress)
''', (subnet_id,))
dhcp_ips = cursor.fetchone()[0]
available_ips = total_ips - assigned_ips - dhcp_ips
used_ips = assigned_ips + dhcp_ips
utilization_percent = (used_ips / total_ips * 100) if total_ips > 0 else 0
admin_subnets.append({
'id': row[0],
'name': row[1],
'cidr': row[2],
'site': row[3] or 'Unassigned',
'utilization': {
'percent': round(utilization_percent, 1),
'assigned': assigned_ips,
'used': used_ips,
'total': total_ips
}
})
# Cache with same structure as admin route expects
admin_result = {
'subnets': admin_subnets,
'can_add_subnet': True, # Will be checked at render time
'can_edit_subnet': True, # Will be checked at render time
'can_delete_subnet': True # Will be checked at render time
}
cache.set('admin', admin_result, ttl=10800)
logging.info(f"Pre-warmed admin cache with {len(admin_subnets)} subnets")
# Pre-warm all subnet detail pages
logging.info("Pre-warming cache: Loading all subnet detail pages...")
for subnet in subnets:
subnet_id = subnet[0]
try:
cursor.execute('SELECT id, name, cidr FROM Subnet WHERE id = %s', (subnet_id,))
subnet_row = cursor.fetchone()
if subnet_row:
cursor.execute('SELECT * FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
ip_addresses = cursor.fetchall()
cursor.execute('SELECT COUNT(*) FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
total_ips = cursor.fetchone()[0]
cursor.execute('''
SELECT COUNT(*) FROM IPAddress ip
WHERE ip.subnet_id = %s AND ip.id IN (SELECT ip_id FROM DeviceIPAddress)
''', (subnet_id,))
assigned_ips = cursor.fetchone()[0]
cursor.execute('''
SELECT COUNT(*) FROM IPAddress ip
WHERE ip.subnet_id = %s AND ip.hostname = 'DHCP' AND ip.id NOT IN (SELECT ip_id FROM DeviceIPAddress)
''', (subnet_id,))
dhcp_ips = cursor.fetchone()[0]
available_ips = total_ips - assigned_ips - dhcp_ips
used_ips = assigned_ips + dhcp_ips
utilization_percent = (used_ips / total_ips * 100) if total_ips > 0 else 0
utilization_stats = {
'total': total_ips,
'assigned': assigned_ips,
'dhcp': dhcp_ips,
'available': available_ips,
'percent': round(utilization_percent, 1)
}
cursor.execute('SELECT id, name, description FROM Device')
devices = cursor.fetchall()
device_name_map = {name.lower(): (id, description) for id, name, description in devices}
ip_addresses_with_device = []
for ip in ip_addresses:
hostname = ip[2]
device_id = None
device_description = None
if hostname:
match = device_name_map.get(hostname.lower())
if match:
device_id, device_description = match
ip_addresses_with_device.append((ip[0], ip[1], hostname, device_id, device_description))
subnet_dict = {'id': subnet_row[0], 'name': subnet_row[1], 'cidr': subnet_row[2]}
result = {
'subnet': subnet_dict,
'ip_addresses': ip_addresses_with_device,
'utilization': utilization_stats
}
cache.set(f'subnet:{subnet_id}', result, ttl=10800)
except Exception as e:
logging.error(f"Error pre-warming subnet {subnet_id}: {e}")
logging.info(f"Pre-warmed {len(subnets)} subnet detail pages")
# Pre-warm all device detail pages
logging.info("Pre-warming cache: Loading all device detail pages...")
cursor.execute('SELECT id FROM Device')
device_ids = [row[0] for row in cursor.fetchall()]
for device_id in device_ids:
try:
cursor.execute('SELECT id, name, description, device_type_id FROM Device WHERE id = %s', (device_id,))
device = cursor.fetchone()
if device:
cursor.execute('SELECT id, name FROM DeviceType ORDER BY name')
device_types = cursor.fetchall()
cursor.execute('SELECT id, name, cidr, site FROM Subnet')
subnets = [dict(id=row[0], name=row[1], cidr=row[2], site=row[3]) for row in cursor.fetchall()]
cursor.execute('''SELECT DeviceIPAddress.id as device_ip_id, IPAddress.ip FROM DeviceIPAddress JOIN IPAddress ON DeviceIPAddress.ip_id = IPAddress.id WHERE DeviceIPAddress.device_id = %s''', (device_id,))
device_ips = [{'device_ip_id': row[0], 'ip': row[1]} for row in cursor.fetchall()]
cursor.execute('''
SELECT t.id, t.name, t.color
FROM DeviceTag dt
JOIN Tag t ON dt.tag_id = t.id
WHERE dt.device_id = %s
ORDER BY t.name
''', (device_id,))
device_tags = [{'id': row[0], 'name': row[1], 'color': row[2]} for row in cursor.fetchall()]
cursor.execute('SELECT id, name, color FROM Tag ORDER BY name')
all_tags = [{'id': row[0], 'name': row[1], 'color': row[2]} for row in cursor.fetchall()]
available_ips_by_subnet = {}
for subnet in subnets:
cursor.execute('SELECT id, ip FROM IPAddress WHERE subnet_id = %s AND id NOT IN (SELECT ip_id FROM DeviceIPAddress)', (subnet['id'],))
ips = [{'id': row[0], 'ip': row[1]} for row in cursor.fetchall()]
cursor.execute('SELECT start_ip, end_ip, excluded_ips FROM DHCPPool WHERE subnet_id = %s', (subnet['id'],))
dhcp_row = cursor.fetchone()
if dhcp_row:
start_ip, end_ip, excluded_ips = dhcp_row
excluded_list = [ip for ip in (excluded_ips or '').replace(' ', '').split(',') if ip]
in_range = False
filtered_ips = []
for ip_obj in ips:
ip = ip_obj['ip']
if ip == start_ip:
in_range = True
if ip in excluded_list or not (in_range and ip not in excluded_list):
filtered_ips.append(ip_obj)
if ip == end_ip:
in_range = False
ips = filtered_ips
available_ips_by_subnet[subnet['id']] = ips
result_data = {
'device': {'id': device[0], 'name': device[1], 'description': device[2], 'device_type_id': device[3]},
'subnets': subnets,
'device_ips': device_ips,
'available_ips_by_subnet': available_ips_by_subnet,
'device_types': device_types,
'device_tags': device_tags,
'all_tags': all_tags,
'can_assign_device_tag': True, # Will be checked at render time
'can_remove_device_tag': True # Will be checked at render time
}
cache.set(f'device:{device_id}', result_data, ttl=10800)
except Exception as e:
logging.error(f"Error pre-warming device {device_id}: {e}")
logging.info(f"Pre-warmed {len(device_ids)} device detail pages")
logging.info("Cache pre-warming completed successfully")
except Exception as e:
logging.error(f"Error during cache pre-warming: {e}")
finally:
conn.close()
except Exception as e:
logging.error(f"Error in cache pre-warming thread: {e}")
# Start pre-warming in background thread
thread = threading.Thread(target=_prewarm, daemon=True)
thread.start()
logging.info("Started background cache pre-warming thread")
def register_routes(app):
logging.basicConfig(level=logging.INFO)
@@ -176,6 +434,11 @@ def register_routes(app):
@app.route('/')
@permission_required('view_index')
def index():
cache_key = 'index'
cached_result = cache.get(cache_key)
if cached_result is not None:
return render_with_user('index.html', sites_subnets=cached_result)
from flask import current_app
conn = get_db_connection(current_app)
try:
@@ -214,6 +477,8 @@ def register_routes(app):
'cidr': subnet[2],
'utilization': round(utilization_percent, 1)
})
# Cache for 3 hours
cache.set(cache_key, sites_subnets, ttl=10800)
return render_with_user('index.html', sites_subnets=sites_subnets)
finally:
conn.close()
@@ -295,6 +560,9 @@ def register_routes(app):
cursor = conn.cursor()
cursor.execute('INSERT INTO Device (name, device_type_id) VALUES (%s, %s)', (name, device_type_id))
conn.commit()
# Invalidate cache
cache.clear('devices')
cache.clear('device_list')
logging.info(f"User {user_name} added device '{name}' (type {device_type_id}).")
return redirect(url_for('devices'))
return render_with_user('add_device.html', device_types=device_types)
@@ -302,6 +570,11 @@ def register_routes(app):
@app.route('/device/<int:device_id>')
@permission_required('view_device')
def device(device_id):
cache_key = f'device:{device_id}'
cached_result = cache.get(cache_key)
if cached_result is not None:
return render_with_user('device.html', **cached_result)
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
@@ -366,6 +639,8 @@ def register_routes(app):
cursor = conn.cursor()
cursor.execute('UPDATE Device SET device_type_id = %s WHERE id = %s', (device_type_id, device_id))
conn.commit()
# Invalidate cache
invalidate_cache_for_device(device_id)
logging.info(f"User {user_name} updated device {device_id} to type {device_type_id}.")
return redirect(url_for('device', device_id=device_id))
@@ -424,6 +699,9 @@ def register_routes(app):
details = f"Assigned IP {ip} ({subnet_name} {subnet_cidr}) to device {device_name}"
add_audit_log(session['user_id'], 'device_add_ip', details, subnet_id_val, conn=conn)
conn.commit()
# Invalidate cache
invalidate_cache_for_device(device_id)
cache.invalidate_subnet(subnet_id_val)
logging.info(f"User {user_name} assigned IP {ip} to device {device_id}.")
return redirect(url_for('device', device_id=device_id))
@@ -450,6 +728,9 @@ def register_routes(app):
cursor.execute('DELETE FROM DeviceIPAddress WHERE id = %s', (device_ip_id,))
cursor.execute('UPDATE IPAddress SET hostname = NULL WHERE id = %s', (ip_id,))
conn.commit()
# Invalidate cache
invalidate_cache_for_device(device_id)
cache.invalidate_subnet(subnet_id_val)
logging.info(f"User {user_name} removed IP {ip} from device {device_id}.")
return redirect(url_for('device', device_id=device_id))
@@ -479,6 +760,9 @@ def register_routes(app):
cursor.execute('INSERT INTO DeviceTag (device_id, tag_id) VALUES (%s, %s)', (device_id, tag_id))
add_audit_log(session['user_id'], 'assign_device_tag', f"Assigned tag '{tag_name}' to device '{device_name}'", conn=conn)
conn.commit()
# Invalidate cache
invalidate_cache_for_device(device_id)
cache.clear('devices')
return redirect(url_for('device', device_id=device_id))
@app.route('/device/<int:device_id>/remove_tag', methods=['POST'])
@@ -503,6 +787,9 @@ def register_routes(app):
cursor.execute('DELETE FROM DeviceTag WHERE device_id = %s AND tag_id = %s', (device_id, tag_id))
add_audit_log(session['user_id'], 'remove_device_tag', f"Removed tag '{tag_name}' from device '{device_name}'", conn=conn)
conn.commit()
# Invalidate cache
invalidate_cache_for_device(device_id)
cache.clear('devices')
return redirect(url_for('device', device_id=device_id))
@app.route('/delete_device', methods=['POST'])
@@ -525,12 +812,22 @@ def register_routes(app):
cursor.execute('DELETE FROM DeviceIPAddress WHERE device_id = %s', (device_id,))
cursor.execute('DELETE FROM Device WHERE id = %s', (device_id,))
conn.commit()
# Invalidate cache
invalidate_cache_for_device(device_id)
cache.clear('devices')
logging.info(f"User {user_name} deleted device '{device_name}'.")
return redirect(url_for('devices'))
@app.route('/subnet/<int:subnet_id>')
@permission_required('view_subnet')
def subnet(subnet_id):
cache_key = f'subnet:{subnet_id}'
cached_result = cache.get(cache_key)
if cached_result is not None:
return render_with_user('subnet.html', subnet=cached_result['subnet'],
ip_addresses=cached_result['ip_addresses'],
utilization=cached_result['utilization'])
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
@@ -580,7 +877,18 @@ def register_routes(app):
if match:
device_id, device_description = match
ip_addresses_with_device.append((ip[0], ip[1], hostname, device_id, device_description))
return render_with_user('subnet.html', subnet={'id': subnet[0], 'name': subnet[1], 'cidr': subnet[2]}, ip_addresses=ip_addresses_with_device, utilization=utilization_stats)
subnet_dict = {'id': subnet[0], 'name': subnet[1], 'cidr': subnet[2]}
result = {
'subnet': subnet_dict,
'ip_addresses': ip_addresses_with_device,
'utilization': utilization_stats
}
# Cache for 3 hours
cache.set(cache_key, result, ttl=10800)
return render_with_user('subnet.html', subnet=subnet_dict,
ip_addresses=ip_addresses_with_device,
utilization=utilization_stats)
@app.route('/add_subnet', methods=['POST'])
@permission_required('add_subnet')
@@ -604,6 +912,11 @@ def register_routes(app):
cursor.executemany('INSERT INTO IPAddress (ip, subnet_id) VALUES (%s, %s)', ip_rows)
add_audit_log(session['user_id'], 'add_subnet', f"Added subnet {name} ({cidr})", subnet_id, conn=conn)
conn.commit()
# Invalidate cache
cache.clear('index')
cache.clear('admin')
cache.clear('subnet_list')
# Note: subnet_id is new, so no need to invalidate specific subnet cache
logging.info(f"User {user_name} added subnet '{name}' ({cidr}) at site '{site}'.")
return redirect(url_for('admin'))
@@ -625,6 +938,8 @@ def register_routes(app):
cursor.execute('UPDATE Subnet SET name = %s, cidr = %s, site = %s WHERE id = %s', (name, cidr, site, subnet_id))
add_audit_log(session['user_id'], 'edit_subnet', f"Edited subnet from {old_name} ({old_cidr}) to {name} ({cidr}) at site {site}", subnet_id, conn=conn)
conn.commit()
# Invalidate cache
invalidate_cache_for_subnet(subnet_id)
logging.info(f"User {user_name} edited subnet {subnet_id}.")
return redirect(url_for('admin'))
@@ -643,16 +958,24 @@ def register_routes(app):
ip_ids = [row[0] for row in cursor.fetchall()]
if ip_ids:
cursor.executemany('DELETE FROM DeviceIPAddress WHERE ip_id = %s', [(ip_id,) for ip_id in ip_ids])
cursor.executemany('UPDATE AuditLog SET subnet_id=NULL WHERE subnet_id = %s', [(subnet_id,) for _ in ip_ids])
# Set subnet_id to NULL in audit logs (foreign key will handle this, but doing it explicitly for clarity)
cursor.execute('UPDATE AuditLog SET subnet_id=NULL WHERE subnet_id = %s', (subnet_id,))
cursor.execute('DELETE FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
cursor.execute('DELETE FROM Subnet WHERE id = %s', (subnet_id,))
conn.commit()
# Invalidate cache
invalidate_cache_for_subnet(subnet_id)
logging.info(f"User {user_name} deleted subnet {subnet_id}.")
return redirect(url_for('admin'))
@app.route('/admin', methods=['GET', 'POST'])
@permission_required('view_admin')
def admin():
cache_key = 'admin'
cached_result = cache.get(cache_key)
if cached_result is not None:
return render_with_user('admin.html', **cached_result)
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor()
@@ -693,10 +1016,15 @@ def register_routes(app):
'total': total_ips
}
})
return render_with_user('admin.html', subnets=subnets,
can_add_subnet=has_permission('add_subnet'),
can_edit_subnet=has_permission('edit_subnet'),
can_delete_subnet=has_permission('delete_subnet'))
result_data = {
'subnets': subnets,
'can_add_subnet': has_permission('add_subnet'),
'can_edit_subnet': has_permission('edit_subnet'),
'can_delete_subnet': has_permission('delete_subnet')
}
# Cache for 3 hours
cache.set(cache_key, result_data, ttl=10800)
return render_with_user('admin.html', **result_data)
@app.route('/api-docs')
@permission_required('view_admin')
@@ -1010,8 +1338,9 @@ def register_routes(app):
params.extend([search_pattern, search_pattern, search_pattern, search_pattern])
count_query = 'SELECT COUNT(*) FROM (' + query + ') AS count_subquery'
with get_db_connection() as conn:
cursor = conn.cursor()
from flask import current_app
with get_db_connection(current_app) as conn:
cursor = conn.cursor(buffered=True)
cursor.execute(count_query, params)
total_logs = cursor.fetchone()[0]
query += ' ORDER BY AuditLog.timestamp DESC LIMIT %s OFFSET %s'
@@ -1108,7 +1437,14 @@ def register_routes(app):
@app.route('/check_update')
@login_required
def check_update():
"""Check for available updates from GitHub"""
"""Check for available updates from GitHub (cached for 3 hours)"""
cache_key = 'check_update'
# Check cache first
cached_result = cache.get(cache_key)
if cached_result is not None:
return jsonify(cached_result)
try:
# Get current version
version_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'VERSION')
@@ -1126,6 +1462,7 @@ def register_routes(app):
latest_version = release_data.get('tag_name', '').lstrip('v')
# Compare versions using semantic versioning
result = {'update_available': False}
if latest_version and latest_version != current_version:
# Simple semantic version comparison
def version_tuple(v):
@@ -1137,21 +1474,26 @@ def register_routes(app):
current_tuple = version_tuple(current_version)
latest_tuple = version_tuple(latest_version)
# Only show update if latest is actually newer
if latest_tuple <= current_tuple:
return jsonify({'update_available': False})
if latest_tuple > current_tuple:
result = {
'update_available': True,
'current_version': current_version,
'latest_version': latest_version,
'release_url': release_data.get('html_url', '')
}
except (ValueError, AttributeError):
# Fallback to string comparison if parsing fails
if latest_version == current_version:
return jsonify({'update_available': False})
return jsonify({
'update_available': True,
'current_version': current_version,
'latest_version': latest_version,
'release_url': release_data.get('html_url', '')
})
else:
return jsonify({'update_available': False})
if latest_version != current_version:
result = {
'update_available': True,
'current_version': current_version,
'latest_version': latest_version,
'release_url': release_data.get('html_url', '')
}
# Cache result for 3 hours (10800 seconds)
cache.set(cache_key, result, ttl=10800)
return jsonify(result)
except requests.RequestException as e:
logging.error(f"Error checking for updates: {e}")
@@ -1402,6 +1744,9 @@ def register_routes(app):
cursor.execute('UPDATE IPAddress SET hostname = %s WHERE hostname = %s', (new_name, old_name))
conn.commit()
add_audit_log(session['user_id'], 'rename_device', f"Renamed device '{old_name}' to '{new_name}'", conn=conn)
# Invalidate cache
invalidate_cache_for_device(device_id)
cache.clear('subnet:') # Invalidate all subnet caches since hostnames changed
logging.info(f"User {user_name} renamed device {device_id} from '{old_name}' to '{new_name}'.")
return redirect(url_for('device', device_id=device_id))
@@ -1416,6 +1761,8 @@ def register_routes(app):
cursor = conn.cursor()
cursor.execute('UPDATE Device SET description = %s WHERE id = %s', (description, device_id))
conn.commit()
# Invalidate cache
invalidate_cache_for_device(device_id)
logging.info(f"User {user_name} updated description for device {device_id}.")
return redirect(url_for('device', device_id=device_id))
+3 -1
View File
@@ -69,8 +69,10 @@ document.addEventListener('DOMContentLoaded', function() {
});
// Pattern 7: "Assigned X to Y" or "Unassigned X from Y"
html = html.replace(/(Assigned|Unassigned) (.+?) (to|from) (.+?)(?:\s|$)/gi, function(match, action, item, prep, target) {
// 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}`;
});
+1 -1
View File
@@ -13,7 +13,7 @@
<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>