diff --git a/gui_app.py b/gui_app.py
index 672925b..0cd2345 100644
--- a/gui_app.py
+++ b/gui_app.py
@@ -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"""
+
+
+
+
+
+
+
+
+
+
Backup Job: {status_text}
+
+
+
+
+
Job Label
+
{run_data['job_label'] or '—'}
+
+
+
VM Name(s)
+
{run_data['vm_name']}
+
+
+
Size
+
{size_gb:.2f} GB
+
+
+
Duration
+
{duration_str}
+
+
+
Start Time
+
{started_str}
+
+
+
End Time
+
{ended_str}
+
+
+
Status
+
{run_data['status']}
+
+
+
+
+
+
+
+ """
+
+ 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)
@@ -845,6 +1268,11 @@ def run_job_thread(jid):
'retention_value': info.get('retention_value', 5)
}
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(
@@ -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')
diff --git a/templates/base.html b/templates/base.html
index 8afa7e3..a95c823 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -539,6 +539,18 @@
NFS Manager
+
+
+
+
+ Reports & Analytics
+
+
+
+
+
+ System Settings
+