diff --git a/backend/app/api/system_activity.py b/backend/app/api/system_activity.py index 0371415..0df6e5b 100644 --- a/backend/app/api/system_activity.py +++ b/backend/app/api/system_activity.py @@ -147,6 +147,7 @@ async def list_runs(): """Paginated task_run history. Query params: queue= filter to one queue status= filter to one status (running/ok/error/timeout/retry) + task= case-insensitive substring match on task_name limit= default 50, max 200 before_id= cursor for keyset pagination @@ -161,6 +162,7 @@ async def list_runs(): queue = request.args.get("queue") status = request.args.get("status") + task = request.args.get("task") before_id_raw = request.args.get("before_id") before_id = int(before_id_raw) if before_id_raw else None @@ -170,6 +172,11 @@ async def list_runs(): stmt = stmt.where(TaskRun.queue == queue) if status: stmt = stmt.where(TaskRun.status == status) + if task: + # Task names contain literal underscores (download_source, + # vacuum_analyze) — escape LIKE wildcards so a search for + # "vacuum_analyze" doesn't treat "_" as a single-char match. + stmt = stmt.where(TaskRun.task_name.ilike(f"%{_escape_like(task)}%", escape="\\")) if before_id is not None: stmt = stmt.where(TaskRun.id < before_id) stmt = stmt.limit(limit + 1) @@ -225,6 +232,12 @@ async def list_failures(): }) +def _escape_like(value: str) -> str: + """Escape SQL LIKE/ILIKE metacharacters so user search text is matched + literally. Pairs with `escape="\\"` on the .ilike() call.""" + return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + def _row_to_dict(r: TaskRun) -> dict: return { "id": r.id, diff --git a/frontend/src/components/settings/SystemActivityTab.vue b/frontend/src/components/settings/SystemActivityTab.vue index 65fd7b7..7342944 100644 --- a/frontend/src/components/settings/SystemActivityTab.vue +++ b/frontend/src/components/settings/SystemActivityTab.vue @@ -21,8 +21,15 @@ - - {{ failureCount }} failures + + + {{ filteredFailures.length }} / {{ failureCount }} @@ -77,6 +84,14 @@ + { const failureCount = computed(() => (store.failures?.recent || []).length) const filteredFailures = computed(() => { - const all = store.failures?.recent || [] - if (!filterErrorType.value) return all - return all.filter(r => r.error_type === filterErrorType.value) + let all = store.failures?.recent || [] + if (filterErrorType.value) { + all = all.filter(r => r.error_type === filterErrorType.value) + } + const q = (failureSearch.value || '').trim().toLowerCase() + if (q) { + all = all.filter(r => + [r.task_name, r.queue, r.target_id, r.error_type] + .some(v => String(v ?? '').toLowerCase().includes(q)), + ) + } + return all }) function toggleErrorTypeFilter(name) { @@ -240,9 +266,23 @@ function toggleErrorTypeFilter(name) { } function onFilterChange() { - store.setFilter({ queue: filterQueue.value, status: filterStatus.value }) + store.setFilter({ + queue: filterQueue.value, + status: filterStatus.value, + task: (filterTask.value || '').trim() || null, + }) store.loadRuns({ reset: true }) } + +// Server-side search hits the full task_run history, not just the loaded +// page — debounce so we don't fire a query per keystroke (matches the +// 300ms idiom in TagsView/AllowlistTable). +let taskSearchDebounce = null +function onTaskSearch() { + if (taskSearchDebounce) clearTimeout(taskSearchDebounce) + taskSearchDebounce = setTimeout(onFilterChange, 300) +} + function onLoadMore() { store.loadRuns({ reset: false }) } function onRefresh() { store.loadRuns({ reset: true }) } diff --git a/frontend/src/stores/systemActivity.js b/frontend/src/stores/systemActivity.js index 44980a6..89ebbab 100644 --- a/frontend/src/stores/systemActivity.js +++ b/frontend/src/stores/systemActivity.js @@ -16,7 +16,7 @@ export const useSystemActivityStore = defineStore('systemActivity', () => { const runs = ref([]) const runsCursor = ref(null) const runsHasMore = ref(false) - const runsFilter = ref({ queue: null, status: null, limit: 50 }) + const runsFilter = ref({ queue: null, status: null, task: null, limit: 50 }) const loading = ref({ queues: false, workers: false, runs: false, failures: false }) const lastError = ref(null) @@ -65,6 +65,7 @@ export const useSystemActivityStore = defineStore('systemActivity', () => { const params = { limit: runsFilter.value.limit } if (runsFilter.value.queue) params.queue = runsFilter.value.queue if (runsFilter.value.status) params.status = runsFilter.value.status + if (runsFilter.value.task) params.task = runsFilter.value.task if (!reset && runsCursor.value) params.before_id = runsCursor.value const body = await api.get('/api/system/activity/runs', { params }) runs.value = reset ? body.runs : [...runs.value, ...body.runs] diff --git a/tests/test_api_system_activity.py b/tests/test_api_system_activity.py index 8191a8e..1322d92 100644 --- a/tests/test_api_system_activity.py +++ b/tests/test_api_system_activity.py @@ -182,6 +182,25 @@ async def test_runs_filter_by_status(client, _seed_runs): assert len(body["runs"]) == 2 # i=3, i=4 +@pytest.mark.asyncio +async def test_runs_filter_by_task_substring(client, _seed_runs): + # Case-insensitive substring across the full task_name. + resp = await client.get("/api/system/activity/runs?task=FAKE") + body = await resp.get_json() + assert len(body["runs"]) == 5 + assert all("fake" in r["task_name"] for r in body["runs"]) + + +@pytest.mark.asyncio +async def test_runs_filter_by_task_escapes_underscore(client, _seed_runs): + # The literal "_" in "task_3" must match one row, not act as a + # single-char wildcard matching every task_N. + resp = await client.get("/api/system/activity/runs?task=task_3") + body = await resp.get_json() + assert len(body["runs"]) == 1 + assert body["runs"][0]["task_name"].endswith("task_3") + + @pytest.mark.asyncio async def test_runs_keyset_cursor(client, _seed_runs): page1 = await (await client.get("/api/system/activity/runs?limit=2")).get_json()