Forward global search filters to overview pages

This commit is contained in:
Ivo Oskamp 2026-02-16 16:49:47 +01:00
parent f90b2bdcf6
commit b46010dbc2
14 changed files with 261 additions and 29 deletions

View File

@ -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"))

View File

@ -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")

View File

@ -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"))
return redirect(url_for("main.inbox"))

View File

@ -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,
)

View File

@ -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"))

View File

@ -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": [
{

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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

View File

@ -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,
)

View File

@ -4,6 +4,9 @@
<h2 class="mb-3">Daily Jobs</h2>
<form method="get" class="row g-3 mb-3">
{% if q %}
<input type="hidden" name="q" value="{{ q }}" />
{% endif %}
<div class="col-auto">
<label for="dj_date" class="form-label">Date</label>
<input

View File

@ -14,12 +14,12 @@
<div class="d-flex justify-content-between align-items-center my-2">
<div>
{% if has_prev %}
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('main.inbox', page=page-1) }}">Previous</a>
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('main.inbox', page=page-1, q=q) }}">Previous</a>
{% else %}
<button class="btn btn-outline-secondary btn-sm" disabled>Previous</button>
{% endif %}
{% if has_next %}
<a class="btn btn-outline-secondary btn-sm ms-2" href="{{ url_for('main.inbox', page=page+1) }}">Next</a>
<a class="btn btn-outline-secondary btn-sm ms-2" href="{{ url_for('main.inbox', page=page+1, q=q) }}">Next</a>
{% else %}
<button class="btn btn-outline-secondary btn-sm ms-2" disabled>Next</button>
{% endif %}

View File

@ -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() {
</script>
{% endblock %}
{% endblock %}

View File

@ -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 <section>" 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`