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(
|
description = _compose_autotask_ticket_description(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
job=job,
|
job=job,
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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]
|
## [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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user