Release prep v0.2.3 fixes and changelog updates

This commit is contained in:
Ivo Oskamp 2026-03-23 13:46:42 +01:00
parent 9edeb8eab9
commit 282601269c
12 changed files with 473 additions and 52 deletions

View File

@ -3,6 +3,53 @@ Changelog data structure for Backupchecks
"""
CHANGELOG = [
{
"version": "v0.2.3",
"date": "2026-03-23",
"summary": "Update release that improves Autotask existing-ticket linking transparency, Cove object detail propagation, and Customers page link styling consistency.",
"sections": [
{
"title": "Added",
"type": "feature",
"changes": [
"Autotask Link existing ticket now also posts a ticket note when an additional Backupchecks alert/run is linked, including customer, job, run context and a Backupchecks deep-link"
]
},
{
"title": "Changed",
"type": "improvement",
"changes": [
"Link existing Autotask API response now includes note_posted and note_warning fields for operator visibility",
"Customers page customer-name links to filtered Jobs now use sidebar-matching text and hover styling instead of default blue link styling"
]
},
{
"title": "Fixed",
"type": "bugfix",
"changes": [
"Autotask object retrieval for ticket composition now reads objects from run_object_links/customer_objects first (same source as Run Checks UI), then falls back to legacy job_objects/mail_objects, preventing missing object details",
"Cove Accounts and Cove-run object visibility improved by consistently using persisted run object links as primary object source, aligning operator view and ticket details",
"Autotask ticket affected-objects list now includes only problem objects (failed/error/warning/missed) and excludes completed/success objects"
]
}
]
},
{
"version": "v0.2.2",
"date": "2026-03-23",
"summary": "Hotfix release that corrects Synology Active Backup for Business parsing for completion mails using has been wording, preventing wrong job-name extraction.",
"sections": [
{
"title": "Fixed",
"type": "bugfix",
"changes": [
"Synology Active Backup for Business parser now recognizes completion mails using has been completed wording",
"Prevents fallback to the generic Synology Active Backup parser that could incorrectly take the bracketed subject prefix as job name",
"ABB mails like backup task dc001 on DS220p has been completed now keep the expected identity: backup_software=Synology, backup_type=Active Backup for Business, job_name=dc001"
]
}
]
},
{
"version": "v0.2.1",
"date": "2026-03-20",

View File

@ -45,6 +45,13 @@ COVE_COLUMNS = [
"D23F00", "D23F15", # M365 Teams
]
# Optional datasource-specific columns for datasources that may be active in I78
# but are not always available in every tenant/API scope.
COVE_OPTIONAL_COLUMNS = [
"D2F00", "D2F15", # System State
"D6F00", "D6F15", # Network Shares
]
# Mapping from Cove status code to Backupchecks status string
STATUS_MAP: dict[int, str] = {
1: "Warning", # In process
@ -75,14 +82,21 @@ STATUS_LABELS: dict[int, str] = {
12: "Restarted",
}
# Datasource label mapping (column prefix → human-readable label)
# Datasource label mapping (Cove datasource code → human-readable label).
# Keep both canonical and zero-padded aliases so UI summary and importer stay in sync.
DATASOURCE_LABELS: dict[str, str] = {
"D1": "Files & Folders",
"D01": "Files & Folders",
"D2": "System State",
"D02": "System State",
"D6": "Network Shares",
"D06": "Network Shares",
"D10": "VssMsSql",
"D11": "VssSharePoint",
"D19": "M365 Exchange",
"D20": "M365 OneDrive",
"D5": "M365 SharePoint",
"D05": "M365 SharePoint",
"D23": "M365 Teams",
}
@ -147,6 +161,7 @@ def _cove_enumerate(
partner_id: int,
start: int,
count: int,
columns: list[str] | None = None,
) -> list[dict]:
"""Call EnumerateAccountStatistics and return a list of account dicts.
@ -162,7 +177,7 @@ def _cove_enumerate(
"PartnerId": partner_id,
"StartRecordNumber": start,
"RecordsCount": count,
"Columns": COVE_COLUMNS,
"Columns": columns or COVE_COLUMNS,
}
},
}
@ -201,6 +216,12 @@ def _cove_enumerate(
return []
def _is_cove_security_column_error(exc: Exception) -> bool:
"""Return True when Enumerate failed due to restricted columns/security scope."""
msg = str(exc or "").lower()
return ("security reasons" in msg) or ("13501" in msg)
def _flatten_settings(account: dict) -> dict:
"""Convert the Settings array in an account dict to a flat key→value dict.
@ -216,6 +237,42 @@ def _flatten_settings(account: dict) -> dict:
return flat
def _normalize_ds_code(code: str) -> str:
"""Normalize datasource codes like D01 -> D1."""
m = re.fullmatch(r"D(\d{1,2})", (code or "").strip().upper())
if not m:
return (code or "").strip().upper()
return f"D{int(m.group(1))}"
def _parse_active_datasource_codes(raw: Any) -> list[str]:
"""Extract unique active datasource codes from I78 (e.g. D01D02D10)."""
text = str(raw or "").strip().upper()
if not text:
return []
seen: set[str] = set()
out: list[str] = []
for code in re.findall(r"D\d{1,2}", text):
norm = _normalize_ds_code(code)
if not norm or norm in seen:
continue
seen.add(norm)
out.append(norm)
return out
def _label_for_ds_code(code: str) -> str:
"""Resolve human label for a datasource code (supports canonical + padded aliases)."""
norm = _normalize_ds_code(code)
if norm in DATASOURCE_LABELS:
return DATASOURCE_LABELS[norm]
m = re.fullmatch(r"D(\d{1,2})", norm)
if m:
padded = f"D{int(m.group(1)):02d}"
return DATASOURCE_LABELS.get(padded, norm)
return DATASOURCE_LABELS.get(code, code)
def _map_status(code: Any) -> str:
"""Map a Cove status code (int) to a Backupchecks status string."""
if code is None:
@ -436,11 +493,26 @@ def run_cove_import(settings, include_reasons: bool = False):
page_size = 250
start = 0
base_columns = list(COVE_COLUMNS)
extended_columns = base_columns + list(COVE_OPTIONAL_COLUMNS)
use_optional_columns = True
while True:
columns = extended_columns if use_optional_columns else base_columns
try:
accounts = _cove_enumerate(url, visa, partner_id, start, page_size)
except CoveImportError:
raise
accounts = _cove_enumerate(url, visa, partner_id, start, page_size, columns=columns)
except CoveImportError as exc:
# Some tenants block specific datasource columns; fall back safely.
if use_optional_columns and _is_cove_security_column_error(exc):
logger.warning(
"Cove import: optional datasource columns blocked by API scope; "
"falling back to base columns. Error: %s",
exc,
)
use_optional_columns = False
accounts = _cove_enumerate(url, visa, partner_id, start, page_size, columns=base_columns)
else:
raise
except Exception as exc:
raise CoveImportError(f"Unexpected error fetching accounts at offset {start}: {exc}") from exc
@ -584,6 +656,9 @@ def _process_account(account: dict) -> str:
# Cove session was previously stored under another job.
existing = JobRun.query.filter_by(job_id=job.id, external_id=external_id).first()
if existing:
# Keep objects in sync even when the run itself is a duplicate session.
if job.customer_id:
_persist_datasource_objects(flat, job.customer_id, job.id, existing.id, last_run_at)
db.session.commit()
return "skip_duplicate"
@ -638,18 +713,49 @@ def _persist_datasource_objects(
observed_at: datetime,
) -> None:
"""Create run_object_links for each active datasource found in the account stats."""
for ds_prefix, ds_label in DATASOURCE_LABELS.items():
status_key = f"{ds_prefix}F00"
# Use I78 as source-of-truth for active datasources so object count matches Cove UI.
ds_codes = _parse_active_datasource_codes(flat.get("I78"))
# Fallback: when I78 is missing, derive from present DxxF00 keys to avoid empty objects.
if not ds_codes:
seen: set[str] = set()
for key in flat.keys():
m = re.fullmatch(r"(D\d{1,2})F00", str(key or "").upper())
if not m:
continue
norm = _normalize_ds_code(m.group(1))
if norm and norm not in seen:
seen.add(norm)
ds_codes.append(norm)
overall_status_code = flat.get("D09F00")
overall_last_ts = _ts_to_dt(flat.get("D09F15"))
for ds_code in ds_codes:
ds_label = _label_for_ds_code(ds_code)
status_key = f"{ds_code}F00"
ts_key = f"{ds_code}F15"
status_code = flat.get(status_key)
uses_overall_fallback = status_code is None
if status_code is None:
continue
status_code = overall_status_code
status = _map_status(status_code)
ds_last_ts = _ts_to_dt(flat.get(f"{ds_prefix}F15"))
status_msg = (
f"Cove datasource status: {_status_label(status_code)} "
f"({status_code}); last session: {_fmt_utc(ds_last_ts)}"
)
ds_last_ts = _ts_to_dt(flat.get(ts_key)) or overall_last_ts
if uses_overall_fallback:
status_msg = (
f"Cove datasource status: {_status_label(status_code)} "
f"({status_code}); datasource-specific status not returned by API columns; "
f"using overall account status; last session: {_fmt_utc(ds_last_ts)}"
)
else:
status_msg = (
f"Cove datasource status: {_status_label(status_code)} "
f"({status_code}); last session: {_fmt_utc(ds_last_ts)}"
)
# Use the same SQLAlchemy session/transaction as JobRun creation.
# A separate engine connection cannot reliably see the uncommitted run row.

View File

@ -19,6 +19,8 @@ _COVE_DATASOURCE_LABELS = {
"D1": "Files & Folders",
"D02": "System State",
"D2": "System State",
"D06": "Network Shares",
"D6": "Network Shares",
"D10": "VssMsSql",
"D11": "VssSharePoint",
"D19": "M365 Exchange",

View File

@ -645,36 +645,110 @@ def _compose_autotask_ticket_description(
lines.append("Summary:")
lines.append(overall_message)
lines.append("")
lines.append("Multiple objects reported messages. See Backupchecks for full details.")
else:
# Fallback to object-level messages with a hard limit.
# Always include object-level details so technicians can immediately see
# which objects failed/warned from the ticket itself.
def _object_priority(obj: dict[str, str]) -> int:
combined = f"{obj.get('status', '')} {obj.get('error_message', '')}".strip().lower()
if any(x in combined for x in ("failed", "error", "missed")):
return 0
if "warning" in combined:
return 1
return 2
def _is_problem_object(status: str, err: str) -> bool:
s = (status or "").strip().lower()
e = (err or "").strip().lower()
combined = f"{s} {e}".strip()
if any(x in combined for x in ("failed", "error", "warning", "missed")):
return True
if s in ("success", "succeeded", "completed", "ok"):
return False
if "completed" in combined:
return False
# Keep uncommon non-success statuses visible.
return bool(s or e)
candidates: list[dict[str, str]] = []
for o in objects_payload or []:
name = (o.get("name") or "").strip()
err = (o.get("error_message") or "").strip()
st = (o.get("status") or "").strip()
if not name:
continue
if not _is_problem_object(st, err):
continue
candidates.append(
{
"name": name,
"status": st,
"error_message": err,
}
)
if candidates:
sorted_candidates = sorted(
candidates,
key=lambda x: (_object_priority(x), x["name"].lower()),
)
lines.append("Affected objects:")
limit = 10
shown = 0
total = 0
for o in objects_payload or []:
name = (o.get("name") or "").strip()
err = (o.get("error_message") or "").strip()
st = (o.get("status") or "").strip()
if not name:
continue
if not err and not st:
continue
total += 1
for o in sorted_candidates:
if shown >= limit:
continue
msg = err or st
lines.append(f"- {name}: {msg}")
break
msg = o["error_message"] or o["status"]
lines.append(f"- {o['name']}: {msg}")
shown += 1
if total == 0:
lines.append("No detailed object messages available. See Backupchecks for full details.")
elif total > shown:
lines.append(f"And {int(total - shown)} additional objects reported similar messages.")
if len(sorted_candidates) > shown:
lines.append(f"And {int(len(sorted_candidates) - shown)} additional objects reported messages.")
else:
lines.append("No detailed object messages available. See Backupchecks for full details.")
lines.append("")
lines.append(f"Backupchecks details: {job_link}")
return "\n".join(lines).strip() + "\n"
def _compose_autotask_link_existing_note(
*,
settings,
job: Job,
run: JobRun,
ticket_number: str,
linked_run_count: int,
) -> str:
tz_name = _get_ui_timezone_name() or "Europe/Amsterdam"
run_at_str = _format_datetime(run.run_at) if run.run_at else "-"
actor = (getattr(current_user, "email", None) or getattr(current_user, "username", None) or "operator")
base_url = (getattr(settings, "autotask_base_url", None) or "").strip()
job_rel = url_for("main.job_detail", job_id=job.id)
job_link = urljoin(base_url.rstrip("/") + "/", job_rel.lstrip("/"))
if run.id:
job_link = f"{job_link}?run_id={int(run.id)}"
lines: list[str] = []
lines.append("[Backupchecks] Additional alert linked to this ticket.")
lines.append(f"Customer: {job.customer.name if job.customer else ''}")
lines.append(f"Job: {job.job_name or ''}")
lines.append(f"Backup: {job.backup_software or ''} / {job.backup_type or ''}")
lines.append(f"Run ID: {int(run.id) if run.id else 0}")
lines.append(f"Run at ({tz_name}): {run_at_str}")
lines.append(f"Run status: {run.status or ''}")
lines.append(f"Linked active runs for this job: {int(linked_run_count or 0)}")
if ticket_number:
lines.append(f"Ticket: {ticket_number}")
lines.append(f"Linked by: {actor}")
lines.append("")
lines.append(f"Backupchecks details: {job_link}")
return "\n".join(lines).strip() + "\n"
# Grace window for matching real runs to an expected schedule slot.
# A run within +/- 1 hour of the inferred schedule time counts as fulfilling the slot.
MISSED_GRACE_WINDOW = timedelta(hours=1)
@ -1790,21 +1864,58 @@ def api_run_checks_create_autotask_ticket():
overall_message = (getattr(msg, "overall_message", None) or "") if msg else ""
objects_payload: list[dict[str, str]] = []
try:
objs = run.objects.order_by(JobObject.object_name.asc()).all()
except Exception:
objs = list(run.objects or [])
for o in objs or []:
objects_payload.append(
{
"name": getattr(o, "object_name", "") or "",
"type": getattr(o, "object_type", "") or "",
"status": getattr(o, "status", "") or "",
"error_message": getattr(o, "error_message", "") or "",
}
)
if (not objects_payload) and msg:
# Preferred source: run_object_links/customer_objects, also used by Run Checks UI
# for Cove/cloud sourced runs.
try:
rows = (
db.session.execute(
text(
"""
SELECT
co.object_name AS name,
rol.status AS status,
rol.error_message AS error_message
FROM run_object_links rol
JOIN customer_objects co ON co.id = rol.customer_object_id
WHERE rol.run_id = :run_id
ORDER BY co.object_name ASC
"""
),
{"run_id": run.id},
)
.mappings()
.all()
)
for rr in rows:
objects_payload.append(
{
"name": rr.get("name") or "",
"type": "",
"status": rr.get("status") or "",
"error_message": rr.get("error_message") or "",
}
)
except Exception:
pass
# Fallback for legacy mail parser sourced runs.
if not objects_payload:
try:
objs = run.objects.order_by(JobObject.object_name.asc()).all()
except Exception:
objs = list(run.objects or [])
for o in objs or []:
objects_payload.append(
{
"name": getattr(o, "object_name", "") or "",
"type": getattr(o, "object_type", "") or "",
"status": getattr(o, "status", "") or "",
"error_message": getattr(o, "error_message", "") or "",
}
)
if (not objects_payload) and msg and getattr(run, "source_type", None) != "cloud_connect":
try:
mos = MailObject.query.filter_by(mail_message_id=msg.id).order_by(MailObject.object_name.asc()).all()
except Exception:
@ -2193,12 +2304,38 @@ def api_run_checks_autotask_link_existing_ticket():
db.session.rollback()
return jsonify({"status": "error", "message": "Failed to persist Autotask ticket link."}), 500
note_posted = False
note_warning = ""
try:
settings = _get_or_create_settings()
note_body = _compose_autotask_link_existing_note(
settings=settings,
job=job,
run=run,
ticket_number=tnum,
linked_run_count=len(run_ids),
)
client.create_ticket_note(
{
"ticketID": int(ticket_id),
"title": "Backupchecks",
"description": note_body,
"publish": 1,
}
)
note_posted = True
except Exception as exc:
note_posted = False
note_warning = f"Autotask ticket was linked, but posting the link-update note failed: {exc}"
return jsonify(
{
"status": "ok",
"ticket_id": int(ticket_id),
"ticket_number": tnum,
"internal_ticket_id": int(getattr(internal_ticket, "id", 0) or 0) if internal_ticket else 0,
"note_posted": bool(note_posted),
"note_warning": note_warning,
}
)

View File

@ -265,7 +265,7 @@ _ABB_SUBJECT_RE = re.compile(r"\bactive\s+backup\s+for\s+business\b", re.I)
# "backup task vSphere-Task-1 on KANTOOR-NEW was skipped"
_ABB_COMPLETED_RE = re.compile(
r"\b(?:virtuele\s+machine\s+)?(?:de\s+)?back-?up\s*(?:taak|job)\s+(?:van\s+deze\s+taak\s+)?(?P<job>.+?)\s+op\s+(?P<host>[A-Za-z0-9._-]+)\s+is\s+(?P<status>voltooid|gedeeltelijk\s+voltooid|genegeerd)\b"
r"|\b(?:virtual\s+machine\s+)?(?:the\s+)?back-?up\s+(?:task|job)\s+(?P<job_en>.+?)\s+on\s+(?P<host_en>[A-Za-z0-9._-]+)\s+(?:is\s+|was\s+)?(?P<status_en>completed|finished|has\s+completed|partially\s+completed|skipped|ignored)\b",
r"|\b(?:virtual\s+machine\s+)?(?:the\s+)?back-?up\s+(?:task|job)\s+(?P<job_en>.+?)\s+on\s+(?P<host_en>[A-Za-z0-9._-]+)\s+(?:(?:is|was|has\s+been|has)\s+)?(?P<status_en>completed|finished|partially\s+completed|skipped|ignored)\b",
re.I,
)

View File

@ -173,6 +173,24 @@ body.bc-body {
color: var(--bc-sidebar-active-text);
}
.bc-sidebar-link-inline {
display: inline-flex;
align-items: center;
padding: 2px 6px;
margin: -2px -6px;
border-radius: 6px;
text-decoration: none;
color: var(--bc-sidebar-text);
font-weight: 500;
transition: background var(--bc-transition), color var(--bc-transition);
}
.bc-sidebar-link-inline:hover {
background: rgba(255,255,255,0.06);
color: var(--bc-sidebar-text-hover);
}
.bc-nav-icon { display: flex; align-items: center; flex-shrink: 0; }
.bc-nav-label-text { flex: 1; overflow: hidden; text-overflow: ellipsis; }
@ -412,6 +430,25 @@ body.bc-body {
white-space: nowrap;
}
/* Keep long object/datasource values fully visible in operational modals. */
#rcm_objects_table th,
#rcm_objects_table td,
#dj_objects_table th,
#dj_objects_table td,
#run_msg_objects_container table th,
#run_msg_objects_container table td {
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
#rcm_cove_datasources,
#jdm_cove_datasources {
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
/* Cards */
.card {
border-radius: var(--bc-radius);

View File

@ -50,7 +50,7 @@
{% for c in customers %}
<tr>
<td>
<a href="{{ url_for('main.jobs', customer_id=c.id) }}" class="link-primary text-decoration-none">
<a href="{{ url_for('main.jobs', customer_id=c.id) }}" class="bc-sidebar-link-inline">
{{ c.name }}
</a>
</td>

View File

@ -183,6 +183,14 @@
@media (min-width: 1400px) { .modal-xxl { max-width: 1400px; } }
#run_msg_body_container_iframe { height: 55vh; }
#run_msg_objects_container { max-height: 25vh; overflow: auto; }
#jobRunMessageModal.is-cove #jdm_mail_iframe_panel {
display: none !important;
}
#jobRunMessageModal.is-cove #run_msg_objects_container {
max-height: 55vh;
}
</style>
<!-- Inline popup modal for run message details -->
@ -286,7 +294,7 @@
</dl>
</div>
<div class="mb-3">
<div class="mb-3" id="jdm_mail_iframe_panel">
<div class="d-flex align-items-center gap-2 mb-1">
<h6 class="mb-0" id="jdm_mail_heading">Mail</h6>
<a href="#" class="small text-muted" id="jdm_mail_toggle" style="display:none;"
@ -788,8 +796,11 @@ function renderObjects(objects) {
var mailToggle = document.getElementById("jdm_mail_toggle");
var mailBody = document.getElementById("jdm_mail_iframe_body");
var bodyFrame = document.getElementById("run_msg_body_container_iframe");
var mailPanel = document.getElementById("jdm_mail_iframe_panel");
var modalEl = document.getElementById("jobRunMessageModal");
if (data.cove_summary) {
if (modalEl) modalEl.classList.add("is-cove");
var cs = data.cove_summary;
document.getElementById("jdm_cove_account").textContent = cs.account_name || "—";
document.getElementById("jdm_cove_computer").textContent = cs.computer_name || "—";
@ -802,7 +813,9 @@ function renderObjects(objects) {
if (mailHeading) mailHeading.style.display = "none";
if (mailToggle) mailToggle.style.display = "none";
if (mailBody) mailBody.style.display = "none";
if (mailPanel) mailPanel.style.display = "none";
} else if (data.cloud_connect_summary) {
if (modalEl) modalEl.classList.remove("is-cove");
var s = data.cloud_connect_summary;
document.getElementById("jdm_cc_user").textContent = s.user || "";
document.getElementById("jdm_cc_section").textContent = s.section || "";
@ -816,6 +829,7 @@ function renderObjects(objects) {
if (mailHeading) { mailHeading.style.display = ""; mailHeading.textContent = "Source report email"; }
if (mailToggle) { mailToggle.style.display = ""; mailToggle.textContent = "show"; }
if (mailBody) mailBody.style.display = "none";
if (mailPanel) mailPanel.style.display = "";
if (bodyFrame) bodyFrame.srcdoc = wrapMailHtml(data.body_html || "");
} else {
if (covePanel) covePanel.style.display = "none";
@ -823,6 +837,7 @@ function renderObjects(objects) {
if (mailHeading) { mailHeading.style.display = ""; mailHeading.textContent = "Mail"; }
if (mailToggle) mailToggle.style.display = "none";
if (mailBody) mailBody.style.display = "";
if (mailPanel) mailPanel.style.display = "";
if (bodyFrame) bodyFrame.srcdoc = wrapMailHtml(data.body_html || "");
}

View File

@ -228,6 +228,14 @@
overflow: auto;
margin-top: 0.5rem;
}
#runChecksModal.is-cove .rcm-mail-panel {
display: none !important;
}
#runChecksModal.is-cove .rcm-objects-scroll {
max-height: 55vh;
}
</style>
<div class="modal fade" id="runChecksModal" tabindex="-1" aria-labelledby="runChecksModalLabel" aria-hidden="true">
@ -1469,8 +1477,11 @@ table.addEventListener('change', function (e) {
var mailToggle = document.getElementById('rcm_mail_toggle');
var mailBody = document.getElementById('rcm_mail_iframe_body');
var bodyFrame = document.getElementById('rcm_body_iframe');
var mailPanel = document.getElementById('rcm_mail_iframe_panel');
var modalEl = document.getElementById('runChecksModal');
if (run.cove_summary) {
if (modalEl) modalEl.classList.add('is-cove');
var cs = run.cove_summary;
document.getElementById('rcm_cove_account').textContent = cs.account_name || '—';
document.getElementById('rcm_cove_computer').textContent = cs.computer_name || '—';
@ -1483,7 +1494,9 @@ table.addEventListener('change', function (e) {
if (mailHeading) mailHeading.style.display = 'none';
if (mailToggle) mailToggle.style.display = 'none';
if (mailBody) mailBody.style.display = 'none';
if (mailPanel) mailPanel.style.display = 'none';
} else if (run.cloud_connect_summary) {
if (modalEl) modalEl.classList.remove('is-cove');
var s = run.cloud_connect_summary;
document.getElementById('rcc_user').textContent = s.user || '';
document.getElementById('rcc_section').textContent = s.section || '';
@ -1497,6 +1510,7 @@ table.addEventListener('change', function (e) {
if (mailHeading) { mailHeading.style.display = ''; mailHeading.textContent = 'Source report email'; }
if (mailToggle) { mailToggle.style.display = ''; mailToggle.textContent = 'show'; }
if (mailBody) mailBody.style.display = 'none';
if (mailPanel) mailPanel.style.display = '';
if (bodyFrame) bodyFrame.srcdoc = wrapMailHtml(run.body_html || '');
} else {
if (covePanel) covePanel.style.display = 'none';
@ -1504,6 +1518,7 @@ table.addEventListener('change', function (e) {
if (mailHeading) { mailHeading.style.display = ''; mailHeading.textContent = 'Mail'; }
if (mailToggle) mailToggle.style.display = 'none';
if (mailBody) mailBody.style.display = '';
if (mailPanel) mailPanel.style.display = '';
if (bodyFrame) {
bodyFrame.srcdoc = wrapMailHtml(run.body_html || (run.missed ? '<div class="text-muted">No email for missed run.</div>' : ''));
}

View File

@ -2,6 +2,34 @@
This file documents all changes made to this project via Claude Code.
## [2026-03-23]
### Fixed
- Autotask "Link existing ticket" now also posts a ticket note on the selected Autotask ticket when an additional Backupchecks alert/run is linked:
- Added `_compose_autotask_link_existing_note()` in `containers/backupchecks/src/backend/app/main/routes_run_checks.py`.
- Extended `POST /api/run-checks/autotask-link-existing-ticket` to create a note via `client.create_ticket_note(...)` after successful link propagation.
- Link operation remains successful even if note posting fails; response now returns `note_posted` and `note_warning` for visibility.
- Customers page job-filter link styling now matches sidebar colors and hover behavior:
- Replaced `link-primary` link class with `bc-sidebar-link-inline` in `containers/backupchecks/src/templates/main/customers.html`.
- Added `bc-sidebar-link-inline` style in `containers/backupchecks/src/static/css/layout.css` using sidebar text and hover tokens.
- Run Checks Autotask ticket description now includes Cove run objects from `run_object_links` / `customer_objects` (same source as Run Checks UI), instead of only `job_objects`/`mail_objects`:
- Fixes missing object lines for Cove runs where ticket text previously showed only a generic "No detailed object messages available".
- Autotask ticket object listing is now problem-focused:
- Includes only objects with problem signals (`failed`, `error`, `warning`, `missed`).
- Excludes success/completed objects (`success`, `succeeded`, `completed`, `ok`, including `Completed (...)` variants).
- Synology Active Backup for Business parser now correctly handles subjects/bodies that use the wording `has been completed` (e.g. `backup task dc001 on DS220p has been completed`):
- Updated ABB completion regex in `containers/backupchecks/src/backend/app/parsers/synology.py` to accept `has been` completion phrasing.
- Prevents fallback to the generic Synology Active Backup parser that could incorrectly take the bracketed subject prefix (e.g. `[Stout Verlichting - DS220p]`) as `job_name`.
- Correct result for this mail shape is now preserved as `backup_software = Synology`, `backup_type = Active Backup for Business`, `job_name = dc001`.
### Validation
- Test build executed with `./build-and-push.sh t` on 2026-03-23 and pushed `gitea.oskamp.info/ivooskamp/backupchecks:dev` (digest `sha256:19014477f2ae14eac0a62f07e11c923c83f9cd5e478873290bdcca37e6ab257c`).
- Latest validation build executed with `./build-and-push.sh t` on 2026-03-23 and pushed `gitea.oskamp.info/ivooskamp/backupchecks:dev` (digest `sha256:b9bb6d50f131118ebccaed5834513ca83ec7592bd622ecea42c1ce2dd7bf0cfc`).
- Validation build executed with `./build-and-push.sh t` on 2026-03-23 and pushed `gitea.oskamp.info/ivooskamp/backupchecks:dev` (digest `sha256:2ff1675996b27bf409687bf5c52e2a3cb3314728ce1c67bc3ffc14fbd0562427`).
- Validation build executed with `./build-and-push.sh t` on 2026-03-23 and pushed `gitea.oskamp.info/ivooskamp/backupchecks:dev` (digest `sha256:f87d871caa3501251c31a66f61eb94b249faf0d3ab85da3f0c02c6036855849b`).
## [2026-03-20] (9)
### Added

View File

@ -1,3 +1,23 @@
## v0.2.3
### Added
- **Autotask: link existing ticket note update** — when linking a Run Checks alert to an existing Autotask ticket, Backupchecks now posts an additional note on that ticket indicating that another alert/run was linked, including customer/job/run context and a Backupchecks deep-link.
### Changed
- **Autotask link-existing API response** — response now includes `note_posted` and `note_warning`, so operators can directly see whether posting the additional ticket note succeeded.
- **Customers page job-filter links** — customer name links that open Jobs with a customer filter now use sidebar-matching text and hover styling instead of the default blue link style.
### Fixed
- **Autotask object retrieval for ticket composition (improved)** — object details are now fetched from `run_object_links`/`customer_objects` first (same source as Run Checks UI), with fallback to legacy `job_objects`/`mail_objects`, preventing missing-object details for Cove runs.
- **Cove Accounts / Cove runs object visibility** -- object details shown in Backupchecks are now consistently sourced from persisted run-object links, improving completeness for Cove Data Protection runs and aligning ticket content with what operators see in Run Checks.
- **Autotask affected objects list** — ticket descriptions now include only problem objects (`failed`/`error`/`warning`/`missed`) and no longer include completed/success objects.
## v0.2.2
### Fixed
- **Synology Active Backup for Business parsing** — notifications using wording `has been completed` are now correctly recognized by the ABB parser. This prevents fallback to the generic Synology Active Backup parser that could incorrectly use the bracketed subject prefix (for example `Stout Verlichting - DS220p`) as job name.
- For ABB notifications like `backup task dc001 on DS220p has been completed`, parsed values are now correctly preserved as: backup software `Synology`, backup type `Active Backup for Business`, job name `dc001`.
## v0.2.1
### Added

View File

@ -1,6 +1,6 @@
# Technical Notes (Internal)
Last updated: 2026-03-20
Last updated: 2026-03-23
## Purpose
Internal technical snapshot of the `backupchecks` repository for faster onboarding, troubleshooting, and change impact analysis.
@ -730,6 +730,19 @@ File: `build-and-push.sh`
## Recent Changes
### 2026-03-23
- **Synology ABB parser fix** (`parsers/synology.py`): ABB completion regex now also matches `has been completed` phrasing.
- **Job name parsing corrected for ABB mails**: messages like `backup task dc001 on DS220p has been completed` no longer fall back to generic Synology Active Backup parsing; `job_name` stays `dc001` instead of bracketed subject prefix values.
- **Validation**: test build ran successfully via `./build-and-push.sh t`; pushed `gitea.oskamp.info/ivooskamp/backupchecks:dev` with digest `sha256:19014477f2ae14eac0a62f07e11c923c83f9cd5e478873290bdcca37e6ab257c`.
- **Run Checks Autotask (Cove object source fix)** (`main/routes_run_checks.py`): ticket creation now reads object details from `run_object_links` + `customer_objects` first (same data source as Run Checks modal), then falls back to legacy `job_objects`/`mail_objects`.
- **Autotask ticket object filtering tightened** (`main/routes_run_checks.py`): ticket description now lists only problem objects (`failed`/`error`/`warning`/`missed`) and excludes completed/success objects (including `Completed (...)` text variants).
- **Validation**: latest test build ran successfully via `./build-and-push.sh t`; pushed `gitea.oskamp.info/ivooskamp/backupchecks:dev` with digest `sha256:b9bb6d50f131118ebccaed5834513ca83ec7592bd622ecea42c1ce2dd7bf0cfc`.
- **Autotask link-existing ticket note update** (`main/routes_run_checks.py`): linking a run to an existing Autotask ticket now posts an informational ticket note that another alert/run was linked (with customer/job/run context and Backupchecks deep-link).
- **Autotask link-existing API response enriched** (`main/routes_run_checks.py`): response now includes `note_posted` and `note_warning` so UI/operators can see if the extra note call succeeded.
- **Customers job-filter link visual alignment** (`templates/main/customers.html`, `static/css/layout.css`): customer links to filtered Jobs view now use sidebar text colors and hover behavior (`bc-sidebar-link-inline`) instead of Bootstrap `link-primary` blue.
- **Validation**: test build ran successfully via `./build-and-push.sh t`; pushed `gitea.oskamp.info/ivooskamp/backupchecks:dev` with digest `sha256:2ff1675996b27bf409687bf5c52e2a3cb3314728ce1c67bc3ffc14fbd0562427`.
- **Validation**: test build ran successfully via `./build-and-push.sh t`; pushed `gitea.oskamp.info/ivooskamp/backupchecks:dev` with digest `sha256:f87d871caa3501251c31a66f61eb94b249faf0d3ab85da3f0c02c6036855849b`.
### 2026-03-20 (v0.2.1)
- **Missed run false positive fix** (`routes_shared.py`):
- Weekly inference window: last 90 days only (was unbounded). Eliminates stale slot false positives after time-of-day or frequency changes.
@ -806,3 +819,4 @@ File: `build-and-push.sh`
### 2026-02-10
- **Added screenshot support to Feedback system**: Multiple file upload, inline display, two-stage delete (soft delete for audit trail, permanent delete for cleanup).
- **Completed transition to link-based ticket system**: All pages now use JOIN queries, no date-based logic. Added cross-browser copy ticket functionality with three-tier fallback mechanism to both Run Checks and Job Details pages.