feat: implement dashboard and reports view with interactive analytics and history logging

This commit is contained in:
Rizqi 2026-06-26 20:29:25 +07:00
parent 99ab2d06b2
commit 65962a8353
4 changed files with 1323 additions and 0 deletions

View File

@ -62,6 +62,26 @@ def init_db():
data TEXT
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS job_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id TEXT,
job_label TEXT,
vm_name TEXT,
started REAL,
ended REAL,
duration REAL,
status TEXT,
size_bytes INTEGER,
notification_sent INTEGER DEFAULT 0
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
)
''')
conn.commit()
finally:
conn.close()
@ -459,6 +479,405 @@ def fmt_time(ts):
return datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M:%S') if ts else ''
def get_dir_size(path):
total = 0
if not path:
return total
try:
if os.path.exists(path):
if os.path.isfile(path):
return os.path.getsize(path)
for dirpath, dirnames, filenames in os.walk(path):
for f in filenames:
fp = os.path.join(dirpath, f)
if not os.path.islink(fp):
total += os.path.getsize(fp)
except Exception:
pass
return total
def fmt_duration(seconds):
seconds = int(seconds)
h = seconds // 3600
m = (seconds % 3600) // 60
s = seconds % 60
if h > 0:
return f"{h}h {m}m {s}s"
elif m > 0:
return f"{m}m {s}s"
else:
return f"{s}s"
def get_setting(key, default=None):
conn = sqlite3.connect(DB_PATH)
try:
cursor = conn.cursor()
cursor.execute("SELECT value FROM settings WHERE key = ?", (key,))
row = cursor.fetchone()
if row is not None:
return row[0]
except Exception:
pass
finally:
conn.close()
return default
def set_setting(key, value):
conn = sqlite3.connect(DB_PATH)
try:
cursor = conn.cursor()
cursor.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (key, str(value)))
conn.commit()
except Exception as e:
print(f"Error setting {key}: {e}", file=sys.stderr)
finally:
conn.close()
def send_webhook_notification(url, payload_type, run_data, raise_on_error=False):
if not url:
return
import requests
size_gb = run_data['size_bytes'] / (1024 * 1024 * 1024)
duration_str = fmt_duration(run_data['duration'])
started_str = datetime.fromtimestamp(run_data['started']).strftime('%Y-%m-%d %H:%M:%S')
status_text = run_data['status'].upper()
color = "#10b981"
if "failed" in run_data['status'].lower():
color = "#ef4444"
elif "error" in run_data['status'].lower():
color = "#f59e0b"
title = f"Backup Job {status_text}: {run_data['job_label'] or run_data['job_id'][:8]}"
if payload_type == 'slack_discord':
if "discord.com" in url:
discord_color = 1096065
if "failed" in run_data['status'].lower():
discord_color = 15680580
elif "error" in run_data['status'].lower():
discord_color = 16096779
payload = {
"embeds": [{
"title": title,
"color": discord_color,
"fields": [
{"name": "VM(s)", "value": run_data['vm_name'], "inline": True},
{"name": "Duration", "value": duration_str, "inline": True},
{"name": "Backup Size", "value": f"{size_gb:.2f} GB", "inline": True},
{"name": "Status", "value": run_data['status'], "inline": True},
{"name": "Start Time", "value": started_str, "inline": True}
],
"footer": {
"text": f"Job ID: {run_data['job_id']}"
}
}]
}
else:
payload = {
"text": f"*{title}*",
"attachments": [{
"color": color,
"fields": [
{"title": "VM(s)", "value": run_data['vm_name'], "short": True},
{"title": "Duration", "value": duration_str, "short": True},
{"title": "Backup Size", "value": f"{size_gb:.2f} GB", "short": True},
{"title": "Status", "value": run_data['status'], "short": True},
{"title": "Start Time", "value": started_str, "short": True},
{"title": "Job ID", "value": run_data['job_id'], "short": True}
]
}]
}
else:
payload = run_data
try:
r = requests.post(url, json=payload, timeout=10)
r.raise_for_status()
except Exception as e:
print(f"Error sending webhook notification: {e}", file=sys.stderr)
if raise_on_error:
raise
def send_email_notification(smtp, run_data, raise_on_error=False):
host = smtp.get('host')
port = int(smtp.get('port') or 587)
user = smtp.get('user')
password = smtp.get('password')
sender = smtp.get('sender')
recipient = smtp.get('recipient')
if not (host and sender and recipient):
return
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
size_gb = run_data['size_bytes'] / (1024 * 1024 * 1024)
duration_str = fmt_duration(run_data['duration'])
started_str = datetime.fromtimestamp(run_data['started']).strftime('%Y-%m-%d %H:%M:%S')
ended_str = datetime.fromtimestamp(run_data['ended']).strftime('%Y-%m-%d %H:%M:%S')
status_text = run_data['status'].upper()
theme_color = "#10b981"
bg_banner = "linear-gradient(135deg, #10b981 0%, #059669 100%)"
if "failed" in run_data['status'].lower():
theme_color = "#ef4444"
bg_banner = "linear-gradient(135deg, #ef4444 0%, #dc2626 100%)"
elif "error" in run_data['status'].lower():
theme_color = "#f59e0b"
bg_banner = "linear-gradient(135deg, #f59e0b 0%, #d97706 100%)"
subject = f"Backup Job {status_text}: {run_data['job_label'] or run_data['job_id'][:8]}"
html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background-color: #080a10;
color: #f8fafc;
margin: 0; padding: 20px;
}}
.card {{
background-color: #0e111a;
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px;
overflow: hidden;
max-width: 600px;
margin: 20px auto;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
}}
.banner {{
background: {bg_banner};
color: #ffffff;
padding: 24px;
text-align: center;
}}
.banner h2 {{
margin: 0; font-size: 20px; font-weight: 700;
}}
.content {{
padding: 28px;
}}
.grid {{
display: table;
width: 100%;
margin-bottom: 20px;
}}
.row {{
display: table-row;
}}
.cell-lbl {{
display: table-cell;
padding: 8px 10px;
font-weight: 600;
color: #94a3b8;
width: 30%;
font-size: 13px;
}}
.cell-val {{
display: table-cell;
padding: 8px 10px;
color: #f8fafc;
font-size: 13.5px;
}}
.footer {{
padding: 16px 28px;
background-color: rgba(8, 10, 16, 0.4);
border-top: 1px solid rgba(255, 255, 255, 0.05);
font-size: 11px;
color: #64748b;
text-align: center;
}}
</style>
</head>
<body>
<div class="card">
<div class="banner">
<h2>Backup Job: {status_text}</h2>
</div>
<div class="content">
<div class="grid">
<div class="row">
<div class="cell-lbl">Job Label</div>
<div class="cell-val">{run_data['job_label'] or ''}</div>
</div>
<div class="row">
<div class="cell-lbl">VM Name(s)</div>
<div class="cell-val"><strong>{run_data['vm_name']}</strong></div>
</div>
<div class="row">
<div class="cell-lbl">Size</div>
<div class="cell-val">{size_gb:.2f} GB</div>
</div>
<div class="row">
<div class="cell-lbl">Duration</div>
<div class="cell-val">{duration_str}</div>
</div>
<div class="row">
<div class="cell-lbl">Start Time</div>
<div class="cell-val">{started_str}</div>
</div>
<div class="row">
<div class="cell-lbl">End Time</div>
<div class="cell-val">{ended_str}</div>
</div>
<div class="row">
<div class="cell-lbl">Status</div>
<div class="cell-val" style="color: {theme_color}; font-weight: 700;">{run_data['status']}</div>
</div>
</div>
</div>
<div class="footer">
vSphere Backup Manager &middot; Job ID: {run_data['job_id']}
</div>
</div>
</body>
</html>
"""
msg = MIMEMultipart()
msg['From'] = sender
msg['To'] = recipient
msg['Subject'] = subject
msg.attach(MIMEText(html, 'html'))
try:
if port == 465:
server = smtplib.SMTP_SSL(host, port, timeout=10)
else:
server = smtplib.SMTP(host, port, timeout=10)
server.starttls()
if user and password:
server.login(user, password)
server.sendmail(sender, recipient, msg.as_string())
server.quit()
except Exception as e:
print(f"Error sending email notification: {e}", file=sys.stderr)
if raise_on_error:
raise
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
vm_names = info.get('vm_names')
if vm_names:
vm_display = ", ".join(vm_names)
else:
vm_display = info.get('vm_name', '')
conn = sqlite3.connect(DB_PATH)
run_id = None
try:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO job_runs (job_id, job_label, vm_name, started, ended, duration, status, size_bytes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''', (jid, info.get('label', ''), vm_display, start_time, end_time, duration, status, size_bytes))
conn.commit()
run_id = cursor.lastrowid
except Exception as e:
print(f"Error writing to job_runs database: {e}", file=sys.stderr)
finally:
conn.close()
# Log Retention Policy Cleanups
retention_days = get_setting('log_retention_days', 'never')
if retention_days != 'never' and str(retention_days).isdigit():
days = int(retention_days)
if days > 0:
cutoff_time = time.time() - (days * 86400)
conn = sqlite3.connect(DB_PATH)
try:
cursor = conn.cursor()
cursor.execute("DELETE FROM job_runs WHERE started < ?", (cutoff_time,))
conn.commit()
except Exception as e:
print(f"Error cleaning up old job runs: {e}", file=sys.stderr)
finally:
conn.close()
alert_level = get_setting('alert_level', 'all')
is_failed = 'failed' in status.lower() or 'error' in status.lower()
should_alert = False
if alert_level == 'all':
should_alert = True
elif alert_level == 'failed' and is_failed:
should_alert = True
if not should_alert:
return
run_data = {
'job_id': jid,
'job_label': info.get('label', ''),
'vm_name': vm_display,
'started': start_time,
'ended': end_time,
'duration': duration,
'status': status,
'size_bytes': size_bytes
}
notification_sent = 0
webhook_enabled = get_setting('webhook_enabled') == 'true'
webhook_url = get_setting('webhook_url')
webhook_type = get_setting('webhook_type', 'slack_discord')
if webhook_enabled and webhook_url:
try:
send_webhook_notification(webhook_url, webhook_type, run_data)
notification_sent = 1
except Exception:
pass
smtp_enabled = get_setting('smtp_enabled') == 'true'
if smtp_enabled:
smtp_settings = {
'host': get_setting('smtp_host'),
'port': get_setting('smtp_port'),
'user': get_setting('smtp_user'),
'password': get_setting('smtp_password'),
'sender': get_setting('smtp_sender'),
'recipient': get_setting('smtp_recipient')
}
try:
t = threading.Thread(target=send_email_notification, args=(smtp_settings, run_data), daemon=True)
t.start()
notification_sent = 1
except Exception:
pass
if notification_sent and run_id:
conn = sqlite3.connect(DB_PATH)
try:
cursor = conn.cursor()
cursor.execute("UPDATE job_runs SET notification_sent = 1 WHERE id = ?", (run_id,))
conn.commit()
except Exception:
pass
finally:
conn.close()
def job_to_display(jid, info):
"""Convert internal job dict to template-friendly dict."""
disk_filter = info.get('disk_filter')
@ -781,6 +1200,10 @@ def run_job_thread(jid):
'detail': f"Batch completed. Success: {len(success_vms)}, Failed: {len(failed_vms)}"
}
save_jobs_db()
final_status = info['status']
run_dest = info.get('run_dest')
log_and_notify_run(jid, info, info['started'], time.time(), final_status, run_dest)
else:
# Single VM backup run (original behavior)
@ -846,6 +1269,11 @@ def run_job_thread(jid):
}
enforce_retention_policy(rep_info, log_path=log_path)
with jobs_db_lock:
final_status = info.get('status', 'failed')
log_and_notify_run(jid, info, info['started'], time.time(), final_status, run_dest)
def create_and_start_job(
vm_name, dest, compress, no_verify_ssl,
@ -961,6 +1389,103 @@ def logout():
return redirect(url_for('login'))
@app.route('/settings', methods=['GET', 'POST'])
@login_required
def settings_page():
if request.method == 'POST':
set_setting('smtp_enabled', 'true' if 'smtp_enabled' in request.form else 'false')
set_setting('smtp_host', request.form.get('smtp_host', '').strip())
set_setting('smtp_port', request.form.get('smtp_port', '587').strip())
set_setting('smtp_user', request.form.get('smtp_user', '').strip())
set_setting('smtp_password', request.form.get('smtp_password', '').strip())
set_setting('smtp_sender', request.form.get('smtp_sender', '').strip())
set_setting('smtp_recipient', request.form.get('smtp_recipient', '').strip())
set_setting('webhook_enabled', 'true' if 'webhook_enabled' in request.form else 'false')
set_setting('webhook_url', request.form.get('webhook_url', '').strip())
set_setting('webhook_type', request.form.get('webhook_type', 'slack_discord'))
set_setting('alert_level', request.form.get('alert_level', 'all'))
set_setting('log_retention_days', request.form.get('log_retention_days', 'never'))
flash('Settings saved successfully.', 'success')
return redirect(url_for('settings_page'))
opts = {
'smtp_enabled': get_setting('smtp_enabled', 'false') == 'true',
'smtp_host': get_setting('smtp_host', ''),
'smtp_port': get_setting('smtp_port', '587'),
'smtp_user': get_setting('smtp_user', ''),
'smtp_password': get_setting('smtp_password', ''),
'smtp_sender': get_setting('smtp_sender', ''),
'smtp_recipient': get_setting('smtp_recipient', ''),
'webhook_enabled': get_setting('webhook_enabled', 'false') == 'true',
'webhook_url': get_setting('webhook_url', ''),
'webhook_type': get_setting('webhook_type', 'slack_discord'),
'alert_level': get_setting('alert_level', 'all'),
'log_retention_days': get_setting('log_retention_days', 'never')
}
return render_template('settings.html', settings=opts)
@app.route('/settings/test-notification', methods=['POST'])
@login_required
def settings_test_notification():
webhook_enabled = 'webhook_enabled' in request.form
webhook_url = request.form.get('webhook_url', '').strip()
webhook_type = request.form.get('webhook_type', 'slack_discord')
smtp_enabled = 'smtp_enabled' in request.form
smtp_settings = {
'host': request.form.get('smtp_host', '').strip(),
'port': request.form.get('smtp_port', '587').strip(),
'user': request.form.get('smtp_user', '').strip(),
'password': request.form.get('smtp_password', '').strip(),
'sender': request.form.get('smtp_sender', '').strip(),
'recipient': request.form.get('smtp_recipient', '').strip()
}
test_run_data = {
'job_id': 'test-run-id-12345',
'job_label': 'Diagnostic Test Alert',
'vm_name': 'mock-vm-1, mock-vm-2',
'started': time.time() - 45,
'ended': time.time(),
'duration': 45.0,
'status': 'finished (Diagnostic Test Success)',
'size_bytes': 1532984025
}
webhook_error = None
email_error = None
if webhook_enabled and webhook_url:
try:
send_webhook_notification(webhook_url, webhook_type, test_run_data, raise_on_error=True)
except Exception as e:
webhook_error = str(e)
if smtp_enabled:
try:
send_email_notification(smtp_settings, test_run_data, raise_on_error=True)
except Exception as e:
email_error = str(e)
if webhook_error or email_error:
err_msg = ""
if webhook_error:
err_msg += f"Webhook Failed: {webhook_error}. "
if email_error:
err_msg += f"Email Failed: {email_error}."
flash(err_msg, 'danger')
else:
flash('Diagnostic test notifications sent successfully. Please check your Inbox and Webhook channel!', 'success')
return redirect(url_for('settings_page'))
# ── VM Browser ────────────────────────────────────────────────────────────────
@app.route('/vms')
@ -1252,6 +1777,90 @@ def batch_jobs():
)
@app.route('/reports')
@login_required
def reports_dashboard():
conn = sqlite3.connect(DB_PATH)
runs = []
try:
cursor = conn.cursor()
cursor.execute('''
SELECT id, job_id, job_label, vm_name, started, ended, duration, status, size_bytes, notification_sent
FROM job_runs
ORDER BY started DESC
''')
rows = cursor.fetchall()
for r in rows:
runs.append({
'id': r[0],
'job_id': r[1],
'job_label': r[2],
'vm_name': r[3],
'started': r[4],
'started_fmt': datetime.fromtimestamp(r[4]).strftime('%Y-%m-%d %H:%M:%S') if r[4] else '',
'ended': r[5],
'duration': r[6],
'duration_fmt': fmt_duration(r[6]) if r[6] else '',
'status': r[7],
'size_bytes': r[8],
'size_gb': round(r[8] / (1024 * 1024 * 1024), 2) if r[8] else 0.0,
'notification_sent': bool(r[9])
})
except Exception as e:
print(f"Error fetching job_runs: {e}", file=sys.stderr)
finally:
conn.close()
total_runs = len(runs)
success_runs = sum(1 for r in runs if r['status'].lower() in ('finished', 'success'))
failed_runs = total_runs - success_runs
success_rate = round((success_runs / total_runs) * 100, 1) if total_runs > 0 else 100.0
total_size_bytes = sum(r['size_bytes'] for r in runs)
total_size_gb = round(total_size_bytes / (1024 * 1024 * 1024), 2)
avg_duration = round(sum(r['duration'] for r in runs) / total_runs, 1) if total_runs > 0 else 0.0
avg_duration_fmt = fmt_duration(avg_duration)
stats = {
'total': total_runs,
'success': success_runs,
'failed': failed_runs,
'success_rate': success_rate,
'total_size_gb': total_size_gb,
'avg_duration_fmt': avg_duration_fmt
}
return render_template('reports.html', runs=runs, stats=stats)
@app.route('/api/reports-data')
@login_required
def reports_data_api():
conn = sqlite3.connect(DB_PATH)
chart_data = []
try:
cursor = conn.cursor()
cursor.execute('''
SELECT started, size_bytes, duration, status
FROM job_runs
ORDER BY started DESC
LIMIT 15
''')
rows = cursor.fetchall()
for r in reversed(rows):
chart_data.append({
'date': datetime.fromtimestamp(r[0]).strftime('%m-%d %H:%M'),
'size_gb': round(r[1] / (1024 * 1024 * 1024), 2) if r[1] else 0.0,
'duration_sec': round(r[2], 1) if r[2] else 0.0,
'status': r[3]
})
except Exception as e:
print(f"Error fetching reports API: {e}", file=sys.stderr)
finally:
conn.close()
return jsonify({'runs': chart_data})
# ── Jobs Dashboard ────────────────────────────────────────────────────────────
@app.route('/jobs')

