refactor: 🎨 remove caching #48

Merged
jamie merged 15 commits from v2.0.0 into main 2026-05-23 21:04:45 +01:00
7 changed files with 313 additions and 123 deletions
Showing only changes of commit e1dd5d1003 - Show all commits
+5 -1
View File
@@ -214,12 +214,16 @@ Subnets must be `/24` or smaller (prefix length ≥ 24).
| Method | Endpoint | Permission | Description | | Method | Endpoint | Permission | Description |
|--------|----------|------------|-------------| |--------|----------|------------|-------------|
| GET | `/api/v1/info` | *(authenticated)* | API version and current user info | | GET | `/api/v1/info` | *(authenticated)* | API version (`2.0`) and current user info |
| GET | `/api/v1/device-types` | `view_device_types` | List device types | | GET | `/api/v1/device-types` | `view_device_types` | List device types |
| GET | `/api/v1/devices/{id}/ip_history` | `view_device` | IP assignment history for a device |
| GET | `/api/v1/ips/{ip}/history` | `view_subnet` | IP assignment history for an address |
| GET | `/api/v1/audit` | `view_audit` | List audit log entries (`limit`, `offset` query params) | | GET | `/api/v1/audit` | `view_audit` | List audit log entries (`limit`, `offset` query params) |
| GET | `/api/v1/users` | `view_users` | List users | | GET | `/api/v1/users` | `view_users` | List users |
| GET | `/api/v1/roles` | `view_users` | List roles with permissions | | GET | `/api/v1/roles` | `view_users` | List roles with permissions |
Mutating API calls (`POST`, `PUT`, `DELETE`, `PATCH`) are logged to the audit log as `api_usage`. GET requests are not audited (v2 change — see [v1-to-v2-breaking-changes.md](v1-to-v2-breaking-changes.md)).
--- ---
## Example ## Example
+4
View File
@@ -85,6 +85,10 @@ GRANT ALL PRIVILEGES ON ipam.* TO 'ipam'@'%';
FLUSH PRIVILEGES; FLUSH PRIVILEGES;
``` ```
### Upgrading from v1.x
See [v1-to-v2-breaking-changes.md](v1-to-v2-breaking-changes.md) for removed features, route changes, and automatic database migrations. Back up your database before upgrading.
## Usage ## Usage
### First Login ### First Login
+150 -113
View File
@@ -110,37 +110,62 @@ def login_required(f):
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated_function return decorated_function
def load_permissions_for_user(user_id, conn):
"""Return the set of permission names granted to a user via their role."""
cursor = conn.cursor()
cursor.execute('SELECT role_id FROM User WHERE id = %s', (user_id,))
role_result = cursor.fetchone()
if not role_result or not role_result[0]:
return set()
cursor.execute('''
SELECT p.name FROM RolePermission rp
JOIN Permission p ON rp.permission_id = p.id
WHERE rp.role_id = %s
''', (role_result[0],))
return {row[0] for row in cursor.fetchall()}
def establish_user_session(user_id, conn=None):
"""Populate session after successful authentication."""
close_conn = False
if conn is None:
conn = get_db_connection(current_app)
close_conn = True
try:
cursor = conn.cursor()
cursor.execute('SELECT name FROM User WHERE id = %s', (user_id,))
row = cursor.fetchone()
session['user_name'] = row[0] if row else ''
session['permissions'] = list(load_permissions_for_user(user_id, conn))
session['logged_in'] = True
session['user_id'] = user_id
session.modified = True
finally:
if close_conn:
conn.close()
def has_permission(permission_name, user_id=None, conn=None): def has_permission(permission_name, user_id=None, conn=None):
"""Check if a user has a specific permission""" """Check if a user has a specific permission."""
if user_id is None: if user_id is None:
user_id = session.get('user_id') user_id = session.get('user_id')
if not user_id: if not user_id:
return False return False
if user_id == session.get('user_id') and 'permissions' in session:
return permission_name in session['permissions']
api_user = getattr(request, 'api_user', None)
if api_user and api_user.get('id') == user_id and 'permissions' in api_user:
return permission_name in api_user['permissions']
close_conn = False close_conn = False
if conn is None: if conn is None:
from flask import current_app
conn = get_db_connection(current_app) conn = get_db_connection(current_app)
close_conn = True close_conn = True
try: try:
cursor = conn.cursor() return permission_name in load_permissions_for_user(user_id, conn)
# Get user's role
cursor.execute('SELECT role_id FROM User WHERE id = %s', (user_id,))
role_result = cursor.fetchone()
if not role_result or not role_result[0]:
return False
role_id = role_result[0]
# Check if role has the permission
cursor.execute('''
SELECT COUNT(*) FROM RolePermission rp
JOIN Permission p ON rp.permission_id = p.id
WHERE rp.role_id = %s AND p.name = %s
''', (role_id, permission_name))
result = cursor.fetchone()
return result[0] > 0 if result else False
finally: finally:
if close_conn: if close_conn:
conn.close() conn.close()
@@ -166,12 +191,14 @@ def get_user_from_api_key(api_key):
cursor.execute('SELECT id, name, email, role_id FROM User WHERE api_key = %s', (api_key,)) cursor.execute('SELECT id, name, email, role_id FROM User WHERE api_key = %s', (api_key,))
result = cursor.fetchone() result = cursor.fetchone()
if result: if result:
return { user = {
'id': result[0], 'id': result[0],
'name': result[1], 'name': result[1],
'email': result[2], 'email': result[2],
'role_id': result[3] 'role_id': result[3],
} }
user['permissions'] = load_permissions_for_user(user['id'], conn)
return user
return None return None
def api_auth_required(f): def api_auth_required(f):
@@ -204,34 +231,25 @@ def api_auth_required(f):
# Execute the function # Execute the function
response = f(*args, **kwargs) response = f(*args, **kwargs)
# Log API usage to audit log # Log mutating API calls only (GET traffic was dominating audit volume)
try: if request.method in ('POST', 'PUT', 'DELETE', 'PATCH'):
api_path = request.path try:
http_method = request.method status_code = None
user_name = user.get('name', 'Unknown') if hasattr(response, 'status_code'):
status_code = response.status_code
# Get response status code if available elif isinstance(response, tuple) and len(response) > 1:
status_code = None status_code = response[1]
if hasattr(response, 'status_code'): details = f"API call: {request.method} {request.path}"
status_code = response.status_code if status_code:
elif isinstance(response, tuple) and len(response) > 1: details += f" (Status: {status_code})"
status_code = response[1] add_audit_log(
user_id=user['id'],
# Build details string with status if available action='api_usage',
if status_code: details=details,
details = f"API call: {http_method} {api_path} (Status: {status_code})" subnet_id=None,
else: )
details = f"API call: {http_method} {api_path}" except Exception as e:
logging.error(f"Failed to log API usage: {e}")
add_audit_log(
user_id=user['id'],
action='api_usage',
details=details,
subnet_id=None
)
except Exception as e:
# Don't fail the request if logging fails
logging.error(f"Failed to log API usage: {e}")
return response return response
return decorated_function return decorated_function
@@ -280,7 +298,6 @@ def get_ip_history_from_audit_logs(device_id=None, ip_address=None, conn=None):
try: try:
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
# Get device name if filtering by device_id
device_name = None device_name = None
if device_id: if device_id:
cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,)) cursor.execute('SELECT name FROM Device WHERE id = %s', (device_id,))
@@ -288,10 +305,8 @@ def get_ip_history_from_audit_logs(device_id=None, ip_address=None, conn=None):
if device_result: if device_result:
device_name = device_result['name'] device_name = device_result['name']
else: else:
# Device doesn't exist, return empty history
return [] return []
# Build query to get relevant audit log entries
query = ''' query = '''
SELECT al.id, al.action, al.details, al.timestamp, SELECT al.id, al.action, al.details, al.timestamp,
COALESCE(u.name, 'Deleted User') as user_name, COALESCE(u.name, 'Deleted User') as user_name,
@@ -307,6 +322,10 @@ def get_ip_history_from_audit_logs(device_id=None, ip_address=None, conn=None):
query += ' AND al.details LIKE %s' query += ' AND al.details LIKE %s'
params.append(f'%IP {ip_address}%') params.append(f'%IP {ip_address}%')
if device_id and device_name:
query += ' AND al.details LIKE %s'
params.append(f'%device {device_name}%')
query += ' ORDER BY al.timestamp DESC' query += ' ORDER BY al.timestamp DESC'
cursor.execute(query, params) cursor.execute(query, params)
@@ -579,7 +598,7 @@ def get_custom_fields_for_entity(entity_type, entity_id, conn=None):
# ── Data helpers (queries & business logic) ─────────────────────────────────── # ── Data helpers (queries & business logic) ───────────────────────────────────
def get_subnet_utilization(cursor, subnet_id, include_available=False): def get_subnet_utilization(cursor, subnet_id, include_available=False):
"""Return utilization stats for a subnet.""" """Return utilization stats for a single subnet."""
cursor.execute('SELECT COUNT(*) FROM IPAddress WHERE subnet_id = %s', (subnet_id,)) cursor.execute('SELECT COUNT(*) FROM IPAddress WHERE subnet_id = %s', (subnet_id,))
total_ips = cursor.fetchone()[0] total_ips = cursor.fetchone()[0]
@@ -611,6 +630,31 @@ def get_subnet_utilization(cursor, subnet_id, include_available=False):
return stats return stats
def get_all_subnet_utilizations(cursor):
"""Return utilization stats keyed by subnet_id."""
cursor.execute('''
SELECT ip.subnet_id,
COUNT(*) AS total,
SUM(CASE WHEN dia.ip_id IS NOT NULL THEN 1 ELSE 0 END) AS assigned,
SUM(CASE WHEN dia.ip_id IS NULL AND ip.hostname = 'DHCP' THEN 1 ELSE 0 END) AS dhcp
FROM IPAddress ip
LEFT JOIN DeviceIPAddress dia ON ip.id = dia.ip_id
GROUP BY ip.subnet_id
''')
result = {}
for subnet_id, total, assigned, dhcp in cursor.fetchall():
used = int(assigned) + int(dhcp)
total = int(total)
result[subnet_id] = {
'total': total,
'assigned': int(assigned),
'dhcp': int(dhcp),
'used': used,
'percent': round((used / total * 100) if total > 0 else 0, 1),
}
return result
def get_dhcp_pool(cursor, subnet_id): def get_dhcp_pool(cursor, subnet_id):
cursor.execute( cursor.execute(
'SELECT start_ip, end_ip, excluded_ips FROM DHCPPool WHERE subnet_id = %s', 'SELECT start_ip, end_ip, excluded_ips FROM DHCPPool WHERE subnet_id = %s',
@@ -981,11 +1025,9 @@ def process_2fa_setup_request(request, user_id, template_name, complete_login=Fa
''', (secret, backup_codes_json, user_id)) ''', (secret, backup_codes_json, user_id))
session.pop('temp_totp_secret', None) session.pop('temp_totp_secret', None)
if complete_login: if complete_login:
session['logged_in'] = True establish_user_session(user_id)
session['user_id'] = user_id
session.pop('pending_user_id', None) session.pop('pending_user_id', None)
session.pop('pending_email', None) session.pop('pending_email', None)
session.modified = True
logging.info(f"User {user_id} enabled 2FA successfully.") logging.info(f"User {user_id} enabled 2FA successfully.")
return render_with_user(template_name, backup_codes=format_backup_codes(backup_codes), step='backup_codes') return render_with_user(template_name, backup_codes=format_backup_codes(backup_codes), step='backup_codes')
return render_with_user(template_name, step='generate') return render_with_user(template_name, step='generate')
@@ -1103,15 +1145,18 @@ def group_devices_by_site(devices):
# ── Template & context helpers ─────────────────────────────────────────────── # ── Template & context helpers ───────────────────────────────────────────────
def get_current_user_name(): def get_current_user_name():
if session.get('user_name'):
return session['user_name']
user_id = session.get('user_id') user_id = session.get('user_id')
if not user_id: if not user_id:
return '' return ''
from flask import current_app
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute('SELECT name FROM User WHERE id = %s', (user_id,)) cursor.execute('SELECT name FROM User WHERE id = %s', (user_id,))
row = cursor.fetchone() row = cursor.fetchone()
return row[0] if row else '' name = row[0] if row else ''
session['user_name'] = name
return name
def render_with_user(*args, **kwargs): def render_with_user(*args, **kwargs):
if 'current_user_name' not in kwargs: if 'current_user_name' not in kwargs:
@@ -1163,9 +1208,8 @@ def login():
return redirect(url_for('verify_2fa')) return redirect(url_for('verify_2fa'))
# Normal login - no 2FA required # Normal login - no 2FA required
session['logged_in'] = True with get_db_connection(current_app) as conn:
session['user_id'] = user_id establish_user_session(user_id, conn=conn)
session.modified = True # Ensure session is saved
logging.info(f"User {email} logged in successfully.") logging.info(f"User {email} logged in successfully.")
return redirect(url_for('index')) return redirect(url_for('index'))
else: else:
@@ -1232,11 +1276,9 @@ def verify_2fa():
cursor.execute('UPDATE User SET backup_codes = %s WHERE id = %s', cursor.execute('UPDATE User SET backup_codes = %s WHERE id = %s',
(updated_codes, pending_user_id)) (updated_codes, pending_user_id))
conn.commit() conn.commit()
session['logged_in'] = True establish_user_session(pending_user_id, conn=conn)
session['user_id'] = pending_user_id
session.pop('pending_user_id', None) session.pop('pending_user_id', None)
session.pop('pending_email', None) session.pop('pending_email', None)
session.modified = True # Ensure session is saved
logging.info(f"User {pending_user_id} logged in with backup code.") logging.info(f"User {pending_user_id} logged in with backup code.")
return redirect(url_for('index')) return redirect(url_for('index'))
else: else:
@@ -1247,11 +1289,9 @@ def verify_2fa():
return render_with_user('verify_2fa.html', error='Invalid code format. Please enter a 6-digit code.') return render_with_user('verify_2fa.html', error='Invalid code format. Please enter a 6-digit code.')
if verify_totp(totp_secret, code): if verify_totp(totp_secret, code):
session['logged_in'] = True establish_user_session(pending_user_id, conn=conn)
session['user_id'] = pending_user_id
session.pop('pending_user_id', None) session.pop('pending_user_id', None)
session.pop('pending_email', None) session.pop('pending_email', None)
session.modified = True # Ensure session is saved
logging.info(f"User {pending_user_id} logged in with 2FA.") logging.info(f"User {pending_user_id} logged in with 2FA.")
return redirect(url_for('index')) return redirect(url_for('index'))
else: else:
@@ -1268,15 +1308,15 @@ def index():
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute('SELECT id, name, cidr, site, vlan_id FROM Subnet') cursor.execute('SELECT id, name, cidr, site, vlan_id FROM Subnet')
subnets = cursor.fetchall() subnets = cursor.fetchall()
utilizations = get_all_subnet_utilizations(cursor)
sites_subnets = {} sites_subnets = {}
for subnet in subnets: for subnet in subnets:
site = subnet[3] or 'Unassigned' site = subnet[3] or 'Unassigned'
if site not in sites_subnets: if site not in sites_subnets:
sites_subnets[site] = [] sites_subnets[site] = []
# Calculate utilization for each subnet
subnet_id = subnet[0] subnet_id = subnet[0]
util = get_subnet_utilization(cursor, subnet_id) util = utilizations.get(subnet_id, {'percent': 0})
sites_subnets[site].append({ sites_subnets[site].append({
'id': subnet[0], 'id': subnet[0],
'name': subnet[1], 'name': subnet[1],
@@ -1295,8 +1335,6 @@ def devices():
tag_filter = request.args.get('tag') tag_filter = request.args.get('tag')
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
tag_filter = request.args.get('tag')
cursor = conn.cursor() cursor = conn.cursor()
# Base device query # Base device query
@@ -1322,18 +1360,20 @@ def devices():
for row in cursor.fetchall(): for row in cursor.fetchall():
device_ips.setdefault(row[0], []).append((row[1], row[2])) device_ips.setdefault(row[0], []).append((row[1], row[2]))
# Get tags for each device # Get tags for all devices in one query
device_tags = {} device_tags = {}
all_tag_names = [] if devices:
for device in devices: device_ids = [device[0] for device in devices]
cursor.execute(''' placeholders = ','.join(['%s'] * len(device_ids))
SELECT t.id, t.name, t.color cursor.execute(f'''
SELECT dt.device_id, t.id, t.name, t.color
FROM DeviceTag dt FROM DeviceTag dt
JOIN Tag t ON dt.tag_id = t.id JOIN Tag t ON dt.tag_id = t.id
WHERE dt.device_id = %s WHERE dt.device_id IN ({placeholders})
ORDER BY t.name ORDER BY t.name
''', (device[0],)) ''', tuple(device_ids))
device_tags[device[0]] = [{'id': row[0], 'name': row[1], 'color': row[2]} for row in cursor.fetchall()] for row in cursor.fetchall():
device_tags.setdefault(row[0], []).append({'id': row[1], 'name': row[2], 'color': row[3]})
cursor.execute('SELECT DISTINCT name FROM Tag ORDER BY name') cursor.execute('SELECT DISTINCT name FROM Tag ORDER BY name')
all_tag_names = [row[0] for row in cursor.fetchall()] all_tag_names = [row[0] for row in cursor.fetchall()]
@@ -1415,31 +1455,22 @@ def device(device_id):
cursor.execute('SELECT id, name, color FROM Tag ORDER BY name') 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()] all_tags = [{'id': row[0], 'name': row[1], 'color': row[2]} for row in cursor.fetchall()]
cursor.execute('''
SELECT ip.subnet_id, ip.id, ip.ip
FROM IPAddress ip
LEFT JOIN DeviceIPAddress dia ON ip.id = dia.ip_id
WHERE dia.ip_id IS NULL
ORDER BY ip.subnet_id, INET_ATON(ip.ip)
''')
unassigned_by_subnet = {}
for row in cursor.fetchall():
unassigned_by_subnet.setdefault(row[0], []).append({'id': row[1], 'ip': row[2]})
available_ips_by_subnet = {} available_ips_by_subnet = {}
for subnet in subnets: for subnet in subnets:
cursor.execute(''' ips = unassigned_by_subnet.get(subnet['id'], [])
SELECT ip.id, ip.ip FROM IPAddress ip available_ips_by_subnet[subnet['id']] = filter_ips_outside_dhcp(cursor, subnet['id'], ips)
LEFT JOIN DeviceIPAddress dia ON ip.id = dia.ip_id
WHERE ip.subnet_id = %s AND dia.ip_id IS NULL
''', (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
# Get custom fields for device # Get custom fields for device
custom_fields = get_custom_fields_for_entity('device', device_id, conn=conn) custom_fields = get_custom_fields_for_entity('device', device_id, conn=conn)
@@ -1457,20 +1488,26 @@ def device(device_id):
custom_fields=custom_fields, custom_fields=custom_fields,
can_edit_device=has_permission('edit_device')) can_edit_device=has_permission('edit_device'))
@app.route('/api/device/<int:device_id>/ip_history') @app.route('/ip/<path:ip_address>/history')
@permission_required('view_device') @permission_required('view_subnet')
def device_ip_history(device_id): def ip_address_history(ip_address):
"""Get IP history for a device as JSON""" """Get IP assignment history for the subnet UI."""
from flask import current_app with get_db_connection(current_app) as conn:
ip_history = get_ip_history_from_audit_logs(ip_address=ip_address, conn=conn)
return jsonify({'history': ip_history, 'ip': ip_address})
@app.route('/api/v1/devices/<int:device_id>/ip_history', methods=['GET'])
@api_permission_required('view_device')
def api_device_ip_history(device_id):
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
ip_history = get_ip_history_from_audit_logs(device_id=device_id, conn=conn) ip_history = get_ip_history_from_audit_logs(device_id=device_id, conn=conn)
return jsonify({'history': ip_history}) return jsonify({'history': ip_history})
@app.route('/api/ip/<ip_address>/history')
@permission_required('view_subnet') @app.route('/api/v1/ips/<path:ip_address>/history', methods=['GET'])
def ip_address_history(ip_address): @api_permission_required('view_subnet')
"""Get IP history for a specific IP address as JSON""" def api_ip_address_history(ip_address):
from flask import current_app
with get_db_connection(current_app) as conn: with get_db_connection(current_app) as conn:
ip_history = get_ip_history_from_audit_logs(ip_address=ip_address, conn=conn) ip_history = get_ip_history_from_audit_logs(ip_address=ip_address, conn=conn)
return jsonify({'history': ip_history, 'ip': ip_address}) return jsonify({'history': ip_history, 'ip': ip_address})
@@ -2888,7 +2925,7 @@ def search():
def api_info(): def api_info():
"""Get API information and authenticated user info""" """Get API information and authenticated user info"""
return jsonify({ return jsonify({
'api_version': '1.0', 'api_version': '2.0',
'user': { 'user': {
'id': request.api_user['id'], 'id': request.api_user['id'],
'name': request.api_user['name'], 'name': request.api_user['name'],
+26
View File
@@ -589,5 +589,31 @@ def init_db(app=None):
create_index_if_not_exists(cursor, 'idx_customfield_entity_order', 'CustomFieldDefinition', 'entity_type, display_order') create_index_if_not_exists(cursor, 'idx_customfield_entity_order', 'CustomFieldDefinition', 'entity_type, display_order')
logging.info("Database indexes created successfully") logging.info("Database indexes created successfully")
run_v2_migrations(cursor, conn)
conn.commit() conn.commit()
conn.close() conn.close()
def run_v2_migrations(cursor, conn):
"""One-time schema cleanup for v2 upgrades from v1.x."""
logging.info("Running v2 database migrations...")
cursor.execute('DROP TABLE IF EXISTS FeatureFlags')
cursor.execute("SHOW COLUMNS FROM CustomFieldDefinition LIKE 'searchable'")
if cursor.fetchone():
cursor.execute('ALTER TABLE CustomFieldDefinition DROP COLUMN searchable')
logging.info("Dropped CustomFieldDefinition.searchable column")
for perm_name in ('view_help',):
cursor.execute('SELECT id FROM Permission WHERE name = %s', (perm_name,))
row = cursor.fetchone()
if row:
perm_id = row[0]
cursor.execute('DELETE FROM RolePermission WHERE permission_id = %s', (perm_id,))
cursor.execute('DELETE FROM Permission WHERE id = %s', (perm_id,))
logging.info("Removed orphaned permission: %s", perm_name)
logging.info("v2 database migrations complete")
+1 -1
View File
@@ -41,7 +41,7 @@ document.addEventListener('DOMContentLoaded', function() {
function loadIPHistory(ip) { function loadIPHistory(ip) {
content.innerHTML = '<div class="text-center text-gray-600 dark:text-gray-400">Loading...</div>'; content.innerHTML = '<div class="text-center text-gray-600 dark:text-gray-400">Loading...</div>';
fetch(`/api/ip/${encodeURIComponent(ip)}/history`) fetch(`/ip/${encodeURIComponent(ip)}/history`)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.history && data.history.length > 0) { if (data.history && data.history.length > 0) {
+1 -1
View File
@@ -1,4 +1,4 @@
document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("ip-history-modal"),t=document.getElementById("close-ip-history-modal"),s=document.getElementById("ip-history-content"),i=document.getElementById("modal-ip-address");document.querySelectorAll(".ip-history-btn").forEach(t=>{t.addEventListener("click",function(){var t;let a=this.getAttribute("data-ip");i.textContent=a,e.classList.remove("hidden"),e.classList.add("flex"),t=a,s.innerHTML='<div class="text-center text-gray-600 dark:text-gray-400">Loading...</div>',fetch(`/api/ip/${encodeURIComponent(t)}/history`).then(e=>e.json()).then(e=>{var t;let i;e.history&&e.history.length>0?(t=e.history,i='<div class="space-y-3">',t.forEach((e,s)=>{let a="assigned"===e.action,n="Unknown";if(e.timestamp)try{let d=new Date(e.timestamp);n=d.toLocaleString()}catch(r){n=e.timestamp}i+=` document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("ip-history-modal"),t=document.getElementById("close-ip-history-modal"),s=document.getElementById("ip-history-content"),i=document.getElementById("modal-ip-address");document.querySelectorAll(".ip-history-btn").forEach(t=>{t.addEventListener("click",function(){var t;let a=this.getAttribute("data-ip");i.textContent=a,e.classList.remove("hidden"),e.classList.add("flex"),t=a,s.innerHTML='<div class="text-center text-gray-600 dark:text-gray-400">Loading...</div>',fetch(`/ip/${encodeURIComponent(t)}/history`).then(e=>e.json()).then(e=>{var t;let i;e.history&&e.history.length>0?(t=e.history,i='<div class="space-y-3">',t.forEach((e,s)=>{let a="assigned"===e.action,n="Unknown";if(e.timestamp)try{let d=new Date(e.timestamp);n=d.toLocaleString()}catch(r){n=e.timestamp}i+=`
<div class="flex items-start gap-3 pb-3 ${s<t.length-1?"border-b border-gray-400 dark:border-zinc-600":""}"> <div class="flex items-start gap-3 pb-3 ${s<t.length-1?"border-b border-gray-400 dark:border-zinc-600":""}">
<div class="flex-shrink-0 mt-1"> <div class="flex-shrink-0 mt-1">
<i class="fas ${a?"fa-plus-circle text-green-500":"fa-minus-circle text-red-500"}"></i> <i class="fas ${a?"fa-plus-circle text-green-500":"fa-minus-circle text-red-500"}"></i>
+119
View File
@@ -0,0 +1,119 @@
# v1 → v2 Breaking Changes
This document lists breaking changes when upgrading from IPAM v1.x to v2.0.
**Upgrade steps:** pull/deploy the v2 image or code, restart the application. Database migrations run automatically on startup via `init_db()`. No manual SQL is required for standard upgrades.
---
## Removed features
| Feature | v1 | v2 |
|---------|----|----|
| Response caching / rate limiting | Flask-Limiter + in-app cache layer | Removed — direct DB queries |
| In-app update checker | `/check_update` + header toast | Removed — check releases yourself |
| Feature flags | Admin toggles for racks, tags, IP notes, bulk ops | Removed — features always available, gated by permissions only |
| Backup & restore | Admin UI + `/backup/*` routes | Removed — use your own DB backup strategy |
| Help page | `/help` | Removed |
| Interactive API docs | `/api-docs` web page | Removed — see [API.md](API.md) |
| Custom field “searchable” flag | UI checkbox + DB column | Removed — was never wired to search |
### Dependencies removed
- `requests` (update checker)
- `Flask-Limiter` and the custom cache module (already gone in v2 prep)
---
## Removed routes
| Method | v1 path | Notes |
|--------|---------|-------|
| GET | `/check_update` | Update checker |
| GET/POST | `/backup`, `/backup/create`, `/backup/download/<file>`, `/backup/restore`, `/backup/delete/<file>` | Backup UI |
| GET | `/help` | Help page |
| GET | `/api-docs` | Swagger-style docs |
| GET/POST | `/admin/feature_flags` | Feature flag toggles |
| GET | `/custom_fields/<entity_type>` | Duplicate of API; use `/api/v1/custom_fields/{entity_type}` |
| GET | `/api/device/<id>/ip_history` | Moved — see below |
| GET | `/api/ip/<ip>/history` | Moved — see below |
---
## API changes
### Version
`GET /api/v1/info` now reports `"api_version": "2.0"`.
### IP history endpoints
| v1 (removed) | v2 replacement | Auth |
|--------------|----------------|------|
| `GET /api/device/<id>/ip_history` | `GET /api/v1/devices/<id>/ip_history` | API key (`X-API-Key` or `Authorization: Bearer`) |
| `GET /api/ip/<ip>/history` | `GET /api/v1/ips/<ip>/history` | API key |
The **subnet page UI** uses a session-authenticated web route instead of the old unversioned API paths:
- `GET /ip/<ip>/history` — requires login + `view_subnet` permission
Update any scripts that called the old `/api/device/...` or `/api/ip/...` paths to use the v1 API routes above with an API key.
### Audit logging for API calls
v1 logged **every** API request (including GETs) to the audit log as `api_usage`.
v2 logs **mutating** requests only: `POST`, `PUT`, `DELETE`, `PATCH`. GET traffic is no longer audited. If you relied on GET audit entries for compliance, adjust your monitoring.
---
## Database migrations (automatic)
On startup, v2 runs these migrations against existing databases:
1. **Drop `FeatureFlags` table** — feature flags removed
2. **Drop `CustomFieldDefinition.searchable` column** — if present
3. **Remove orphaned permission `view_help`** — help page removed
No data loss for core IPAM entities (subnets, IPs, devices, racks, tags, users, audit log).
---
## Permissions & sessions
- **`view_help`** permission is removed from the database on upgrade.
- Feature-flag toggles no longer hide UI; use **roles and permissions** instead.
- Permissions are **cached in the session at login**. If an admin changes a user's role, that user must **log out and back in** for permission changes to take effect.
---
## Configuration
No new required environment variables. Removed features did not introduce new config keys.
Docker images no longer need a `/backups` volume mount for in-app backup (remove if you added it only for IPAM backup).
---
## Codebase layout (operators / fork maintainers)
| v1 | v2 |
|----|----|
| `app.py` + `routes.py` + `cache.py` + `totp_utils.py` | Single `app.py` + `db.py` |
| `templates/api_docs.html`, `templates/backup.html`, `templates/help.html` | Deleted |
---
## Recommended pre-upgrade checklist
1. **Back up your MariaDB/MySQL database** using your normal tooling (`mysqldump`, volume snapshot, etc.).
2. Note any integrations using removed routes (update checker, backup API, legacy IP history paths).
3. Deploy v2 and restart the container/process once.
4. Verify login, home page, devices, and one API call with your API key.
5. Have affected users re-login if roles were changed recently.
---
## Questions
For API reference after upgrade, see [API.md](API.md).