Compare commits

...

2 Commits
v0.3.0 ... main

Author SHA1 Message Date
6fb5040a87 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>
2026-05-01 13:35:40 +02:00
06e0d88333 Update changelog.py for v0.3.0 2026-05-01 11:09:19 +02:00
12 changed files with 671 additions and 8 deletions

View File

@ -3,6 +3,63 @@ Changelog data structure for Backupchecks
""" """
CHANGELOG = [ 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",
"summary": "Consolidated release since v0.2.5. Highlights: Smart Overrides Phase 1 (create overrides directly from Run Checks), Cove workstation offline handling (no 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.",
"sections": [
{
"title": "Added",
"type": "feature",
"changes": [
"Smart Overrides Phase 1: Mark as Success in Run Checks now offers a follow-up dialog to create a broader override for future runs (scope: only this run / this job + same error / all jobs with same software/type + error; duration: 1 week / 1 month / permanent). Error text is pre-filled and broader overrides are audit-logged with scope, duration, and source run.",
"Cove workstation offline detection (Settings -> Integrations -> Cove): optional colorbar-based check that flags Cove workstations as Warning / Error after a configurable number of consecutive inactive days (defaults 7 / 14, off by default). Synthetic offline runs use a stable external_id per account so they escalate in place and clear automatically once activity resumes; reviewed runs are never mutated.",
"Settings -> Maintenance: new Generate test run card creating a single JobRun with three persisted objects on a fixed test job (__test-override-job__) so operators can exercise the Smart Override flow without a real backup.",
"Restored Mark as Success button in the Run Checks modal footer (the JS hook existed but the button was missing)."
]
},
{
"title": "Changed",
"type": "improvement",
"changes": [
"Cove workstation jobs are excluded from schedule-based missed-run detection. Workstations are routinely powered off outside business hours; Cove Server and Microsoft 365 jobs remain unaffected and real Cove statuses (Failed / Warning / Not started) still surface via the normal import flow.",
"In-app documentation refreshed across getting-started, users, mail-import, integrations (Cove), settings, backup-review, customers-jobs and autotask sections: Smart Overrides documentation, Cove/Cloud Connect summary panels in the Run Checks modal, archived-jobs filter on the Inbox, captcha + Entra SSO authentication options (SSO marked as implemented but not yet validated in production), expanded supported-software list, and Cove offline-handling sections.",
"Adopted the shared docker-build-and-push script. Modes are now t (test) / r (release); release version is read from docs/changelog.md; the script no longer maintains version.txt and performs no git operations (commit, tag, and push are run manually after the registry has accepted the images).",
"Renamed docs/technical-notes-codex.md to docs/TECHNICAL.md and docs/changelog-claude.md to docs/changelog-develop.md."
]
},
{
"title": "Fixed",
"type": "bugfix",
"changes": [
"Run Checks Cove same-day suppression: once the first complete success run for a Cove job/day is recorded (status Success with all object statuses Success), all newer Cove runs on that same local day are hidden from Run Checks (overview aggregation + modal details), regardless of status.",
"Inbox excludes mail messages whose linked job has been archived (messages without a linked job remain visible).",
"Run Checks and Search overview now apply the Customer.active filter that was previously only on the missed-run sweep."
]
}
]
},
{ {
"version": "v0.2.5", "version": "v0.2.5",
"date": "2026-04-13", "date": "2026-04-13",

View File

@ -396,6 +396,15 @@ def customers_create():
try: try:
customer = Customer(name=name, active=active) customer = Customer(name=name, active=active)
db.session.add(customer) 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() db.session.commit()
flash("Customer created.", "success") flash("Customer created.", "success")
except Exception as exc: except Exception as exc:
@ -602,6 +611,16 @@ def customers_import():
try: try:
db.session.commit() 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( flash(
f"Import finished. Created: {created}, Updated: {updated}, Skipped: {skipped}. Autotask IDs imported: {'yes' if include_autotask_ids else 'no'}.", f"Import finished. Created: {created}, Updated: {updated}, Skipped: {skipped}. Autotask IDs imported: {'yes' if include_autotask_ids else 'no'}.",
"success", "success",

View File

@ -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, # Schedule-based missed-run detection produces noise for "device was off" days,
# so skip entirely. Real Cove statuses (Failed/Warning/Not started) still surface # so skip entirely. Real Cove statuses (Failed/Warning/Not started) still surface
# via the regular import flow. Servers and Microsoft 365 remain unaffected. # 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 ( if (
(getattr(job, "backup_software", "") or "") == "Cove Data Protection" (getattr(job, "backup_software", "") or "") == "Cove Data Protection"
and (getattr(job, "backup_type", "") or "").lower() == "workstation" 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 return 0
tz = _get_ui_timezone() tz = _get_ui_timezone()
@ -1863,6 +1877,7 @@ def run_checks_details():
body_html = "" body_html = ""
cloud_connect_summary = None cloud_connect_summary = None
cove_summary = None cove_summary = None
cove_offline_notice = None
# For Cove API runs, suppress the mail section entirely and show # For Cove API runs, suppress the mail section entirely and show
# structured Cove account details instead. # 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 "", "last_run_at": _format_datetime(_cove_acc.last_run_at) if _cove_acc.last_run_at else "",
"status": run.status or "", "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 # No mail_meta / body_html for Cove runs — Cove has no email
# For Cloud Connect runs, suppress the raw report email (it contains all # For Cloud Connect runs, suppress the raw report email (it contains all
@ -2051,6 +2083,7 @@ def run_checks_details():
"body_html": body_html, "body_html": body_html,
"cloud_connect_summary": cloud_connect_summary, "cloud_connect_summary": cloud_connect_summary,
"cove_summary": cove_summary, "cove_summary": cove_summary,
"cove_offline_notice": cove_offline_notice,
"objects": objects_payload, "objects": objects_payload,
"autotask_ticket_id": getattr(run, "autotask_ticket_id", None), "autotask_ticket_id": getattr(run, "autotask_ticket_id", None),
"autotask_ticket_number": getattr(run, "autotask_ticket_number", None) or "", "autotask_ticket_number": getattr(run, "autotask_ticket_number", None) or "",

View File

@ -556,6 +556,43 @@ def settings_cleanup_test_runs():
return redirect(url_for("main.settings", section="testing")) 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"]) @main_bp.route("/settings/objects/backfill", methods=["POST"])
@login_required @login_required
@roles_required("admin") @roles_required("admin")
@ -935,6 +972,15 @@ def settings_jobs_import():
pass pass
db.session.commit() 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( 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'}.", 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", "success",
@ -986,6 +1032,8 @@ def settings():
old_ui_timezone = settings.ui_timezone old_ui_timezone = settings.ui_timezone
old_require_daily_dashboard_visit = settings.require_daily_dashboard_visit old_require_daily_dashboard_visit = settings.require_daily_dashboard_visit
old_is_sandbox_environment = settings.is_sandbox_environment 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_login_captcha_enabled = getattr(settings, "login_captcha_enabled", True)
old_graph_tenant_id = settings.graph_tenant_id old_graph_tenant_id = settings.graph_tenant_id
old_graph_client_id = settings.graph_client_id old_graph_client_id = settings.graph_client_id
@ -1033,6 +1081,22 @@ def settings():
# Checkbox: present in form = checked, absent = unchecked. # Checkbox: present in form = checked, absent = unchecked.
settings.is_sandbox_environment = bool(request.form.get("is_sandbox_environment")) 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). # Login captcha toggle — same form (General tab).
settings.login_captcha_enabled = bool(request.form.get("login_captcha_enabled")) 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} 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: if old_is_sandbox_environment != settings.is_sandbox_environment:
changes_general["is_sandbox_environment"] = {"old": old_is_sandbox_environment, "new": 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: if old_login_captcha_enabled != settings.login_captcha_enabled:
changes_general["login_captcha_enabled"] = {"old": old_login_captcha_enabled, "new": 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: if changes_general:
_log_admin_event( _log_admin_event(
"settings_general", "settings_general",

View File

@ -1280,6 +1280,44 @@ def migrate_cove_integration() -> None:
print(f"[migrations] Failed to migrate Cove integration columns: {exc}") 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: def migrate_cove_offline_detection() -> None:
"""Add Cove workstation offline-detection settings to system_settings. """Add Cove workstation offline-detection settings to system_settings.
@ -1516,6 +1554,7 @@ def run_migrations() -> None:
migrate_cove_integration() migrate_cove_integration()
migrate_cove_accounts_table() migrate_cove_accounts_table()
migrate_cove_offline_detection() migrate_cove_offline_detection()
migrate_sandbox_default_autotask_company()
migrate_cloud_connect_accounts_table() migrate_cloud_connect_accounts_table()
migrate_cc_accounts_repo_unique_key() migrate_cc_accounts_repo_unique_key()
migrate_cc_remove_synthetic_missed_runs() migrate_cc_remove_synthetic_missed_runs()

View File

@ -123,6 +123,15 @@ class SystemSettings(db.Model):
# this is not a production environment. # this is not a production environment.
is_sandbox_environment = db.Column(db.Boolean, nullable=False, default=False) 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 page captcha (simple math question). Default True for new installs.
login_captcha_enabled = db.Column(db.Boolean, nullable=False, default=True) login_captcha_enabled = db.Column(db.Boolean, nullable=False, default=True)

View 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}

View File

@ -497,7 +497,11 @@
(function () { (function () {
var currentRunId = null; 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) { function copyToClipboard(text, button) {
var value = (text || "").toString().trim(); var value = (text || "").toString().trim();
if (!value) { if (!value) {
@ -520,6 +524,9 @@
} }
function fallbackCopy(text, button) { function fallbackCopy(text, button) {
var container = (button && button.closest) ? button.closest('.modal.show') : null;
if (!container) container = document.body;
var textarea = document.createElement("textarea"); var textarea = document.createElement("textarea");
textarea.value = text; textarea.value = text;
textarea.setAttribute("readonly", "readonly"); textarea.setAttribute("readonly", "readonly");
@ -527,7 +534,8 @@
textarea.style.opacity = "0"; textarea.style.opacity = "0";
textarea.style.top = "0"; textarea.style.top = "0";
textarea.style.left = "0"; textarea.style.left = "0";
document.body.appendChild(textarea); textarea.style.zIndex = "2147483647";
container.appendChild(textarea);
textarea.focus(); textarea.focus();
textarea.select(); textarea.select();
textarea.setSelectionRange(0, text.length); textarea.setSelectionRange(0, text.length);
@ -539,14 +547,79 @@
successful = false; successful = false;
} }
document.body.removeChild(textarea); container.removeChild(textarea);
if (successful) { if (successful) {
showCopyFeedback(button); showCopyFeedback(button);
return; 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) { function showCopyFeedback(button) {

View File

@ -416,6 +416,28 @@
</div> </div>
</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>
<div class="table-responsive rcm-objects-scroll"> <div class="table-responsive rcm-objects-scroll">
<table class="table table-sm table-bordered" id="rcm_objects_table"> <table class="table table-sm table-bordered" id="rcm_objects_table">
@ -670,7 +692,12 @@ function escapeHtml(s) {
.replace(/'/g, "&#39;"); .replace(/'/g, "&#39;");
} }
// 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) { function copyToClipboard(text, button) {
var value = (text || "").toString().trim(); var value = (text || "").toString().trim();
if (!value) { if (!value) {
@ -693,6 +720,11 @@ function escapeHtml(s) {
} }
function fallbackCopy(text, button) { 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"); var textarea = document.createElement("textarea");
textarea.value = text; textarea.value = text;
textarea.setAttribute("readonly", "readonly"); textarea.setAttribute("readonly", "readonly");
@ -700,7 +732,9 @@ function escapeHtml(s) {
textarea.style.opacity = "0"; textarea.style.opacity = "0";
textarea.style.top = "0"; textarea.style.top = "0";
textarea.style.left = "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.focus();
textarea.select(); textarea.select();
textarea.setSelectionRange(0, text.length); textarea.setSelectionRange(0, text.length);
@ -712,14 +746,86 @@ function escapeHtml(s) {
successful = false; successful = false;
} }
document.body.removeChild(textarea); container.removeChild(textarea);
if (successful) { if (successful) {
showCopyFeedback(button); showCopyFeedback(button);
return; 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) { function showCopyFeedback(button) {
@ -1710,6 +1816,21 @@ table.addEventListener('change', function (e) {
var ccPanel = document.getElementById('rcm_cc_summary_panel'); var ccPanel = document.getElementById('rcm_cc_summary_panel');
var covePanel = document.getElementById('rcm_cove_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 mailHeading = document.getElementById('rcm_mail_heading');
var mailToggle = document.getElementById('rcm_mail_toggle'); var mailToggle = document.getElementById('rcm_mail_toggle');
var mailBody = document.getElementById('rcm_mail_iframe_body'); var mailBody = document.getElementById('rcm_mail_iframe_body');

View File

@ -161,6 +161,34 @@
<label class="form-check-label" for="is_sandbox_environment">Mark this as a Sandbox/Development environment</label> <label class="form-check-label" for="is_sandbox_environment">Mark this as a Sandbox/Development environment</label>
</div> </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="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>
</div> </div>
@ -773,6 +801,40 @@
</div> </div>
</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="col-12 col-lg-6">
<div class="card h-100 border-warning"> <div class="card h-100 border-warning">
<div class="card-header bg-warning">Cleanup orphaned jobs</div> <div class="card-header bg-warning">Cleanup orphaned jobs</div>

View File

@ -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. 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 — Released as v0.3.0
## [2026-05-01] ## [2026-05-01]

View File

@ -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 ## 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. 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.