feat: implement dashboard and reports view with interactive analytics and history logging
This commit is contained in:
parent
99ab2d06b2
commit
65962a8353
609
gui_app.py
609
gui_app.py
@ -62,6 +62,26 @@ def init_db():
|
|||||||
data TEXT
|
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()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@ -459,6 +479,405 @@ def fmt_time(ts):
|
|||||||
return datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M:%S') if ts else '—'
|
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 · 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):
|
def job_to_display(jid, info):
|
||||||
"""Convert internal job dict to template-friendly dict."""
|
"""Convert internal job dict to template-friendly dict."""
|
||||||
disk_filter = info.get('disk_filter')
|
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)}"
|
'detail': f"Batch completed. Success: {len(success_vms)}, Failed: {len(failed_vms)}"
|
||||||
}
|
}
|
||||||
save_jobs_db()
|
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:
|
else:
|
||||||
# Single VM backup run (original behavior)
|
# Single VM backup run (original behavior)
|
||||||
@ -846,6 +1269,11 @@ def run_job_thread(jid):
|
|||||||
}
|
}
|
||||||
enforce_retention_policy(rep_info, log_path=log_path)
|
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(
|
def create_and_start_job(
|
||||||
vm_name, dest, compress, no_verify_ssl,
|
vm_name, dest, compress, no_verify_ssl,
|
||||||
@ -961,6 +1389,103 @@ def logout():
|
|||||||
return redirect(url_for('login'))
|
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 ────────────────────────────────────────────────────────────────
|
# ── VM Browser ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route('/vms')
|
@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 ────────────────────────────────────────────────────────────
|
# ── Jobs Dashboard ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route('/jobs')
|
@app.route('/jobs')
|
||||||
|
|||||||
@ -539,6 +539,18 @@
|
|||||||
</span>
|
</span>
|
||||||
NFS Manager
|
NFS Manager
|
||||||
</a>
|
</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>
|
</nav>
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
@ -666,7 +678,9 @@
|
|||||||
{ icon: '🖥', label: 'Virtual Machines', kbd: '', action: function() { window.location.href = '/vms'; } },
|
{ icon: '🖥', label: 'Virtual Machines', kbd: '', action: function() { window.location.href = '/vms'; } },
|
||||||
{ 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: '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: 'Refresh Page', kbd: 'R', action: function() { location.reload(); } },
|
{ icon: '🔄', label: 'Refresh Page', kbd: 'R', action: function() { location.reload(); } },
|
||||||
{ icon: '🚪', label: 'Logout', kbd: '', action: function() { window.location.href = '/logout'; } },
|
{ icon: '🚪', label: 'Logout', kbd: '', action: function() { window.location.href = '/logout'; } },
|
||||||
];
|
];
|
||||||
|
|||||||
413
templates/reports.html
Normal file
413
templates/reports.html
Normal 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
287
templates/settings.html
Normal 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 %}
|
||||||
Loading…
Reference in New Issue
Block a user