feat(activity): search/filter on both Activity-tab panes
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:
@@ -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 }) }
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user