feat: implement audit logs view with base template structure and route integration

This commit is contained in:
Rizqi 2026-06-28 11:18:55 +07:00
parent 55f702a46c
commit 53b1a43092
3 changed files with 466 additions and 2 deletions

View File

@ -66,6 +66,30 @@ def save_job_to_db_direct(jid, info):
except Exception as e:
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}
jobs: dict = {}
@ -101,6 +125,17 @@ def init_db():
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()
finally:
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
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')
if vm_names:
vm_display = ", ".join(vm_names)
@ -1268,6 +1309,7 @@ def enforce_retention_policy(info, log_path=None):
import shutil
shutil.rmtree(path)
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:
log_msg(f"ERROR deleting {name}: {e}")
@ -1287,6 +1329,7 @@ def enforce_retention_policy(info, log_path=None):
try:
shutil.rmtree(path)
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
except Exception as 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…'}
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():
with jobs_db_lock:
return jobs.get(jid, {}).get('status') == 'cancelling'
@ -1449,7 +1496,12 @@ def run_job_thread_impl(jid):
rep_dest = info.get('replication_dest')
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}")
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:
is_cancel_err = "cancelled by user" in str(e).lower()
if is_cancel_err:
@ -1547,7 +1599,12 @@ def run_job_thread_impl(jid):
rep_dest = info.get('replication_dest')
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}")
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:
with jobs_db_lock:
if "cancelled by user" in str(e).lower():
@ -1678,6 +1735,7 @@ def login():
session['user'] = user
session['password'] = password
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')
return redirect(url_for('vms'))
@ -1686,7 +1744,9 @@ def login():
@app.route('/logout')
def logout():
user = session.get('user', 'unknown')
session.clear()
log_audit(user, 'USER_LOGOUT', '-', "Logged out", req=request)
flash('Logged out.', 'info')
return redirect(url_for('login'))
@ -1715,6 +1775,7 @@ def settings_page():
set_setting('alert_level', request.form.get('alert_level', 'all'))
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')
return redirect(url_for('settings_page'))
@ -1745,6 +1806,7 @@ def settings_page():
@app.route('/settings/test-notification', methods=['POST'])
@login_required
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_url = request.form.get('webhook_url', '').strip()
webhook_type = request.form.get('webhook_type', 'slack_discord')
@ -1972,6 +2034,13 @@ def create_job():
use_cbt=use_cbt,
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'
flash(f'Job created — {n_disks} disk(s) selected.', 'success')
return redirect(url_for('job_detail', jobid=jid))
@ -2101,6 +2170,13 @@ def batch_jobs():
use_cbt=use_cbt,
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)
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')
@login_required
def reports_dashboard():
@ -2286,6 +2447,13 @@ def cancel_schedule(jobid):
info['schedule_id'] = None
info['status'] = info.get('status', 'finished') if info.get('status') not in ('queued', 'running') else info['status']
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')
return redirect(url_for('job_detail', jobid=jobid))
@ -2310,6 +2478,13 @@ def reactivate_schedule(jobid):
if info.get('status') not in ('running', 'queued'):
info['status'] = 'scheduled'
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')
else:
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
info['status'] = 'queued'
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
t = threading.Thread(target=run_job_thread, args=(jobid,), daemon=True)
@ -2351,6 +2533,13 @@ def stop_job(jobid):
info['status'] = 'cancelling'
info['progress'] = {'pct': info.get('progress', {}).get('pct', 0), 'phase': 'cancelling', 'detail': 'Stopping backup execution…'}
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')
else:
flash('Job is not running or queued.', 'warning')
@ -2377,6 +2566,13 @@ def delete_job(jobid):
# Remove from jobs dict
jobs.pop(jobid, None)
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
import shutil
@ -2488,6 +2684,13 @@ def edit_job(jobid):
info['status'] = 'finished' if info.get('status') == 'scheduled' else info.get('status', 'finished')
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')
return redirect(url_for('job_detail', jobid=jobid))
@ -2536,6 +2739,13 @@ def nfs_mount():
return redirect(url_for('nfs_manager'))
try:
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')
except Exception as e:
flash(f'Mount failed: {e}', 'danger')
@ -2551,6 +2761,13 @@ def nfs_umount():
return redirect(url_for('nfs_manager'))
try:
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')
except Exception as e:
flash(f'Unmount failed: {e}', 'danger')

240
templates/audit_logs.html Normal file
View 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 %}

View File

@ -545,6 +545,12 @@
</span>
Reports & Analytics
</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 %}">
<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>
@ -679,6 +685,7 @@
{ 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: '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: 'System Settings', kbd: '', action: function() { window.location.href = '/settings'; } },
{ icon: '🔄', label: 'Refresh Page', kbd: 'R', action: function() { location.reload(); } },