feat: implement audit logs view with base template structure and route integration
This commit is contained in:
parent
55f702a46c
commit
53b1a43092
221
gui_app.py
221
gui_app.py
@ -66,6 +66,30 @@ def save_job_to_db_direct(jid, info):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"ERROR: Failed to save job {jid} directly to SQLite: {e}", file=sys.stderr)
|
print(f"ERROR: Failed to save job {jid} directly to SQLite: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
def log_audit(actor, action, target, details, req=None):
|
||||||
|
ip_addr = '-'
|
||||||
|
if req:
|
||||||
|
try:
|
||||||
|
ip_addr = req.headers.get('X-Forwarded-For', '').split(',')[0].strip() or req.remote_addr or '-'
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO audit_logs (timestamp, actor, action, target, details, ip_address) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(time.time(), actor, action, target, details, ip_addr)
|
||||||
|
)
|
||||||
|
# Auto-prune audit logs older than 360 days
|
||||||
|
cutoff = time.time() - (360 * 86400)
|
||||||
|
cursor.execute("DELETE FROM audit_logs WHERE timestamp < ?", (cutoff,))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR: Failed to write audit log: {e}", file=sys.stderr)
|
||||||
|
|
||||||
# In-memory job store: {job_id: job_dict}
|
# In-memory job store: {job_id: job_dict}
|
||||||
jobs: dict = {}
|
jobs: dict = {}
|
||||||
|
|
||||||
@ -101,6 +125,17 @@ def init_db():
|
|||||||
value TEXT
|
value TEXT
|
||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp REAL,
|
||||||
|
actor TEXT,
|
||||||
|
action TEXT,
|
||||||
|
target TEXT,
|
||||||
|
details TEXT,
|
||||||
|
ip_address TEXT
|
||||||
|
)
|
||||||
|
''')
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@ -1045,6 +1080,12 @@ def log_and_notify_run(jid, info, start_time, end_time, status, run_dest):
|
|||||||
size_bytes = get_dir_size(run_dest) if run_dest else 0
|
size_bytes = get_dir_size(run_dest) if run_dest else 0
|
||||||
duration = end_time - start_time
|
duration = end_time - start_time
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
is_failed = 'failed' in status.lower() or 'error' in status.lower()
|
||||||
|
details = f"Status: {status}, Size: {size_bytes / (1024**3):.2f} GB, Duration: {fmt_duration(duration)}"
|
||||||
|
action = 'BACKUP_FAILURE' if is_failed else 'BACKUP_SUCCESS'
|
||||||
|
log_audit('system', action, jid, details)
|
||||||
|
|
||||||
vm_names = info.get('vm_names')
|
vm_names = info.get('vm_names')
|
||||||
if vm_names:
|
if vm_names:
|
||||||
vm_display = ", ".join(vm_names)
|
vm_display = ", ".join(vm_names)
|
||||||
@ -1268,6 +1309,7 @@ def enforce_retention_policy(info, log_path=None):
|
|||||||
import shutil
|
import shutil
|
||||||
shutil.rmtree(path)
|
shutil.rmtree(path)
|
||||||
log_msg(f"Deleted old backup directory: {name}")
|
log_msg(f"Deleted old backup directory: {name}")
|
||||||
|
log_audit('system', 'RETENTION_PRUNE', vm_name, f"Pruned old successful backup folder '{name}' for VM '{vm_name}' (policy: keep {retention_val} count)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log_msg(f"ERROR deleting {name}: {e}")
|
log_msg(f"ERROR deleting {name}: {e}")
|
||||||
|
|
||||||
@ -1287,6 +1329,7 @@ def enforce_retention_policy(info, log_path=None):
|
|||||||
try:
|
try:
|
||||||
shutil.rmtree(path)
|
shutil.rmtree(path)
|
||||||
log_msg(f"Deleted backup older than {retention_val} days: {name}")
|
log_msg(f"Deleted backup older than {retention_val} days: {name}")
|
||||||
|
log_audit('system', 'RETENTION_PRUNE', vm_name, f"Pruned backup folder '{name}' older than {retention_val} days for VM '{vm_name}'")
|
||||||
deleted_count += 1
|
deleted_count += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log_msg(f"ERROR deleting {name}: {e}")
|
log_msg(f"ERROR deleting {name}: {e}")
|
||||||
@ -1368,6 +1411,10 @@ def run_job_thread_impl(jid):
|
|||||||
info['progress'] = {'pct': 0, 'phase': 'starting', 'detail': 'Initializing…'}
|
info['progress'] = {'pct': 0, 'phase': 'starting', 'detail': 'Initializing…'}
|
||||||
save_jobs_db()
|
save_jobs_db()
|
||||||
|
|
||||||
|
vm_names = info.get('vm_names')
|
||||||
|
target_display = ", ".join(vm_names) if vm_names else info.get('vm_name', '—')
|
||||||
|
log_audit('system', 'BACKUP_START', jid, f"Backup run started for job {jid} (label: '{info.get('label')}', target: {target_display})")
|
||||||
|
|
||||||
def is_cancelled():
|
def is_cancelled():
|
||||||
with jobs_db_lock:
|
with jobs_db_lock:
|
||||||
return jobs.get(jid, {}).get('status') == 'cancelling'
|
return jobs.get(jid, {}).get('status') == 'cancelling'
|
||||||
@ -1449,7 +1496,12 @@ def run_job_thread_impl(jid):
|
|||||||
rep_dest = info.get('replication_dest')
|
rep_dest = info.get('replication_dest')
|
||||||
if rep_dest and str(rep_dest).strip() and str(rep_dest).strip().lower() != 'none':
|
if rep_dest and str(rep_dest).strip() and str(rep_dest).strip().lower() != 'none':
|
||||||
rep_vm_dest = os.path.join(rep_dest, vm, f"backup-{run_timestamp}")
|
rep_vm_dest = os.path.join(rep_dest, vm, f"backup-{run_timestamp}")
|
||||||
replicate_backup_folder(vm_dest, rep_vm_dest, log_path=log_path)
|
log_audit('system', 'REPLICATION_START', jid, f"Started replication for VM {vm} to {rep_vm_dest}")
|
||||||
|
rep_ok = replicate_backup_folder(vm_dest, rep_vm_dest, log_path=log_path)
|
||||||
|
if rep_ok:
|
||||||
|
log_audit('system', 'REPLICATION_SUCCESS', jid, f"Replication completed successfully for VM {vm}")
|
||||||
|
else:
|
||||||
|
log_audit('system', 'REPLICATION_FAILURE', jid, f"Replication failed for VM {vm}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
is_cancel_err = "cancelled by user" in str(e).lower()
|
is_cancel_err = "cancelled by user" in str(e).lower()
|
||||||
if is_cancel_err:
|
if is_cancel_err:
|
||||||
@ -1547,7 +1599,12 @@ def run_job_thread_impl(jid):
|
|||||||
rep_dest = info.get('replication_dest')
|
rep_dest = info.get('replication_dest')
|
||||||
if rep_dest and str(rep_dest).strip() and str(rep_dest).strip().lower() != 'none':
|
if rep_dest and str(rep_dest).strip() and str(rep_dest).strip().lower() != 'none':
|
||||||
rep_run_dest = os.path.join(rep_dest, info['vm_name'], f"backup-{run_timestamp}")
|
rep_run_dest = os.path.join(rep_dest, info['vm_name'], f"backup-{run_timestamp}")
|
||||||
replicate_backup_folder(run_dest, rep_run_dest, log_path=log_path)
|
log_audit('system', 'REPLICATION_START', jid, f"Started replication for VM {info['vm_name']} to {rep_run_dest}")
|
||||||
|
rep_ok = replicate_backup_folder(run_dest, rep_run_dest, log_path=log_path)
|
||||||
|
if rep_ok:
|
||||||
|
log_audit('system', 'REPLICATION_SUCCESS', jid, f"Replication completed successfully for VM {info['vm_name']}")
|
||||||
|
else:
|
||||||
|
log_audit('system', 'REPLICATION_FAILURE', jid, f"Replication failed for VM {info['vm_name']}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
with jobs_db_lock:
|
with jobs_db_lock:
|
||||||
if "cancelled by user" in str(e).lower():
|
if "cancelled by user" in str(e).lower():
|
||||||
@ -1678,6 +1735,7 @@ def login():
|
|||||||
session['user'] = user
|
session['user'] = user
|
||||||
session['password'] = password
|
session['password'] = password
|
||||||
session['no_verify_ssl'] = no_verify_ssl
|
session['no_verify_ssl'] = no_verify_ssl
|
||||||
|
log_audit(user, 'USER_LOGIN', host, f"Logged in to vSphere host: {host}", req=request)
|
||||||
flash(f'Connected to {host} — {len(vm_list)} VMs found.', 'success')
|
flash(f'Connected to {host} — {len(vm_list)} VMs found.', 'success')
|
||||||
return redirect(url_for('vms'))
|
return redirect(url_for('vms'))
|
||||||
|
|
||||||
@ -1686,7 +1744,9 @@ def login():
|
|||||||
|
|
||||||
@app.route('/logout')
|
@app.route('/logout')
|
||||||
def logout():
|
def logout():
|
||||||
|
user = session.get('user', 'unknown')
|
||||||
session.clear()
|
session.clear()
|
||||||
|
log_audit(user, 'USER_LOGOUT', '-', "Logged out", req=request)
|
||||||
flash('Logged out.', 'info')
|
flash('Logged out.', 'info')
|
||||||
return redirect(url_for('login'))
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
@ -1715,6 +1775,7 @@ def settings_page():
|
|||||||
set_setting('alert_level', request.form.get('alert_level', 'all'))
|
set_setting('alert_level', request.form.get('alert_level', 'all'))
|
||||||
set_setting('log_retention_days', request.form.get('log_retention_days', 'never'))
|
set_setting('log_retention_days', request.form.get('log_retention_days', 'never'))
|
||||||
|
|
||||||
|
log_audit(session.get('user', 'unknown'), 'SETTINGS_UPDATE', 'System Settings', 'Updated SMTP, Webhook, and retention settings', req=request)
|
||||||
flash('Settings saved successfully.', 'success')
|
flash('Settings saved successfully.', 'success')
|
||||||
return redirect(url_for('settings_page'))
|
return redirect(url_for('settings_page'))
|
||||||
|
|
||||||
@ -1745,6 +1806,7 @@ def settings_page():
|
|||||||
@app.route('/settings/test-notification', methods=['POST'])
|
@app.route('/settings/test-notification', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def settings_test_notification():
|
def settings_test_notification():
|
||||||
|
log_audit(session.get('user', 'unknown'), 'DIAGNOSTIC_TEST', 'Notification Settings', 'Triggered settings diagnostic connection test', req=request)
|
||||||
webhook_enabled = 'webhook_enabled' in request.form
|
webhook_enabled = 'webhook_enabled' in request.form
|
||||||
webhook_url = request.form.get('webhook_url', '').strip()
|
webhook_url = request.form.get('webhook_url', '').strip()
|
||||||
webhook_type = request.form.get('webhook_type', 'slack_discord')
|
webhook_type = request.form.get('webhook_type', 'slack_discord')
|
||||||
@ -1972,6 +2034,13 @@ def create_job():
|
|||||||
use_cbt=use_cbt,
|
use_cbt=use_cbt,
|
||||||
replication_dest=replication_dest
|
replication_dest=replication_dest
|
||||||
)
|
)
|
||||||
|
log_audit(
|
||||||
|
session.get('user', 'unknown'),
|
||||||
|
'JOB_CREATE',
|
||||||
|
jid,
|
||||||
|
f"Created backup job (VM: {vm_name}, label: '{label}', schedule: {schedule_type})",
|
||||||
|
req=request
|
||||||
|
)
|
||||||
n_disks = len(disk_filter) if disk_filter is not None else 'all'
|
n_disks = len(disk_filter) if disk_filter is not None else 'all'
|
||||||
flash(f'Job created — {n_disks} disk(s) selected.', 'success')
|
flash(f'Job created — {n_disks} disk(s) selected.', 'success')
|
||||||
return redirect(url_for('job_detail', jobid=jid))
|
return redirect(url_for('job_detail', jobid=jid))
|
||||||
@ -2101,6 +2170,13 @@ def batch_jobs():
|
|||||||
use_cbt=use_cbt,
|
use_cbt=use_cbt,
|
||||||
replication_dest=replication_dest
|
replication_dest=replication_dest
|
||||||
)
|
)
|
||||||
|
log_audit(
|
||||||
|
session.get('user', 'unknown'),
|
||||||
|
'JOB_CREATE',
|
||||||
|
jid,
|
||||||
|
f"Created batch backup job for {len(vm_names)} VMs (label: '{label}', strategy: {disk_strategy}, schedule: {schedule_type})",
|
||||||
|
req=request
|
||||||
|
)
|
||||||
|
|
||||||
strat_label = {'all': 'all disks', 'os': 'OS disk only', 'vmx': 'VMX config only'}.get(disk_strategy, disk_strategy)
|
strat_label = {'all': 'all disks', 'os': 'OS disk only', 'vmx': 'VMX config only'}.get(disk_strategy, disk_strategy)
|
||||||
flash(f'Batch backup job created for {len(vm_names)} VMs ({strat_label}).', 'success')
|
flash(f'Batch backup job created for {len(vm_names)} VMs ({strat_label}).', 'success')
|
||||||
@ -2122,6 +2198,91 @@ def batch_jobs():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/audit-logs')
|
||||||
|
@login_required
|
||||||
|
def audit_logs_page():
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
per_page = 25
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
|
q = request.args.get('q', '').strip()
|
||||||
|
actor_type = request.args.get('actor_type', 'all').strip().lower()
|
||||||
|
action_type = request.args.get('action_type', 'all').strip()
|
||||||
|
|
||||||
|
query = "SELECT timestamp, actor, action, target, details, ip_address FROM audit_logs WHERE 1=1"
|
||||||
|
count_query = "SELECT count(*) FROM audit_logs WHERE 1=1"
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if q:
|
||||||
|
filter_sql = " AND (actor LIKE ? OR target LIKE ? OR details LIKE ?)"
|
||||||
|
query += filter_sql
|
||||||
|
count_query += filter_sql
|
||||||
|
like_q = f"%{q}%"
|
||||||
|
params.extend([like_q, like_q, like_q])
|
||||||
|
|
||||||
|
if actor_type == 'user':
|
||||||
|
# Anything that is NOT 'system'
|
||||||
|
query += " AND actor != 'system'"
|
||||||
|
count_query += " AND actor != 'system'"
|
||||||
|
elif actor_type == 'system':
|
||||||
|
query += " AND actor = 'system'"
|
||||||
|
count_query += " AND actor = 'system'"
|
||||||
|
|
||||||
|
if action_type != 'all':
|
||||||
|
query += " AND action = ?"
|
||||||
|
count_query += " AND action = ?"
|
||||||
|
params.append(action_type)
|
||||||
|
|
||||||
|
query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?"
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
logs_list = []
|
||||||
|
total_count = 0
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Get total count
|
||||||
|
cursor.execute(count_query, params)
|
||||||
|
total_count = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
# Get paginated data
|
||||||
|
query_params = params + [per_page, offset]
|
||||||
|
cursor.execute(query, query_params)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
for r in rows:
|
||||||
|
logs_list.append({
|
||||||
|
'timestamp_fmt': datetime.fromtimestamp(r[0]).strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
'actor': r[1],
|
||||||
|
'action': r[2],
|
||||||
|
'target': r[3] or '—',
|
||||||
|
'details': r[4] or '',
|
||||||
|
'ip_address': r[5] or '—'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get all distinct actions for the dropdown filter
|
||||||
|
cursor.execute("SELECT DISTINCT action FROM audit_logs ORDER BY action")
|
||||||
|
actions_list = [row[0] for row in cursor.fetchall()]
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error fetching audit logs: {e}", file=sys.stderr)
|
||||||
|
actions_list = []
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
total_pages = max(1, (total_count + per_page - 1) // per_page)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'audit_logs.html',
|
||||||
|
logs=logs_list,
|
||||||
|
page=page,
|
||||||
|
total_pages=total_pages,
|
||||||
|
total_count=total_count,
|
||||||
|
q=q,
|
||||||
|
actor_type=actor_type,
|
||||||
|
action_type=action_type,
|
||||||
|
actions_list=actions_list
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/reports')
|
@app.route('/reports')
|
||||||
@login_required
|
@login_required
|
||||||
def reports_dashboard():
|
def reports_dashboard():
|
||||||
@ -2286,6 +2447,13 @@ def cancel_schedule(jobid):
|
|||||||
info['schedule_id'] = None
|
info['schedule_id'] = None
|
||||||
info['status'] = info.get('status', 'finished') if info.get('status') not in ('queued', 'running') else info['status']
|
info['status'] = info.get('status', 'finished') if info.get('status') not in ('queued', 'running') else info['status']
|
||||||
save_jobs_db()
|
save_jobs_db()
|
||||||
|
log_audit(
|
||||||
|
session.get('user', 'unknown'),
|
||||||
|
'SCHEDULE_CANCEL',
|
||||||
|
jobid,
|
||||||
|
f"Cancelled recurring schedule (old schedule ID: {sched_id})",
|
||||||
|
req=request
|
||||||
|
)
|
||||||
flash('Recurring schedule cancelled.', 'success')
|
flash('Recurring schedule cancelled.', 'success')
|
||||||
return redirect(url_for('job_detail', jobid=jobid))
|
return redirect(url_for('job_detail', jobid=jobid))
|
||||||
|
|
||||||
@ -2310,6 +2478,13 @@ def reactivate_schedule(jobid):
|
|||||||
if info.get('status') not in ('running', 'queued'):
|
if info.get('status') not in ('running', 'queued'):
|
||||||
info['status'] = 'scheduled'
|
info['status'] = 'scheduled'
|
||||||
save_jobs_db()
|
save_jobs_db()
|
||||||
|
log_audit(
|
||||||
|
session.get('user', 'unknown'),
|
||||||
|
'SCHEDULE_REACTIVATE',
|
||||||
|
jobid,
|
||||||
|
f"Reactivated recurring schedule (new schedule ID: {sched_id}, type: {info.get('schedule_type')})",
|
||||||
|
req=request
|
||||||
|
)
|
||||||
flash('Recurring schedule reactivated successfully.', 'success')
|
flash('Recurring schedule reactivated successfully.', 'success')
|
||||||
else:
|
else:
|
||||||
flash('Failed to reactivate schedule.', 'danger')
|
flash('Failed to reactivate schedule.', 'danger')
|
||||||
@ -2331,6 +2506,13 @@ def run_job_now(jobid):
|
|||||||
# Mark status as queued atomically to prevent double run race condition
|
# Mark status as queued atomically to prevent double run race condition
|
||||||
info['status'] = 'queued'
|
info['status'] = 'queued'
|
||||||
save_jobs_db()
|
save_jobs_db()
|
||||||
|
log_audit(
|
||||||
|
session.get('user', 'unknown'),
|
||||||
|
'BACKUP_TRIGGER',
|
||||||
|
jobid,
|
||||||
|
"Manually triggered backup execution",
|
||||||
|
req=request
|
||||||
|
)
|
||||||
|
|
||||||
# Start backup execution in a background thread
|
# Start backup execution in a background thread
|
||||||
t = threading.Thread(target=run_job_thread, args=(jobid,), daemon=True)
|
t = threading.Thread(target=run_job_thread, args=(jobid,), daemon=True)
|
||||||
@ -2351,6 +2533,13 @@ def stop_job(jobid):
|
|||||||
info['status'] = 'cancelling'
|
info['status'] = 'cancelling'
|
||||||
info['progress'] = {'pct': info.get('progress', {}).get('pct', 0), 'phase': 'cancelling', 'detail': 'Stopping backup execution…'}
|
info['progress'] = {'pct': info.get('progress', {}).get('pct', 0), 'phase': 'cancelling', 'detail': 'Stopping backup execution…'}
|
||||||
save_jobs_db()
|
save_jobs_db()
|
||||||
|
log_audit(
|
||||||
|
session.get('user', 'unknown'),
|
||||||
|
'BACKUP_STOP',
|
||||||
|
jobid,
|
||||||
|
"Requested backup execution stop",
|
||||||
|
req=request
|
||||||
|
)
|
||||||
flash('Request to stop backup sent.', 'info')
|
flash('Request to stop backup sent.', 'info')
|
||||||
else:
|
else:
|
||||||
flash('Job is not running or queued.', 'warning')
|
flash('Job is not running or queued.', 'warning')
|
||||||
@ -2377,6 +2566,13 @@ def delete_job(jobid):
|
|||||||
# Remove from jobs dict
|
# Remove from jobs dict
|
||||||
jobs.pop(jobid, None)
|
jobs.pop(jobid, None)
|
||||||
save_jobs_db()
|
save_jobs_db()
|
||||||
|
log_audit(
|
||||||
|
session.get('user', 'unknown'),
|
||||||
|
'JOB_DELETE',
|
||||||
|
jobid,
|
||||||
|
f"Deleted backup job (label: '{info.get('label')}')",
|
||||||
|
req=request
|
||||||
|
)
|
||||||
|
|
||||||
# Remove the job directory containing the log file
|
# Remove the job directory containing the log file
|
||||||
import shutil
|
import shutil
|
||||||
@ -2488,6 +2684,13 @@ def edit_job(jobid):
|
|||||||
info['status'] = 'finished' if info.get('status') == 'scheduled' else info.get('status', 'finished')
|
info['status'] = 'finished' if info.get('status') == 'scheduled' else info.get('status', 'finished')
|
||||||
|
|
||||||
save_jobs_db()
|
save_jobs_db()
|
||||||
|
log_audit(
|
||||||
|
session.get('user', 'unknown'),
|
||||||
|
'JOB_EDIT',
|
||||||
|
jobid,
|
||||||
|
f"Updated job configuration (label: '{label}', schedule: {schedule_type})",
|
||||||
|
req=request
|
||||||
|
)
|
||||||
flash('Job updated successfully.', 'success')
|
flash('Job updated successfully.', 'success')
|
||||||
return redirect(url_for('job_detail', jobid=jobid))
|
return redirect(url_for('job_detail', jobid=jobid))
|
||||||
|
|
||||||
@ -2536,6 +2739,13 @@ def nfs_mount():
|
|||||||
return redirect(url_for('nfs_manager'))
|
return redirect(url_for('nfs_manager'))
|
||||||
try:
|
try:
|
||||||
mount_nfs(server, export, mountpoint, nfs_version=nfs_ver, extra_opts=extra_opts)
|
mount_nfs(server, export, mountpoint, nfs_version=nfs_ver, extra_opts=extra_opts)
|
||||||
|
log_audit(
|
||||||
|
session.get('user', 'unknown'),
|
||||||
|
'NFS_MOUNT',
|
||||||
|
mountpoint,
|
||||||
|
f"Successfully mounted NFS export {server}:{export} to {mountpoint}",
|
||||||
|
req=request
|
||||||
|
)
|
||||||
flash(f'Mounted {server}:{export} → {mountpoint} successfully.', 'success')
|
flash(f'Mounted {server}:{export} → {mountpoint} successfully.', 'success')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(f'Mount failed: {e}', 'danger')
|
flash(f'Mount failed: {e}', 'danger')
|
||||||
@ -2551,6 +2761,13 @@ def nfs_umount():
|
|||||||
return redirect(url_for('nfs_manager'))
|
return redirect(url_for('nfs_manager'))
|
||||||
try:
|
try:
|
||||||
umount_nfs(mountpoint)
|
umount_nfs(mountpoint)
|
||||||
|
log_audit(
|
||||||
|
session.get('user', 'unknown'),
|
||||||
|
'NFS_UMOUNT',
|
||||||
|
mountpoint,
|
||||||
|
f"Successfully unmounted NFS mount point at {mountpoint}",
|
||||||
|
req=request
|
||||||
|
)
|
||||||
flash(f'Unmounted {mountpoint} successfully.', 'success')
|
flash(f'Unmounted {mountpoint} successfully.', 'success')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(f'Unmount failed: {e}', 'danger')
|
flash(f'Unmount failed: {e}', 'danger')
|
||||||
|
|||||||
240
templates/audit_logs.html
Normal file
240
templates/audit_logs.html
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set active_page = 'audit-logs' %}
|
||||||
|
{% block title %}Audit Logs — vSphere Backup Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
.history-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
.search-input-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px 8px 36px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runs-table-wrap {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.runs-table-wrap table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.runs-table-wrap th, .runs-table-wrap td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.runs-table-wrap th {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.runs-table-wrap td {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.runs-table-wrap tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.runs-table-wrap tr:hover td {
|
||||||
|
background: rgba(255, 255, 255, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-right { text-align: right !important; }
|
||||||
|
|
||||||
|
.badge-action {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.action-user {
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
color: #818cf8;
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
.action-system {
|
||||||
|
background: rgba(6, 182, 212, 0.15);
|
||||||
|
color: #22d3ee;
|
||||||
|
border: 1px solid rgba(6, 182, 212, 0.3);
|
||||||
|
}
|
||||||
|
.action-backup-success {
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
color: #34d399;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
.action-backup-fail {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: #f87171;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-filter-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.4);
|
||||||
|
color: #f87171;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background-color 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
.clear-filter-btn:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="topbar">
|
||||||
|
<div>
|
||||||
|
<div class="topbar-title">Audit Logs</div>
|
||||||
|
<div class="topbar-subtitle">Historical records of all user management actions and system-level events (Auto-pruned after 360 days)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="history-card">
|
||||||
|
<!-- Filters Toolbar -->
|
||||||
|
<form method="get" action="/audit-logs" style="margin-bottom: 20px;">
|
||||||
|
<div style="display: flex; gap: 12px; flex-wrap: wrap; align-items: center;">
|
||||||
|
<div class="search-input-wrap" style="flex: 1; min-width: 240px;">
|
||||||
|
<span class="search-icon">🔍</span>
|
||||||
|
<input type="text" name="q" value="{{ q or '' }}" class="form-control search-input" placeholder="Search actor, target, details..." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 8px; align-items: center;">
|
||||||
|
<label style="font-size: 11px; color: var(--text-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em;">Type:</label>
|
||||||
|
<select name="actor_type" class="form-control" style="font-size: 13px; padding: 6px 12px; height: 36px; min-width: 130px; width: auto;" onchange="this.form.submit()">
|
||||||
|
<option value="all" {% if actor_type == 'all' %}selected{% endif %}>All Events</option>
|
||||||
|
<option value="user" {% if actor_type == 'user' %}selected{% endif %}>User Actions</option>
|
||||||
|
<option value="system" {% if actor_type == 'system' %}selected{% endif %}>System Events</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 8px; align-items: center;">
|
||||||
|
<label style="font-size: 11px; color: var(--text-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em;">Action:</label>
|
||||||
|
<select name="action_type" class="form-control" style="font-size: 13px; padding: 6px 12px; height: 36px; min-width: 165px; width: auto;" onchange="this.form.submit()">
|
||||||
|
<option value="all" {% if action_type == 'all' %}selected{% endif %}>All Actions</option>
|
||||||
|
{% for act in actions_list %}
|
||||||
|
<option value="{{ act }}" {% if action_type == act %}selected{% endif %}>{{ act }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-secondary" style="height: 36px;">Apply</button>
|
||||||
|
{% if q or actor_type != 'all' or action_type != 'all' %}
|
||||||
|
<a href="/audit-logs" class="clear-filter-btn" style="height: 36px;">Clear Filters</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="runs-table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 160px;">Timestamp</th>
|
||||||
|
<th style="width: 180px;">Actor</th>
|
||||||
|
<th style="width: 180px;">Action</th>
|
||||||
|
<th style="width: 140px;">Target</th>
|
||||||
|
<th>Details</th>
|
||||||
|
<th style="width: 130px;" class="text-right">IP Address</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% if logs %}
|
||||||
|
{% for log in logs %}
|
||||||
|
<tr>
|
||||||
|
<td class="mono" style="font-size: 12px; color: var(--text-secondary);">{{ log.timestamp_fmt }}</td>
|
||||||
|
<td>
|
||||||
|
{% if log.actor == 'system' %}
|
||||||
|
<span style="font-weight: 600; color: #22d3ee;">🤖 system</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="font-weight: 500; color: var(--text-primary);">👤 {{ log.actor }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if log.action in ('USER_LOGIN', 'USER_LOGOUT') %}
|
||||||
|
<span class="badge-action action-user">{{ log.action }}</span>
|
||||||
|
{% elif 'SUCCESS' in log.action or 'OK' in log.action %}
|
||||||
|
<span class="badge-action action-backup-success">{{ log.action }}</span>
|
||||||
|
{% elif 'FAIL' in log.action or 'FAILURE' in log.action or 'ERROR' in log.action %}
|
||||||
|
<span class="badge-action action-backup-fail">{{ log.action }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge-action action-system">{{ log.action }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="mono" style="font-size: 12px; color: var(--text-muted);">{{ log.target }}</td>
|
||||||
|
<td style="color: var(--text-secondary); word-break: break-word;">{{ log.details }}</td>
|
||||||
|
<td class="text-right mono" style="font-size: 12px; color: var(--text-muted);">{{ log.ip_address }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" style="text-align: center; padding: 48px; color: var(--text-muted);">
|
||||||
|
No matching audit logs found. User actions and system events will appear here chronologically.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination Controls -->
|
||||||
|
{% if total_pages > 1 %}
|
||||||
|
<div class="pagination-wrap" style="display: flex; justify-content: space-between; align-items: center; margin-top: 20px;">
|
||||||
|
<div style="font-size: 13px; color: var(--text-muted);">
|
||||||
|
Showing <strong>{{ logs|length }}</strong> of <strong>{{ total_count }}</strong> log entries
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 8px; align-items: center;">
|
||||||
|
{% if page > 1 %}
|
||||||
|
<a href="{{ url_for('audit_logs_page', page=page-1, q=q, actor_type=actor_type, action_type=action_type) }}" class="btn btn-secondary btn-sm" style="padding: 6px 12px; display: inline-flex; align-items: center; justify-content: center; height: 32px; text-decoration: none;">← Previous</a>
|
||||||
|
{% else %}
|
||||||
|
<button class="btn btn-secondary btn-sm" disabled style="padding: 6px 12px; display: inline-flex; align-items: center; justify-content: center; height: 32px; opacity: 0.5; cursor: not-allowed;">← Previous</button>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<span style="font-size: 13px; color: var(--text-secondary); margin: 0 4px;">Page <strong>{{ page }}</strong> of <strong>{{ total_pages }}</strong></span>
|
||||||
|
|
||||||
|
{% if page < total_pages %}
|
||||||
|
<a href="{{ url_for('audit_logs_page', page=page+1, q=q, actor_type=actor_type, action_type=action_type) }}" class="btn btn-secondary btn-sm" style="padding: 6px 12px; display: inline-flex; align-items: center; justify-content: center; height: 32px; text-decoration: none;">Next →</a>
|
||||||
|
{% else %}
|
||||||
|
<button class="btn btn-secondary btn-sm" disabled style="padding: 6px 12px; display: inline-flex; align-items: center; justify-content: center; height: 32px; opacity: 0.5; cursor: not-allowed;">Next →</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@ -545,6 +545,12 @@
|
|||||||
</span>
|
</span>
|
||||||
Reports & Analytics
|
Reports & Analytics
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/audit-logs" class="nav-link {% if active_page == 'audit-logs' %}active{% endif %}">
|
||||||
|
<span class="icon">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||||
|
</span>
|
||||||
|
Audit Logs
|
||||||
|
</a>
|
||||||
<a href="/settings" class="nav-link {% if active_page == 'settings' %}active{% endif %}">
|
<a href="/settings" class="nav-link {% if active_page == 'settings' %}active{% endif %}">
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
@ -679,6 +685,7 @@
|
|||||||
{ icon: '📋', label: 'Backup Jobs', kbd: '', action: function() { window.location.href = '/jobs'; } },
|
{ icon: '📋', label: 'Backup Jobs', kbd: '', action: function() { window.location.href = '/jobs'; } },
|
||||||
{ icon: '➕', label: 'Create New Job', kbd: 'N', action: function() { window.location.href = '/jobs/create'; } },
|
{ icon: '➕', label: 'Create New Job', kbd: 'N', action: function() { window.location.href = '/jobs/create'; } },
|
||||||
{ icon: '📊', label: 'Reports & Analytics', kbd: '', action: function() { window.location.href = '/reports'; } },
|
{ icon: '📊', label: 'Reports & Analytics', kbd: '', action: function() { window.location.href = '/reports'; } },
|
||||||
|
{ icon: '🛡️', label: 'Audit Logs', kbd: '', action: function() { window.location.href = '/audit-logs'; } },
|
||||||
{ icon: '💾', label: 'NFS Manager', kbd: '', action: function() { window.location.href = '/nfs'; } },
|
{ icon: '💾', label: 'NFS Manager', kbd: '', action: function() { window.location.href = '/nfs'; } },
|
||||||
{ icon: '⚙️', label: 'System Settings', kbd: '', action: function() { window.location.href = '/settings'; } },
|
{ icon: '⚙️', label: 'System Settings', kbd: '', action: function() { window.location.href = '/settings'; } },
|
||||||
{ icon: '🔄', label: 'Refresh Page', kbd: 'R', action: function() { location.reload(); } },
|
{ icon: '🔄', label: 'Refresh Page', kbd: 'R', action: function() { location.reload(); } },
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user