From b46010dbc270babd2e2ed1707ecea2d2f8f7285b Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Mon, 16 Feb 2026 16:49:47 +0100 Subject: [PATCH] Forward global search filters to overview pages --- .../src/backend/app/main/routes_customers.py | 24 +++++++++++-- .../src/backend/app/main/routes_daily_jobs.py | 30 ++++++++++++++-- .../src/backend/app/main/routes_inbox.py | 31 +++++++++++++++- .../src/backend/app/main/routes_jobs.py | 24 +++++++++++++ .../src/backend/app/main/routes_overrides.py | 35 ++++++++++++++++--- .../backend/app/main/routes_reporting_api.py | 35 +++++++++++++++---- .../src/backend/app/main/routes_reports.py | 34 ++++++++++++++---- .../src/backend/app/main/routes_run_checks.py | 24 +++++++++++++ .../src/backend/app/main/routes_search.py | 17 +++++++++ .../src/backend/app/main/routes_tickets.py | 19 ++++++++-- .../src/templates/main/daily_jobs.html | 3 ++ .../src/templates/main/inbox.html | 4 +-- .../src/templates/main/reports.html | 7 ++-- docs/changelog-claude.md | 3 ++ 14 files changed, 261 insertions(+), 29 deletions(-) diff --git a/containers/backupchecks/src/backend/app/main/routes_customers.py b/containers/backupchecks/src/backend/app/main/routes_customers.py index e665a1a..996d95d 100644 --- a/containers/backupchecks/src/backend/app/main/routes_customers.py +++ b/containers/backupchecks/src/backend/app/main/routes_customers.py @@ -63,7 +63,27 @@ def _get_or_create_settings_local(): @login_required @roles_required("admin", "operator", "viewer") def customers(): - items = Customer.query.order_by(Customer.name.asc()).all() + q = (request.args.get("q") or "").strip() + + def _patterns(raw: str) -> list[str]: + out = [] + for tok in [t.strip() for t in (raw or "").split() if t.strip()]: + p = tok.replace("\\", "\\\\") + p = p.replace("%", "\\%").replace("_", "\\_") + p = p.replace("*", "%") + if not p.startswith("%"): + p = "%" + p + if not p.endswith("%"): + p = p + "%" + out.append(p) + return out + + query = Customer.query + if q: + for pat in _patterns(q): + query = query.filter(func.coalesce(Customer.name, "").ilike(pat, escape="\\")) + + items = query.order_by(Customer.name.asc()).all() settings = _get_or_create_settings_local() autotask_enabled = bool(getattr(settings, "autotask_enabled", False)) @@ -105,6 +125,7 @@ def customers(): can_manage=can_manage, autotask_enabled=autotask_enabled, autotask_configured=autotask_configured, + q=q, ) @@ -600,4 +621,3 @@ def customers_import(): return redirect(url_for("main.customers")) - 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 121d1b8..4d3976f 100644 --- a/containers/backupchecks/src/backend/app/main/routes_daily_jobs.py +++ b/containers/backupchecks/src/backend/app/main/routes_daily_jobs.py @@ -9,6 +9,21 @@ MISSED_GRACE_WINDOW = timedelta(hours=1) @login_required @roles_required("admin", "operator", "viewer") def daily_jobs(): + q = (request.args.get("q") or "").strip() + + def _patterns(raw: str) -> list[str]: + out = [] + for tok in [t.strip() for t in (raw or "").split() if t.strip()]: + p = tok.replace("\\", "\\\\") + p = p.replace("%", "\\%").replace("_", "\\_") + p = p.replace("*", "%") + if not p.startswith("%"): + p = "%" + p + if not p.endswith("%"): + p = p + "%" + out.append(p) + return out + # Determine target date (default: today) in Europe/Amsterdam date_str = request.args.get("date") try: @@ -74,10 +89,21 @@ def daily_jobs(): weekday_idx = target_date.weekday() # 0=Mon..6=Sun - jobs = ( + jobs_query = ( Job.query.join(Customer, isouter=True) .filter(Job.archived.is_(False)) .filter(db.or_(Customer.id.is_(None), Customer.active.is_(True))) + ) + if q: + for pat in _patterns(q): + jobs_query = jobs_query.filter( + (func.coalesce(Customer.name, "").ilike(pat, escape="\\")) + | (func.coalesce(Job.backup_software, "").ilike(pat, escape="\\")) + | (func.coalesce(Job.backup_type, "").ilike(pat, escape="\\")) + | (func.coalesce(Job.job_name, "").ilike(pat, escape="\\")) + ) + jobs = ( + jobs_query .order_by(Customer.name.asc().nullslast(), Job.backup_software.asc(), Job.backup_type.asc(), Job.job_name.asc()) .all() ) @@ -306,7 +332,7 @@ def daily_jobs(): ) target_date_str = target_date.strftime("%Y-%m-%d") - return render_template("main/daily_jobs.html", rows=rows, target_date_str=target_date_str) + return render_template("main/daily_jobs.html", rows=rows, target_date_str=target_date_str, q=q) @main_bp.route("/daily-jobs/details") diff --git a/containers/backupchecks/src/backend/app/main/routes_inbox.py b/containers/backupchecks/src/backend/app/main/routes_inbox.py index c558717..b138ff9 100644 --- a/containers/backupchecks/src/backend/app/main/routes_inbox.py +++ b/containers/backupchecks/src/backend/app/main/routes_inbox.py @@ -9,12 +9,28 @@ from ..ticketing_utils import link_open_internal_tickets_to_run import time import re import html as _html +from sqlalchemy import cast, String @main_bp.route("/inbox") @login_required @roles_required("admin", "operator", "viewer") def inbox(): + q = (request.args.get("q") or "").strip() + + def _patterns(raw: str) -> list[str]: + out = [] + for tok in [t.strip() for t in (raw or "").split() if t.strip()]: + p = tok.replace("\\", "\\\\") + p = p.replace("%", "\\%").replace("_", "\\_") + p = p.replace("*", "%") + if not p.startswith("%"): + p = "%" + p + if not p.endswith("%"): + p = p + "%" + out.append(p) + return out + try: page = int(request.args.get("page", "1")) except ValueError: @@ -28,6 +44,18 @@ def inbox(): # Use location column if available; otherwise just return all if hasattr(MailMessage, "location"): query = query.filter(MailMessage.location == "inbox") + if q: + for pat in _patterns(q): + query = query.filter( + (func.coalesce(MailMessage.from_address, "").ilike(pat, escape="\\")) + | (func.coalesce(MailMessage.subject, "").ilike(pat, escape="\\")) + | (cast(MailMessage.received_at, String).ilike(pat, escape="\\")) + | (func.coalesce(MailMessage.backup_software, "").ilike(pat, escape="\\")) + | (func.coalesce(MailMessage.backup_type, "").ilike(pat, escape="\\")) + | (func.coalesce(MailMessage.job_name, "").ilike(pat, escape="\\")) + | (func.coalesce(MailMessage.parse_result, "").ilike(pat, escape="\\")) + | (cast(MailMessage.parsed_at, String).ilike(pat, escape="\\")) + ) total_items = query.count() total_pages = max(1, math.ceil(total_items / per_page)) if total_items else 1 @@ -79,6 +107,7 @@ def inbox(): customers=customer_rows, can_bulk_delete=(get_active_role() in ("admin", "operator")), is_admin=(get_active_role() == "admin"), + q=q, ) @@ -1320,4 +1349,4 @@ def inbox_reparse_all(): "info", ) - return redirect(url_for("main.inbox")) \ No newline at end of file + return redirect(url_for("main.inbox")) diff --git a/containers/backupchecks/src/backend/app/main/routes_jobs.py b/containers/backupchecks/src/backend/app/main/routes_jobs.py index c07cce1..f9c9d99 100644 --- a/containers/backupchecks/src/backend/app/main/routes_jobs.py +++ b/containers/backupchecks/src/backend/app/main/routes_jobs.py @@ -15,6 +15,7 @@ from .routes_shared import ( def jobs(): selected_customer_id = None selected_customer_name = "" + q = (request.args.get("q") or "").strip() customer_id_raw = (request.args.get("customer_id") or "").strip() if customer_id_raw: try: @@ -22,6 +23,19 @@ def jobs(): except ValueError: selected_customer_id = None + def _patterns(raw: str) -> list[str]: + out = [] + for tok in [t.strip() for t in (raw or "").split() if t.strip()]: + p = tok.replace("\\", "\\\\") + p = p.replace("%", "\\%").replace("_", "\\_") + p = p.replace("*", "%") + if not p.startswith("%"): + p = "%" + p + if not p.endswith("%"): + p = p + "%" + out.append(p) + return out + base_query = ( Job.query .filter(Job.archived.is_(False)) @@ -37,6 +51,15 @@ def jobs(): # Default listing hides jobs for inactive customers. base_query = base_query.filter(db.or_(Customer.id.is_(None), Customer.active.is_(True))) + if q: + for pat in _patterns(q): + base_query = base_query.filter( + (func.coalesce(Customer.name, "").ilike(pat, escape="\\")) + | (func.coalesce(Job.backup_software, "").ilike(pat, escape="\\")) + | (func.coalesce(Job.backup_type, "").ilike(pat, escape="\\")) + | (func.coalesce(Job.job_name, "").ilike(pat, escape="\\")) + ) + # Join with customers for display jobs = ( base_query @@ -77,6 +100,7 @@ def jobs(): can_manage_jobs=can_manage_jobs, selected_customer_id=selected_customer_id, selected_customer_name=selected_customer_name, + q=q, ) diff --git a/containers/backupchecks/src/backend/app/main/routes_overrides.py b/containers/backupchecks/src/backend/app/main/routes_overrides.py index ab488d4..5f3274a 100644 --- a/containers/backupchecks/src/backend/app/main/routes_overrides.py +++ b/containers/backupchecks/src/backend/app/main/routes_overrides.py @@ -11,6 +11,16 @@ _OVERRIDE_DEFAULT_START_AT = datetime(1970, 1, 1) def overrides(): can_manage = get_active_role() in ("admin", "operator") can_delete = get_active_role() == "admin" + q = (request.args.get("q") or "").strip() + + def _match_query(text: str, raw_query: str) -> bool: + hay = (text or "").lower() + tokens = [t.strip() for t in (raw_query or "").split() if t.strip()] + for tok in tokens: + needle = tok.lower().replace("*", "") + if needle and needle not in hay: + return False + return True overrides_q = Override.query.order_by(Override.level.asc(), Override.start_at.desc()).all() @@ -92,16 +102,31 @@ def overrides(): rows = [] for ov in overrides_q: + scope_text = _describe_scope(ov) + start_text = _format_datetime(ov.start_at) + end_text = _format_datetime(ov.end_at) if ov.end_at else "" + comment_text = ov.comment or "" + if q: + full_text = " | ".join([ + ov.level or "", + scope_text, + start_text, + end_text, + comment_text, + ]) + if not _match_query(full_text, q): + continue + rows.append( { "id": ov.id, "level": ov.level or "", - "scope": _describe_scope(ov), - "start_at": _format_datetime(ov.start_at), - "end_at": _format_datetime(ov.end_at) if ov.end_at else "", + "scope": scope_text, + "start_at": start_text, + "end_at": end_text, "active": bool(ov.active), "treat_as_success": bool(ov.treat_as_success), - "comment": ov.comment or "", + "comment": comment_text, "match_status": ov.match_status or "", "match_error_contains": ov.match_error_contains or "", "match_error_mode": getattr(ov, "match_error_mode", None) or "", @@ -122,6 +147,7 @@ def overrides(): jobs_for_select=jobs_for_select, backup_software_options=backup_software_options, backup_type_options=backup_type_options, + q=q, ) @@ -398,4 +424,3 @@ def overrides_toggle(override_id: int): flash("Override status updated.", "success") return redirect(url_for("main.overrides")) - diff --git a/containers/backupchecks/src/backend/app/main/routes_reporting_api.py b/containers/backupchecks/src/backend/app/main/routes_reporting_api.py index 767b74b..1da2aa3 100644 --- a/containers/backupchecks/src/backend/app/main/routes_reporting_api.py +++ b/containers/backupchecks/src/backend/app/main/routes_reporting_api.py @@ -1,6 +1,6 @@ from .routes_shared import * # noqa: F401,F403 -from sqlalchemy import text +from sqlalchemy import text, cast, String import json import csv import io @@ -101,12 +101,33 @@ def api_reports_list(): if err is not None: return err - rows = ( - db.session.query(ReportDefinition) - .order_by(ReportDefinition.created_at.desc()) - .limit(200) - .all() - ) + q = (request.args.get("q") or "").strip() + + def _patterns(raw: str) -> list[str]: + out = [] + for tok in [t.strip() for t in (raw or "").split() if t.strip()]: + p = tok.replace("\\", "\\\\") + p = p.replace("%", "\\%").replace("_", "\\_") + p = p.replace("*", "%") + if not p.startswith("%"): + p = "%" + p + if not p.endswith("%"): + p = p + "%" + out.append(p) + return out + + query = db.session.query(ReportDefinition) + if q: + for pat in _patterns(q): + query = query.filter( + (func.coalesce(ReportDefinition.name, "").ilike(pat, escape="\\")) + | (func.coalesce(ReportDefinition.report_type, "").ilike(pat, escape="\\")) + | (func.coalesce(ReportDefinition.output_format, "").ilike(pat, escape="\\")) + | (cast(ReportDefinition.period_start, String).ilike(pat, escape="\\")) + | (cast(ReportDefinition.period_end, String).ilike(pat, escape="\\")) + ) + + rows = query.order_by(ReportDefinition.created_at.desc()).limit(200).all() return { "items": [ { diff --git a/containers/backupchecks/src/backend/app/main/routes_reports.py b/containers/backupchecks/src/backend/app/main/routes_reports.py index 84be6f9..e8befd4 100644 --- a/containers/backupchecks/src/backend/app/main/routes_reports.py +++ b/containers/backupchecks/src/backend/app/main/routes_reports.py @@ -1,6 +1,7 @@ from .routes_shared import * # noqa: F401,F403 from datetime import date, timedelta from .routes_reporting_api import build_report_columns_meta, build_report_job_filters_meta +from sqlalchemy import cast, String def get_default_report_period(): """Return default report period (last 7 days).""" @@ -52,13 +53,33 @@ def _build_report_item(r): @main_bp.route("/reports") @login_required def reports(): + q = (request.args.get("q") or "").strip() + + def _patterns(raw: str) -> list[str]: + out = [] + for tok in [t.strip() for t in (raw or "").split() if t.strip()]: + p = tok.replace("\\", "\\\\") + p = p.replace("%", "\\%").replace("_", "\\_") + p = p.replace("*", "%") + if not p.startswith("%"): + p = "%" + p + if not p.endswith("%"): + p = p + "%" + out.append(p) + return out + # Pre-render items so the page is usable even if JS fails to load/execute. - rows = ( - db.session.query(ReportDefinition) - .order_by(ReportDefinition.created_at.desc()) - .limit(200) - .all() - ) + query = db.session.query(ReportDefinition) + if q: + for pat in _patterns(q): + query = query.filter( + (func.coalesce(ReportDefinition.name, "").ilike(pat, escape="\\")) + | (func.coalesce(ReportDefinition.report_type, "").ilike(pat, escape="\\")) + | (func.coalesce(ReportDefinition.output_format, "").ilike(pat, escape="\\")) + | (cast(ReportDefinition.period_start, String).ilike(pat, escape="\\")) + | (cast(ReportDefinition.period_end, String).ilike(pat, escape="\\")) + ) + rows = query.order_by(ReportDefinition.created_at.desc()).limit(200).all() items = [_build_report_item(r) for r in rows] period_start, period_end = get_default_report_period() @@ -70,6 +91,7 @@ def reports(): job_filters_meta=build_report_job_filters_meta(), default_period_start=period_start.isoformat(), default_period_end=period_end.isoformat(), + q=q, ) 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 8c5cee9..f155922 100644 --- a/containers/backupchecks/src/backend/app/main/routes_run_checks.py +++ b/containers/backupchecks/src/backend/app/main/routes_run_checks.py @@ -830,6 +830,21 @@ def _ensure_missed_runs_for_job(job: Job, start_from: date, end_inclusive: date) def run_checks_page(): """Run Checks page: list jobs that have runs to review (including generated missed runs).""" + q = (request.args.get("q") or "").strip() + + def _patterns(raw: str) -> list[str]: + out = [] + for tok in [t.strip() for t in (raw or "").split() if t.strip()]: + p = tok.replace("\\", "\\\\") + p = p.replace("%", "\\%").replace("_", "\\_") + p = p.replace("*", "%") + if not p.startswith("%"): + p = "%" + p + if not p.endswith("%"): + p = p + "%" + out.append(p) + return out + include_reviewed = False if get_active_role() == "admin": include_reviewed = request.args.get("include_reviewed", "0") in ("1", "true", "yes", "on") @@ -889,6 +904,14 @@ def run_checks_page(): .outerjoin(Customer, Customer.id == Job.customer_id) .filter(Job.archived.is_(False)) ) + if q: + for pat in _patterns(q): + base = base.filter( + (func.coalesce(Customer.name, "").ilike(pat, escape="\\")) + | (func.coalesce(Job.backup_software, "").ilike(pat, escape="\\")) + | (func.coalesce(Job.backup_type, "").ilike(pat, escape="\\")) + | (func.coalesce(Job.job_name, "").ilike(pat, escape="\\")) + ) # Runs to show in the overview: unreviewed (or all if admin toggle enabled) run_filter = [] @@ -1136,6 +1159,7 @@ def run_checks_page(): is_admin=(get_active_role() == "admin"), include_reviewed=include_reviewed, autotask_enabled=autotask_enabled, + q=q, ) diff --git a/containers/backupchecks/src/backend/app/main/routes_search.py b/containers/backupchecks/src/backend/app/main/routes_search.py index 281c488..4e1b479 100644 --- a/containers/backupchecks/src/backend/app/main/routes_search.py +++ b/containers/backupchecks/src/backend/app/main/routes_search.py @@ -813,6 +813,23 @@ def search_page(): for s in visible_sections: key = s["key"] cur = int(s.get("current_page", 1) or 1) + if query: + if key == "inbox": + s["view_all_url"] = url_for("main.inbox", q=query) + elif key == "customers": + s["view_all_url"] = url_for("main.customers", q=query) + elif key == "jobs": + s["view_all_url"] = url_for("main.jobs", q=query) + elif key == "daily_jobs": + s["view_all_url"] = url_for("main.daily_jobs", q=query) + elif key == "run_checks": + s["view_all_url"] = url_for("main.run_checks_page", q=query) + elif key == "tickets": + s["view_all_url"] = url_for("main.tickets_page", q=query) + elif key == "overrides": + s["view_all_url"] = url_for("main.overrides", q=query) + elif key == "reports": + s["view_all_url"] = url_for("main.reports", q=query) if s.get("has_prev"): prev_pages = dict(current_pages) prev_pages[key] = cur - 1 diff --git a/containers/backupchecks/src/backend/app/main/routes_tickets.py b/containers/backupchecks/src/backend/app/main/routes_tickets.py index b4465e9..5463207 100644 --- a/containers/backupchecks/src/backend/app/main/routes_tickets.py +++ b/containers/backupchecks/src/backend/app/main/routes_tickets.py @@ -28,17 +28,33 @@ 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 = ( + query + .outerjoin(TicketScope, TicketScope.ticket_id == Ticket.id) + .outerjoin(Customer, Customer.id == TicketScope.customer_id) + .outerjoin(Job, Job.id == TicketScope.job_id) + ) + joined_scope = True query = query.filter( (Ticket.ticket_code.ilike(like_q)) | (Ticket.description.ilike(like_q)) + | (Customer.name.ilike(like_q)) + | (TicketScope.scope_type.ilike(like_q)) + | (TicketScope.backup_software.ilike(like_q)) + | (TicketScope.backup_type.ilike(like_q)) + | (TicketScope.job_name_match.ilike(like_q)) + | (Job.job_name.ilike(like_q)) ) + query = query.distinct() if customer_id or backup_software or backup_type: - query = query.join(TicketScope, TicketScope.ticket_id == Ticket.id) + if not joined_scope: + query = query.join(TicketScope, TicketScope.ticket_id == Ticket.id) if customer_id: query = query.filter(TicketScope.customer_id == customer_id) if backup_software: @@ -322,4 +338,3 @@ def ticket_detail(ticket_id: int): scopes=scopes, runs=runs, ) - diff --git a/containers/backupchecks/src/templates/main/daily_jobs.html b/containers/backupchecks/src/templates/main/daily_jobs.html index a78c79d..6a40ed1 100644 --- a/containers/backupchecks/src/templates/main/daily_jobs.html +++ b/containers/backupchecks/src/templates/main/daily_jobs.html @@ -4,6 +4,9 @@

