diff --git a/.last-branch b/.last-branch index b7e5a1e..261aab8 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260209-01-fix-ticket-description +v20260209-05-responsive-navbar-fix diff --git a/containers/backupchecks/src/backend/app/main/routes_run_checks.py b/containers/backupchecks/src/backend/app/main/routes_run_checks.py index 72ce89a..12ee4b0 100644 --- a/containers/backupchecks/src/backend/app/main/routes_run_checks.py +++ b/containers/backupchecks/src/backend/app/main/routes_run_checks.py @@ -1464,7 +1464,7 @@ def api_run_checks_create_autotask_ticket(): } ) - subject = f"[Backupchecks] {customer.name} - {job.job_name or ''} - {status_display}" + subject = f"[Backupchecks] {job.job_name or ''} - {status_display}" description = _compose_autotask_ticket_description( settings=settings, job=job, diff --git a/containers/backupchecks/src/backend/app/main/routes_settings.py b/containers/backupchecks/src/backend/app/main/routes_settings.py index 2a16256..5fde857 100644 --- a/containers/backupchecks/src/backend/app/main/routes_settings.py +++ b/containers/backupchecks/src/backend/app/main/routes_settings.py @@ -124,6 +124,446 @@ def settings_jobs_delete_all(): return redirect(url_for("main.settings")) +@main_bp.route("/settings/jobs/orphaned", methods=["GET"]) +@login_required +@roles_required("admin") +def settings_jobs_orphaned(): + """Show list of orphaned jobs for verification before deletion.""" + # Find jobs without valid customer + orphaned_jobs = Job.query.outerjoin(Customer, Job.customer_id == Customer.id).filter( + db.or_( + Job.customer_id.is_(None), + Customer.id.is_(None) + ) + ).order_by(Job.job_name.asc()).all() + + # Build list with details + jobs_list = [] + for job in orphaned_jobs: + run_count = JobRun.query.filter_by(job_id=job.id).count() + mail_count = JobRun.query.filter_by(job_id=job.id).filter(JobRun.mail_message_id.isnot(None)).count() + + jobs_list.append({ + "id": job.id, + "job_name": job.job_name or "Unnamed", + "backup_software": job.backup_software or "-", + "backup_type": job.backup_type or "-", + "customer_id": job.customer_id, + "run_count": run_count, + "mail_count": mail_count, + }) + + return render_template( + "main/settings_orphaned_jobs.html", + orphaned_jobs=jobs_list, + ) + + +@main_bp.route("/settings/jobs/delete-orphaned", methods=["POST"]) +@login_required +@roles_required("admin") +def settings_jobs_delete_orphaned(): + """Delete jobs that have no customer (customer_id is NULL or customer does not exist). + + Also deletes all related emails from the database since the customer is gone. + """ + try: + # Find jobs without valid customer + orphaned_jobs = Job.query.outerjoin(Customer, Job.customer_id == Customer.id).filter( + db.or_( + Job.customer_id.is_(None), + Customer.id.is_(None) + ) + ).all() + + if not orphaned_jobs: + flash("No orphaned jobs found.", "info") + return redirect(url_for("main.settings", section="maintenance")) + + job_count = len(orphaned_jobs) + mail_count = 0 + run_count = 0 + + # Collect mail message ids and run ids for cleanup + mail_message_ids = [] + run_ids = [] + job_ids = [job.id for job in orphaned_jobs] + + for job in orphaned_jobs: + for run in job.runs: + if run.id is not None: + run_ids.append(run.id) + run_count += 1 + if run.mail_message_id: + mail_message_ids.append(run.mail_message_id) + + # Helper function for safe SQL execution + def _safe_execute(stmt, params): + try: + db.session.execute(stmt, params) + except Exception: + pass + + # Clean up auxiliary tables that may not have ON DELETE CASCADE + if run_ids: + from sqlalchemy import text, bindparam + _safe_execute( + text("DELETE FROM ticket_job_runs WHERE job_run_id IN :run_ids").bindparams( + bindparam("run_ids", expanding=True) + ), + {"run_ids": run_ids}, + ) + _safe_execute( + text("DELETE FROM remark_job_runs WHERE job_run_id IN :run_ids").bindparams( + bindparam("run_ids", expanding=True) + ), + {"run_ids": run_ids}, + ) + + if job_ids: + from sqlalchemy import text, bindparam + # Clean up scopes + _safe_execute( + text("DELETE FROM ticket_scopes WHERE job_id IN :job_ids").bindparams( + bindparam("job_ids", expanding=True) + ), + {"job_ids": job_ids}, + ) + _safe_execute( + text("DELETE FROM remark_scopes WHERE job_id IN :job_ids").bindparams( + bindparam("job_ids", expanding=True) + ), + {"job_ids": job_ids}, + ) + # Clean up overrides + _safe_execute( + text("DELETE FROM overrides WHERE job_id IN :job_ids").bindparams( + bindparam("job_ids", expanding=True) + ), + {"job_ids": job_ids}, + ) + + # Unlink mails from jobs before deleting jobs + # mail_messages.job_id references jobs.id + _safe_execute( + text("UPDATE mail_messages SET job_id = NULL WHERE job_id IN :job_ids").bindparams( + bindparam("job_ids", expanding=True) + ), + {"job_ids": job_ids}, + ) + + # Delete mail_objects before deleting mails + # mail_objects.mail_message_id references mail_messages.id + if mail_message_ids: + from sqlalchemy import text, bindparam + _safe_execute( + text("DELETE FROM mail_objects WHERE mail_message_id IN :mail_ids").bindparams( + bindparam("mail_ids", expanding=True) + ), + {"mail_ids": mail_message_ids}, + ) + + # Delete all orphaned jobs (runs/objects are cascaded via ORM relationships) + for job in orphaned_jobs: + db.session.delete(job) + + # Now delete related mails permanently (customer is gone) + # This must happen AFTER deleting jobs/runs to avoid foreign key constraint violations + if mail_message_ids: + mail_count = len(mail_message_ids) + MailMessage.query.filter(MailMessage.id.in_(mail_message_ids)).delete(synchronize_session=False) + + db.session.commit() + + flash( + f"Deleted {job_count} orphaned job(s), {run_count} run(s), and {mail_count} email(s).", + "success" + ) + + _log_admin_event( + event_type="maintenance_delete_orphaned_jobs", + message=f"Deleted {job_count} orphaned jobs, {run_count} runs, and {mail_count} emails", + details=json.dumps({ + "jobs_deleted": job_count, + "runs_deleted": run_count, + "mails_deleted": mail_count, + }), + ) + + except Exception as exc: + db.session.rollback() + print(f"[settings-jobs] Failed to delete orphaned jobs: {exc}") + flash("Failed to delete orphaned jobs.", "danger") + + return redirect(url_for("main.settings", section="maintenance")) + + +@main_bp.route("/settings/test-emails/generate/", methods=["POST"]) +@login_required +@roles_required("admin") +def settings_generate_test_emails(status_type): + """Generate test emails in inbox for testing parsers and orphaned jobs cleanup. + + Fixed sets for consistent testing and reproducibility. + """ + try: + from datetime import datetime, timedelta + + # Fixed test email sets per status type (Veeam only for consistent testing) + # All emails use the same job name "Test-Backup-Job" so they appear as different + # runs of the same job for proper status testing + email_sets = { + "success": [ + { + "from_address": "veeam@test.local", + "subject": 'Veeam Backup Job "Test-Backup-Job" finished with Success', + "body": """Backup job: Test-Backup-Job + +Session details: +Start time: 2026-02-09 01:00:00 +End time: 2026-02-09 02:15:00 +Total size: 150 GB +Duration: 01:15:00 + +Processing VM-APP01 +Success + +Processing VM-DB01 +Success + +Processing VM-WEB01 +Success + +All backup operations completed without issues.""", + }, + { + "from_address": "veeam@test.local", + "subject": 'Veeam Backup Job "Test-Backup-Job" finished with Success', + "body": """Backup job: Test-Backup-Job + +Session details: +Start time: 2026-02-08 01:00:00 +End time: 2026-02-08 02:10:00 +Total size: 145 GB +Duration: 01:10:00 + +Processing VM-APP01 +Success + +Processing VM-DB01 +Success + +Processing VM-WEB01 +Success + +All backup operations completed without issues.""", + }, + { + "from_address": "veeam@test.local", + "subject": 'Veeam Backup Job "Test-Backup-Job" finished with Success', + "body": """Backup job: Test-Backup-Job + +Session details: +Start time: 2026-02-07 01:00:00 +End time: 2026-02-07 02:20:00 +Total size: 152 GB +Duration: 01:20:00 + +Processing VM-APP01 +Success + +Processing VM-DB01 +Success + +Processing VM-WEB01 +Success + +All backup operations completed without issues.""", + }, + ], + "warning": [ + { + "from_address": "veeam@test.local", + "subject": 'Veeam Backup Job "Test-Backup-Job" finished with WARNING', + "body": """Backup job: Test-Backup-Job + +Session details: +Start time: 2026-02-09 01:00:00 +End time: 2026-02-09 02:30:00 +Total size: 148 GB +Duration: 01:30:00 + +Processing VM-APP01 +Success + +Processing VM-DB01 +Warning +Warning: Low free space on target datastore + +Processing VM-WEB01 +Success + +Backup completed but some files were skipped.""", + }, + { + "from_address": "veeam@test.local", + "subject": 'Veeam Backup Job "Test-Backup-Job" finished with WARNING', + "body": """Backup job: Test-Backup-Job + +Session details: +Start time: 2026-02-08 01:00:00 +End time: 2026-02-08 02:25:00 +Total size: 142 GB +Duration: 01:25:00 + +Processing VM-APP01 +Warning +Warning: Retry was successful after initial failure + +Processing VM-DB01 +Success + +Processing VM-WEB01 +Success + +Backup completed with warnings.""", + }, + { + "from_address": "veeam@test.local", + "subject": 'Veeam Backup Job "Test-Backup-Job" finished with WARNING', + "body": """Backup job: Test-Backup-Job + +Session details: +Start time: 2026-02-07 01:00:00 +End time: 2026-02-07 02:35:00 +Total size: 140 GB +Duration: 01:35:00 + +Processing VM-APP01 +Success + +Processing VM-DB01 +Success + +Processing VM-WEB01 +Warning +Warning: Some files were locked and skipped + +Backup completed with warnings.""", + }, + ], + "error": [ + { + "from_address": "veeam@test.local", + "subject": 'Veeam Backup Job "Test-Backup-Job" finished with Failed', + "body": """Backup job: Test-Backup-Job + +Session details: +Start time: 2026-02-09 01:00:00 +End time: 2026-02-09 01:15:00 +Total size: 0 GB +Duration: 00:15:00 + +Processing VM-APP01 +Failed +Error: Cannot create snapshot: VSS error 0x800423f4 + +Processing VM-DB01 +Success + +Processing VM-WEB01 +Success + +Backup failed. Please check the logs for details.""", + }, + { + "from_address": "veeam@test.local", + "subject": 'Veeam Backup Job "Test-Backup-Job" finished with Failed', + "body": """Backup job: Test-Backup-Job + +Session details: +Start time: 2026-02-08 01:00:00 +End time: 2026-02-08 01:10:00 +Total size: 0 GB +Duration: 00:10:00 + +Processing VM-APP01 +Success + +Processing VM-DB01 +Failed +Error: Disk space exhausted on backup repository + +Processing VM-WEB01 +Success + +Backup failed due to storage issue.""", + }, + { + "from_address": "veeam@test.local", + "subject": 'Veeam Backup Job "Test-Backup-Job" finished with Failed', + "body": """Backup job: Test-Backup-Job + +Session details: +Start time: 2026-02-07 01:00:00 +End time: 2026-02-07 01:05:00 +Total size: 0 GB +Duration: 00:05:00 + +Processing VM-APP01 +Success + +Processing VM-DB01 +Success + +Processing VM-WEB01 +Failed +Error: Network connection lost to ESXi host + +Backup failed. Network issue detected.""", + }, + ], + } + + if status_type not in email_sets: + flash("Invalid status type.", "danger") + return redirect(url_for("main.settings", section="maintenance")) + + emails = email_sets[status_type] + created_count = 0 + now = datetime.utcnow() + + for email_data in emails: + mail = MailMessage( + from_address=email_data["from_address"], + subject=email_data["subject"], + text_body=email_data["body"], + html_body=f"
{email_data['body']}
", + received_at=now - timedelta(hours=created_count), + location="inbox", + job_id=None, + ) + db.session.add(mail) + created_count += 1 + + db.session.commit() + + flash(f"Generated {created_count} {status_type} test email(s) in inbox.", "success") + + _log_admin_event( + event_type="maintenance_generate_test_emails", + message=f"Generated {created_count} {status_type} test emails", + details=json.dumps({"status_type": status_type, "count": created_count}), + ) + + except Exception as exc: + db.session.rollback() + print(f"[settings-test] Failed to generate test emails: {exc}") + flash("Failed to generate test emails.", "danger") + + return redirect(url_for("main.settings", section="maintenance")) + + @main_bp.route("/settings/objects/backfill", methods=["POST"]) @login_required @roles_required("admin") diff --git a/containers/backupchecks/src/templates/layout/base.html b/containers/backupchecks/src/templates/layout/base.html index 346069b..4a06250 100644 --- a/containers/backupchecks/src/templates/layout/base.html +++ b/containers/backupchecks/src/templates/layout/base.html @@ -197,7 +197,7 @@ -
+
{% with messages = get_flashed_messages(with_categories=true) %} {% if messages %}
@@ -216,6 +216,58 @@ + +