diff --git a/containers/backupchecks/src/backend/app/main/routes_api.py b/containers/backupchecks/src/backend/app/main/routes_api.py index e168134..33e9de5 100644 --- a/containers/backupchecks/src/backend/app/main/routes_api.py +++ b/containers/backupchecks/src/backend/app/main/routes_api.py @@ -137,7 +137,7 @@ def api_job_run_alerts(run_id: int): db.session.execute( text( """ - SELECT DISTINCT r.id, r.body, r.start_date, r.resolved_at, r.active_from_date + SELECT DISTINCT r.id, r.body, r.source, r.ticket_id, r.start_date, r.resolved_at, r.active_from_date FROM remarks r JOIN remark_job_runs rjr ON rjr.remark_id = r.id WHERE rjr.job_run_id = :run_id @@ -171,6 +171,8 @@ def api_job_run_alerts(run_id: int): { "id": remark_id, "body": body, + "source": (rr.get("source") or "manual"), + "ticket_id": rr.get("ticket_id"), "start_date": _format_datetime(rr.get("start_date")) if rr.get("start_date") else "-", "active_from_date": str(rr.get("active_from_date")) if rr.get("active_from_date") else "", "resolved_at": _format_datetime(rr.get("resolved_at")) if rr.get("resolved_at") else "", @@ -185,7 +187,7 @@ def api_job_run_alerts(run_id: int): db.session.execute( text( """ - SELECT DISTINCT r.id, r.body, r.start_date, r.resolved_at, r.active_from_date + SELECT DISTINCT r.id, r.body, r.source, r.ticket_id, r.start_date, r.resolved_at, r.active_from_date FROM remarks r JOIN remark_scopes rs ON rs.remark_id = r.id WHERE rs.job_id = :job_id @@ -228,6 +230,8 @@ def api_job_run_alerts(run_id: int): { "id": remark_id, "body": body, + "source": (rr.get("source") or "manual"), + "ticket_id": rr.get("ticket_id"), "start_date": _format_datetime(rr.get("start_date")) if rr.get("start_date") else "-", "active_from_date": str(rr.get("active_from_date")) if rr.get("active_from_date") else "", "resolved_at": _format_datetime(rr.get("resolved_at")) if rr.get("resolved_at") else "", @@ -262,8 +266,6 @@ def api_tickets(): customer_id = 0 query = Ticket.query - if active: - query = query.filter(Ticket.resolved_at.is_(None)) if q: like_q = f"%{q}%" query = query.filter( @@ -273,9 +275,43 @@ def api_tickets(): if customer_id: query = query.join(TicketScope, TicketScope.ticket_id == Ticket.id).filter(TicketScope.customer_id == customer_id) - query = query.order_by(Ticket.start_date.desc()).limit(500) + tickets_raw = query.order_by(Ticket.start_date.desc()).limit(500).all() + ticket_ids = [t.id for t in tickets_raw] + scope_total_map = {} + scope_open_map = {} + if ticket_ids: + try: + rows = ( + db.session.execute( + text( + """ + SELECT + ticket_id, + COUNT(*) AS total_count, + SUM(CASE WHEN resolved_at IS NULL THEN 1 ELSE 0 END) AS open_count + FROM ticket_scopes + WHERE ticket_id = ANY(:ids) + GROUP BY ticket_id + """ + ), + {"ids": ticket_ids}, + ) + .fetchall() + ) + for tid, total_cnt, open_cnt in rows: + scope_total_map[int(tid)] = int(total_cnt or 0) + scope_open_map[int(tid)] = int(open_cnt or 0) + except Exception: + scope_total_map = {} + scope_open_map = {} + items = [] - for t in query.all(): + for t in tickets_raw: + total_scopes = int(scope_total_map.get(t.id, 0) or 0) + open_scopes = int(scope_open_map.get(t.id, 0) or 0) + active_effective = (t.resolved_at is None) and (total_scopes == 0 or open_scopes > 0) + if active and not active_effective: + continue items.append( { "id": t.id, @@ -284,7 +320,7 @@ def api_tickets(): "active_from_date": str(getattr(t, "active_from_date", "") or ""), "start_date": _format_datetime(t.start_date), "resolved_at": _format_datetime(t.resolved_at) if t.resolved_at else "", - "active": (t.resolved_at is None and TicketScope.query.filter_by(ticket_id=t.id, resolved_at=None).first() is not None), + "active": bool(active_effective), } ) return jsonify({"status": "ok", "tickets": items}) @@ -538,6 +574,8 @@ def api_remarks(): { "id": r.id, "body": r.body or "", + "source": (getattr(r, "source", None) or "manual"), + "ticket_id": getattr(r, "ticket_id", None), "active_from_date": str(getattr(r, "active_from_date", "") or ""), "start_date": _format_datetime(r.start_date) if r.start_date else "-", "resolved_at": _format_datetime(r.resolved_at) if r.resolved_at else "", @@ -569,6 +607,8 @@ def api_remarks(): remark = Remark( title=None, body=body, + source="manual", + ticket_id=None, active_from_date=_to_amsterdam_date(run.run_at) or _to_amsterdam_date(now) or now.date(), start_date=now, resolved_at=None, @@ -604,6 +644,8 @@ def api_remarks(): "remark": { "id": remark.id, "body": remark.body or "", + "source": (getattr(remark, "source", None) or "manual"), + "ticket_id": getattr(remark, "ticket_id", None), "start_date": _format_datetime(remark.start_date), "resolved_at": "", "active": True, diff --git a/containers/backupchecks/src/backend/app/main/routes_core.py b/containers/backupchecks/src/backend/app/main/routes_core.py index 079e669..776b849 100644 --- a/containers/backupchecks/src/backend/app/main/routes_core.py +++ b/containers/backupchecks/src/backend/app/main/routes_core.py @@ -1,5 +1,12 @@ from .routes_shared import * # noqa: F401,F403 -from .routes_shared import _format_datetime, _get_database_size_bytes, _apply_overrides_to_run, _format_bytes, _get_free_disk_bytes, _infer_schedule_map_from_runs +from .routes_shared import ( + _format_datetime, + _get_database_size_bytes, + _apply_overrides_to_run, + _format_bytes, + _get_expected_times_for_job_on_date, + _get_free_disk_bytes, +) @main_bp.route("/") @login_required @@ -56,8 +63,6 @@ def dashboard(): ) end_of_day = start_of_day + timedelta(days=1) - weekday_idx = today_date.weekday() # 0=Mon..6=Sun - jobs_success_count = 0 jobs_success_override_count = 0 jobs_expected_count = 0 @@ -71,8 +76,7 @@ def dashboard(): jobs = Job.query.join(Customer, isouter=True).all() for job in jobs: - schedule_map = _infer_schedule_map_from_runs(job.id) - expected_times = schedule_map.get(weekday_idx) or [] + expected_times = _get_expected_times_for_job_on_date(job, today_date) if not expected_times: continue diff --git a/containers/backupchecks/src/backend/app/main/routes_daily_jobs.py b/containers/backupchecks/src/backend/app/main/routes_daily_jobs.py index 4d3976f..3e08cd7 100644 --- a/containers/backupchecks/src/backend/app/main/routes_daily_jobs.py +++ b/containers/backupchecks/src/backend/app/main/routes_daily_jobs.py @@ -1,9 +1,14 @@ from .routes_shared import * # noqa: F401,F403 -from .routes_shared import _format_datetime, _get_or_create_settings, _apply_overrides_to_run, _infer_schedule_map_from_runs, _infer_monthly_schedule_from_runs +from .routes_shared import ( + _format_datetime, + _get_or_create_settings, + _apply_overrides_to_run, + _get_expected_times_for_job_on_date, +) # Grace window for today's Expected/Missed transition. # A job is only marked Missed after the latest expected time plus this grace. -MISSED_GRACE_WINDOW = timedelta(hours=1) +MISSED_GRACE_WINDOW = timedelta(hours=3) @main_bp.route("/daily-jobs") @login_required @@ -87,8 +92,6 @@ def daily_jobs(): minute_bucket = (d.minute // 15) * 15 return f"{d.hour:02d}:{minute_bucket:02d}" - weekday_idx = target_date.weekday() # 0=Mon..6=Sun - jobs_query = ( Job.query.join(Customer, isouter=True) .filter(Job.archived.is_(False)) @@ -110,24 +113,7 @@ def daily_jobs(): rows = [] for job in jobs: - schedule_map = _infer_schedule_map_from_runs(job.id) - expected_times = schedule_map.get(weekday_idx) or [] - - # If no weekly schedule is inferred (e.g. monthly jobs), try monthly inference. - if not expected_times: - monthly = _infer_monthly_schedule_from_runs(job.id) - if monthly: - dom = int(monthly.get("day_of_month") or 0) - mtimes = monthly.get("times") or [] - # For months shorter than dom, treat the last day of month as the scheduled day. - try: - import calendar as _calendar - last_dom = _calendar.monthrange(target_date.year, target_date.month)[1] - except Exception: - last_dom = target_date.day - scheduled_dom = dom if (dom and dom <= last_dom) else last_dom - if target_date.day == scheduled_dom: - expected_times = list(mtimes) + expected_times = _get_expected_times_for_job_on_date(job, target_date) if not expected_times: continue diff --git a/containers/backupchecks/src/backend/app/main/routes_jobs.py b/containers/backupchecks/src/backend/app/main/routes_jobs.py index baea853..5e61471 100644 --- a/containers/backupchecks/src/backend/app/main/routes_jobs.py +++ b/containers/backupchecks/src/backend/app/main/routes_jobs.py @@ -3,12 +3,16 @@ from .routes_shared import ( _apply_overrides_to_run, _describe_schedule, _format_datetime, + _get_effective_schedule_for_job, _get_ui_timezone_name, _infer_schedule_map_from_runs, + _parse_schedule_times_csv, _schedule_map_to_desc, _to_amsterdam_date, ) +_WEEKDAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + @main_bp.route("/jobs") @login_required @roles_required("admin", "operator", "viewer") @@ -231,12 +235,106 @@ def job_set_cove_account(job_id: int): return redirect(url_for("main.job_detail", job_id=job_id)) +@main_bp.route("/jobs//schedule", methods=["POST"]) +@login_required +@roles_required("admin", "operator") +def job_set_schedule(job_id: int): + """Save or clear manual schedule override for this job.""" + job = Job.query.get_or_404(job_id) + + if (request.form.get("clear_schedule") or "").strip() == "1": + job.schedule_type = None + job.schedule_days_of_week = None + job.schedule_day_of_month = None + job.schedule_times = None + db.session.commit() + try: + log_admin_event( + "job_schedule_cleared", + f"Cleared manual schedule override for job {job.id}", + details=f"job_name={job.job_name}", + ) + except Exception: + pass + flash("Manual schedule override removed. Inferred schedule is active again.", "success") + return redirect(url_for("main.job_detail", job_id=job_id)) + + schedule_type = (request.form.get("schedule_type") or "").strip().lower() + times_raw = (request.form.get("schedule_times") or "").strip() + times = _parse_schedule_times_csv(times_raw) + if not times: + flash("Invalid schedule times. Use comma-separated HH:MM values, e.g. 01:00,13:15.", "warning") + return redirect(url_for("main.job_detail", job_id=job_id)) + + if schedule_type not in ("daily", "weekly", "monthly"): + flash("Invalid schedule type. Choose Daily, Weekly, or Monthly.", "warning") + return redirect(url_for("main.job_detail", job_id=job_id)) + + day_labels: list[str] = [] + day_of_month: int | None = None + + if schedule_type == "weekly": + raw_days = request.form.getlist("schedule_weekdays") + idxs: list[int] = [] + for value in raw_days: + try: + idx = int(str(value).strip()) + except Exception: + continue + if 0 <= idx <= 6 and idx not in idxs: + idxs.append(idx) + idxs = sorted(idxs) + if not idxs: + flash("Weekly schedule requires at least one day.", "warning") + return redirect(url_for("main.job_detail", job_id=job_id)) + day_labels = [_WEEKDAY_LABELS[i] for i in idxs] + + if schedule_type == "monthly": + dom_raw = (request.form.get("schedule_day_of_month") or "").strip() + try: + day_of_month = int(dom_raw) + except Exception: + day_of_month = None + if day_of_month is None or day_of_month < 1 or day_of_month > 31: + flash("Monthly schedule requires a day of month between 1 and 31.", "warning") + return redirect(url_for("main.job_detail", job_id=job_id)) + + job.schedule_type = schedule_type + job.schedule_times = ",".join(times) + job.schedule_days_of_week = ",".join(day_labels) if day_labels else None + job.schedule_day_of_month = day_of_month if schedule_type == "monthly" else None + db.session.commit() + + try: + details = f"type={schedule_type}; times={job.schedule_times}; days={job.schedule_days_of_week}; dom={job.schedule_day_of_month}" + log_admin_event( + "job_schedule_set", + f"Set manual schedule override for job {job.id}", + details=details, + ) + except Exception: + pass + + flash("Manual schedule override saved.", "success") + return redirect(url_for("main.job_detail", job_id=job_id)) + + @main_bp.route("/jobs/") @login_required @roles_required("admin", "operator", "viewer") def job_detail(job_id: int): job = Job.query.get_or_404(job_id) + first_detected_run_at = ( + db.session.query(func.min(JobRun.run_at)) + .filter( + JobRun.job_id == job.id, + JobRun.run_at.isnot(None), + JobRun.missed.is_(False), + ) + .scalar() + ) + # History pagination try: page = int(request.args.get("page", "1")) @@ -523,13 +621,24 @@ def job_detail(job_id: int): can_manage_jobs = current_user.is_authenticated and get_active_role() in ("admin", "operator") - schedule_map = None schedule_desc = _describe_schedule(job) - if schedule_desc.startswith("No schedule configured"): - schedule_map = _infer_schedule_map_from_runs(job.id) - schedule_desc = _schedule_map_to_desc(schedule_map) - else: - schedule_map = _infer_schedule_map_from_runs(job.id) + inferred_schedule_map = _infer_schedule_map_from_runs(job.id) + effective_schedule = _get_effective_schedule_for_job(job) + effective_source = effective_schedule.get("source") or "none" + effective_weekly_map = effective_schedule.get("weekly_map") or {i: [] for i in range(7)} + effective_monthly = effective_schedule.get("monthly") + + if effective_source == "inferred_weekly": + schedule_desc = _schedule_map_to_desc(effective_weekly_map) + elif effective_source == "inferred_monthly" and effective_monthly: + dom = effective_monthly.get("day_of_month") + mtimes = effective_monthly.get("times") or [] + if mtimes: + schedule_desc = f"Inferred monthly on day {dom} at {', '.join(mtimes)}." + else: + schedule_desc = f"Inferred monthly on day {dom}." + elif effective_source == "none": + schedule_desc = _schedule_map_to_desc(inferred_schedule_map) # For convenience, also load customer customer = None @@ -545,8 +654,12 @@ def job_detail(job_id: int): "main/job_detail.html", job=job, customer=customer, + first_detected_run_at=first_detected_run_at, schedule_desc=schedule_desc, - schedule_map=schedule_map, + schedule_map=inferred_schedule_map, + effective_schedule_source=effective_source, + effective_weekly_schedule_map=effective_weekly_map, + effective_monthly_schedule=effective_monthly, history_rows=history_rows, ticket_open_count=int(ticket_open_count or 0), ticket_total_count=int(ticket_total_count or 0), diff --git a/containers/backupchecks/src/backend/app/main/routes_run_checks.py b/containers/backupchecks/src/backend/app/main/routes_run_checks.py index a532c31..7238a95 100644 --- a/containers/backupchecks/src/backend/app/main/routes_run_checks.py +++ b/containers/backupchecks/src/backend/app/main/routes_run_checks.py @@ -13,12 +13,11 @@ from sqlalchemy import and_, or_, func, text from .routes_shared import ( _apply_overrides_to_run, + _get_effective_schedule_for_job, _format_datetime, _get_ui_timezone, _get_ui_timezone_name, _get_or_create_settings, - _infer_schedule_map_from_runs, - _infer_monthly_schedule_from_runs, _to_amsterdam_date, main_bp, roles_required, @@ -35,6 +34,9 @@ from ..models import ( MailMessage, MailObject, Override, + Remark, + RemarkJobRun, + RemarkScope, Ticket, TicketJobRun, TicketScope, @@ -44,6 +46,7 @@ from ..ticketing_utils import link_open_internal_tickets_to_run AUTOTASK_TERMINAL_STATUS_IDS = {5} +BACKUPCHECKS_RESOLVE_MARKER = "[Backupchecks] Marked as resolved in Backupchecks" RUN_CHECKS_SORT_MODES = {"customer", "status"} # --------------------------------------------------------------------------- @@ -276,6 +279,148 @@ def _resolve_internal_ticket_for_job( db.session.add(TicketJobRun(ticket_id=ticket.id, job_run_id=rid, link_source="autotask")) +def _extract_autotask_resolution_text(ticket_payload: dict | None) -> str: + if not isinstance(ticket_payload, dict): + return "" + + preferred = [ + "resolution", + "resolutionText", + "resolution_text", + "resolutionNote", + "resolutionNotes", + ] + for key in preferred: + val = ticket_payload.get(key) + txt = str(val or "").strip() + if txt: + return txt + + for key, val in ticket_payload.items(): + key_l = str(key or "").strip().lower() + if "resolution" not in key_l: + continue + txt = str(val or "").strip() + if txt: + return txt + + return "" + + +def _maybe_create_autotask_resolution_remark( + *, + ticket_payload: dict | None, + ticket_id: int, + runs_for_ticket: list[JobRun], + now: datetime, +) -> None: + """Persist PSA resolution text as an active internal remark (deduplicated).""" + if not runs_for_ticket: + return + + resolution_text = _extract_autotask_resolution_text(ticket_payload) + if not resolution_text: + return + if BACKUPCHECKS_RESOLVE_MARKER in resolution_text: + # Do not mirror Backupchecks-generated resolve notes back as remarks. + return + + job = Job.query.get(runs_for_ticket[0].job_id) if runs_for_ticket else None + if not job: + return + + ticket_number = "" + if isinstance(ticket_payload, dict): + ticket_number = str( + ticket_payload.get("ticketNumber") + or ticket_payload.get("number") + or ticket_payload.get("ticket_number") + or "" + ).strip() + if not ticket_number: + for rr in runs_for_ticket: + code = str(getattr(rr, "autotask_ticket_number", "") or "").strip() + if code: + ticket_number = code + break + + active_from_dt = None + try: + dts = [getattr(x, "run_at", None) for x in runs_for_ticket if getattr(x, "run_at", None)] + active_from_dt = min(dts) if dts else None + except Exception: + active_from_dt = None + + internal_ticket = _ensure_internal_ticket_for_autotask( + ticket_number=ticket_number, + job=job, + run_ids=[int(x.id) for x in runs_for_ticket if getattr(x, "id", None)], + now=now, + active_from_dt=active_from_dt, + ) + + if (getattr(internal_ticket, "resolved_origin", None) or "").strip().lower() == "backupchecks": + return + + internal_ticket_id = getattr(internal_ticket, "id", None) + + exists = ( + db.session.query(Remark.id) + .join(RemarkScope, RemarkScope.remark_id == Remark.id) + .filter(Remark.source == "autotask_resolution") + .filter(Remark.body == resolution_text) + .filter(RemarkScope.job_id == job.id) + ) + if internal_ticket_id: + exists = exists.filter(Remark.ticket_id == int(internal_ticket_id)) + if exists.first(): + return + + active_from_date = _to_amsterdam_date(active_from_dt or now) or (active_from_dt or now).date() + title = ( + f"Autotask resolution ({ticket_number})" + if ticket_number + else f"Autotask resolution (ID {int(ticket_id)})" + ) + remark = Remark( + title=title, + body=resolution_text, + source="autotask_resolution", + ticket_id=(int(internal_ticket_id) if internal_ticket_id else None), + active_from_date=active_from_date, + start_date=now, + resolved_at=None, + ) + db.session.add(remark) + db.session.flush() + + db.session.add( + RemarkScope( + remark_id=remark.id, + scope_type="job", + customer_id=job.customer_id, + backup_software=job.backup_software, + backup_type=job.backup_type, + job_id=job.id, + job_name_match=job.job_name, + job_name_match_mode="exact", + ) + ) + + for rr in runs_for_ticket: + rid = int(getattr(rr, "id", 0) or 0) + if rid <= 0: + continue + if not RemarkJobRun.query.filter_by(remark_id=remark.id, job_run_id=rid).first(): + db.session.add( + RemarkJobRun( + remark_id=remark.id, + job_run_id=rid, + link_source="autotask_resolution", + ) + ) + + def _poll_autotask_ticket_states_for_runs(*, run_ids: list[int]) -> None: """Phase 2: Read-only PSA-driven ticket completion sync. @@ -570,6 +715,27 @@ def _poll_autotask_ticket_states_for_runs(*, run_ids: list[int]) -> None: origin="psa", ) + # Mirror Autotask resolution text into active internal remarks for follow-up visibility. + for tid in ticket_ids: + if tid in deleted_map: + continue + runs_for_ticket = ticket_to_runs.get(tid) or [] + if not runs_for_ticket: + continue + try: + ticket_payload = client.get_ticket(int(tid)) + except Exception: + continue + try: + _maybe_create_autotask_resolution_remark( + ticket_payload=ticket_payload, + ticket_id=int(tid), + runs_for_ticket=runs_for_ticket, + now=now, + ) + except Exception: + continue + try: db.session.commit() except Exception: @@ -750,8 +916,8 @@ def _compose_autotask_link_existing_note( # 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) +# A run within +/- 3 hours of the inferred schedule time counts as fulfilling the slot. +MISSED_GRACE_WINDOW = timedelta(hours=3) def _status_is_success(status: str | None) -> bool: @@ -817,12 +983,10 @@ def _ensure_missed_runs_for_job(job: Job, start_from: date, end_inclusive: date) return 0 tz = _get_ui_timezone() - schedule_map = _infer_schedule_map_from_runs(job.id) or {} + resolved_schedule = _get_effective_schedule_for_job(job) + schedule_map = resolved_schedule.get("weekly_map") or {i: [] for i in range(7)} has_weekly_times = any((schedule_map.get(i) or []) for i in range(7)) - - monthly = None - if not has_weekly_times: - monthly = _infer_monthly_schedule_from_runs(job.id) + monthly = resolved_schedule.get("monthly") if (not has_weekly_times) and (not monthly): return 0 @@ -2404,7 +2568,7 @@ def api_run_checks_autotask_resolve_note(): ticket_number = str(getattr(run, "autotask_ticket_number", "") or "").strip() # Build dynamic message based on time entry check - marker = "[Backupchecks] Marked as resolved in Backupchecks" + marker = BACKUPCHECKS_RESOLVE_MARKER if has_time_entries: status_note = "(ticket remains open in Autotask due to existing time entries)" else: diff --git a/containers/backupchecks/src/backend/app/main/routes_search.py b/containers/backupchecks/src/backend/app/main/routes_search.py index 0f56bb7..be1c52d 100644 --- a/containers/backupchecks/src/backend/app/main/routes_search.py +++ b/containers/backupchecks/src/backend/app/main/routes_search.py @@ -3,9 +3,8 @@ from .routes_shared import ( _apply_overrides_to_run, _format_datetime, _get_or_create_settings, + _get_expected_times_for_job_on_date, _get_ui_timezone, - _infer_monthly_schedule_from_runs, - _infer_schedule_map_from_runs, ) from sqlalchemy import and_, cast, func, or_, String @@ -382,23 +381,8 @@ def _build_daily_jobs_results(patterns: list[str], page: int) -> dict: ) _enrich_paging(section, total, current_page, total_pages) for row in rows: - expected_times = (_infer_schedule_map_from_runs(row.job_id).get(target_date.weekday()) or []) - if not expected_times: - monthly = _infer_monthly_schedule_from_runs(row.job_id) - if monthly: - try: - dom = int(monthly.get("day_of_month") or 0) - except Exception: - dom = 0 - mtimes = monthly.get("times") or [] - try: - import calendar as _calendar - last_dom = _calendar.monthrange(target_date.year, target_date.month)[1] - except Exception: - last_dom = target_date.day - scheduled_dom = dom if (dom and dom <= last_dom) else last_dom - if target_date.day == scheduled_dom: - expected_times = list(mtimes) + job_obj = Job.query.get(int(row.job_id)) + expected_times = _get_expected_times_for_job_on_date(job_obj, target_date) runs_for_day = ( JobRun.query.filter( @@ -416,7 +400,6 @@ def _build_daily_jobs_results(patterns: list[str], page: int) -> dict: if run_count > 0: last_run = runs_for_day[-1] try: - job_obj = Job.query.get(int(row.job_id)) status_display, _override_applied, _override_level, _ov_id, _ov_reason = _apply_overrides_to_run(job_obj, last_run) if getattr(last_run, "missed", False): last_status = status_display or "Missed" diff --git a/containers/backupchecks/src/backend/app/main/routes_shared.py b/containers/backupchecks/src/backend/app/main/routes_shared.py index 1e52727..82c5f2c 100644 --- a/containers/backupchecks/src/backend/app/main/routes_shared.py +++ b/containers/backupchecks/src/backend/app/main/routes_shared.py @@ -655,7 +655,9 @@ def _infer_schedule_map_from_runs(job_id: int): Returns dict weekday->sorted list of 'HH:MM' strings in configured UI local time. Notes: - - Only considers real runs that came from mail reports (mail_message_id is not NULL). + - Considers real runs from: + - mail reports (mail_message_id is not NULL), and + - Cove API imports (source_type == "cove_api"). - Synthetic missed rows never influence schedule inference. - To reduce noise, a weekday/time bucket must occur at least MIN_OCCURRENCES times. """ @@ -691,7 +693,8 @@ def _infer_schedule_map_from_runs(job_id: int): pass try: - # Only infer schedules from real runs that came from mail reports. + # Only infer schedules from real runs that came from mail reports + # or Cove API imports. # Synthetic "Missed" rows must never influence schedule inference. # Limit to the last 90 days so that schedule changes (different day, # time, or frequency) take effect quickly and do not leave stale slots @@ -703,7 +706,10 @@ def _infer_schedule_map_from_runs(job_id: int): JobRun.job_id == job_id, JobRun.run_at.isnot(None), JobRun.missed.is_(False), - JobRun.mail_message_id.isnot(None), + or_( + JobRun.mail_message_id.isnot(None), + JobRun.source_type == "cove_api", + ), JobRun.run_at >= cutoff_utc, ) .order_by(JobRun.run_at.desc()) @@ -782,14 +788,17 @@ def _infer_monthly_schedule_from_runs(job_id: int): or None if not enough evidence. Rules: - - Uses only real mail-based runs (mail_message_id is not NULL) and excludes synthetic missed rows. + - Uses only real runs from mail and Cove API imports: + - mail-based runs (mail_message_id is not NULL) + - Cove API runs (source_type == "cove_api") + and excludes synthetic missed rows. - Requires at least MIN_OCCURRENCES occurrences for the inferred day-of-month. - Uses a simple cadence heuristic: typical gaps between runs must be >= 20 days to qualify as monthly. """ MIN_OCCURRENCES = 3 try: - # Same "real run" rule as weekly inference. + # Same "real run" rule as weekly inference (mail + Cove API). # 180 days gives ~6 occurrences for a monthly job (enough for # MIN_OCCURRENCES=3) while still discarding stale schedule data. cutoff_utc = datetime.utcnow() - timedelta(days=180) @@ -799,7 +808,10 @@ def _infer_monthly_schedule_from_runs(job_id: int): JobRun.job_id == job_id, JobRun.run_at.isnot(None), JobRun.missed.is_(False), - JobRun.mail_message_id.isnot(None), + or_( + JobRun.mail_message_id.isnot(None), + JobRun.source_type == "cove_api", + ), JobRun.run_at >= cutoff_utc, ) .order_by(JobRun.run_at.asc()) @@ -899,6 +911,161 @@ def _infer_monthly_schedule_from_runs(job_id: int): return {"day_of_month": int(best_dom), "times": keep_times} +def _parse_schedule_times_csv(raw: str | None) -> list[str]: + out: list[str] = [] + seen: set[str] = set() + for part in str(raw or "").split(","): + token = part.strip() + if not token: + continue + m = re.match(r"^(\d{1,2}):(\d{2})$", token) + if not m: + continue + hh = int(m.group(1)) + mm = int(m.group(2)) + if hh < 0 or hh > 23 or mm < 0 or mm > 59: + continue + norm = f"{hh:02d}:{mm:02d}" + if norm in seen: + continue + seen.add(norm) + out.append(norm) + return sorted(out) + + +def _parse_schedule_days_csv(raw: str | None) -> list[int]: + day_map = { + "mon": 0, + "monday": 0, + "tue": 1, + "tues": 1, + "tuesday": 1, + "wed": 2, + "wednesday": 2, + "thu": 3, + "thurs": 3, + "thursday": 3, + "fri": 4, + "friday": 4, + "sat": 5, + "saturday": 5, + "sun": 6, + "sunday": 6, + } + out: list[int] = [] + seen: set[int] = set() + for part in str(raw or "").split(","): + token = part.strip().lower() + if not token: + continue + wd = None + if token.isdigit(): + v = int(token) + if 0 <= v <= 6: + wd = v + else: + wd = day_map.get(token) + if wd is None or wd in seen: + continue + seen.add(wd) + out.append(wd) + return sorted(out) + + +def _get_manual_schedule_for_job(job: Job | None): + """Return normalized manual schedule payload or None when not configured/invalid.""" + if not job: + return None + + stype = str(getattr(job, "schedule_type", "") or "").strip().lower() + if not stype: + return None + + times = _parse_schedule_times_csv(getattr(job, "schedule_times", None)) + if not times: + return None + + if stype == "daily": + weekly_map = {i: list(times) for i in range(7)} + return {"mode": "weekly", "weekly_map": weekly_map} + + if stype == "weekly": + weekly_map = {i: [] for i in range(7)} + for wd in _parse_schedule_days_csv(getattr(job, "schedule_days_of_week", None)): + weekly_map[wd] = list(times) + if not any(weekly_map.get(i) for i in range(7)): + return None + return {"mode": "weekly", "weekly_map": weekly_map} + + if stype == "monthly": + try: + dom = int(getattr(job, "schedule_day_of_month", None) or 0) + except Exception: + dom = 0 + if dom < 1 or dom > 31: + return None + return {"mode": "monthly", "monthly": {"day_of_month": dom, "times": list(times)}} + + return None + + +def _get_effective_schedule_for_job(job: Job | None): + """Resolve schedule with precedence: manual override first, inferred fallback.""" + empty_weekly = {i: [] for i in range(7)} + if not job: + return {"source": "none", "weekly_map": empty_weekly, "monthly": None} + + manual = _get_manual_schedule_for_job(job) + if manual: + if manual.get("mode") == "weekly": + return {"source": "manual", "weekly_map": manual.get("weekly_map") or empty_weekly, "monthly": None} + return {"source": "manual", "weekly_map": empty_weekly, "monthly": manual.get("monthly")} + + inferred_weekly = _infer_schedule_map_from_runs(job.id) or empty_weekly + has_weekly = any((inferred_weekly.get(i) or []) for i in range(7)) + if has_weekly: + return {"source": "inferred_weekly", "weekly_map": inferred_weekly, "monthly": None} + + monthly = _infer_monthly_schedule_from_runs(job.id) + if monthly: + return {"source": "inferred_monthly", "weekly_map": empty_weekly, "monthly": monthly} + + return {"source": "none", "weekly_map": inferred_weekly, "monthly": None} + + +def _get_expected_times_for_job_on_date(job: Job | None, target_date) -> list[str]: + """Return expected HH:MM slots for a job on a specific date using effective schedule.""" + if not job or not target_date: + return [] + + resolved = _get_effective_schedule_for_job(job) + weekly_map = resolved.get("weekly_map") or {} + expected_times = list(weekly_map.get(int(target_date.weekday())) or []) + if expected_times: + return expected_times + + monthly = resolved.get("monthly") + if not monthly: + return [] + + try: + dom = int(monthly.get("day_of_month") or 0) + except Exception: + dom = 0 + mtimes = monthly.get("times") or [] + if dom <= 0 or not mtimes: + return [] + + try: + last_dom = calendar.monthrange(target_date.year, target_date.month)[1] + except Exception: + last_dom = target_date.day + scheduled_dom = dom if dom <= last_dom else last_dom + if int(target_date.day) != int(scheduled_dom): + return [] + return list(mtimes) + + def _schedule_map_to_desc(schedule_map): weekday_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] any_times = any(schedule_map.get(i) for i in range(7)) diff --git a/containers/backupchecks/src/backend/app/main/routes_tickets.py b/containers/backupchecks/src/backend/app/main/routes_tickets.py index 5463207..9703bef 100644 --- a/containers/backupchecks/src/backend/app/main/routes_tickets.py +++ b/containers/backupchecks/src/backend/app/main/routes_tickets.py @@ -29,8 +29,6 @@ def tickets_page(): if tab == "tickets": query = Ticket.query joined_scope = False - if active_only: - query = query.filter(Ticket.resolved_at.is_(None)) if q: like_q = f"%{q}%" query = ( @@ -68,6 +66,8 @@ def tickets_page(): ticket_ids = [t.id for t in tickets_raw] customer_map = {} run_count_map = {} + scope_total_map = {} + scope_open_map = {} if ticket_ids: try: @@ -113,6 +113,31 @@ def tickets_page(): except Exception: run_count_map = {} + try: + rows = ( + db.session.execute( + text( + """ + SELECT + ticket_id, + COUNT(*) AS total_count, + SUM(CASE WHEN resolved_at IS NULL THEN 1 ELSE 0 END) AS open_count + FROM ticket_scopes + WHERE ticket_id = ANY(:ids) + GROUP BY ticket_id + """ + ), + {"ids": ticket_ids}, + ) + .fetchall() + ) + for tid, total_cnt, open_cnt in rows: + scope_total_map[int(tid)] = int(total_cnt or 0) + scope_open_map[int(tid)] = int(open_cnt or 0) + except Exception: + scope_total_map = {} + scope_open_map = {} + for t in tickets_raw: customers_for_ticket = customer_map.get(t.id) or [] if customers_for_ticket: @@ -141,6 +166,11 @@ def tickets_page(): scope_summary = " / ".join([p for p in parts if p]) or "-" except Exception: scope_summary = "-" + total_scopes = int(scope_total_map.get(t.id, 0) or 0) + open_scopes = int(scope_open_map.get(t.id, 0) or 0) + active_effective = (t.resolved_at is None) and (total_scopes == 0 or open_scopes > 0) + if active_only and not active_effective: + continue tickets.append( { @@ -150,7 +180,7 @@ def tickets_page(): "active_from_date": str(getattr(t, "active_from_date", "") or ""), "start_date": _format_datetime(t.start_date), "resolved_at": _format_datetime(t.resolved_at) if t.resolved_at else "", - "active": t.resolved_at is None, + "active": bool(active_effective), "customers": customer_display, "scope_summary": scope_summary, "linked_runs": run_count_map.get(t.id, 0), diff --git a/containers/backupchecks/src/backend/app/migrations.py b/containers/backupchecks/src/backend/app/migrations.py index 8cc8427..cb4ccdf 100644 --- a/containers/backupchecks/src/backend/app/migrations.py +++ b/containers/backupchecks/src/backend/app/migrations.py @@ -631,6 +631,46 @@ def migrate_remarks_active_from_date() -> None: print("[migrations] remarks.active_from_date added and backfilled.") +def migrate_remarks_source_and_ticket_id() -> None: + """Ensure remarks.source and remarks.ticket_id exist.""" + table = "remarks" + + try: + engine = db.get_engine() + except Exception as exc: + print(f"[migrations] Could not get engine for remarks source/ticket migration: {exc}") + return + + try: + with engine.begin() as conn: + if not _column_exists_on_conn(conn, table, "source"): + conn.execute(text('ALTER TABLE "remarks" ADD COLUMN source VARCHAR(64)')) + conn.execute( + text( + """ + UPDATE "remarks" + SET source = 'manual' + WHERE source IS NULL OR source = ''; + """ + ) + ) + try: + conn.execute(text('ALTER TABLE "remarks" ALTER COLUMN source SET NOT NULL')) + except Exception: + pass + + if not _column_exists_on_conn(conn, table, "ticket_id"): + conn.execute(text('ALTER TABLE "remarks" ADD COLUMN ticket_id INTEGER REFERENCES tickets(id)')) + + conn.execute(text('CREATE INDEX IF NOT EXISTS idx_remarks_source ON remarks (source)')) + conn.execute(text('CREATE INDEX IF NOT EXISTS idx_remarks_ticket_id ON remarks (ticket_id)')) + except Exception as exc: + print(f"[migrations] Failed migrate_remarks_source_and_ticket_id: {exc}") + return + + print("[migrations] migrate_remarks_source_and_ticket_id completed.") + + def migrate_overrides_match_columns() -> None: """Add match_status / match_error columns to overrides table if missing.""" engine = db.get_engine() @@ -1428,6 +1468,7 @@ def run_migrations() -> None: migrate_tickets_active_from_date() migrate_tickets_resolved_origin() migrate_remarks_active_from_date() + migrate_remarks_source_and_ticket_id() migrate_overrides_match_columns() migrate_job_runs_review_tracking() migrate_job_runs_override_metadata() @@ -2220,6 +2261,8 @@ def migrate_object_persistence_tables() -> None: id SERIAL PRIMARY KEY, title VARCHAR(255), body TEXT NOT NULL, + source VARCHAR(64) NOT NULL DEFAULT 'manual', + ticket_id INTEGER REFERENCES tickets(id), start_date TIMESTAMP, resolved_at TIMESTAMP, created_at TIMESTAMP NOT NULL, diff --git a/containers/backupchecks/src/backend/app/models.py b/containers/backupchecks/src/backend/app/models.py index e80ebe9..7ecc651 100644 --- a/containers/backupchecks/src/backend/app/models.py +++ b/containers/backupchecks/src/backend/app/models.py @@ -584,6 +584,8 @@ class Remark(db.Model): id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(255)) body = db.Column(db.Text, nullable=False) + source = db.Column(db.String(64), nullable=False, default="manual") + ticket_id = db.Column(db.Integer, db.ForeignKey("tickets.id"), nullable=True) # Date (Europe/Amsterdam) from which this remark should be considered active # for the scoped job(s) in Daily Jobs / Job Details views. diff --git a/containers/backupchecks/src/templates/main/job_detail.html b/containers/backupchecks/src/templates/main/job_detail.html index dac2e01..d8dca18 100644 --- a/containers/backupchecks/src/templates/main/job_detail.html +++ b/containers/backupchecks/src/templates/main/job_detail.html @@ -22,6 +22,59 @@
Remarks
{{ remark_open_count }} open / {{ remark_total_count }} total
+ +
First backup detected
+
+ {% if first_detected_run_at %} + {{ first_detected_run_at|local_datetime }} + {% else %} + Not detected yet + {% endif %} +
+ +
Schedule (effective)
+
+ {% if effective_schedule_source == 'manual' %} + Manual override + {% elif effective_schedule_source == 'inferred_weekly' %} + Inferred (weekly) + {% elif effective_schedule_source == 'inferred_monthly' %} + Inferred (monthly) + {% else %} + No schedule + {% endif %} +
{{ schedule_desc }}
+
+ + {% if effective_weekly_schedule_map and (effective_weekly_schedule_map[0] or effective_weekly_schedule_map[1] or effective_weekly_schedule_map[2] or effective_weekly_schedule_map[3] or effective_weekly_schedule_map[4] or effective_weekly_schedule_map[5] or effective_weekly_schedule_map[6]) %} +
Effective schedule
+
+
+ + + + + + + + + + + + + +
DayTimes (15 min blocks)
Mon{{ ', '.join(effective_weekly_schedule_map[0]) if effective_weekly_schedule_map[0] else '—' }}
Tue{{ ', '.join(effective_weekly_schedule_map[1]) if effective_weekly_schedule_map[1] else '—' }}
Wed{{ ', '.join(effective_weekly_schedule_map[2]) if effective_weekly_schedule_map[2] else '—' }}
Thu{{ ', '.join(effective_weekly_schedule_map[3]) if effective_weekly_schedule_map[3] else '—' }}
Fri{{ ', '.join(effective_weekly_schedule_map[4]) if effective_weekly_schedule_map[4] else '—' }}
Sat{{ ', '.join(effective_weekly_schedule_map[5]) if effective_weekly_schedule_map[5] else '—' }}
Sun{{ ', '.join(effective_weekly_schedule_map[6]) if effective_weekly_schedule_map[6] else '—' }}
+
+
+ {% endif %} + + {% if effective_monthly_schedule %} +
Effective monthly
+
+ Day {{ effective_monthly_schedule.day_of_month }} at {{ ', '.join(effective_monthly_schedule.times or []) }} +
+ {% endif %} + {% if schedule_map %}
Schedule (inferred)
@@ -48,6 +101,86 @@ {% if can_manage_jobs %} +
+
+ Schedule Override + +
+
+
+
+
+ + +
+
+ + +
+
+ +
+ {% set selected_weekdays = ((job.schedule_days_of_week or '').split(',')) %} + {% for idx, label in [(0, 'Mon'), (1, 'Tue'), (2, 'Wed'), (3, 'Thu'), (4, 'Fri'), (5, 'Sat'), (6, 'Sun')] %} +
+ + +
+ {% endfor %} +
+
+
+ + +
+
+ + +
+
+ Manual schedule override is leading for Expected/Missed and missed-run generation. +
+
+
+
+
+
@@ -366,47 +499,54 @@ // Cross-browser copy to clipboard function function copyToClipboard(text, button) { - // Method 1: Modern Clipboard API (works in most browsers with HTTPS) - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(text) + var value = (text || "").toString().trim(); + if (!value) { + alert("No ticket number available to copy."); + return; + } + + if (window.isSecureContext && navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(value) .then(function () { showCopyFeedback(button); }) .catch(function () { - // Fallback to method 2 if clipboard API fails - fallbackCopy(text, button); + fallbackCopy(value, button); }); - } else { - // Method 2: Legacy execCommand method - fallbackCopy(text, button); + return; } + + fallbackCopy(value, button); } function fallbackCopy(text, button) { - var textarea = document.createElement('textarea'); + var textarea = document.createElement("textarea"); textarea.value = text; - textarea.style.position = 'fixed'; - textarea.style.opacity = '0'; - textarea.style.top = '0'; - textarea.style.left = '0'; + textarea.setAttribute("readonly", "readonly"); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + textarea.style.top = "0"; + textarea.style.left = "0"; document.body.appendChild(textarea); textarea.focus(); textarea.select(); + textarea.setSelectionRange(0, text.length); + var successful = false; try { - var successful = document.execCommand('copy'); - if (successful) { - showCopyFeedback(button); - } else { - // If execCommand fails, use prompt as last resort - window.prompt('Copy ticket number:', text); - } + successful = document.execCommand("copy"); } catch (err) { - // If all else fails, show prompt - window.prompt('Copy ticket number:', text); + successful = false; } document.body.removeChild(textarea); + + if (successful) { + showCopyFeedback(button); + return; + } + + window.prompt("Copy ticket number:", text); } function showCopyFeedback(button) { @@ -502,13 +642,13 @@ Array.prototype.forEach.call(box.querySelectorAll('button[data-action]'), function (btn) { btn.addEventListener('click', function (ev) { ev.preventDefault(); + ev.stopPropagation(); var action = btn.getAttribute('data-action'); var id = btn.getAttribute('data-id'); if (!action) return; if (action === 'copy-ticket') { - var code = btn.getAttribute('data-code') || ''; - if (!code) return; + var code = (btn.getAttribute('data-code') || '').trim(); copyToClipboard(code, btn); return; } diff --git a/containers/backupchecks/src/templates/main/run_checks.html b/containers/backupchecks/src/templates/main/run_checks.html index f726985..a656b22 100644 --- a/containers/backupchecks/src/templates/main/run_checks.html +++ b/containers/backupchecks/src/templates/main/run_checks.html @@ -174,11 +174,16 @@ @media (min-width: 1400px) { .modal-xxl { max-width: 1400px; } } #runChecksModal .modal-content { - height: 90vh; + height: min(90vh, calc(100dvh - 1rem)); + max-height: calc(100dvh - 1rem); display: flex; flex-direction: column; } + #runChecksModal .modal-dialog { + margin: 0.5rem auto; + } + #runChecksModal .modal-body { overflow: hidden; flex: 1 1 auto; @@ -236,8 +241,40 @@ #runChecksModal.is-cove .rcm-objects-scroll { max-height: 55vh; } - + @media (max-width: 991.98px) { + #runChecksModal .modal-dialog { + max-width: calc(100vw - 1rem); + margin: 0.5rem; + } + + #runChecksModal .modal-content { + height: calc(100dvh - 1rem); + max-height: calc(100dvh - 1rem); + } + + #runChecksModal .modal-body { + overflow: auto; + } + + #runChecksModal #rcm_content, + #runChecksModal .rcm-main-row, + #runChecksModal .rcm-main-row > .col-md-3, + #runChecksModal .rcm-detail-col { + height: auto; + min-height: initial; + } + + #runChecksModal #rcm_runs_list { + max-height: 28vh; + } + + #runChecksModal .rcm-objects-scroll, + #runChecksModal.is-cove .rcm-objects-scroll { + max-height: none; + } + } +