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:
|
"""Paginated task_run history. Query params:
|
||||||
queue=<name> filter to one queue
|
queue=<name> filter to one queue
|
||||||
status=<status> filter to one status (running/ok/error/timeout/retry)
|
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
|
limit=<int> default 50, max 200
|
||||||
before_id=<int> cursor for keyset pagination
|
before_id=<int> cursor for keyset pagination
|
||||||
|
|
||||||
@@ -161,6 +162,7 @@ async def list_runs():
|
|||||||
|
|
||||||
queue = request.args.get("queue")
|
queue = request.args.get("queue")
|
||||||
status = request.args.get("status")
|
status = request.args.get("status")
|
||||||
|
task = request.args.get("task")
|
||||||
before_id_raw = request.args.get("before_id")
|
before_id_raw = request.args.get("before_id")
|
||||||
before_id = int(before_id_raw) if before_id_raw else None
|
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)
|
stmt = stmt.where(TaskRun.queue == queue)
|
||||||
if status:
|
if status:
|
||||||
stmt = stmt.where(TaskRun.status == 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:
|
if before_id is not None:
|
||||||
stmt = stmt.where(TaskRun.id < before_id)
|
stmt = stmt.where(TaskRun.id < before_id)
|
||||||
stmt = stmt.limit(limit + 1)
|
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:
|
def _row_to_dict(r: TaskRun) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": r.id,
|
"id": r.id,
|
||||||
|
|||||||
@@ -21,8 +21,15 @@
|
|||||||
<v-card class="mb-4">
|
<v-card class="mb-4">
|
||||||
<CardHeading icon="mdi-alert-circle-outline" title="Recent failures (last 24h)">
|
<CardHeading icon="mdi-alert-circle-outline" title="Recent failures (last 24h)">
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<span class="text-caption fc-muted">
|
<v-text-field
|
||||||
{{ failureCount }} failures
|
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>
|
</span>
|
||||||
</CardHeading>
|
</CardHeading>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
@@ -77,6 +84,14 @@
|
|||||||
<v-card>
|
<v-card>
|
||||||
<CardHeading icon="mdi-format-list-bulleted" title="All recent activity">
|
<CardHeading icon="mdi-format-list-bulleted" title="All recent activity">
|
||||||
<v-spacer />
|
<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-select
|
||||||
v-model="filterQueue"
|
v-model="filterQueue"
|
||||||
:items="queueOptions" density="compact" hide-details
|
:items="queueOptions" density="compact" hide-details
|
||||||
@@ -170,6 +185,8 @@ const store = useSystemActivityStore()
|
|||||||
const filterQueue = ref(null)
|
const filterQueue = ref(null)
|
||||||
const filterStatus = ref(null)
|
const filterStatus = ref(null)
|
||||||
const filterErrorType = 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 = [
|
const queueOptions = [
|
||||||
{ title: 'All queues', value: null },
|
{ title: 'All queues', value: null },
|
||||||
@@ -225,9 +242,18 @@ const errorTypes = computed(() => {
|
|||||||
const failureCount = computed(() => (store.failures?.recent || []).length)
|
const failureCount = computed(() => (store.failures?.recent || []).length)
|
||||||
|
|
||||||
const filteredFailures = computed(() => {
|
const filteredFailures = computed(() => {
|
||||||
const all = store.failures?.recent || []
|
let all = store.failures?.recent || []
|
||||||
if (!filterErrorType.value) return all
|
if (filterErrorType.value) {
|
||||||
return all.filter(r => r.error_type === 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) {
|
function toggleErrorTypeFilter(name) {
|
||||||
@@ -240,9 +266,23 @@ function toggleErrorTypeFilter(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onFilterChange() {
|
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 })
|
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 onLoadMore() { store.loadRuns({ reset: false }) }
|
||||||
function onRefresh() { store.loadRuns({ reset: true }) }
|
function onRefresh() { store.loadRuns({ reset: true }) }
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const useSystemActivityStore = defineStore('systemActivity', () => {
|
|||||||
const runs = ref([])
|
const runs = ref([])
|
||||||
const runsCursor = ref(null)
|
const runsCursor = ref(null)
|
||||||
const runsHasMore = ref(false)
|
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 loading = ref({ queues: false, workers: false, runs: false, failures: false })
|
||||||
const lastError = ref(null)
|
const lastError = ref(null)
|
||||||
@@ -65,6 +65,7 @@ export const useSystemActivityStore = defineStore('systemActivity', () => {
|
|||||||
const params = { limit: runsFilter.value.limit }
|
const params = { limit: runsFilter.value.limit }
|
||||||
if (runsFilter.value.queue) params.queue = runsFilter.value.queue
|
if (runsFilter.value.queue) params.queue = runsFilter.value.queue
|
||||||
if (runsFilter.value.status) params.status = runsFilter.value.status
|
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
|
if (!reset && runsCursor.value) params.before_id = runsCursor.value
|
||||||
const body = await api.get('/api/system/activity/runs', { params })
|
const body = await api.get('/api/system/activity/runs', { params })
|
||||||
runs.value = reset ? body.runs : [...runs.value, ...body.runs]
|
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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_runs_keyset_cursor(client, _seed_runs):
|
async def test_runs_keyset_cursor(client, _seed_runs):
|
||||||
page1 = await (await client.get("/api/system/activity/runs?limit=2")).get_json()
|
page1 = await (await client.get("/api/system/activity/runs?limit=2")).get_json()
|
||||||
|
|||||||
Reference in New Issue
Block a user