Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fb5040a87 | |||
| 06e0d88333 |
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 "",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
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 () {
|
(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) {
|
||||||
|
|||||||
@ -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, "'");
|
.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) {
|
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');
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user