View File

@ -539,6 +539,18 @@
</span>
NFS Manager
</a>
<a href="/reports" class="nav-link {% if active_page == 'reports' %}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"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
</span>
Reports & Analytics
</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>
</span>
System Settings
</a>
</nav>
<div class="sidebar-footer">
@ -666,7 +678,9 @@
{ icon: '🖥', label: 'Virtual Machines', kbd: '', action: function() { window.location.href = '/vms'; } },
{ 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: '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(); } },
{ icon: '🚪', label: 'Logout', kbd: '', action: function() { window.location.href = '/logout'; } },
];

413
templates/reports.html Normal file
View File

@ -0,0 +1,413 @@
{% extends "base.html" %}
{% set active_page = 'reports' %}
{% block title %}Reports & Analytics — vSphere Backup Manager{% endblock %}
{% block head %}
<!-- Chart.js from CDN -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.metric-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
box-shadow: var(--shadow);
backdrop-filter: blur(8px);
transition: transform 0.2s, border-color 0.2s;
}
.metric-card:hover {
transform: translateY(-2px);
border-color: var(--border-bright);
}
.metric-value {
font-size: 28px;
font-weight: 800;
margin-top: 6px;
letter-spacing: -0.02em;
}
.metric-label {
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.charts-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 24px;
}
@media (max-width: 900px) {
.charts-grid { grid-template-columns: 1fr; }
}
.chart-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
box-shadow: var(--shadow);
backdrop-filter: blur(8px);
}
.chart-title {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.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;
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
gap: 16px;
flex-wrap: wrap;
}
.history-title {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
.search-input-wrap {
position: relative;
max-width: 320px;
width: 100%;
}
.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; }
</style>
{% endblock %}
{% block content %}
<div class="topbar">
<div>
<div class="topbar-title">Reports & Analytics</div>
<div class="topbar-subtitle">System backup telemetry, storage usage growth, and runs logs history</div>
</div>
</div>
<div class="content">
<!-- Summary Metrics Cards -->
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-label">Total Backups</div>
<div class="metric-value" style="color: var(--text-primary);">{{ stats.total }}</div>
</div>
<div class="metric-card">
<div class="metric-label">Success Rate</div>
<div class="metric-value" style="color: var(--success);">{{ stats.success_rate }}%</div>
</div>
<div class="metric-card">
<div class="metric-label">Total Storage Saved</div>
<div class="metric-value" style="color: var(--accent-2);">{{ stats.total_size_gb }} GB</div>
</div>
<div class="metric-card">
<div class="metric-label">Avg Duration</div>
<div class="metric-value" style="color: var(--accent);">{{ stats.avg_duration_fmt }}</div>
</div>
</div>
<!-- Trends Charts -->
<div class="charts-grid">
<!-- Backup Size Chart -->
<div class="chart-card">
<div class="chart-title">
<span>💾</span> Backup Size Trend (Last 15 Runs)
</div>
<div style="position: relative; height: 260px; width: 100%;">
<canvas id="sizeChart"></canvas>
</div>
</div>
<!-- Backup Duration Chart -->
<div class="chart-card">
<div class="chart-title">
<span>⏱️</span> Backup Duration Trend (Last 15 Runs)
</div>
<div style="position: relative; height: 260px; width: 100%;">
<canvas id="durationChart"></canvas>
</div>
</div>
</div>
<!-- Historical Run Logs Table -->
<div class="history-card">
<div class="history-header">
<div class="history-title">Backup Run History</div>
<div class="search-input-wrap">
<span class="search-icon">🔍</span>
<input type="text" id="runSearch" class="form-control search-input" placeholder="Search by VM, Job or Status..." />
</div>
</div>
<div class="runs-table-wrap">
<table>
<thead>
<tr>
<th>Job Details</th>
<th>Virtual Machine</th>
<th>Size (GB)</th>
<th>Duration</th>
<th>Start Time</th>
<th>Alert Status</th>
<th class="text-right">Status</th>
</tr>
</thead>
<tbody id="runsTableBody">
{% if runs %}
{% for run in runs %}
<tr class="run-row" data-search="{{ run.job_label|lower }} {{ run.job_id|lower }} {{ run.vm_name|lower }} {{ run.status|lower }}">
<td>
<div style="font-weight: 600; color: var(--text-primary);">{{ run.job_label or 'Job (No Label)' }}</div>
<div class="text-small text-muted mono" style="font-size: 11px;">ID: {{ run.job_id[:12] }}...</div>
</td>
<td>
<span style="font-weight: 500; color: var(--text-secondary);">{{ run.vm_name }}</span>
</td>
<td>{{ run.size_gb }} GB</td>
<td>{{ run.duration_fmt }}</td>
<td>
<div style="font-size: 12.5px;">{{ run.started_fmt }}</div>
</td>
<td>
{% if run.notification_sent %}
<span style="color: var(--success); font-weight: 600; display: inline-flex; align-items: center; gap: 4px;">
<span style="font-size: 11px;">🟢</span> Sent
</span>
{% else %}
<span style="color: var(--text-muted); display: inline-flex; align-items: center; gap: 4px;">
<span style="font-size: 11px;"></span> Skipped/None
</span>
{% endif %}
</td>
<td class="text-right">
{% if run.status.lower() in ('finished', 'success') %}
<span class="badge badge-green">Success</span>
{% elif 'failed' in run.status.lower() %}
<span class="badge badge-red" title="{{ run.status }}">Failed</span>
{% elif 'error' in run.status.lower() %}
<span class="badge badge-yellow" title="{{ run.status }}">Error</span>
{% else %}
<span class="badge badge-gray" title="{{ run.status }}">{{ run.status }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="7" style="text-align: center; padding: 48px; color: var(--text-muted);">
No historical backup runs logged yet. Runs will appear here as scheduled or manual backups complete.
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Dynamic JavaScript Search Filtering
const searchInput = document.getElementById('runSearch');
const tableRows = document.querySelectorAll('.run-row');
if (searchInput) {
searchInput.addEventListener('input', function() {
const query = this.value.toLowerCase().trim();
let matches = 0;
tableRows.forEach(row => {
const searchText = row.getAttribute('data-search');
if (searchText.includes(query)) {
row.style.display = '';
matches++;
} else {
row.style.display = 'none';
}
});
let noMatchRow = document.getElementById('no-runs-match');
if (matches === 0 && tableRows.length > 0) {
if (!noMatchRow) {
const tbody = document.getElementById('runsTableBody');
noMatchRow = document.createElement('tr');
noMatchRow.id = 'no-runs-match';
noMatchRow.innerHTML = '<td colspan="7" style="text-align:center;padding:32px;color:var(--text-muted);">No matching runs found.</td>';
tbody.appendChild(noMatchRow);
}
} else if (noMatchRow) {
noMatchRow.remove();
}
});
}
// Load and render Chart.js plots
document.addEventListener("DOMContentLoaded", function() {
fetch('/api/reports-data')
.then(response => response.json())
.then(data => {
const runs = data.runs || [];
// Extract chart vectors
const labels = runs.map(r => r.date);
const sizes = runs.map(r => r.size_gb);
const durations = runs.map(r => r.duration_sec);
const statuses = runs.map(r => r.status);
// Size Chart Settings
const sizeCtx = document.getElementById('sizeChart').getContext('2d');
const sizeGrad = sizeCtx.createLinearGradient(0, 0, 0, 240);
sizeGrad.addColorStop(0, 'rgba(6, 182, 212, 0.4)');
sizeGrad.addColorStop(1, 'rgba(6, 182, 212, 0.0)');
new Chart(sizeCtx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Backup Size (GB)',
data: sizes,
borderColor: '#06b6d4',
borderWidth: 2.5,
backgroundColor: sizeGrad,
fill: true,
tension: 0.35,
pointBackgroundColor: '#06b6d4',
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
x: {
grid: { color: 'rgba(255, 255, 255, 0.05)' },
ticks: { color: '#94a3b8', font: { size: 10 } }
},
y: {
grid: { color: 'rgba(255, 255, 255, 0.05)' },
ticks: { color: '#94a3b8', font: { size: 10 } }
}
}
}
});
// Duration Chart Settings
const durationCtx = document.getElementById('durationChart').getContext('2d');
const durationGrad = durationCtx.createLinearGradient(0, 0, 0, 240);
durationGrad.addColorStop(0, 'rgba(99, 102, 241, 0.4)');
durationGrad.addColorStop(1, 'rgba(99, 102, 241, 0.0)');
new Chart(durationCtx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Duration (Seconds)',
data: durations,
borderColor: '#6366f1',
borderWidth: 2.5,
backgroundColor: durationGrad,
fill: true,
tension: 0.35,
pointBackgroundColor: '#6366f1',
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
x: {
grid: { color: 'rgba(255, 255, 255, 0.05)' },
ticks: { color: '#94a3b8', font: { size: 10 } }
},
y: {
grid: { color: 'rgba(255, 255, 255, 0.05)' },
ticks: { color: '#94a3b8', font: { size: 10 } }
}
}
}
});
})
.catch(err => console.error("Error drawing charts:", err));
});
</script>
{% endblock %}

