diff --git a/containers/backupchecks/src/backend/app/changelog.py b/containers/backupchecks/src/backend/app/changelog.py index 6926535..6a6393e 100644 --- a/containers/backupchecks/src/backend/app/changelog.py +++ b/containers/backupchecks/src/backend/app/changelog.py @@ -3,6 +3,27 @@ Changelog data structure for Backupchecks """ CHANGELOG = [ + { + "version": "v0.3.1", + "date": "2026-05-01", + "summary": "Patch release on top of v0.3.0 with two follow-ups for the new Cove workstation handling: clear Device-offline notice in the Run Checks modal for offline-detection runs (with No-Autotask-ticket-required note), and automatic cleanup of legacy synthetic missed-runs that were generated for Cove workstations before the v0.3.0 missed-run exemption.", + "sections": [ + { + "title": "Added", + "type": "feature", + "changes": [ + "Run Checks modal: synthetic Cove offline-detection runs (external_id starts with cove-offline-) now show an info-alert above the objects table with computer name, inactive-day count, last successful run timestamp and an explicit 'No Autotask ticket required' explanation that the alert comes from the Cove 28-day colorbar rather than a real backup failure. Runs payload carries a new cove_offline_notice field; the inactive streak is recomputed live from CoveAccount.colorbar_28d." + ] + }, + { + "title": "Fixed", + "type": "bugfix", + "changes": [ + "Cove workstation missed-run exemption now also cleans up legacy synthetic missed-runs. The early-return in _ensure_missed_runs_for_job previously skipped the cleanup step, leaving unreviewed Missed rows (generated before the exemption) visible in Run Checks. Unreviewed synthetic missed-runs (missed=True, mail_message_id IS NULL, reviewed_at IS NULL) are now deleted on the next Run Checks page-load; reviewed missed-runs are kept as historical record." + ] + } + ] + }, { "version": "v0.3.0", "date": "2026-05-01", diff --git a/containers/backupchecks/src/backend/app/main/routes_customers.py b/containers/backupchecks/src/backend/app/main/routes_customers.py index eb5e1ec..e5aee4e 100644 --- a/containers/backupchecks/src/backend/app/main/routes_customers.py +++ b/containers/backupchecks/src/backend/app/main/routes_customers.py @@ -396,6 +396,15 @@ def customers_create(): try: customer = Customer(name=name, active=active) db.session.add(customer) + db.session.flush() + try: + from ..models import SystemSettings + from ..sandbox_link import apply_to_customer + _settings = SystemSettings.query.first() + if _settings is not None: + apply_to_customer(customer, _settings) + except Exception as _exc: + print(f"[customers] Sandbox auto-link on customer create failed: {_exc}") db.session.commit() flash("Customer created.", "success") except Exception as exc: @@ -602,6 +611,16 @@ def customers_import(): try: db.session.commit() + # Sandbox: rewrite all customer Autotask mappings to the configured + # sandbox company. Hard-gated; no-op outside sandbox. + try: + from ..models import SystemSettings + from ..sandbox_link import apply_sandbox_backfill_now + _settings = SystemSettings.query.first() + if _settings is not None: + apply_sandbox_backfill_now(_settings) + except Exception as _exc: + print(f"[customers] Sandbox backfill after CSV import failed: {_exc}") flash( f"Import finished. Created: {created}, Updated: {updated}, Skipped: {skipped}. Autotask IDs imported: {'yes' if include_autotask_ids else 'no'}.", "success", 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 2f28d3d..cfeca9f 100644 --- a/containers/backupchecks/src/backend/app/main/routes_run_checks.py +++ b/containers/backupchecks/src/backend/app/main/routes_run_checks.py @@ -1098,10 +1098,24 @@ def _ensure_missed_runs_for_job(job: Job, start_from: date, end_inclusive: date) # Schedule-based missed-run detection produces noise for "device was off" days, # so skip entirely. Real Cove statuses (Failed/Warning/Not started) still surface # via the regular import flow. Servers and Microsoft 365 remain unaffected. + # + # Also clean up any previously generated synthetic missed-runs for this job so + # leftover noise from before this exemption disappears the next time Run Checks + # is opened for the job. Reviewed missed-runs are kept as historical record. if ( (getattr(job, "backup_software", "") or "") == "Cove Data Protection" and (getattr(job, "backup_type", "") or "").lower() == "workstation" ): + try: + db.session.query(JobRun).filter( + JobRun.job_id == job.id, + JobRun.missed.is_(True), + JobRun.mail_message_id.is_(None), + JobRun.reviewed_at.is_(None), + ).delete(synchronize_session=False) + db.session.commit() + except Exception: + db.session.rollback() return 0 tz = _get_ui_timezone() @@ -1863,6 +1877,7 @@ def run_checks_details(): body_html = "" cloud_connect_summary = None cove_summary = None + cove_offline_notice = None # For Cove API runs, suppress the mail section entirely and show # structured Cove account details instead. @@ -1887,6 +1902,23 @@ def run_checks_details(): "last_run_at": _format_datetime(_cove_acc.last_run_at) if _cove_acc.last_run_at else "", "status": run.status or "", } + # Detect synthetic offline-detection runs (no real backup activity, + # only a "device offline for N days" alert). These have no objects + # and exist purely to surface inactivity. Build a structured notice + # so the UI can show a clear explanation instead of an empty objects + # table — and signal that no Autotask ticket is needed. + if (run.external_id or "").startswith("cove-offline-"): + from ..cove_importer import ( + _parse_colorbar_codes as _cove_parse_colorbar, + _trailing_inactive_streak as _cove_trailing_streak, + ) + _codes = _cove_parse_colorbar(_cove_acc.colorbar_28d or "") + _streak = _cove_trailing_streak(_codes) + cove_offline_notice = { + "streak_days": int(_streak), + "computer_name": _cove_acc.computer_name or _cove_acc.account_name or "", + "last_run_at": _format_datetime(_cove_acc.last_run_at) if _cove_acc.last_run_at else "", + } # No mail_meta / body_html for Cove runs — Cove has no email # For Cloud Connect runs, suppress the raw report email (it contains all @@ -2051,6 +2083,7 @@ def run_checks_details(): "body_html": body_html, "cloud_connect_summary": cloud_connect_summary, "cove_summary": cove_summary, + "cove_offline_notice": cove_offline_notice, "objects": objects_payload, "autotask_ticket_id": getattr(run, "autotask_ticket_id", None), "autotask_ticket_number": getattr(run, "autotask_ticket_number", None) or "", diff --git a/containers/backupchecks/src/backend/app/main/routes_settings.py b/containers/backupchecks/src/backend/app/main/routes_settings.py index c219a9d..a20c41b 100644 --- a/containers/backupchecks/src/backend/app/main/routes_settings.py +++ b/containers/backupchecks/src/backend/app/main/routes_settings.py @@ -556,6 +556,43 @@ def settings_cleanup_test_runs(): return redirect(url_for("main.settings", section="testing")) +@main_bp.route("/settings/sandbox/backfill-links", methods=["POST"]) +@login_required +@roles_required("admin") +def settings_sandbox_backfill_links(): + """One-shot: rewrite every Customer's Autotask mapping to the configured + sandbox default Autotask company. + + Hard-gated by `is_sandbox_environment` and + `sandbox_default_autotask_company_id`. No-op (with a clear message) when + either guard fails. + """ + from ..sandbox_link import apply_sandbox_backfill_now + + settings = _get_or_create_settings() + try: + result = apply_sandbox_backfill_now(settings) + except Exception as exc: + flash(f"Sandbox backfill failed: {exc}", "danger") + _log_admin_event("sandbox_backfill_error", f"Sandbox backfill failed: {exc}") + return redirect(url_for("main.settings", section="maintenance")) + + if result.get("skipped"): + flash(f"Sandbox backfill skipped: {result.get('reason') or 'guards not satisfied'}.", "warning") + return redirect(url_for("main.settings", section="maintenance")) + + n = int(result.get("customers_linked", 0)) + flash( + f"Sandbox backfill complete — linked {n} customer(s) to the sandbox Autotask company.", + "success", + ) + _log_admin_event( + "sandbox_backfill", + f"Sandbox backfill linked customers={n}", + ) + return redirect(url_for("main.settings", section="maintenance")) + + @main_bp.route("/settings/objects/backfill", methods=["POST"]) @login_required @roles_required("admin") @@ -935,6 +972,15 @@ def settings_jobs_import(): pass db.session.commit() + # Sandbox: rewrite every customer's Autotask mapping to the configured + # sandbox company. Hard-gated; no-op outside sandbox. + try: + from ..sandbox_link import apply_sandbox_backfill_now + _settings = SystemSettings.query.first() + if _settings is not None: + apply_sandbox_backfill_now(_settings) + except Exception as _exc: + print(f"[settings] Sandbox backfill after JSON import failed: {_exc}") flash( f"Import completed. Customers created: {created_customers}, updated: {updated_customers}. Jobs created: {created_jobs}, updated: {updated_jobs}. Autotask IDs imported: {'yes' if include_autotask_ids else 'no'}.", "success", @@ -986,6 +1032,8 @@ def settings(): old_ui_timezone = settings.ui_timezone old_require_daily_dashboard_visit = settings.require_daily_dashboard_visit old_is_sandbox_environment = settings.is_sandbox_environment + old_sandbox_default_at_id = getattr(settings, "sandbox_default_autotask_company_id", None) + old_sandbox_default_at_name = getattr(settings, "sandbox_default_autotask_company_name", None) old_login_captcha_enabled = getattr(settings, "login_captcha_enabled", True) old_graph_tenant_id = settings.graph_tenant_id old_graph_client_id = settings.graph_client_id @@ -1033,6 +1081,22 @@ def settings(): # Checkbox: present in form = checked, absent = unchecked. settings.is_sandbox_environment = bool(request.form.get("is_sandbox_environment")) + # Sandbox default Autotask company (General tab). Empty id clears the + # mapping. The id is an external Autotask identifier — we don't + # validate it against a local table; the name is a free-text label + # cached for display. + sdc_raw = (request.form.get("sandbox_default_autotask_company_id") or "").strip() + sdc_name_raw = (request.form.get("sandbox_default_autotask_company_name") or "").strip() + if sdc_raw == "": + settings.sandbox_default_autotask_company_id = None + settings.sandbox_default_autotask_company_name = None + else: + try: + settings.sandbox_default_autotask_company_id = int(sdc_raw) + settings.sandbox_default_autotask_company_name = sdc_name_raw or None + except (ValueError, TypeError): + pass + # Login captcha toggle — same form (General tab). settings.login_captcha_enabled = bool(request.form.get("login_captcha_enabled")) @@ -1254,9 +1318,46 @@ def settings(): changes_general["require_daily_dashboard_visit"] = {"old": old_require_daily_dashboard_visit, "new": settings.require_daily_dashboard_visit} if old_is_sandbox_environment != settings.is_sandbox_environment: changes_general["is_sandbox_environment"] = {"old": old_is_sandbox_environment, "new": settings.is_sandbox_environment} + if old_sandbox_default_at_id != settings.sandbox_default_autotask_company_id: + changes_general["sandbox_default_autotask_company_id"] = { + "old": old_sandbox_default_at_id, + "new": settings.sandbox_default_autotask_company_id, + } + if old_sandbox_default_at_name != settings.sandbox_default_autotask_company_name: + changes_general["sandbox_default_autotask_company_name"] = { + "old": old_sandbox_default_at_name, + "new": settings.sandbox_default_autotask_company_name, + } if old_login_captcha_enabled != settings.login_captcha_enabled: changes_general["login_captcha_enabled"] = {"old": old_login_captcha_enabled, "new": settings.login_captcha_enabled} + # Auto-trigger sandbox backfill when either guard changes. + # No-op when guards fail. + if ( + old_is_sandbox_environment != settings.is_sandbox_environment + or old_sandbox_default_at_id != settings.sandbox_default_autotask_company_id + or old_sandbox_default_at_name != settings.sandbox_default_autotask_company_name + ): + try: + from ..sandbox_link import apply_sandbox_backfill_now + _result = apply_sandbox_backfill_now(settings) + if not _result.get("skipped"): + _n = int(_result.get("customers_linked", 0)) + if _n: + flash( + f"Sandbox backfill linked {_n} customer(s) to the sandbox Autotask company.", + "info", + ) + _log_admin_event( + "sandbox_backfill", + f"Auto-backfill on settings change linked customers={_n}", + ) + except Exception as _exc: + _log_admin_event( + "sandbox_backfill_error", + f"Auto-backfill on settings change failed: {_exc}", + ) + if changes_general: _log_admin_event( "settings_general", diff --git a/containers/backupchecks/src/backend/app/migrations.py b/containers/backupchecks/src/backend/app/migrations.py index eca63b7..305ec31 100644 --- a/containers/backupchecks/src/backend/app/migrations.py +++ b/containers/backupchecks/src/backend/app/migrations.py @@ -1280,6 +1280,44 @@ def migrate_cove_integration() -> None: print(f"[migrations] Failed to migrate Cove integration columns: {exc}") +def migrate_sandbox_default_autotask_company() -> None: + """Add system_settings.sandbox_default_autotask_company_{id,name}. + + Used in sandbox/dev environments only: when is_sandbox_environment is True, + every Customer row's Autotask mapping is rewritten to this single company. + Also drops the older `sandbox_default_customer_id` column that targeted a + different (and incorrect) design — it never shipped to production. + """ + try: + engine = db.get_engine() + except Exception as exc: + print(f"[migrations] Could not get engine for sandbox default autotask company migration: {exc}") + return + + try: + with engine.begin() as conn: + if not _column_exists_on_conn(conn, "system_settings", "sandbox_default_autotask_company_id"): + conn.execute(text( + 'ALTER TABLE "system_settings" ' + 'ADD COLUMN sandbox_default_autotask_company_id INTEGER NULL' + )) + if not _column_exists_on_conn(conn, "system_settings", "sandbox_default_autotask_company_name"): + conn.execute(text( + 'ALTER TABLE "system_settings" ' + 'ADD COLUMN sandbox_default_autotask_company_name VARCHAR(255) NULL' + )) + if _column_exists_on_conn(conn, "system_settings", "sandbox_default_customer_id"): + try: + conn.execute(text( + 'ALTER TABLE "system_settings" DROP COLUMN sandbox_default_customer_id' + )) + except Exception as drop_exc: + print(f"[migrations] Could not drop legacy sandbox_default_customer_id column (safe to ignore): {drop_exc}") + print("[migrations] migrate_sandbox_default_autotask_company completed.") + except Exception as exc: + print(f"[migrations] Failed to migrate sandbox_default_autotask_company: {exc}") + + def migrate_cove_offline_detection() -> None: """Add Cove workstation offline-detection settings to system_settings. @@ -1516,6 +1554,7 @@ def run_migrations() -> None: migrate_cove_integration() migrate_cove_accounts_table() migrate_cove_offline_detection() + migrate_sandbox_default_autotask_company() migrate_cloud_connect_accounts_table() migrate_cc_accounts_repo_unique_key() migrate_cc_remove_synthetic_missed_runs() diff --git a/containers/backupchecks/src/backend/app/models.py b/containers/backupchecks/src/backend/app/models.py index a425263..4531a56 100644 --- a/containers/backupchecks/src/backend/app/models.py +++ b/containers/backupchecks/src/backend/app/models.py @@ -123,6 +123,15 @@ class SystemSettings(db.Model): # this is not a production environment. is_sandbox_environment = db.Column(db.Boolean, nullable=False, default=False) + # Sandbox default Autotask company: when sandbox is enabled, every + # Customer row gets its Autotask mapping rewritten to this single company + # so test data copied in from production does not point at real Autotask + # companies. Has no effect when is_sandbox_environment is False — guards + # against accidental production use. Stored as (id, name) since Autotask + # companies are external and we don't keep a local FK table for them. + sandbox_default_autotask_company_id = db.Column(db.Integer, nullable=True) + sandbox_default_autotask_company_name = db.Column(db.String(255), nullable=True) + # Login page captcha (simple math question). Default True for new installs. login_captcha_enabled = db.Column(db.Boolean, nullable=False, default=True) diff --git a/containers/backupchecks/src/backend/app/sandbox_link.py b/containers/backupchecks/src/backend/app/sandbox_link.py new file mode 100644 index 0000000..f42da77 --- /dev/null +++ b/containers/backupchecks/src/backend/app/sandbox_link.py @@ -0,0 +1,114 @@ +"""Sandbox Autotask-company auto-link helper. + +In sandbox/development environments customer records are typically copied +in from production for testing. We do not want them to point at real +Autotask companies, so this helper rewrites every `Customer.autotask_company_*` +mapping to a single configured "sandbox" Autotask company. + +The auto-link is gated by two settings: + +- `is_sandbox_environment` must be True. Hard guard so a stale value cannot + pollute production after the sandbox flag is turned off. +- `sandbox_default_autotask_company_id` must be set (Autotask company id; + external — no DB FK). + +Public API: + +- `apply_to_customer(customer, settings) -> bool` — set the Autotask mapping + on a single Customer; returns True when applied. +- `apply_sandbox_backfill_now(settings) -> dict` — one-shot pass that applies + the configured Autotask company to every existing Customer row. +""" +from __future__ import annotations + +import logging +from datetime import datetime +from typing import Optional, Tuple + +from .database import db + +logger = logging.getLogger(__name__) + + +def _resolve_sandbox_autotask_company(settings) -> Optional[Tuple[int, Optional[str]]]: + """Return (company_id, company_name) when both guards pass, else None.""" + if not getattr(settings, "is_sandbox_environment", False): + return None + raw_id = getattr(settings, "sandbox_default_autotask_company_id", None) + if not raw_id: + return None + try: + company_id = int(raw_id) + except (ValueError, TypeError): + return None + company_name = getattr(settings, "sandbox_default_autotask_company_name", None) + return company_id, company_name + + +def apply_to_customer(customer, settings) -> bool: + """Overwrite a Customer's Autotask mapping with the configured sandbox value. + + Returns True when applied (and the customer row was changed), False otherwise. + Caller is responsible for committing. + """ + if customer is None: + return False + resolved = _resolve_sandbox_autotask_company(settings) + if resolved is None: + return False + company_id, company_name = resolved + + if ( + customer.autotask_company_id == company_id + and customer.autotask_company_name == company_name + and customer.autotask_mapping_status == "ok" + ): + return False + + customer.autotask_company_id = company_id + customer.autotask_company_name = company_name + customer.autotask_mapping_status = "ok" + customer.autotask_last_sync_at = datetime.utcnow() + return True + + +def apply_sandbox_backfill_now(settings) -> dict: + """One-shot backfill: apply the configured sandbox Autotask company to every + existing Customer row. + + Returns a summary dict: + {"customers_linked": int, "skipped": bool, "reason": str|None} + + `skipped=True` means the guards did not pass; `customers_linked` is 0 then. + """ + from .models import Customer + + resolved = _resolve_sandbox_autotask_company(settings) + if resolved is None: + if not getattr(settings, "is_sandbox_environment", False): + reason = "sandbox mode is disabled" + elif not getattr(settings, "sandbox_default_autotask_company_id", None): + reason = "no sandbox default Autotask company configured" + else: + reason = "sandbox default Autotask company id is invalid" + return {"customers_linked": 0, "skipped": True, "reason": reason} + + linked = 0 + for customer in Customer.query.all(): + if apply_to_customer(customer, settings): + linked += 1 + + if linked: + try: + db.session.commit() + except Exception: + db.session.rollback() + raise + + company_id, _ = resolved + logger.info( + "Sandbox backfill: linked %s customer(s) to Autotask company %s", + linked, + company_id, + ) + return {"customers_linked": linked, "skipped": False, "reason": None} diff --git a/containers/backupchecks/src/templates/main/job_detail.html b/containers/backupchecks/src/templates/main/job_detail.html index d8dca18..d19d97e 100644 --- a/containers/backupchecks/src/templates/main/job_detail.html +++ b/containers/backupchecks/src/templates/main/job_detail.html @@ -497,7 +497,11 @@ (function () { var currentRunId = null; - // Cross-browser copy to clipboard function + // Cross-browser copy to clipboard function. + // On HTTP (non-secure context) the Clipboard API is unavailable, so we fall + // back to execCommand. The fallback textarea is appended INSIDE the closest + // open Bootstrap modal (when applicable) so the modal's focus-trap does not + // immediately steal focus and clear the selection. function copyToClipboard(text, button) { var value = (text || "").toString().trim(); if (!value) { @@ -520,6 +524,9 @@ } function fallbackCopy(text, button) { + var container = (button && button.closest) ? button.closest('.modal.show') : null; + if (!container) container = document.body; + var textarea = document.createElement("textarea"); textarea.value = text; textarea.setAttribute("readonly", "readonly"); @@ -527,7 +534,8 @@ textarea.style.opacity = "0"; textarea.style.top = "0"; textarea.style.left = "0"; - document.body.appendChild(textarea); + textarea.style.zIndex = "2147483647"; + container.appendChild(textarea); textarea.focus(); textarea.select(); textarea.setSelectionRange(0, text.length); @@ -539,14 +547,79 @@ successful = false; } - document.body.removeChild(textarea); + container.removeChild(textarea); if (successful) { showCopyFeedback(button); return; } - window.prompt("Copy ticket number:", text); + showManualCopyPopover(text, button); + } + + // Inline popover shown when both Clipboard API and execCommand fail. + function showManualCopyPopover(text, button) { + var anchor = button && button.closest ? button.closest('.modal.show, body') : document.body; + var existing = document.getElementById('jd_manual_copy_popover'); + if (existing && existing.parentNode) existing.parentNode.removeChild(existing); + + var wrap = document.createElement('div'); + wrap.id = 'jd_manual_copy_popover'; + wrap.setAttribute('role', 'dialog'); + wrap.setAttribute('aria-label', 'Copy ticket number'); + wrap.style.position = 'fixed'; + wrap.style.zIndex = '2147483647'; + wrap.style.background = '#fff'; + wrap.style.border = '1px solid rgba(0,0,0,.2)'; + wrap.style.borderRadius = '6px'; + wrap.style.boxShadow = '0 .5rem 1rem rgba(0,0,0,.15)'; + wrap.style.padding = '.6rem .75rem'; + wrap.style.minWidth = '260px'; + + var rect = button && button.getBoundingClientRect ? button.getBoundingClientRect() : null; + if (rect) { + wrap.style.top = Math.max(8, rect.bottom + 6) + 'px'; + wrap.style.left = Math.max(8, rect.left) + 'px'; + } else { + wrap.style.top = '20%'; + wrap.style.left = '50%'; + wrap.style.transform = 'translateX(-50%)'; + } + + var label = document.createElement('div'); + label.className = 'small text-muted mb-1'; + label.textContent = 'Press Ctrl+C / ⌘+C to copy'; + wrap.appendChild(label); + + var input = document.createElement('input'); + input.type = 'text'; + input.readOnly = true; + input.value = text; + input.className = 'form-control form-control-sm'; + input.style.width = '100%'; + wrap.appendChild(input); + + (anchor || document.body).appendChild(wrap); + setTimeout(function () { + input.focus(); + input.select(); + input.setSelectionRange(0, text.length); + }, 0); + + function close() { + document.removeEventListener('keydown', onKey, true); + document.removeEventListener('mousedown', onOutside, true); + if (wrap.parentNode) wrap.parentNode.removeChild(wrap); + } + function onKey(ev) { + if (ev.key === 'Escape') { close(); } + } + function onOutside(ev) { + if (!wrap.contains(ev.target)) close(); + } + document.addEventListener('keydown', onKey, true); + document.addEventListener('mousedown', onOutside, true); + setTimeout(close, 8000); } function showCopyFeedback(button) { diff --git a/containers/backupchecks/src/templates/main/run_checks.html b/containers/backupchecks/src/templates/main/run_checks.html index b40177c..a863478 100644 --- a/containers/backupchecks/src/templates/main/run_checks.html +++ b/containers/backupchecks/src/templates/main/run_checks.html @@ -416,6 +416,28 @@ + + +
@@ -670,7 +692,12 @@ function escapeHtml(s) { .replace(/'/g, "'"); } - // Cross-browser copy to clipboard function + // Cross-browser copy to clipboard function. + // On HTTP (non-secure context) the Clipboard API is unavailable, so we fall + // back to execCommand. The fallback textarea is appended INSIDE the closest + // open Bootstrap modal (when applicable) so the modal's focus-trap does not + // immediately steal focus and clear the selection — that was the cause of + // silent failures on http://10.19.3.32:8380. function copyToClipboard(text, button) { var value = (text || "").toString().trim(); if (!value) { @@ -693,6 +720,11 @@ function escapeHtml(s) { } function fallbackCopy(text, button) { + // Anchor the helper textarea inside the closest open modal so Bootstrap's + // focus-trap does not yank focus away before execCommand("copy") fires. + var container = (button && button.closest) ? button.closest('.modal.show') : null; + if (!container) container = document.body; + var textarea = document.createElement("textarea"); textarea.value = text; textarea.setAttribute("readonly", "readonly"); @@ -700,7 +732,9 @@ function escapeHtml(s) { textarea.style.opacity = "0"; textarea.style.top = "0"; textarea.style.left = "0"; - document.body.appendChild(textarea); + // High z-index keeps the textarea selectable above any modal backdrop. + textarea.style.zIndex = "2147483647"; + container.appendChild(textarea); textarea.focus(); textarea.select(); textarea.setSelectionRange(0, text.length); @@ -712,14 +746,86 @@ function escapeHtml(s) { successful = false; } - document.body.removeChild(textarea); + container.removeChild(textarea); if (successful) { showCopyFeedback(button); return; } - window.prompt("Copy ticket number:", text); + // execCommand failed (deprecated, blocked, or focus lost) — surface a + // small inline popover with a pre-selected input so the user can finish + // the copy with Ctrl+C / Cmd+C. Avoids the ugly browser prompt() dialog. + showManualCopyPopover(text, button); + } + + // Inline popover shown when both Clipboard API and execCommand fail. The + // popover hosts a focused, pre-selected input that the user can copy from + // with Ctrl+C / Cmd+C. Closes on Escape, click outside, or after 8 seconds. + function showManualCopyPopover(text, button) { + var anchor = button && button.closest ? button.closest('.modal.show, body') : document.body; + var existing = document.getElementById('rcm_manual_copy_popover'); + if (existing && existing.parentNode) existing.parentNode.removeChild(existing); + + var wrap = document.createElement('div'); + wrap.id = 'rcm_manual_copy_popover'; + wrap.setAttribute('role', 'dialog'); + wrap.setAttribute('aria-label', 'Copy ticket number'); + wrap.style.position = 'fixed'; + wrap.style.zIndex = '2147483647'; + wrap.style.background = '#fff'; + wrap.style.border = '1px solid rgba(0,0,0,.2)'; + wrap.style.borderRadius = '6px'; + wrap.style.boxShadow = '0 .5rem 1rem rgba(0,0,0,.15)'; + wrap.style.padding = '.6rem .75rem'; + wrap.style.minWidth = '260px'; + + var rect = button && button.getBoundingClientRect ? button.getBoundingClientRect() : null; + if (rect) { + wrap.style.top = Math.max(8, rect.bottom + 6) + 'px'; + wrap.style.left = Math.max(8, rect.left) + 'px'; + } else { + wrap.style.top = '20%'; + wrap.style.left = '50%'; + wrap.style.transform = 'translateX(-50%)'; + } + + var label = document.createElement('div'); + label.className = 'small text-muted mb-1'; + label.textContent = 'Press Ctrl+C / ⌘+C to copy'; + wrap.appendChild(label); + + var input = document.createElement('input'); + input.type = 'text'; + input.readOnly = true; + input.value = text; + input.className = 'form-control form-control-sm'; + input.style.width = '100%'; + wrap.appendChild(input); + + (anchor || document.body).appendChild(wrap); + // Defer focus so the click that opened the popover does not immediately + // close it via the outside-click handler below. + setTimeout(function () { + input.focus(); + input.select(); + input.setSelectionRange(0, text.length); + }, 0); + + function close() { + document.removeEventListener('keydown', onKey, true); + document.removeEventListener('mousedown', onOutside, true); + if (wrap.parentNode) wrap.parentNode.removeChild(wrap); + } + function onKey(ev) { + if (ev.key === 'Escape') { close(); } + } + function onOutside(ev) { + if (!wrap.contains(ev.target)) close(); + } + document.addEventListener('keydown', onKey, true); + document.addEventListener('mousedown', onOutside, true); + setTimeout(close, 8000); } function showCopyFeedback(button) { @@ -1710,6 +1816,21 @@ table.addEventListener('change', function (e) { var ccPanel = document.getElementById('rcm_cc_summary_panel'); var covePanel = document.getElementById('rcm_cove_summary_panel'); + var coveOfflineNotice = document.getElementById('rcm_cove_offline_notice'); + if (coveOfflineNotice) { + if (run.cove_offline_notice) { + var n = run.cove_offline_notice; + var elComputer = document.getElementById('rcm_cove_offline_computer'); + var elStreak = document.getElementById('rcm_cove_offline_streak'); + var elLastRun = document.getElementById('rcm_cove_offline_last_run'); + if (elComputer) elComputer.textContent = n.computer_name || '—'; + if (elStreak) elStreak.textContent = String(n.streak_days != null ? n.streak_days : '—'); + if (elLastRun) elLastRun.textContent = n.last_run_at || '—'; + coveOfflineNotice.style.display = ''; + } else { + coveOfflineNotice.style.display = 'none'; + } + } var mailHeading = document.getElementById('rcm_mail_heading'); var mailToggle = document.getElementById('rcm_mail_toggle'); var mailBody = document.getElementById('rcm_mail_iframe_body'); diff --git a/containers/backupchecks/src/templates/main/settings.html b/containers/backupchecks/src/templates/main/settings.html index d92daa3..8fbf34f 100644 --- a/containers/backupchecks/src/templates/main/settings.html +++ b/containers/backupchecks/src/templates/main/settings.html @@ -161,6 +161,34 @@
When enabled, a visual banner will be displayed on all pages to indicate this is not a production environment.
+ +
+ +
+
+ +
+
+ +
+
+
+ When sandbox mode is enabled and an Autotask company is set here, every + customer's Autotask mapping is rewritten to point at this single company — so + test data copied in from production never targets real Autotask companies. Clear + the ID to disable. Has no effect when sandbox mode is off — guards against + accidental production use. Use the "Backfill sandbox links" button under + Maintenance to apply this to all existing customers at once. +
+
@@ -773,6 +801,40 @@ + {% if settings.is_sandbox_environment %} +
+
+
Sandbox: backfill Autotask links
+
+

+ Rewrite every customer's Autotask company mapping to the configured + sandbox default Autotask company. New customers are + auto-linked at create/import time already; this button reapplies the + mapping to all existing customers in one go. +

+ {% if settings.sandbox_default_autotask_company_id %} +

+ Sandbox default Autotask company is set under + General + (currently + {{ settings.sandbox_default_autotask_company_id }}{% if settings.sandbox_default_autotask_company_name %} — {{ settings.sandbox_default_autotask_company_name }}{% endif %}). +

+
+ + + {% else %} +
+ No sandbox default Autotask company is configured. Set one under + General → Environment + before running the backfill. +
+ {% endif %} +
+
+
+ {% endif %} +
Cleanup orphaned jobs
diff --git a/docs/changelog-develop.md b/docs/changelog-develop.md index 367e3da..2ef5d4f 100644 --- a/docs/changelog-develop.md +++ b/docs/changelog-develop.md @@ -2,6 +2,31 @@ This file is the long-form append-only development log. It captures every change in detail and is summarised into `docs/changelog.md` at release time. Release markers (`## YYYY-MM-DD — Released as vX.Y.Z`) indicate which entries are already published. +## [2026-05-01d] + +### Added +- **Sandbox auto-link to a default Autotask company.** New optional setting `sandbox_default_autotask_company_{id,name}` (Settings → General → Environment) gated by `is_sandbox_environment`. When both guards pass, every existing `Customer.autotask_company_id`/`_name` is rewritten to point at the configured Autotask company so test data copied in from production never targets real Autotask companies. Applied automatically when (a) a single customer is created via the Customers page, (b) customers are imported via CSV (Customers page) or JSON (Settings → Backups), and (c) when the sandbox flag or default company changes on the General tab. Helper module `app/sandbox_link.py` exposes `apply_to_customer` and `apply_sandbox_backfill_now`. Migration `migrate_sandbox_default_autotask_company` adds the two columns and drops the legacy `sandbox_default_customer_id` column. +- **Settings → Maintenance: "Backfill sandbox Autotask links" card.** One-shot button that runs the same logic on every existing customer. Card is only shown when sandbox mode is enabled and the configured id is summarised inline. +- **Hard guard against production use.** The auto-link is silently skipped whenever `is_sandbox_environment=False`, regardless of whether the sandbox Autotask id is still set — protects against accidentally rewriting customer mappings after the sandbox flag is turned off. + +### Changed +- **Reworked sandbox auto-link design.** Earlier in this development cycle the sandbox setting was modelled as a `sandbox_default_customer_id` that caused all unmatched Cove/Cloud Connect accounts to land as Jobs under one Customer. That collapsed every job under a single customer instead of preserving the customer→job structure copied in from production. The setting now points at an Autotask company id and rewrites the customer Autotask mappings instead; the Cove/Cloud Connect importers no longer create sandbox Jobs and the legacy column is dropped by the migration. + +## [2026-05-01c] + +### Fixed +- Ticket-number copy button now works on HTTP (non-secure context) inside the Run Checks and Job Detail modals. The fallback `execCommand("copy")` was failing silently because the helper textarea was appended to `document.body`, where Bootstrap's modal focus-trap immediately stole focus and cleared the selection. The textarea is now appended inside the open modal (`.modal.show`) so the selection survives, and a small inline popover with a focused, pre-selected input is shown as a final fallback if `execCommand` still fails — replacing the previous `window.prompt` dialog. + +## 2026-05-01 — Released as v0.3.1 + +## [2026-05-01b] + +### Added +- Run Checks modal: Cove offline-detection runs (`external_id` starts with `cove-offline-`) now show a clear info-alert above the objects table — "Device offline — no backup activity", with computer name, inactive-day count, last successful run timestamp, and an explicit "No Autotask ticket required" note explaining that this is an automated alert from the Cove 28-day colorbar, not a real backup failure. The runs payload carries a new `cove_offline_notice` field (`{streak_days, computer_name, last_run_at}`); the route recomputes the streak from `CoveAccount.colorbar_28d` so the UI shows the current value rather than the value frozen in the run remark. + +### Fixed +- Cove workstation missed-run exemption now also cleans up legacy synthetic missed-runs. `_ensure_missed_runs_for_job` previously returned early before the cleanup step, leaving previously-generated unreviewed `Missed` rows visible in Run Checks. The function now deletes any unreviewed synthetic missed-runs (`missed=True`, `mail_message_id IS NULL`, `reviewed_at IS NULL`) for the affected jobs before returning, so the leftover noise disappears on the next Run Checks page-load. Reviewed missed-runs are kept as historical record. + ## 2026-05-01 — Released as v0.3.0 ## [2026-05-01] diff --git a/docs/changelog.md b/docs/changelog.md index c390f8e..d4132ae 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,3 +1,13 @@ +## v0.3.1 + +Patch release on top of v0.3.0 with two follow-ups for the new Cove workstation handling. + +### Added +- **Run Checks modal: clear "Device offline" notice for Cove offline-detection runs.** Synthetic offline-detection runs (`external_id` starting with `cove-offline-`) carry no per-datasource objects, which previously left the modal looking like an unexplained Error. The modal now shows an info-alert above the objects table with the computer name, inactive-day count, last successful run timestamp, and an explicit *"No Autotask ticket required"* note explaining that this is an automated colorbar-based alert, not a real backup failure. The runs payload exposes a new `cove_offline_notice` field; the inactive streak is recomputed from `CoveAccount.colorbar_28d` so the UI reflects the current value rather than the value frozen in the run remark. + +### Fixed +- **Cove workstation missed-run exemption now also cleans up legacy synthetic missed-runs.** `_ensure_missed_runs_for_job` previously returned early before reaching the cleanup step, so unreviewed `Missed` rows generated for Cove workstations before the v0.3.0 exemption stayed visible in Run Checks. The function now deletes any unreviewed synthetic missed-runs (`missed=True`, `mail_message_id IS NULL`, `reviewed_at IS NULL`) for the affected jobs before returning, so the leftover noise disappears on the next Run Checks page-load. Reviewed missed-runs are kept as historical record. + ## v0.3.0 This release bundles all changes made since `v0.2.5`. Highlights: Smart Overrides Phase 1 (create overrides directly from Run Checks), Cove workstation offline handling (no more false-positive Missed alerts for powered-off PCs, plus an optional colorbar-based offline-detection toggle), a test-run generator in Settings → Maintenance, and a full in-app documentation refresh.