Daily Jobs

+ {% if q %} + + {% endif %}
{% if has_prev %} - Previous + Previous {% else %} {% endif %} {% if has_next %} - Next + Next {% else %} {% endif %} diff --git a/containers/backupchecks/src/templates/main/reports.html b/containers/backupchecks/src/templates/main/reports.html index a7e67bd..b78064f 100644 --- a/containers/backupchecks/src/templates/main/reports.html +++ b/containers/backupchecks/src/templates/main/reports.html @@ -422,7 +422,10 @@ function loadRawData() { function loadReports() { setTableLoading('Loading…'); - fetch('/api/reports', { credentials: 'same-origin' }) + var params = new URLSearchParams(window.location.search || ''); + var q = (params.get('q') || '').trim(); + var apiUrl = '/api/reports' + (q ? ('?q=' + encodeURIComponent(q)) : ''); + fetch(apiUrl, { credentials: 'same-origin' }) .then(function (r) { return r.json(); }) .then(function (data) { renderTable((data && data.items) ? data.items : []); @@ -521,4 +524,4 @@ function loadRawData() { -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/docs/changelog-claude.md b/docs/changelog-claude.md index d38bb3c..bed49c7 100644 --- a/docs/changelog-claude.md +++ b/docs/changelog-claude.md @@ -22,6 +22,9 @@ This file documents all changes made to this project via Claude Code. - 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) - Changed `docs/technical-notes-codex.md` to include search pagination query params, Daily Jobs modal-open search behavior, and latest successful test-build digest - Changed search pagination buttons to preserve scroll position by linking back to the active section anchor after page navigation +- Changed "Open
" behavior from global search to pass `q` into destination pages and apply page-level filtering, so opened overviews reflect the same search term +- Changed filtering support on Inbox, Customers, Jobs, Daily Jobs, Run Checks, Tickets, Overrides, and Reports routes to accept wildcard-enabled `q` terms from search +- Changed Reports frontend loading (`/api/reports`) to forward URL `q` so client-side refresh keeps the same filtered result set ### 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`