Rework sandbox auto-link to point customers at one Autotask company
Previous design routed every unmatched Cove/Cloud Connect account into a single Customer's Job, collapsing the customer→job structure copied in from production. Now the sandbox setting points at a single Autotask company id and rewrites every Customer's `autotask_company_*` mapping to it on customer create, on CSV/JSON import, on settings change, and via the Maintenance backfill button. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
06e0d88333
commit
6fb5040a87
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 "",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
114
containers/backupchecks/src/backend/app/sandbox_link.py
Normal file
114
containers/backupchecks/src/backend/app/sandbox_link.py
Normal file
@ -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}
|
||||
@ -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) {
|
||||
|
||||
@ -416,6 +416,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cove offline-detection notice (shown when no real backup objects exist) -->
|
||||
<div class="alert alert-info mb-3" id="rcm_cove_offline_notice" style="display:none;" role="alert">
|
||||
<div class="d-flex">
|
||||
<div class="me-2" aria-hidden="true">ℹ️</div>
|
||||
<div>
|
||||
<h6 class="alert-heading mb-1">Device offline — no backup activity</h6>
|
||||
<p class="mb-1">
|
||||
<strong id="rcm_cove_offline_computer"></strong>
|
||||
has had <strong><span id="rcm_cove_offline_streak"></span> day(s)</strong>
|
||||
without a backup. Last successful run: <span id="rcm_cove_offline_last_run">—</span>.
|
||||
</p>
|
||||
<p class="mb-0 small">
|
||||
<strong>No Autotask ticket required:</strong> this is an automated
|
||||
offline-detection alert generated from the Cove 28-day colorbar — not
|
||||
a real backup failure. Mark as reviewed once you have verified the
|
||||
device is intentionally off, or investigate connectivity if the absence
|
||||
is unexpected. Thresholds: Settings → Integrations → Cove.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="table-responsive rcm-objects-scroll">
|
||||
<table class="table table-sm table-bordered" id="rcm_objects_table">
|
||||
@ -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');
|
||||
|
||||
@ -161,6 +161,34 @@
|
||||
<label class="form-check-label" for="is_sandbox_environment">Mark this as a Sandbox/Development environment</label>
|
||||
</div>
|
||||
<div class="form-text">When enabled, a visual banner will be displayed on all pages to indicate this is not a production environment.</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label class="form-label">Sandbox default Autotask company</label>
|
||||
<div class="row g-2">
|
||||
<div class="col-sm-4">
|
||||
<input type="number" min="1" class="form-control"
|
||||
id="sandbox_default_autotask_company_id"
|
||||
name="sandbox_default_autotask_company_id"
|
||||
value="{{ settings.sandbox_default_autotask_company_id or '' }}"
|
||||
placeholder="Autotask company ID">
|
||||
</div>
|
||||
<div class="col-sm-8">
|
||||
<input type="text" class="form-control"
|
||||
id="sandbox_default_autotask_company_name"
|
||||
name="sandbox_default_autotask_company_name"
|
||||
value="{{ settings.sandbox_default_autotask_company_name or '' }}"
|
||||
placeholder="Display name (optional)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
When sandbox mode is enabled <em>and</em> 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.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -773,6 +801,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if settings.is_sandbox_environment %}
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card h-100 border-info">
|
||||
<div class="card-header bg-info text-white">Sandbox: backfill Autotask links</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-2">
|
||||
Rewrite every customer's Autotask company mapping to the configured
|
||||
<strong>sandbox default Autotask company</strong>. New customers are
|
||||
auto-linked at create/import time already; this button reapplies the
|
||||
mapping to all existing customers in one go.
|
||||
</p>
|
||||
{% if settings.sandbox_default_autotask_company_id %}
|
||||
<p class="text-muted small mb-3">
|
||||
Sandbox default Autotask company is set under
|
||||
<a href="{{ url_for('main.settings', section='general') }}#sandbox_default_autotask_company_id">General</a>
|
||||
(currently
|
||||
<code>{{ settings.sandbox_default_autotask_company_id }}</code>{% if settings.sandbox_default_autotask_company_name %} — {{ settings.sandbox_default_autotask_company_name }}{% endif %}).
|
||||
</p>
|
||||
<form method="post" action="{{ url_for('main.settings_sandbox_backfill_links') }}"
|
||||
onsubmit="return confirm('Rewrite every customer\'s Autotask mapping to the sandbox company now?');">
|
||||
<button type="submit" class="btn btn-info text-white">Backfill sandbox links</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="alert alert-warning mb-0">
|
||||
No sandbox default Autotask company is configured. Set one under
|
||||
<a href="{{ url_for('main.settings', section='general') }}#sandbox_default_autotask_company_id">General → Environment</a>
|
||||
before running the backfill.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card h-100 border-warning">
|
||||
<div class="card-header bg-warning">Cleanup orphaned jobs</div>
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user