diff --git a/containers/backupchecks/src/backend/app/main/routes_search.py b/containers/backupchecks/src/backend/app/main/routes_search.py index fad8192..281c488 100644 --- a/containers/backupchecks/src/backend/app/main/routes_search.py +++ b/containers/backupchecks/src/backend/app/main/routes_search.py @@ -1,5 +1,12 @@ from .routes_shared import * # noqa: F401,F403 -from .routes_shared import _format_datetime +from .routes_shared import ( + _apply_overrides_to_run, + _format_datetime, + _get_or_create_settings, + _get_ui_timezone, + _infer_monthly_schedule_from_runs, + _infer_schedule_map_from_runs, +) from sqlalchemy import and_, cast, func, or_, String import math @@ -276,6 +283,65 @@ def _build_daily_jobs_results(patterns: list[str], page: int) -> dict: if not _is_section_allowed("daily_jobs"): return section + try: + tz = _get_ui_timezone() + except Exception: + tz = None + + try: + target_date = datetime.now(tz).date() if tz else datetime.utcnow().date() + except Exception: + target_date = datetime.utcnow().date() + + settings = _get_or_create_settings() + missed_start_date = getattr(settings, "daily_jobs_start_date", None) + + if tz: + local_midnight = datetime( + year=target_date.year, + month=target_date.month, + day=target_date.day, + hour=0, + minute=0, + second=0, + tzinfo=tz, + ) + start_of_day = local_midnight.astimezone(datetime_module.timezone.utc).replace(tzinfo=None) + end_of_day = (local_midnight + timedelta(days=1)).astimezone(datetime_module.timezone.utc).replace(tzinfo=None) + else: + start_of_day = datetime( + year=target_date.year, + month=target_date.month, + day=target_date.day, + hour=0, + minute=0, + second=0, + ) + end_of_day = start_of_day + timedelta(days=1) + + def _to_local(dt_utc): + if not dt_utc or not tz: + return dt_utc + try: + if dt_utc.tzinfo is None: + dt_utc = dt_utc.replace(tzinfo=datetime_module.timezone.utc) + return dt_utc.astimezone(tz) + except Exception: + return dt_utc + + def _bucket_15min(dt_utc): + d = _to_local(dt_utc) + if not d: + return None + minute_bucket = (d.minute // 15) * 15 + return f"{d.hour:02d}:{minute_bucket:02d}" + + def _is_success_status(value: str) -> bool: + s = (value or "").strip().lower() + if not s: + return False + return ("success" in s) or ("override" in s) + query = ( db.session.query( Job.id.label("job_id"), @@ -314,12 +380,71 @@ 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) + + runs_for_day = ( + JobRun.query.filter( + JobRun.job_id == row.job_id, + JobRun.run_at >= start_of_day, + JobRun.run_at < end_of_day, + ) + .order_by(JobRun.run_at.asc()) + .all() + ) + run_count = len(runs_for_day) + + last_status = "-" + expected_display = expected_times[-1] if expected_times else "-" + 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" + else: + last_status = status_display or (last_run.status or "-") + except Exception: + last_status = last_run.status or "-" + expected_display = _bucket_15min(last_run.run_at) or expected_display + else: + try: + today_local = datetime.now(tz).date() if tz else datetime.utcnow().date() + except Exception: + today_local = datetime.utcnow().date() + if target_date > today_local: + last_status = "Expected" + elif target_date == today_local: + last_status = "Expected" + else: + if missed_start_date and target_date < missed_start_date: + last_status = "-" + else: + last_status = "Missed" + + success_text = "Yes" if _is_success_status(last_status) else "No" section["items"].append( { "title": row.job_name or f"Job #{row.job_id}", "subtitle": f"{row.customer_name or '-'} | {row.backup_software or '-'} / {row.backup_type or '-'}", - "meta": "", - "link": url_for("main.daily_jobs"), + "meta": f"Expected: {expected_display} | Successful: {success_text} | Runs: {run_count}", + "link": url_for("main.daily_jobs", date=target_date.strftime("%Y-%m-%d"), open_job_id=row.job_id), } ) diff --git a/containers/backupchecks/src/templates/main/daily_jobs.html b/containers/backupchecks/src/templates/main/daily_jobs.html index 91c79e3..a78c79d 100644 --- a/containers/backupchecks/src/templates/main/daily_jobs.html +++ b/containers/backupchecks/src/templates/main/daily_jobs.html @@ -665,7 +665,7 @@ if (tStatus) tStatus.textContent = ''; }); } - function attachDailyJobsHandlers() { + function attachDailyJobsHandlers() { var rows = document.querySelectorAll(".daily-job-row"); if (!rows.length) { return; @@ -771,9 +771,43 @@ if (tStatus) tStatus.textContent = ''; }); } + function autoOpenJobFromQuery() { + try { + var params = new URLSearchParams(window.location.search || ""); + var openJobId = (params.get("open_job_id") || "").trim(); + if (!openJobId) { + return; + } + + var rows = document.querySelectorAll(".daily-job-row"); + var targetRow = null; + rows.forEach(function (row) { + if ((row.getAttribute("data-job-id") || "") === openJobId) { + targetRow = row; + } + }); + + if (!targetRow) { + return; + } + + targetRow.click(); + + params.delete("open_job_id"); + var nextQuery = params.toString(); + var nextUrl = window.location.pathname + (nextQuery ? ("?" + nextQuery) : ""); + if (window.history && window.history.replaceState) { + window.history.replaceState({}, document.title, nextUrl); + } + } catch (e) { + // no-op + } + } + document.addEventListener("DOMContentLoaded", function () { bindInlineCreateForms(); attachDailyJobsHandlers(); + autoOpenJobFromQuery(); }); })(); diff --git a/docs/changelog-claude.md b/docs/changelog-claude.md index 4a9a9bc..637e3ec 100644 --- a/docs/changelog-claude.md +++ b/docs/changelog-claude.md @@ -18,6 +18,8 @@ This file documents all changes made to this project via Claude Code. - Changed global search visibility to only include sections accessible to the currently active role - Changed `docs/technical-notes-codex.md` with a dedicated Global Grouped Search section (route/UI/behavior/access rules) and latest test build digest for `v20260216-02-global-search` - Changed global search to support per-section pagination (previous/next) so results beyond the first 10 can be browsed per section while preserving the current query/state +- Changed Daily Jobs search result metadata to include expected run time, success indicator, and run count for the selected day +- Changed Daily Jobs search result links to open the same Daily Jobs modal flow via `open_job_id` (instead of only navigating to the overview page) ### Fixed - Fixed `/search` page crash (`TypeError: 'builtin_function_or_method' object is not iterable`) by replacing Jinja dict access from `section.items` to `section['items']` in `templates/main/search.html`