287
templates/settings.html Normal file
View File

@ -0,0 +1,287 @@
{% extends "base.html" %}
{% set active_page = 'settings' %}
{% block title %}System Settings — vSphere Backup Manager{% endblock %}
{% block head %}
<style>
.settings-wrap { max-width: 800px; margin: 0 auto; }
.section-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 24px;
overflow: hidden;
box-shadow: var(--shadow);
backdrop-filter: blur(8px);
transition: border-color 0.25s ease;
}
.section-card.enabled {
border-color: rgba(99, 102, 241, 0.4);
}
.section-card-header {
padding: 18px 24px;
border-bottom: 1px solid var(--border);
background: rgba(255,255,255,0.01);
display: flex; align-items: center; gap: 12px;
font-size: 15px; font-weight: 700;
letter-spacing: -0.01em;
}
.section-card-body { padding: 24px; }
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
@media (max-width: 600px) {
.form-row { grid-template-columns: 1fr; }
}
.form-group {
margin-bottom: 16px;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-check {
display: flex;
align-items: center;
gap: 10px;
}
.form-check input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--accent);
cursor: pointer;
}
.form-check label {
cursor: pointer;
user-select: none;
}
.settings-toggle-area {
transition: opacity 0.25s ease, max-height 0.25s ease;
overflow: hidden;
}
.settings-toggle-area.collapsed {
max-height: 0;
opacity: 0;
pointer-events: none;
margin-top: 0;
}
.settings-toggle-area.expanded {
max-height: 1000px;
opacity: 1;
margin-top: 20px;
}
.action-bar {
display: flex;
gap: 14px;
align-items: center;
padding: 16px 0 32px;
border-top: 1px solid var(--border);
margin-top: 8px;
}
.banner-tip {
padding: 14px 18px;
background: rgba(6,182,212,0.06);
border: 1px solid rgba(6,182,212,0.18);
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 20px;
display: flex;
align-items: flex-start;
gap: 10px;
}
.banner-tip svg {
color: var(--accent-2);
flex-shrink: 0;
margin-top: 2px;
}
</style>
{% endblock %}
{% block content %}
<div class="topbar">
<div>
<div class="topbar-title">Global Settings</div>
<div class="topbar-subtitle">Configure SMTP connection details, Webhook channels, and Log retention policies</div>
</div>
</div>
<div class="content">
<div class="settings-wrap">
<div class="banner-tip">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
<div>
<strong>Diagnostic Connection Testing:</strong> You can click the **"Send Test Notification"** button at the bottom of the page to verify your credentials synchronously. It tests whatever inputs are typed in the form, without requiring you to save them first.
</div>
</div>
<form method="post" action="{{ url_for('settings_page') }}" id="settingsForm">
<!-- SMTP Section -->
<div class="section-card {% if settings.smtp_enabled %}enabled{% endif %}" id="smtpCard">
<div class="section-card-header">
<span style="font-size:18px;">📧</span> SMTP Email Configuration
</div>
<div class="section-card-body">
<div class="form-check">
<input type="checkbox" id="smtp_enabled" name="smtp_enabled" {% if settings.smtp_enabled %}checked{% endif %} onchange="toggleSmtp(this.checked)" />
<label for="smtp_enabled" style="font-weight:600; font-size:14.5px;">Enable SMTP Email Notifications</label>
</div>
<div class="settings-toggle-area {% if settings.smtp_enabled %}expanded{% else %}collapsed{% endif %}" id="smtpFields">
<div class="form-row">
<div class="form-group">
<label class="form-label" for="smtp_host">SMTP Server Host</label>
<input type="text" id="smtp_host" name="smtp_host" class="form-control" placeholder="e.g. smtp.gmail.com" value="{{ settings.smtp_host }}" />
</div>
<div class="form-group">
<label class="form-label" for="smtp_port">SMTP Port</label>
<input type="text" id="smtp_port" name="smtp_port" class="form-control" placeholder="e.g. 587 or 465" value="{{ settings.smtp_port }}" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="smtp_user">SMTP Username (Optional)</label>
<input type="text" id="smtp_user" name="smtp_user" class="form-control" placeholder="e.g. admin@company.com" value="{{ settings.smtp_user }}" autocomplete="off" />
</div>
<div class="form-group">
<label class="form-label" for="smtp_password">SMTP Password (Optional)</label>
<input type="password" id="smtp_password" name="smtp_password" class="form-control" placeholder="••••••••" value="{{ settings.smtp_password }}" autocomplete="new-password" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="smtp_sender">Sender Email Address</label>
<input type="email" id="smtp_sender" name="smtp_sender" class="form-control" placeholder="e.g. backups@company.com" value="{{ settings.smtp_sender }}" />
</div>
<div class="form-group">
<label class="form-label" for="smtp_recipient">Recipient Email Address</label>
<input type="email" id="smtp_recipient" name="smtp_recipient" class="form-control" placeholder="e.g. admin@company.com" value="{{ settings.smtp_recipient }}" />
</div>
</div>
</div>
</div>
</div>
<!-- Webhook Section -->
<div class="section-card {% if settings.webhook_enabled %}enabled{% endif %}" id="webhookCard">
<div class="section-card-header">
<span style="font-size:18px;">🔗</span> Webhook Alert Configuration
</div>
<div class="section-card-body">
<div class="form-check">
<input type="checkbox" id="webhook_enabled" name="webhook_enabled" {% if settings.webhook_enabled %}checked{% endif %} onchange="toggleWebhook(this.checked)" />
<label for="webhook_enabled" style="font-weight:600; font-size:14.5px;">Enable Webhook Notifications</label>
</div>
<div class="settings-toggle-area {% if settings.webhook_enabled %}expanded{% else %}collapsed{% endif %}" id="webhookFields">
<div class="form-group">
<label class="form-label" for="webhook_url">Webhook Destination URL</label>
<input type="url" id="webhook_url" name="webhook_url" class="form-control" placeholder="e.g. https://discord.com/api/webhooks/... or https://hooks.slack.com/services/..." value="{{ settings.webhook_url }}" />
</div>
<div class="form-group">
<label class="form-label" for="webhook_type">Webhook Payload Format</label>
<select id="webhook_type" name="webhook_type" class="form-control">
<option value="slack_discord" {% if settings.webhook_type == 'slack_discord' %}selected{% endif %}>Slack & Discord Format (Rich Embeds / Color Bars)</option>
<option value="raw_json" {% if settings.webhook_type == 'raw_json' %}selected{% endif %}>Raw JSON Payload (Full backup runs telemetry data)</option>
</select>
</div>
</div>
</div>
</div>
<!-- Notification Rules & Log Retention -->
<div class="section-card">
<div class="section-card-header">
<span style="font-size:18px;">⚙️</span> Global Notification & Retention Rules
</div>
<div class="section-card-body">
<div class="form-row">
<div class="form-group">
<label class="form-label" for="alert_level">Alert Trigger Threshold</label>
<select id="alert_level" name="alert_level" class="form-control">
<option value="all" {% if settings.alert_level == 'all' %}selected{% endif %}>Notify on All Completed Runs (Success and Failures)</option>
<option value="failed" {% if settings.alert_level == 'failed' %}selected{% endif %}>Notify on Failures / Errors Only</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="log_retention_days">Run History Retention Policy</label>
<select id="log_retention_days" name="log_retention_days" class="form-control">
<option value="never" {% if settings.log_retention_days == 'never' %}selected{% endif %}>Never delete (Keep full logs history)</option>
<option value="30" {% if settings.log_retention_days == '30' %}selected{% endif %}>Keep logs for 30 Days</option>
<option value="90" {% if settings.log_retention_days == '90' %}selected{% endif %}>Keep logs for 90 Days</option>
<option value="180" {% if settings.log_retention_days == '180' %}selected{% endif %}>Keep logs for 180 Days</option>
<option value="365" {% if settings.log_retention_days == '365' %}selected{% endif %}>Keep logs for 1 Year (365 Days)</option>
</select>
</div>
</div>
</div>
</div>
<!-- Action buttons -->
<div class="action-bar">
<button type="submit" class="btn btn-primary">
💾 Save Settings
</button>
<button type="submit" formaction="{{ url_for('settings_test_notification') }}" class="btn btn-secondary" id="testAlertBtn">
🔔 Send Test Notification
</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function toggleSmtp(checked) {
const fields = document.getElementById('smtpFields');
const card = document.getElementById('smtpCard');
if (checked) {
fields.classList.remove('collapsed');
fields.classList.add('expanded');
card.classList.add('enabled');
} else {
fields.classList.remove('expanded');
fields.classList.add('collapsed');
card.classList.remove('enabled');
}
}
function toggleWebhook(checked) {
const fields = document.getElementById('webhookFields');
const card = document.getElementById('webhookCard');
if (checked) {
fields.classList.remove('collapsed');
fields.classList.add('expanded');
card.classList.add('enabled');
} else {
fields.classList.remove('expanded');
fields.classList.add('collapsed');
card.classList.remove('enabled');
}
}
document.getElementById('settingsForm').addEventListener('submit', function(e) {
const btn = document.activeElement;
if (btn && btn.id === 'testAlertBtn') {
btn.textContent = 'Sending Diagnostic...';
}
});
</script>
{% endblock %}