From f6433812fdf8792192ec5993a0dd4eee95e0d5ec Mon Sep 17 00:00:00 2001 From: Sarah Funkhouser <147884153+golanglemonade@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:25:58 -0600 Subject: [PATCH 1/2] feat: add ability to filter jobs by tags Signed-off-by: Sarah Funkhouser <147884153+golanglemonade@users.noreply.github.com> --- handler_api_endpoint.go | 13 ++++++++-- src/components/job-search/types.ts | 6 +++++ src/routes/jobs/index.schema.ts | 4 ++++ src/routes/jobs/index.tsx | 38 +++++++++++++++++++++++------- src/services/jobs.ts | 6 ++++- 5 files changed, 56 insertions(+), 11 deletions(-) diff --git a/handler_api_endpoint.go b/handler_api_endpoint.go index 22cd326f..3ac375fb 100644 --- a/handler_api_endpoint.go +++ b/handler_api_endpoint.go @@ -146,8 +146,8 @@ func (a *autocompleteListEndpoint[TTx]) Execute(ctx context.Context, req *autoco return listResponseFrom(queuePtrs), nil - default: - return nil, apierror.NewBadRequestf("Invalid facet %q. Valid facets are: job_kind, queue_name", req.Facet) +default: + return nil, apierror.NewBadRequestf("Invalid facet %q. Valid facets are: job_kind, job_tag, queue_name", req.Facet) } }) } @@ -418,6 +418,7 @@ type jobListRequest struct { Priorities []int16 `json:"-" validate:"omitempty,min=0,max=10"` // from ExtractRaw Queues []string `json:"-" validate:"omitempty,max=100"` // from ExtractRaw State *rivertype.JobState `json:"-" validate:"omitempty,oneof=available cancelled completed discarded pending retryable running scheduled"` // from ExtractRaw + Tags []string `json:"-" validate:"omitempty,max=100"` // from ExtractRaw } func (req *jobListRequest) ExtractRaw(r *http.Request) error { @@ -462,6 +463,10 @@ func (req *jobListRequest) ExtractRaw(r *http.Request) error { req.Queues = queues } + if tags := r.URL.Query()["tags"]; len(tags) > 0 { + req.Tags = tags + } + return nil } @@ -487,6 +492,10 @@ func (a *jobListEndpoint[TTx]) Execute(ctx context.Context, req *jobListRequest) params = params.Queues(req.Queues...) } + if len(req.Tags) > 0 { + params = params.Where("ARRAY(SELECT lower(t) FROM unnest(tags) t) && ARRAY(SELECT lower(v) FROM unnest(@tags::varchar[]) v)", river.NamedArgs{"tags": req.Tags}) + } + if req.State == nil { params = params.States(rivertype.JobStateRunning).OrderBy(river.JobListOrderByTime, river.SortOrderAsc) } else { diff --git a/src/components/job-search/types.ts b/src/components/job-search/types.ts index 853ed5d4..6775b6aa 100644 --- a/src/components/job-search/types.ts +++ b/src/components/job-search/types.ts @@ -3,6 +3,7 @@ export enum JobFilterTypeID { KIND = "kind", PRIORITY = "priority", QUEUE = "queue", + TAGS = "tags", } export interface FilterType { @@ -39,4 +40,9 @@ export const AVAILABLE_FILTERS: FilterType[] = [ label: "queue", match: "queue:", }, + { + id: JobFilterTypeID.TAGS, + label: "tags", + match: "tags:", + }, ]; diff --git a/src/routes/jobs/index.schema.ts b/src/routes/jobs/index.schema.ts index 356b9cd0..ab1b4064 100644 --- a/src/routes/jobs/index.schema.ts +++ b/src/routes/jobs/index.schema.ts @@ -49,4 +49,8 @@ export const jobSearchSchema = z.object({ .optional() .transform((v) => (Array.isArray(v) ? v : v ? [v] : undefined)), state: z.nativeEnum(JobState).default(defaultValues.state), + tags: z + .union([z.string(), z.array(z.string().min(1))]) + .optional() + .transform((v) => (Array.isArray(v) ? v : v ? [v] : undefined)), }); diff --git a/src/routes/jobs/index.tsx b/src/routes/jobs/index.tsx index b2b8bc34..314c2002 100644 --- a/src/routes/jobs/index.tsx +++ b/src/routes/jobs/index.tsx @@ -46,14 +46,14 @@ export const Route = createFileRoute("/jobs/")({ search: { middlewares: [ stripSearchParams(defaultValues), - retainSearchParams(["id", "kind", "limit", "priority", "queue"]), + retainSearchParams(["id", "kind", "limit", "priority", "queue", "tags"]), ], }, beforeLoad: async ({ context }) => { // No need to check for search.state since it has a default value now return context; }, - loaderDeps: ({ search: { limit, state, kind, queue, priority, id } }) => { + loaderDeps: ({ search: { limit, state, kind, queue, priority, id, tags } }) => { return { kind, limit: limit || defaultValues.limit, @@ -61,15 +61,16 @@ export const Route = createFileRoute("/jobs/")({ queue, state, id, + tags, }; }, loader: async ({ context: { queryClient }, - deps: { limit, state, kind, queue, id }, + deps: { limit, state, kind, queue, id, tags }, }) => { await Promise.all([ queryClient.ensureQueryData({ - ...jobsQueryOptions({ limit, state, kind, queue, id }), + ...jobsQueryOptions({ limit, state, kind, queue, id, tags }), }), queryClient.ensureQueryData(statesQueryOptions()), ]); @@ -80,7 +81,7 @@ export const Route = createFileRoute("/jobs/")({ function JobsIndexComponent() { const navigate = Route.useNavigate(); - const { id, limit, state, kind, queue, priority } = Route.useLoaderDeps(); + const { id, limit, state, kind, queue, priority, tags } = Route.useLoaderDeps(); const refreshSettings = useRefreshSetting(); const refetchInterval = refreshSettings.intervalMs; const [pauseRefetches, setJobRefetchesPaused] = useState(false); @@ -95,6 +96,7 @@ function JobsIndexComponent() { kind, queue, priority, + tags, }, { pauseRefetches, @@ -147,6 +149,7 @@ function JobsIndexComponent() { priority: undefined, queue: undefined, id: undefined, + tags: undefined, }; // Only set values for filters that exist and have values @@ -170,6 +173,11 @@ function JobsIndexComponent() { ? filter.values : undefined; break; + case FilterTypeId.TAGS: + searchParams.tags = filter.values.length + ? filter.values + : undefined; + break; } }); @@ -178,6 +186,7 @@ function JobsIndexComponent() { kind, priority: priority?.map(String), queue, + tags, }; // Avoid no-op navigations that can race with route transitions. @@ -188,7 +197,8 @@ function JobsIndexComponent() { currentSearchParams.priority, searchParams.priority, ) && - areStringArraysEqual(currentSearchParams.queue, searchParams.queue) + areStringArraysEqual(currentSearchParams.queue, searchParams.queue) && + areStringArraysEqual(currentSearchParams.tags, searchParams.tags) ) { return; } @@ -204,10 +214,11 @@ function JobsIndexComponent() { priority?: string[]; queue?: string[]; state: JobState; + tags?: string[]; }, }); }, - [id, kind, navigate, priority, queue], + [id, kind, navigate, priority, queue, tags], ); // Convert current search params to initial filters @@ -245,8 +256,16 @@ function JobsIndexComponent() { values: queue, }); } + if (tags?.length) { + filters.push({ + id: "tags-filter", + match: "tags:", + typeId: FilterTypeId.TAGS, + values: tags, + }); + } return filters; - }, [id, kind, priority, queue]); + }, [id, kind, priority, queue, tags]); const cancelMutation = useMutation({ mutationFn: async (jobIDs: bigint[], context) => @@ -331,6 +350,7 @@ const jobsQueryOptions = ( kind, queue, priority, + tags, }: { id?: bigint[]; kind?: string[]; @@ -338,6 +358,7 @@ const jobsQueryOptions = ( priority?: number[]; queue?: string[]; state: JobState; + tags?: string[]; }, opts?: { pauseRefetches: boolean; refetchInterval: number }, ) => { @@ -360,6 +381,7 @@ const jobsQueryOptions = ( queues: queue, priorities: priority, ids: id, + tags, }), queryFn: listJobs, placeholderData: keepPreviousDataUnlessStateChanged, diff --git a/src/services/jobs.ts b/src/services/jobs.ts index caddc780..dba92ecd 100644 --- a/src/services/jobs.ts +++ b/src/services/jobs.ts @@ -186,6 +186,7 @@ export type ListJobsKey = [ priorities: number[] | undefined; queues: string[] | undefined; state: JobState | undefined; + tags: string[] | undefined; }, ]; @@ -196,6 +197,7 @@ type ListJobsFilters = { priorities?: number[]; queues?: string[]; state?: JobState; + tags?: string[]; }; export const listJobsKey = (args: ListJobsFilters): ListJobsKey => { @@ -208,6 +210,7 @@ export const listJobsKey = (args: ListJobsFilters): ListJobsKey => { priorities: args.priorities, queues: args.queues, state: args.state, + tags: args.tags, }, ]; }; @@ -216,7 +219,7 @@ export const listJobs: QueryFunction = async ({ queryKey, signal, }) => { - const [, { ids, kinds, limit, priorities, queues, state }] = queryKey; + const [, { ids, kinds, limit, priorities, queues, state, tags }] = queryKey; // Build query params object with only defined values const params: Record = { @@ -227,6 +230,7 @@ export const listJobs: QueryFunction = async ({ if (priorities?.length) params.priorities = priorities.map(String); if (queues?.length) params.queues = queues; if (state) params.state = state; + if (tags?.length) params.tags = tags; // Convert to URLSearchParams, handling arrays correctly const query = new URLSearchParams(); From 5d81e531cd22f6c4ff95a74ae7b59435ee69e767 Mon Sep 17 00:00:00 2001 From: Sarah Funkhouser <147884153+golanglemonade@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:53:29 -0600 Subject: [PATCH 2/2] revert bad spacing change Signed-off-by: Sarah Funkhouser <147884153+golanglemonade@users.noreply.github.com> --- handler_api_endpoint.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/handler_api_endpoint.go b/handler_api_endpoint.go index 3ac375fb..8e0b6d0a 100644 --- a/handler_api_endpoint.go +++ b/handler_api_endpoint.go @@ -146,7 +146,7 @@ func (a *autocompleteListEndpoint[TTx]) Execute(ctx context.Context, req *autoco return listResponseFrom(queuePtrs), nil -default: + default: return nil, apierror.NewBadRequestf("Invalid facet %q. Valid facets are: job_kind, job_tag, queue_name", req.Facet) } })