Merge branch 'v20260209-05-responsive-navbar-fix' into main
This commit is contained in:
commit
2a03ff0764
@ -1 +1 @@
|
||||
v20260209-01-fix-ticket-description
|
||||
v20260209-05-responsive-navbar-fix
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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/<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"])
|
||||
@login_required
|
||||
@roles_required("admin")
|
||||
|
||||
@ -197,7 +197,7 @@
|
||||
</div>
|
||||
</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) %}
|
||||
{% if messages %}
|
||||
<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>
|
||||
// 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>
|
||||
(function () {
|
||||
function isOverflowing(el) {
|
||||
|
||||
@ -549,6 +549,36 @@
|
||||
</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="card h-100 border-danger">
|
||||
<div class="card-header bg-danger text-white">Jobs maintenance</div>
|
||||
|
||||
@ -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 %}
|
||||
@ -4,8 +4,17 @@ This file documents all changes made to this project via Claude Code.
|
||||
|
||||
## [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 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
|
||||
- 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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user