feat(activity): search/filter on both Activity-tab panes
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 39s
CI / integration (push) Successful in 3m17s

Recent failures gains a client-side search over the already-loaded 24h
rows (task/queue/target/error), shown as a filtered/total count alongside
the existing error-type chips. All recent activity gains a debounced
server-side task-name search (new `task` ILIKE param on /runs) so it
spans the full history, not just the loaded page. LIKE wildcards are
escaped so task names' literal underscores match literally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 11:34:42 -04:00
parent 14c244bd3d
commit 70d4017cf6
4 changed files with 80 additions and 7 deletions
+13
View File
@@ -147,6 +147,7 @@ async def list_runs():
"""Paginated task_run history. Query params:
queue=<name> filter to one queue
status=<status> filter to one status (running/ok/error/timeout/retry)
task=<substr> case-insensitive substring match on task_name
limit=<int> default 50, max 200
before_id=<int> 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,
@@ -21,8 +21,15 @@
<v-card class="mb-4">
<CardHeading icon="mdi-alert-circle-outline" title="Recent failures (last 24h)">
<v-spacer />
<span class="text-caption fc-muted">
{{ failureCount }} failures
<v-text-field
v-model="failureSearch"
placeholder="Search task, queue, target, error…"
density="compact" hide-details clearable
prepend-inner-icon="mdi-magnify"
style="max-width: 280px;"
/>
<span class="text-caption fc-muted ml-3">
{{ filteredFailures.length }} / {{ failureCount }}
</span>
</CardHeading>
<v-card-text>
@@ -77,6 +84,14 @@
<v-card>
<CardHeading icon="mdi-format-list-bulleted" title="All recent activity">
<v-spacer />
<v-text-field
v-model="filterTask"
placeholder="Search task…"
density="compact" hide-details clearable
prepend-inner-icon="mdi-magnify"
style="max-width: 220px;"
@update:model-value="onTaskSearch"
/>
<v-select
v-model="filterQueue"
:items="queueOptions" density="compact" hide-details
@@ -170,6 +185,8 @@ const store = useSystemActivityStore()
const filterQueue = ref(null)
const filterStatus = ref(null)
const filterErrorType = ref(null)
const filterTask = ref(null) // server-side task-name search (All activity)
const failureSearch = ref('') // client-side search over loaded failures
const queueOptions = [
{ title: 'All queues', value: null },
@@ -225,9 +242,18 @@ const errorTypes = computed(() => {
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 }) }
+2 -1
View File
@@ -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]
+19
View File
@@ -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()