Merge branch 'v20260209-05-responsive-navbar-fix' into main

This commit is contained in:
Ivo Oskamp 2026-02-09 17:25:19 +01:00
commit 2a03ff0764
7 changed files with 621 additions and 3 deletions

View File

@ -1 +1 @@
v20260209-01-fix-ticket-description v20260209-05-responsive-navbar-fix

View File

@ -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( description = _compose_autotask_ticket_description(
settings=settings, settings=settings,
job=job, job=job,

View File

@ -124,6 +124,446 @@ def settings_jobs_delete_all():
return redirect(url_for("main.settings")) 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/<status_type>", 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"<pre>{email_data['body']}</pre>",
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"]) @main_bp.route("/settings/objects/backfill", methods=["POST"])
@login_required @login_required
@roles_required("admin") @roles_required("admin")

View File

@ -197,7 +197,7 @@
</div> </div>
</nav> </nav>
<main class="{% block main_class %}container content-container{% endblock %}" style="padding-top: 80px;"> <main class="{% block main_class %}container content-container{% endblock %}" id="main-content">
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
<div class="mb-3"> <div class="mb-3">
@ -216,6 +216,58 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Dynamic navbar height adjustment
(function () {
function adjustContentPadding() {
try {
var navbar = document.querySelector('.navbar.fixed-top');
var mainContent = document.getElementById('main-content');
if (!navbar || !mainContent) return;
// Get actual navbar height
var navbarHeight = navbar.offsetHeight;
// Add small buffer (20px) for visual spacing
var paddingTop = navbarHeight + 20;
// Apply padding to main content
mainContent.style.paddingTop = paddingTop + 'px';
} catch (e) {
// Fallback to 80px if something goes wrong
var mainContent = document.getElementById('main-content');
if (mainContent) {
mainContent.style.paddingTop = '80px';
}
}
}
// Run on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', adjustContentPadding);
} else {
adjustContentPadding();
}
// Run after navbar is fully rendered
window.addEventListener('load', adjustContentPadding);
// Run on window resize
var resizeTimeout;
window.addEventListener('resize', function () {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(adjustContentPadding, 100);
});
// Run when navbar collapse is toggled
var navbarCollapse = document.getElementById('navbarNav');
if (navbarCollapse) {
navbarCollapse.addEventListener('shown.bs.collapse', adjustContentPadding);
navbarCollapse.addEventListener('hidden.bs.collapse', adjustContentPadding);
}
})();
</script>
<script> <script>
(function () { (function () {
function isOverflowing(el) { function isOverflowing(el) {

View File

@ -549,6 +549,36 @@
</div> </div>
</div> </div>
<div class="col-12 col-lg-6">
<div class="card h-100 border-warning">
<div class="card-header bg-warning">Cleanup orphaned jobs</div>
<div class="card-body">
<p class="mb-3">Delete jobs that are no longer linked to an existing customer. Related emails and runs will be <strong>permanently deleted</strong> from the database.</p>
<a href="{{ url_for('main.settings_jobs_orphaned') }}" class="btn btn-warning">Preview orphaned jobs</a>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card h-100 border-info">
<div class="card-header bg-info text-white">Generate test emails</div>
<div class="card-body">
<p class="mb-3">Generate fixed test email sets in the inbox for testing parsers and maintenance operations. Each set contains 3 emails simulating Veeam, Synology, and NAKIVO backups.</p>
<div class="d-flex flex-column gap-2">
<form method="post" action="{{ url_for('main.settings_generate_test_emails', status_type='success') }}">
<button type="submit" class="btn btn-success w-100">Generate success emails (3)</button>
</form>
<form method="post" action="{{ url_for('main.settings_generate_test_emails', status_type='warning') }}">
<button type="submit" class="btn btn-warning w-100">Generate warning emails (3)</button>
</form>
<form method="post" action="{{ url_for('main.settings_generate_test_emails', status_type='error') }}">
<button type="submit" class="btn btn-danger w-100">Generate error emails (3)</button>
</form>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
<div class="card h-100 border-danger"> <div class="card h-100 border-danger">
<div class="card-header bg-danger text-white">Jobs maintenance</div> <div class="card-header bg-danger text-white">Jobs maintenance</div>

View File

@ -0,0 +1,87 @@
{% extends "layout/base.html" %}
{% block title %}Orphaned Jobs Preview{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2>Orphaned Jobs Preview</h2>
<p class="text-muted mb-0">Jobs without a valid customer link</p>
</div>
<a href="{{ url_for('main.settings', section='maintenance') }}" class="btn btn-outline-secondary">Back to Settings</a>
</div>
{% if orphaned_jobs %}
<div class="alert alert-warning">
<strong>⚠️ Warning:</strong> Found {{ orphaned_jobs|length }} orphaned job(s). Review the list below before deleting.
</div>
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Orphaned Jobs List</span>
<form method="post" action="{{ url_for('main.settings_jobs_delete_orphaned') }}" onsubmit="return confirm('Delete all {{ orphaned_jobs|length }} orphaned jobs and their emails? This cannot be undone.');">
<button type="submit" class="btn btn-sm btn-danger">Delete All ({{ orphaned_jobs|length }} jobs)</button>
</form>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Job Name</th>
<th>Backup Software</th>
<th>Backup Type</th>
<th>Customer ID</th>
<th class="text-end">Runs</th>
<th class="text-end">Emails</th>
</tr>
</thead>
<tbody>
{% for job in orphaned_jobs %}
<tr>
<td>{{ job.job_name }}</td>
<td>{{ job.backup_software }}</td>
<td>{{ job.backup_type }}</td>
<td>
{% if job.customer_id %}
<span class="badge bg-danger">{{ job.customer_id }} (deleted)</span>
{% else %}
<span class="badge bg-secondary">NULL</span>
{% endif %}
</td>
<td class="text-end">{{ job.run_count }}</td>
<td class="text-end">{{ job.mail_count }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="table-light">
<td colspan="4"><strong>Total</strong></td>
<td class="text-end"><strong>{{ orphaned_jobs|sum(attribute='run_count') }}</strong></td>
<td class="text-end"><strong>{{ orphaned_jobs|sum(attribute='mail_count') }}</strong></td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="alert alert-info">
<strong> What will be deleted:</strong>
<ul class="mb-0">
<li>{{ orphaned_jobs|length }} job(s)</li>
<li>{{ orphaned_jobs|sum(attribute='run_count') }} job run(s)</li>
<li>{{ orphaned_jobs|sum(attribute='mail_count') }} email(s)</li>
<li>All related data (backup objects, ticket/remark links, scopes, overrides)</li>
</ul>
</div>
{% else %}
<div class="alert alert-success">
<strong>✅ No orphaned jobs found.</strong>
<p class="mb-0">All jobs are properly linked to existing customers.</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -4,8 +4,17 @@ This file documents all changes made to this project via Claude Code.
## [2026-02-09] ## [2026-02-09]
### Added
- Added "Cleanup orphaned jobs" maintenance option in Settings → Maintenance to delete jobs without valid customer links and their associated emails/runs permanently from database (useful when customers are removed)
- Added "Preview orphaned jobs" button to show detailed list of jobs to be deleted with run/email counts before confirming deletion (verification step for safety)
- Added "Generate test emails" feature in Settings → Maintenance with three separate buttons to create fixed test email sets (success/warning/error) in inbox for testing parsers and maintenance operations (each set contains exactly 3 Veeam Backup Job emails with the same job name "Test-Backup-Job" and different dates/objects/statuses for reproducible testing and proper status flow testing)
### Changed
- Removed customer name from Autotask ticket title to keep titles concise (format changed from "[Backupchecks] Customer - Job Name - Status" to "[Backupchecks] Job Name - Status")
### Fixed ### Fixed
- Fixed Autotask ticket description being set to NULL when resolving tickets via `update_ticket_resolution_safe` by adding "description" to the optional_fields list, ensuring the original description is preserved during PUT operations - Fixed Autotask ticket description being set to NULL when resolving tickets via `update_ticket_resolution_safe` by adding "description" to the optional_fields list, ensuring the original description is preserved during PUT operations
- Fixed responsive navbar overlapping page content on smaller screens by implementing dynamic padding adjustment (JavaScript measures actual navbar height and adjusts main content padding-top automatically on page load, window resize, and navbar collapse toggle events)
### Changed ### Changed
- Updated `docs/changelog.md` with comprehensive v0.1.23 release notes consolidating all changes from 2026-02-06 through 2026-02-08 (Documentation System, Audit Logging, Timezone-Aware Display, Autotask Improvements, Environment Identification, Bug Fixes) - Updated `docs/changelog.md` with comprehensive v0.1.23 release notes consolidating all changes from 2026-02-06 through 2026-02-08 (Documentation System, Audit Logging, Timezone-Aware Display, Autotask Improvements, Environment Identification, Bug Fixes)