-
-
- {workflowName}
-
- {
- if (!navigator.clipboard?.writeText) {
- console.error(
- "Failed to copy workflow name: Clipboard API unavailable",
- );
- return;
- }
-
- navigator.clipboard.writeText(workflowName).then(
- () => {
- setWorkflowNameCopied(true);
- toastSuccess({
- message: "Workflow name copied to clipboard",
- });
- setTimeout(() => setWorkflowNameCopied(false), 2000);
- },
- (error: unknown) => {
- console.error(
- "Failed to copy workflow name to clipboard:",
- error,
- );
- },
- );
- }}
- title="Copy workflow name"
- type="button"
- >
- {workflowNameCopied ? (
-
- ) : (
-
- )}
-
-
+
ID: {workflow.id}
diff --git a/src/components/job-search/JobSearch.test.tsx b/src/components/job-search/JobSearch.test.tsx
index 36e83522..64b346e7 100644
--- a/src/components/job-search/JobSearch.test.tsx
+++ b/src/components/job-search/JobSearch.test.tsx
@@ -121,6 +121,78 @@ describe("JobSearch", () => {
expect(searchInput).toHaveAttribute("autoComplete", "off");
});
+ it("preserves ongoing typing when a parent acknowledges an earlier filter change", async () => {
+ const onFiltersChange = vi.fn();
+ const { rerender } = render(
+ ,
+ );
+ const input = screen.getByTestId("job-search-input");
+ fireEvent.change(input, { target: { value: "kind:batch" } });
+ await waitFor(() =>
+ expect(onFiltersChange).toHaveBeenCalledWith([
+ expect.objectContaining({ values: ["batch"] }),
+ ]),
+ );
+ fireEvent.change(input, { target: { value: "kind:batch queue:ma" } });
+ rerender(
+ ,
+ );
+ expect(input).toHaveValue("kind:batch queue:ma");
+ });
+
+ it("keeps initial filters uncontrolled unless synchronization is enabled", () => {
+ const { rerender } = render( );
+ const input = screen.getByTestId("job-search-input");
+ fireEvent.change(input, { target: { value: "kind:batch" } });
+ rerender(
+ ,
+ );
+ expect(input).toHaveValue("kind:batch");
+ });
+
+ it("aborts a suggestion request when a newer one replaces it", async () => {
+ const signals: AbortSignal[] = [];
+ const fetchSuggestions = vi.fn(
+ (
+ _type: FilterTypeId,
+ _query: string,
+ _selected: string[],
+ signal?: AbortSignal,
+ ) => {
+ if (signal) signals.push(signal);
+ return new Promise(() => {});
+ },
+ );
+ render( );
+ const input = screen.getByTestId("job-search-input");
+ fireEvent.change(input, { target: { value: "kind:b" } });
+ await waitFor(() => expect(signals).toHaveLength(1));
+ fireEvent.change(input, { target: { value: "kind:ba" } });
+ await waitFor(() => expect(signals).toHaveLength(2));
+ expect(signals[0].aborted).toBe(true);
+ expect(signals[1].aborted).toBe(false);
+ });
+
it("notifies parent of filter changes", async () => {
const onFiltersChange = vi.fn();
render( );
diff --git a/src/components/job-search/JobSearch.tsx b/src/components/job-search/JobSearch.tsx
index edb10e44..887ab2f5 100644
--- a/src/components/job-search/JobSearch.tsx
+++ b/src/components/job-search/JobSearch.tsx
@@ -22,12 +22,14 @@ export interface JobSearchProps {
* @param filterTypeId - The ID of the filter type
* @param query - The search query
* @param selectedValues - Values already selected for this filter
+ * @param signal - Aborted when a newer request replaces this one
* @returns Promise resolving to an array of suggestion strings
*/
fetchSuggestions?: (
filterTypeId: JobFilterTypeID,
query: string,
selectedValues: string[],
+ signal?: AbortSignal,
) => Promise;
/**
* Initial filters to display
@@ -39,6 +41,8 @@ export interface JobSearchProps {
* @param filters - The updated list of filters
*/
onFiltersChange?: (filters: JobFilter[]) => void;
+ /** Restore external filter changes, such as browser Back, without resetting ongoing edits. */
+ syncInitialFilters?: boolean;
}
export function JobSearch({
@@ -46,6 +50,7 @@ export function JobSearch({
fetchSuggestions = defaultFetchSuggestions,
initialFilters = [],
onFiltersChange,
+ syncInitialFilters = false,
}: JobSearchProps) {
const inputRef = useRef(null);
const containerRef = useRef(null);
@@ -67,6 +72,7 @@ export function JobSearch({
fetchSuggestions,
initialFilters,
onFiltersChange,
+ syncInitialFilters,
});
// Handle clicking outside to close suggestions
@@ -180,6 +186,7 @@ export function JobSearch({
{inputValue && (
{
if (filterTypeId === JobFilterTypeID.PRIORITY) {
// Priority is a hardcoded list of values—we just need to filter out
@@ -31,7 +32,13 @@ export async function fetchSuggestions(
}
if (fetchType) {
- return await fetchAutocomplete(fetchType, query, undefined, selectedValues);
+ return await fetchAutocomplete(
+ fetchType,
+ query,
+ undefined,
+ selectedValues,
+ signal,
+ );
}
return [];
diff --git a/src/components/job-search/hooks.ts b/src/components/job-search/hooks.ts
index c35a4484..60b1d752 100644
--- a/src/components/job-search/hooks.ts
+++ b/src/components/job-search/hooks.ts
@@ -21,9 +21,11 @@ interface UseFilterInputProps {
filterTypeId: JobFilterTypeID,
query: string,
selectedValues: string[],
+ signal?: AbortSignal,
) => Promise;
initialFilters: JobFilter[];
onFiltersChange?: (filters: JobFilter[]) => void;
+ syncInitialFilters?: boolean;
}
interface UseSuggestionsProps {
@@ -31,6 +33,7 @@ interface UseSuggestionsProps {
filterTypeId: JobFilterTypeID,
query: string,
selectedValues: string[],
+ signal?: AbortSignal,
) => Promise;
suppressSuggestions: boolean;
}
@@ -39,8 +42,12 @@ export function useFilterInput({
fetchSuggestions,
initialFilters,
onFiltersChange,
+ syncInitialFilters = false,
}: UseFilterInputProps) {
const initialText = serializeFiltersToText(initialFilters);
+ const initialKey = filterValuesKey(initialFilters);
+ const previousInitialKey = useRef(initialKey);
+ const pendingFilterKey = useRef(undefined);
const [inputValue, setInputValue] = useState(initialText);
const [cursorPosition, setCursorPosition] = useState(inputValue.length);
const [suppressSuggestions, setSuppressSuggestions] = useState(false);
@@ -62,6 +69,19 @@ export function useFilterInput({
updateSuggestions,
} = useSuggestions({ fetchSuggestions, suppressSuggestions });
+ useEffect(() => {
+ if (!syncInitialFilters || previousInitialKey.current === initialKey)
+ return;
+ previousInitialKey.current = initialKey;
+ // Acknowledging a debounced edit must not overwrite newer typing.
+ const isOwnChange = pendingFilterKey.current === initialKey;
+ pendingFilterKey.current = undefined;
+ if (isOwnChange) return;
+ setInputValue(initialText);
+ setCursorPosition(initialText.length);
+ clearSuggestions();
+ }, [clearSuggestions, initialKey, initialText, syncInitialFilters]);
+
// Debounced filter change notification
useEffect(() => {
if (debounceTimeoutRef.current) {
@@ -69,6 +89,7 @@ export function useFilterInput({
}
debounceTimeoutRef.current = window.setTimeout(() => {
+ pendingFilterKey.current = filterValuesKey(currentFilters);
onFiltersChange?.(currentFilters);
}, 200);
@@ -218,10 +239,15 @@ export function useSuggestions({
});
const latestSuggestionRequestRef = useRef(0);
+ const suggestionAbortRef = useRef(undefined);
const updateSuggestions = useCallback(
async (text: string, cursor: number) => {
const requestId = ++latestSuggestionRequestRef.current;
+ // A newer request replaces this one, so stop the old fetch instead of
+ // leaving its query running on the server.
+ suggestionAbortRef.current?.abort();
+ suggestionAbortRef.current = undefined;
const context = analyzeAutocompleteContext(
text,
cursor,
@@ -270,10 +296,13 @@ export function useSuggestions({
let suggestions: string[] = [];
if (context.type === "filter-value" && context.filterTypeId) {
+ const abortController = new AbortController();
+ suggestionAbortRef.current = abortController;
suggestions = await fetchSuggestions(
context.filterTypeId,
context.currentToken || "",
context.existingValues || [],
+ abortController.signal,
);
}
@@ -322,3 +351,15 @@ export function useSuggestions({
updateSuggestions,
};
}
+
+function filterValuesKey(filters: JobFilter[]): string {
+ return JSON.stringify(
+ filters
+ .filter((filter) => filter.values.length > 0)
+ .map((filter) => ({
+ type: filter.typeId,
+ values: [...filter.values].sort(),
+ }))
+ .sort((a, b) => a.type.localeCompare(b.type)),
+ );
+}
diff --git a/src/contexts/RefreshSettings.provider.tsx b/src/contexts/RefreshSettings.provider.tsx
index fe97fcc1..1233305f 100644
--- a/src/contexts/RefreshSettings.provider.tsx
+++ b/src/contexts/RefreshSettings.provider.tsx
@@ -1,6 +1,10 @@
import { Fragment, useContext, useMemo, useState } from "react";
-import { RefreshSettingContext } from "./RefreshSettings";
+import {
+ DeadLetterJobsRefreshSettingContext,
+ defaultDeadLetterJobsRefreshIntervalMs,
+ RefreshSettingContext,
+} from "./RefreshSettings";
export interface RefreshSettingProviderProps {
children?: React.ReactNode;
@@ -20,6 +24,10 @@ const RefreshSetting: React.FC = ({
children,
}) => {
const [intervalMs, setIntervalMs] = useState(2000);
+ // Keep the dead letter choice independent and retain it across navigation.
+ const [deadLetterJobsIntervalMs, setDeadLetterJobsIntervalMs] = useState(
+ defaultDeadLetterJobsRefreshIntervalMs,
+ );
const providerValue = useMemo(
() => ({
@@ -29,9 +37,21 @@ const RefreshSetting: React.FC = ({
[intervalMs, setIntervalMs],
);
+ const deadLetterJobsProviderValue = useMemo(
+ () => ({
+ intervalMs: deadLetterJobsIntervalMs,
+ setIntervalMs: setDeadLetterJobsIntervalMs,
+ }),
+ [deadLetterJobsIntervalMs, setDeadLetterJobsIntervalMs],
+ );
+
return (
- {children}
+
+ {children}
+
);
};
diff --git a/src/contexts/RefreshSettings.tsx b/src/contexts/RefreshSettings.tsx
index 8f096e77..8ba11014 100644
--- a/src/contexts/RefreshSettings.tsx
+++ b/src/contexts/RefreshSettings.tsx
@@ -1,7 +1,7 @@
import { createContext } from "react";
export interface UseRefreshSettingProps {
- /** List of all available theme names */
+ /** Refresh interval in milliseconds; 0 disables refresh. */
intervalMs: number;
/** Update the interval setting. Set to 0 to disable refresh. */
setIntervalMs: React.Dispatch>;
@@ -10,3 +10,13 @@ export interface UseRefreshSettingProps {
export const RefreshSettingContext = createContext<
undefined | UseRefreshSettingProps
>(undefined);
+
+// Temporary: restore to 2000 once dead letter indexes and query performance
+// are verified. Users can still enable live updates with the selector.
+export const defaultDeadLetterJobsRefreshIntervalMs = 0;
+
+export const DeadLetterJobsRefreshSettingContext =
+ createContext({
+ intervalMs: defaultDeadLetterJobsRefreshIntervalMs,
+ setIntervalMs: () => {},
+ });
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index 80f3f6f6..bbfec102 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -10,10 +10,13 @@
import { Route as rootRouteImport } from "./routes/__root"
import { Route as IndexRouteImport } from "./routes/index"
+import { Route as DeadLetterJobsRouteImport } from "./routes/dead-letter-jobs"
import { Route as JobsRouteImport } from "./routes/jobs"
import { Route as QueuesRouteImport } from "./routes/queues"
import { Route as SettingsRouteImport } from "./routes/settings"
import { Route as AboutAboutRouteImport } from "./routes/about/about"
+import { Route as DeadLetterJobsIndexRouteImport } from "./routes/dead-letter-jobs/index"
+import { Route as DeadLetterJobsJobIdRouteImport } from "./routes/dead-letter-jobs/$jobId"
import { Route as JobsIndexRouteImport } from "./routes/jobs/index"
import { Route as JobsJobIdRouteImport } from "./routes/jobs/$jobId"
import { Route as PeriodicJobsIndexRouteImport } from "./routes/periodic-jobs/index"
@@ -27,6 +30,11 @@ const IndexRoute = IndexRouteImport.update({
path: "/",
getParentRoute: () => rootRouteImport,
} as any)
+const DeadLetterJobsRoute = DeadLetterJobsRouteImport.update({
+ id: "/dead-letter-jobs",
+ path: "/dead-letter-jobs",
+ getParentRoute: () => rootRouteImport,
+} as any)
const JobsRoute = JobsRouteImport.update({
id: "/jobs",
path: "/jobs",
@@ -47,6 +55,16 @@ const AboutAboutRoute = AboutAboutRouteImport.update({
path: "/about/about",
getParentRoute: () => rootRouteImport,
} as any)
+const DeadLetterJobsIndexRoute = DeadLetterJobsIndexRouteImport.update({
+ id: "/",
+ path: "/",
+ getParentRoute: () => DeadLetterJobsRoute,
+} as any)
+const DeadLetterJobsJobIdRoute = DeadLetterJobsJobIdRouteImport.update({
+ id: "/$jobId",
+ path: "/$jobId",
+ getParentRoute: () => DeadLetterJobsRoute,
+} as any)
const JobsIndexRoute = JobsIndexRouteImport.update({
id: "/",
path: "/",
@@ -85,13 +103,16 @@ const WorkflowsWorkflowIdRoute = WorkflowsWorkflowIdRouteImport.update({
export interface FileRoutesByFullPath {
"/": typeof IndexRoute
+ "/dead-letter-jobs": typeof DeadLetterJobsRouteWithChildren
"/jobs": typeof JobsRouteWithChildren
"/queues": typeof QueuesRouteWithChildren
"/settings": typeof SettingsRoute
"/about/about": typeof AboutAboutRoute
+ "/dead-letter-jobs/$jobId": typeof DeadLetterJobsJobIdRoute
"/jobs/$jobId": typeof JobsJobIdRoute
"/queues/$name": typeof QueuesNameRoute
"/workflows/$workflowId": typeof WorkflowsWorkflowIdRoute
+ "/dead-letter-jobs/": typeof DeadLetterJobsIndexRoute
"/jobs/": typeof JobsIndexRoute
"/periodic-jobs/": typeof PeriodicJobsIndexRoute
"/queues/": typeof QueuesIndexRoute
@@ -101,9 +122,11 @@ export interface FileRoutesByTo {
"/": typeof IndexRoute
"/settings": typeof SettingsRoute
"/about/about": typeof AboutAboutRoute
+ "/dead-letter-jobs/$jobId": typeof DeadLetterJobsJobIdRoute
"/jobs/$jobId": typeof JobsJobIdRoute
"/queues/$name": typeof QueuesNameRoute
"/workflows/$workflowId": typeof WorkflowsWorkflowIdRoute
+ "/dead-letter-jobs": typeof DeadLetterJobsIndexRoute
"/jobs": typeof JobsIndexRoute
"/periodic-jobs": typeof PeriodicJobsIndexRoute
"/queues": typeof QueuesIndexRoute
@@ -112,13 +135,16 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
"/": typeof IndexRoute
+ "/dead-letter-jobs": typeof DeadLetterJobsRouteWithChildren
"/jobs": typeof JobsRouteWithChildren
"/queues": typeof QueuesRouteWithChildren
"/settings": typeof SettingsRoute
"/about/about": typeof AboutAboutRoute
+ "/dead-letter-jobs/$jobId": typeof DeadLetterJobsJobIdRoute
"/jobs/$jobId": typeof JobsJobIdRoute
"/queues/$name": typeof QueuesNameRoute
"/workflows/$workflowId": typeof WorkflowsWorkflowIdRoute
+ "/dead-letter-jobs/": typeof DeadLetterJobsIndexRoute
"/jobs/": typeof JobsIndexRoute
"/periodic-jobs/": typeof PeriodicJobsIndexRoute
"/queues/": typeof QueuesIndexRoute
@@ -128,13 +154,16 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| "/"
+ | "/dead-letter-jobs"
| "/jobs"
| "/queues"
| "/settings"
| "/about/about"
+ | "/dead-letter-jobs/$jobId"
| "/jobs/$jobId"
| "/queues/$name"
| "/workflows/$workflowId"
+ | "/dead-letter-jobs/"
| "/jobs/"
| "/periodic-jobs/"
| "/queues/"
@@ -144,9 +173,11 @@ export interface FileRouteTypes {
| "/"
| "/settings"
| "/about/about"
+ | "/dead-letter-jobs/$jobId"
| "/jobs/$jobId"
| "/queues/$name"
| "/workflows/$workflowId"
+ | "/dead-letter-jobs"
| "/jobs"
| "/periodic-jobs"
| "/queues"
@@ -154,13 +185,16 @@ export interface FileRouteTypes {
id:
| "__root__"
| "/"
+ | "/dead-letter-jobs"
| "/jobs"
| "/queues"
| "/settings"
| "/about/about"
+ | "/dead-letter-jobs/$jobId"
| "/jobs/$jobId"
| "/queues/$name"
| "/workflows/$workflowId"
+ | "/dead-letter-jobs/"
| "/jobs/"
| "/periodic-jobs/"
| "/queues/"
@@ -169,6 +203,7 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
+ DeadLetterJobsRoute: typeof DeadLetterJobsRouteWithChildren
JobsRoute: typeof JobsRouteWithChildren
QueuesRoute: typeof QueuesRouteWithChildren
SettingsRoute: typeof SettingsRoute
@@ -187,6 +222,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
+ "/dead-letter-jobs": {
+ id: "/dead-letter-jobs"
+ path: "/dead-letter-jobs"
+ fullPath: "/dead-letter-jobs"
+ preLoaderRoute: typeof DeadLetterJobsRouteImport
+ parentRoute: typeof rootRouteImport
+ }
"/jobs": {
id: "/jobs"
path: "/jobs"
@@ -215,6 +257,20 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof AboutAboutRouteImport
parentRoute: typeof rootRouteImport
}
+ "/dead-letter-jobs/": {
+ id: "/dead-letter-jobs/"
+ path: "/"
+ fullPath: "/dead-letter-jobs/"
+ preLoaderRoute: typeof DeadLetterJobsIndexRouteImport
+ parentRoute: typeof DeadLetterJobsRoute
+ }
+ "/dead-letter-jobs/$jobId": {
+ id: "/dead-letter-jobs/$jobId"
+ path: "/$jobId"
+ fullPath: "/dead-letter-jobs/$jobId"
+ preLoaderRoute: typeof DeadLetterJobsJobIdRouteImport
+ parentRoute: typeof DeadLetterJobsRoute
+ }
"/jobs/": {
id: "/jobs/"
path: "/"
@@ -267,6 +323,20 @@ declare module "@tanstack/react-router" {
}
}
+interface DeadLetterJobsRouteChildren {
+ DeadLetterJobsJobIdRoute: typeof DeadLetterJobsJobIdRoute
+ DeadLetterJobsIndexRoute: typeof DeadLetterJobsIndexRoute
+}
+
+const DeadLetterJobsRouteChildren: DeadLetterJobsRouteChildren = {
+ DeadLetterJobsJobIdRoute: DeadLetterJobsJobIdRoute,
+ DeadLetterJobsIndexRoute: DeadLetterJobsIndexRoute,
+}
+
+const DeadLetterJobsRouteWithChildren = DeadLetterJobsRoute._addFileChildren(
+ DeadLetterJobsRouteChildren,
+)
+
interface JobsRouteChildren {
JobsJobIdRoute: typeof JobsJobIdRoute
JobsIndexRoute: typeof JobsIndexRoute
@@ -294,6 +364,7 @@ const QueuesRouteWithChildren =
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
+ DeadLetterJobsRoute: DeadLetterJobsRouteWithChildren,
JobsRoute: JobsRouteWithChildren,
QueuesRoute: QueuesRouteWithChildren,
SettingsRoute: SettingsRoute,
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
index 8e12b44a..03f56222 100644
--- a/src/routes/__root.tsx
+++ b/src/routes/__root.tsx
@@ -1,3 +1,5 @@
+import { Button } from "@components/Button";
+import NotFoundPage from "@components/NotFoundPage";
import { Root } from "@components/Root";
import { type Features, featuresKey, getFeatures } from "@services/features";
import { QueryClient } from "@tanstack/react-query";
@@ -18,6 +20,20 @@ export const Route = createRootRouteWithContext<{
};
},
component: RootComponent,
+ // Also shown for a pane this server doesn't support, such as a Pro-only
+ // route opened on an OSS server.
+ notFoundComponent: () => (
+
+ Return to job list →
+
+ }
+ description="This page doesn't exist, or this River UI server doesn't support it."
+ heading="Page not found"
+ title="Not Found"
+ />
+ ),
});
function RootComponent() {
diff --git a/src/routes/dead-letter-jobs.test.tsx b/src/routes/dead-letter-jobs.test.tsx
new file mode 100644
index 00000000..73650260
--- /dev/null
+++ b/src/routes/dead-letter-jobs.test.tsx
@@ -0,0 +1,766 @@
+import Layout from "@components/Layout";
+import { FeaturesContext } from "@contexts/Features";
+import { RefreshSettingProvider } from "@contexts/RefreshSettings.provider";
+import { SidebarSettingProvider } from "@contexts/SidebarSetting.provider";
+import {
+ deadLetterJobRouteSearchSchema,
+ deadLetterJobSearchSchema,
+} from "@routes/dead-letter-jobs/index.schema";
+import { listDeadLetterJobsKey } from "@services/deadLetterJobs";
+import { featuresKey } from "@services/features";
+import { Job, listJobs, listJobsKey } from "@services/jobs";
+import { countsByState, StatesAndCounts } from "@services/states";
+import { toastError } from "@services/toast";
+import { JobState } from "@services/types";
+import {
+ focusManager,
+ onlineManager,
+ QueryClient,
+ QueryClientProvider,
+ QueryFunctionContext,
+} from "@tanstack/react-query";
+import {
+ createMemoryHistory,
+ createRouter,
+ Outlet,
+ RouterProvider,
+} from "@tanstack/react-router";
+import { jobFactory } from "@test/factories/job";
+import { createFeatures } from "@test/utils/features";
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import { APIResponseError, NotFoundError } from "@utils/api";
+import qs from "query-string";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import "../global-type-overrides";
+import { routeTree } from "../routeTree.gen";
+
+const {
+ fetchDeadLetterSuggestions,
+ getDeadLetterJob,
+ listDeadLetterJobs,
+ retryDeadLetterJob,
+} = vi.hoisted(() => ({
+ fetchDeadLetterSuggestions: vi.fn(),
+ getDeadLetterJob: vi.fn(),
+ listDeadLetterJobs: vi.fn(),
+ retryDeadLetterJob: vi.fn(),
+}));
+
+vi.mock("@services/deadLetterJobs", async (importOriginal) => ({
+ ...(await importOriginal
()),
+ fetchDeadLetterSuggestions,
+ getDeadLetterJob,
+ listDeadLetterJobs,
+ retryDeadLetterJob,
+}));
+vi.mock("@components/Root", () => ({
+ Root: () => (
+
+
+
+ ),
+}));
+vi.mock("@services/toast", () => ({
+ toastError: vi.fn(),
+ toastSuccess: vi.fn(),
+}));
+vi.mock("@services/jobs", async (importOriginal) => ({
+ ...(await importOriginal()),
+ getJob: vi.fn(),
+ listJobs: vi.fn(),
+}));
+vi.mock("@services/states", async (importOriginal) => ({
+ ...(await importOriginal()),
+ countsByState: vi.fn(),
+}));
+
+const job = jobFactory.discarded().build({
+ argsRaw: '{"id":1970670598291982290}',
+ id: 1970670598291982290n,
+ kind: "send_email",
+ queue: "mail",
+});
+
+async function renderRoute(path: string, pro = true, staleTime = Infinity) {
+ const features = createFeatures({ deadLetterJobs: pro });
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, staleTime },
+ mutations: { retry: false },
+ },
+ });
+ queryClient.setQueryData(featuresKey(), features);
+ const router = createRouter({
+ context: { features, queryClient },
+ defaultPendingMinMs: 0,
+ history: createMemoryHistory({ initialEntries: [path] }),
+ parseSearch: (search) => qs.parse(search, { arrayFormat: "comma" }),
+ stringifySearch: (search) => {
+ const result = qs.stringify(search, {
+ arrayFormat: "comma",
+ skipEmptyString: true,
+ });
+ return result ? `?${result}` : "";
+ },
+ routeTree,
+ });
+ await act(async () => {
+ await router.load();
+ render(
+
+
+
+
+
+
+
+
+ ,
+ );
+ });
+ return { queryClient, router };
+}
+
+async function selectRefreshInterval(name: string) {
+ fireEvent.click(screen.getByRole("button", { name: /live updates/i }));
+ fireEvent.click(await screen.findByRole("option", { name }));
+ await waitFor(() =>
+ expect(screen.queryByRole("listbox")).not.toBeInTheDocument(),
+ );
+}
+
+afterEach(() => {
+ focusManager.setFocused(undefined);
+ onlineManager.setOnline(true);
+ vi.useRealTimers();
+});
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ fetchDeadLetterSuggestions.mockResolvedValue([]);
+ listDeadLetterJobs.mockResolvedValue([job]);
+ getDeadLetterJob.mockResolvedValue(job);
+ vi.mocked(listJobs).mockResolvedValue([]);
+ const zero = { accuracy: "exact", count: 0n } as const;
+ const counts: StatesAndCounts = {
+ available: zero,
+ cancelled: zero,
+ completed: zero,
+ discarded: zero,
+ pending: zero,
+ retryable: zero,
+ running: zero,
+ scheduled: zero,
+ };
+ vi.mocked(countsByState).mockResolvedValue(counts);
+});
+
+describe("Dead letter jobs", () => {
+ const filteredSearch =
+ "?id=1970670598291982290&kind=send_email&queue=mail&priority=1,3&tags=batch,urgent&limit=40";
+
+ it.each(["/dead-letter-jobs", `/dead-letter-jobs/${job.id}`])(
+ "defaults to paused live updates for %s",
+ async (path) => {
+ focusManager.setFocused(true);
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ const { queryClient } = await renderRoute(path, true, 0);
+ const fetchJob =
+ path === "/dead-letter-jobs" ? listDeadLetterJobs : getDeadLetterJob;
+ expect(fetchJob).toHaveBeenCalled();
+ expect(
+ screen.getByRole("button", { name: /Resume live updates/ }),
+ ).toBeInTheDocument();
+ const initialCalls = fetchJob.mock.calls.length;
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(6000);
+ });
+ expect(fetchJob).toHaveBeenCalledTimes(initialCalls);
+ await act(async () => {
+ focusManager.setFocused(false);
+ focusManager.setFocused(true);
+ onlineManager.setOnline(false);
+ onlineManager.setOnline(true);
+ await vi.advanceTimersByTimeAsync(10);
+ });
+ expect(fetchJob).toHaveBeenCalledTimes(initialCalls);
+ queryClient.clear();
+ },
+ );
+
+ it.each(["/dead-letter-jobs", `/dead-letter-jobs/${job.id}`])(
+ "allows enabling and pausing live updates with the selector for %s",
+ async (path) => {
+ focusManager.setFocused(true);
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ const { queryClient } = await renderRoute(path, true, 0);
+ const fetchJob =
+ path === "/dead-letter-jobs" ? listDeadLetterJobs : getDeadLetterJob;
+ const initialCalls = fetchJob.mock.calls.length;
+
+ await selectRefreshInterval("1s");
+ expect(
+ screen.getByRole("button", { name: /Pause live updates/ }),
+ ).toBeInTheDocument();
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1200);
+ });
+ expect(fetchJob.mock.calls.length).toBeGreaterThan(initialCalls);
+
+ await selectRefreshInterval("Pause");
+ const callsAfterPausing = fetchJob.mock.calls.length;
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(6000);
+ focusManager.setFocused(false);
+ focusManager.setFocused(true);
+ onlineManager.setOnline(false);
+ onlineManager.setOnline(true);
+ await vi.advanceTimersByTimeAsync(10);
+ });
+ expect(fetchJob).toHaveBeenCalledTimes(callsAfterPausing);
+ queryClient.clear();
+ },
+ );
+
+ it("keeps standard Jobs refreshing after leaving Dead Letters", async () => {
+ focusManager.setFocused(true);
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ const { queryClient, router } = await renderRoute("/dead-letter-jobs");
+ await act(async () => {
+ await router.navigate({ to: "/jobs" });
+ });
+ await screen.findByTestId("job-search-input");
+ const initialCalls = vi.mocked(listJobs).mock.calls.length;
+ expect(
+ screen.getByText("Pause live updates", { selector: "span" }),
+ ).toBeInTheDocument();
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(2500);
+ });
+ expect(vi.mocked(listJobs).mock.calls.length).toBeGreaterThan(initialCalls);
+ expect(listDeadLetterJobs).toHaveBeenCalledTimes(1);
+ queryClient.clear();
+ });
+
+ it("retains separate refresh choices across panes and dead letter details", async () => {
+ focusManager.setFocused(true);
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ const { queryClient, router } = await renderRoute("/jobs");
+ await selectRefreshInterval("Pause");
+
+ await act(async () => {
+ await router.navigate({ to: "/dead-letter-jobs" });
+ });
+ expect(
+ screen.getByRole("button", { name: /Resume live updates/ }),
+ ).toBeInTheDocument();
+ await selectRefreshInterval("1s");
+
+ await act(async () => {
+ await router.navigate({
+ to: "/dead-letter-jobs/$jobId",
+ params: { jobId: job.id },
+ });
+ });
+ const initialDetailCalls = getDeadLetterJob.mock.calls.length;
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1200);
+ });
+ expect(getDeadLetterJob.mock.calls.length).toBeGreaterThan(
+ initialDetailCalls,
+ );
+
+ await act(async () => {
+ await router.navigate({ to: "/jobs" });
+ });
+ expect(
+ screen.getByRole("button", { name: /Resume live updates/ }),
+ ).toBeInTheDocument();
+ const initialJobsCalls = vi.mocked(listJobs).mock.calls.length;
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(6000);
+ });
+ expect(listJobs).toHaveBeenCalledTimes(initialJobsCalls);
+
+ await act(async () => {
+ await router.navigate({ to: "/dead-letter-jobs" });
+ });
+ expect(
+ screen.getByRole("button", { name: /Pause live updates/ }),
+ ).toBeInTheDocument();
+ const initialListCalls = listDeadLetterJobs.mock.calls.length;
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1200);
+ });
+ expect(listDeadLetterJobs.mock.calls.length).toBeGreaterThan(
+ initialListCalls,
+ );
+ queryClient.clear();
+ });
+
+ it("clears filters and pagination when navigating from Dead Letters to Jobs", async () => {
+ const { router, queryClient } = await renderRoute(
+ "/dead-letter-jobs" + filteredSearch,
+ );
+ const jobsLink = screen.getByRole("link", { name: "Jobs" });
+ expect(jobsLink).toHaveAttribute("href", "/jobs");
+ await act(async () => {
+ fireEvent.click(jobsLink);
+ });
+ await waitFor(() => expect(router.state.location.pathname).toBe("/jobs"));
+ expect(router.state.location.search).toEqual({});
+ expect(await screen.findByTestId("job-search-input")).toHaveValue("");
+ expect(
+ queryClient.getQueryData(
+ listJobsKey({ limit: 20, state: JobState.Running }),
+ ),
+ ).toEqual([]);
+ expect(listJobs).toHaveBeenCalledWith(
+ expect.objectContaining({
+ queryKey: listJobsKey({ limit: 20, state: JobState.Running }),
+ }),
+ );
+ });
+
+ it("clears filters and pagination when navigating from Jobs to Dead Letters", async () => {
+ const { router } = await renderRoute(
+ "/jobs" + filteredSearch + "&state=discarded",
+ );
+ const deadLettersLink = screen.getByRole("link", { name: "Dead Letters" });
+ expect(deadLettersLink).toHaveAttribute("href", "/dead-letter-jobs");
+ await act(async () => {
+ fireEvent.click(deadLettersLink);
+ });
+ await waitFor(() =>
+ expect(router.state.location.pathname).toBe("/dead-letter-jobs"),
+ );
+ expect(router.state.location.search).toEqual({});
+ expect(await screen.findByTestId("job-search-input")).toHaveValue("");
+ expect(listDeadLetterJobs).toHaveBeenCalledWith(
+ expect.objectContaining({ queryKey: listDeadLetterJobsKey(20) }),
+ );
+ });
+
+ it("preserves filters and pagination between job states and within the Jobs pane", async () => {
+ const { router } = await renderRoute("/jobs" + filteredSearch);
+ const filters = router.state.location.search;
+ const input = await screen.findByTestId("job-search-input");
+ const initialText = input.getAttribute("value");
+ await act(async () => {
+ fireEvent.click(screen.getByRole("link", { name: "Discarded" }));
+ });
+ await waitFor(() =>
+ expect(router.state.location.search).toEqual({
+ ...filters,
+ state: JobState.Discarded,
+ }),
+ );
+ expect(await screen.findByTestId("job-search-input")).toHaveValue(
+ initialText,
+ );
+ await act(async () => {
+ fireEvent.click(screen.getByRole("link", { name: "Jobs" }));
+ });
+ await waitFor(() => expect(router.state.location.search).toEqual(filters));
+ expect(await screen.findByTestId("job-search-input")).toHaveValue(
+ initialText,
+ );
+ });
+
+ it.each(["/dead-letter-jobs", `/dead-letter-jobs/${job.id}`])(
+ "hides navigation and blocks OSS access to %s",
+ async (path) => {
+ await renderRoute(path, false);
+ expect(
+ screen.queryByRole("link", { name: "Dead Letters" }),
+ ).not.toBeInTheDocument();
+ expect(listDeadLetterJobs).not.toHaveBeenCalled();
+ expect(getDeadLetterJob).not.toHaveBeenCalled();
+ expect(
+ screen.getByRole("heading", { name: "Page not found" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("link", { name: "Return to job list" }),
+ ).toHaveAttribute("href", "/jobs");
+ },
+ );
+
+ it("opens the list when a bookmarked URL has filters it can't parse", async () => {
+ const { router } = await renderRoute(
+ "/dead-letter-jobs?id=abc&priority=5&queue=mail",
+ );
+ expect(
+ await screen.findByRole("link", { name: `send_email #${job.id}` }),
+ ).toBeInTheDocument();
+ expect(screen.getByTestId("job-search-input")).toHaveValue("queue:mail");
+ expect(router.state.location.search).toEqual({ queue: "mail" });
+ expect(listDeadLetterJobs).toHaveBeenCalledWith(
+ expect.objectContaining({
+ queryKey: listDeadLetterJobsKey(20, { queues: ["mail"] }),
+ }),
+ );
+ });
+
+ it("keeps the loaded jobs visible when a refresh fails", async () => {
+ const { queryClient } = await renderRoute("/dead-letter-jobs");
+ listDeadLetterJobs.mockRejectedValue(new Error("Failed to fetch"));
+ await act(async () => {
+ await queryClient.refetchQueries({ queryKey: listDeadLetterJobsKey() });
+ });
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Couldn't refresh dead letter jobs: Failed to fetch",
+ );
+ expect(
+ screen.getByRole("link", { name: `send_email #${job.id}` }),
+ ).toBeInTheDocument();
+ expect(screen.getByTestId("job-search-input")).toBeInTheDocument();
+ });
+
+ it("shows the Pro navigation and opens the selected dead letter job", async () => {
+ const { router } = await renderRoute("/dead-letter-jobs");
+ expect(
+ screen.getByRole("link", { name: "Dead Letters" }),
+ ).toBeInTheDocument();
+ await act(async () => {
+ fireEvent.click(
+ screen.getByRole("link", { name: `send_email #${job.id}` }),
+ );
+ });
+ await waitFor(() =>
+ expect(router.state.location.pathname).toBe(
+ `/dead-letter-jobs/${job.id}`,
+ ),
+ );
+ expect(
+ await screen.findByRole("button", { name: "Retry job" }),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Args").parentElement).toHaveTextContent(
+ "1970670598291982290",
+ );
+ expect(
+ screen.queryByRole("button", { name: "Delete" }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows an empty queue", async () => {
+ listDeadLetterJobs.mockResolvedValue([]);
+ await renderRoute("/dead-letter-jobs");
+ expect(screen.getByText("No dead letter jobs.")).toBeInTheDocument();
+ });
+
+ it("requests larger and smaller bounded lists and keeps the limit in the URL", async () => {
+ const jobs = Array.from({ length: 65 }, (_, i) => ({
+ ...job,
+ id: BigInt(i + 1),
+ }));
+ listDeadLetterJobs.mockImplementation(
+ ({
+ queryKey,
+ }: QueryFunctionContext>) =>
+ jobs.slice(0, queryKey[1]?.limit ?? 20),
+ );
+ const { queryClient, router } = await renderRoute("/dead-letter-jobs");
+ expect(screen.getAllByRole("link", { name: /^send_email #/ })).toHaveLength(
+ 20,
+ );
+ expect(screen.getByRole("button", { name: "Fewer" })).toBeDisabled();
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: "More" }));
+ });
+ await screen.findByRole("link", { name: "send_email #40" });
+ expect(screen.getAllByRole("link", { name: /^send_email #/ })).toHaveLength(
+ 40,
+ );
+ expect(router.state.location.search).toEqual({ limit: "40" });
+ expect(queryClient.getQueryData(listDeadLetterJobsKey(20))).toHaveLength(
+ 20,
+ );
+ expect(queryClient.getQueryData(listDeadLetterJobsKey(40))).toHaveLength(
+ 40,
+ );
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: "Fewer" }));
+ });
+ await waitFor(() =>
+ expect(
+ screen.getAllByRole("link", { name: /^send_email #/ }),
+ ).toHaveLength(20),
+ );
+ expect(router.state.location.search).toEqual({});
+ });
+
+ it("honors a bookmarked limit and caps the controls at 200", async () => {
+ const { router } = await renderRoute("/dead-letter-jobs?limit=200");
+ expect(listDeadLetterJobs).toHaveBeenCalledWith(
+ expect.objectContaining({ queryKey: listDeadLetterJobsKey(200) }),
+ );
+ expect(screen.getByRole("button", { name: "More" })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Fewer" })).toBeEnabled();
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: "Fewer" }));
+ });
+ await waitFor(() =>
+ expect(router.state.location.search).toEqual({ limit: "180" }),
+ );
+ });
+
+ it("disables More when the query returns fewer jobs than the limit", async () => {
+ await renderRoute("/dead-letter-jobs");
+ expect(screen.getByRole("button", { name: "More" })).toBeDisabled();
+ });
+
+ it("normalizes invalid limits and accepts numeric strings", () => {
+ for (const limit of [undefined, "bad", -1, 0, 19, 201, 20.5]) {
+ expect(deadLetterJobSearchSchema.parse({ limit })).toEqual({ limit: 20 });
+ }
+ expect(deadLetterJobSearchSchema.parse({ limit: "40" })).toEqual({
+ limit: 40,
+ });
+ });
+
+ it("restores bookmarked filters and preserves them while paginating", async () => {
+ listDeadLetterJobs.mockResolvedValue(
+ Array.from({ length: 40 }, (_, i) => ({ ...job, id: BigInt(i + 1) })),
+ );
+ const { router, queryClient } = await renderRoute(
+ "/dead-letter-jobs?kind=send_email,report&queue=mail&priority=1,3&tags=urgent&id=1970670598291982290",
+ );
+ const filters = {
+ ids: ["1970670598291982290"],
+ kinds: ["report", "send_email"],
+ priorities: ["1", "3"],
+ queues: ["mail"],
+ tags: ["urgent"],
+ };
+ expect(
+ queryClient.getQueryData(listDeadLetterJobsKey(20, filters)),
+ ).toHaveLength(40);
+ expect(screen.getByTestId("job-search-input")).toHaveValue(
+ "id:1970670598291982290 kind:report,send_email priority:1,3 queue:mail tags:urgent",
+ );
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: "More" }));
+ });
+ await waitFor(() =>
+ expect(
+ queryClient.getQueryData(listDeadLetterJobsKey(40, filters)),
+ ).toHaveLength(40),
+ );
+ expect(router.state.location.search).toMatchObject({
+ limit: "40",
+ kind: ["report", "send_email"],
+ queue: "mail",
+ });
+ });
+
+ it("applies edited filters, distinguishes empty results, and clears the search", async () => {
+ const { router, queryClient } = await renderRoute("/dead-letter-jobs");
+ listDeadLetterJobs.mockResolvedValue([]);
+ fireEvent.change(screen.getByTestId("job-search-input"), {
+ target: { value: "kind:report queue:mail tags:urgent,batch priority:2" },
+ });
+ await screen.findByText("No dead letter jobs match these filters.");
+ expect(router.state.location.search).toMatchObject({
+ kind: "report",
+ queue: "mail",
+ tags: ["batch", "urgent"],
+ priority: "2",
+ });
+ expect(
+ queryClient.getQueryData(
+ listDeadLetterJobsKey(20, {
+ kinds: ["report"],
+ queues: ["mail"],
+ tags: ["batch", "urgent"],
+ priorities: ["2"],
+ }),
+ ),
+ ).toEqual([]);
+ fireEvent.click(screen.getByRole("button", { name: "Clear filters" }));
+ await screen.findByRole("link", { name: `send_email #${job.id}` });
+ expect(router.state.location.search).toEqual({});
+ });
+
+ it("restores the search field on browser Back", async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ const { router } = await renderRoute("/dead-letter-jobs?queue=mail");
+ await act(async () => {
+ await router.navigate({
+ to: "/dead-letter-jobs",
+ search: { kind: ["report"] },
+ });
+ });
+ await waitFor(() =>
+ expect(screen.getByTestId("job-search-input")).toHaveValue("kind:report"),
+ );
+ await act(async () => {
+ router.history.back();
+ });
+ await waitFor(() =>
+ expect(screen.getByTestId("job-search-input")).toHaveValue("queue:mail"),
+ );
+ // Run the filter debounce to confirm it doesn't rewrite the restored URL.
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(300);
+ });
+ expect(router.state.location.search).toEqual({ queue: "mail" });
+ });
+
+ it("shows invalid numeric filters without issuing a misleading unfiltered query", async () => {
+ const { router } = await renderRoute("/dead-letter-jobs?queue=mail");
+ listDeadLetterJobs.mockClear();
+ fireEvent.change(screen.getByTestId("job-search-input"), {
+ target: { value: "id:bad" },
+ });
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Job IDs must be positive integers.",
+ );
+ expect(router.state.location.search).toEqual({ queue: "mail" });
+ expect(listDeadLetterJobs).not.toHaveBeenCalled();
+ });
+
+ it("uses dead letter autocomplete for kinds and queues", async () => {
+ await renderRoute("/dead-letter-jobs");
+ fetchDeadLetterSuggestions.mockResolvedValue(["archive_report"]);
+ const input = screen.getByTestId("job-search-input");
+ fireEvent.change(input, { target: { value: "kind:archive_" } });
+ await waitFor(() =>
+ expect(fetchDeadLetterSuggestions).toHaveBeenCalledWith(
+ "job_kind",
+ "archive_",
+ [],
+ expect.any(AbortSignal),
+ ),
+ );
+ fireEvent.change(input, { target: { value: "queue:mail,re" } });
+ await waitFor(() =>
+ expect(fetchDeadLetterSuggestions).toHaveBeenCalledWith(
+ "queue_name",
+ "re",
+ ["mail"],
+ expect.any(AbortSignal),
+ ),
+ );
+ });
+
+ it("validates and normalizes filter values without losing ID precision", () => {
+ expect(
+ deadLetterJobSearchSchema.parse({
+ id: "9223372036854775807",
+ kind: ["report", "email", "report"],
+ tags: [],
+ }),
+ ).toEqual({
+ limit: 20,
+ id: ["9223372036854775807"],
+ kind: ["email", "report"],
+ tags: undefined,
+ });
+ for (const id of ["bad", "1.5", "0", "-1", "9223372036854775808"])
+ expect(deadLetterJobSearchSchema.safeParse({ id }).success).toBe(false);
+ for (const priority of ["bad", "0", "5", "1.5"])
+ expect(deadLetterJobSearchSchema.safeParse({ priority }).success).toBe(
+ false,
+ );
+ // The route schema drops only the filters it can't parse.
+ expect(
+ deadLetterJobRouteSearchSchema.parse({
+ id: "bad",
+ kind: "report",
+ priority: "5",
+ }),
+ ).toEqual({ limit: 20, kind: ["report"] });
+ });
+
+ it("retries through the Pro API and opens the newly created job", async () => {
+ let finishRetry: ((value: Job) => void) | undefined;
+ retryDeadLetterJob.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ finishRetry = resolve;
+ }),
+ );
+ const { queryClient, router } = await renderRoute(
+ `/dead-letter-jobs/${job.id}`,
+ );
+ queryClient.setQueryData(listDeadLetterJobsKey(20), [job]);
+ queryClient.setQueryData(listDeadLetterJobsKey(40), [job]);
+ const filteredKey = listDeadLetterJobsKey(20, { queues: ["mail"] });
+ queryClient.setQueryData(filteredKey, [job]);
+ queryClient.setQueryData(["listJobs"], [job]);
+ queryClient.setQueryData(["countsByState"], {});
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: "Retry job" }));
+ });
+ expect(
+ await screen.findByRole("button", { name: "Retrying…" }),
+ ).toBeDisabled();
+ expect(retryDeadLetterJob).toHaveBeenCalledWith(job.id, expect.anything());
+ const newJob = jobFactory
+ .available()
+ .build({ id: job.id + 1n, kind: job.kind });
+ await act(async () => {
+ finishRetry?.(newJob);
+ });
+ await waitFor(() =>
+ expect(router.state.location.pathname).toBe(`/jobs/${newJob.id}`),
+ );
+ expect(
+ queryClient.getQueryData(["getDeadLetterJob", job.id.toString()]),
+ ).toBeUndefined();
+ expect(
+ queryClient.getQueryState(listDeadLetterJobsKey(20))?.isInvalidated,
+ ).toBe(true);
+ expect(
+ queryClient.getQueryState(listDeadLetterJobsKey(40))?.isInvalidated,
+ ).toBe(true);
+ expect(queryClient.getQueryState(["listJobs"])?.isInvalidated).toBe(true);
+ expect(queryClient.getQueryState(filteredKey)?.isInvalidated).toBe(true);
+ expect(queryClient.getQueryState(["countsByState"])?.isInvalidated).toBe(
+ true,
+ );
+ });
+
+ it("keeps the job open and displays retry errors", async () => {
+ retryDeadLetterJob.mockRejectedValue(
+ new APIResponseError(
+ "Another active job has the same unique properties.",
+ 409,
+ "Conflict",
+ ),
+ );
+ const { router } = await renderRoute(`/dead-letter-jobs/${job.id}`);
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: "Retry job" }));
+ });
+ await waitFor(() =>
+ expect(toastError).toHaveBeenCalledWith({
+ message: "Job retry failed",
+ subtext: "Another active job has the same unique properties.",
+ }),
+ );
+ expect(router.state.location.pathname).toBe(`/dead-letter-jobs/${job.id}`);
+ expect(screen.getByRole("button", { name: "Retry job" })).toBeEnabled();
+ });
+
+ it("handles a job already retried elsewhere", async () => {
+ getDeadLetterJob.mockRejectedValue(
+ new NotFoundError("This dead letter job has already been retried."),
+ );
+ await renderRoute(`/dead-letter-jobs/${job.id}`);
+ expect(
+ screen.getByText("This dead letter job has already been retried."),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("link", { name: "Return to dead letters" }),
+ ).toHaveAttribute("href", "/dead-letter-jobs");
+ });
+});
diff --git a/src/routes/dead-letter-jobs.tsx b/src/routes/dead-letter-jobs.tsx
new file mode 100644
index 00000000..08e25173
--- /dev/null
+++ b/src/routes/dead-letter-jobs.tsx
@@ -0,0 +1,23 @@
+import {
+ DeadLetterJobsRefreshSettingContext,
+ RefreshSettingContext,
+} from "@contexts/RefreshSettings";
+import { createFileRoute, notFound, Outlet } from "@tanstack/react-router";
+import { useContext } from "react";
+
+export const Route = createFileRoute("/dead-letter-jobs")({
+ beforeLoad: ({ context: { features } }) => {
+ if (!features.deadLetterJobs) throw notFound();
+ },
+ component: DeadLetterJobsLayout,
+});
+
+function DeadLetterJobsLayout() {
+ const refreshSetting = useContext(DeadLetterJobsRefreshSettingContext);
+
+ return (
+
+
+
+ );
+}
diff --git a/src/routes/dead-letter-jobs/$jobId.tsx b/src/routes/dead-letter-jobs/$jobId.tsx
new file mode 100644
index 00000000..b91311aa
--- /dev/null
+++ b/src/routes/dead-letter-jobs/$jobId.tsx
@@ -0,0 +1,99 @@
+import { Button } from "@components/Button";
+import DeadLetterJobDetail from "@components/DeadLetterJobDetail";
+import NotFoundPage from "@components/NotFoundPage";
+import { useRefreshSetting } from "@contexts/RefreshSettings.hook";
+import { refreshQueryOptions } from "@contexts/RefreshSettings.query";
+import {
+ getDeadLetterJob,
+ getDeadLetterJobKey,
+ listDeadLetterJobsKey,
+ retryDeadLetterJob,
+} from "@services/deadLetterJobs";
+import { getJobKey, listJobsKeyPrefix } from "@services/jobs";
+import { countsByStateKey } from "@services/states";
+import { toastError, toastSuccess } from "@services/toast";
+import {
+ queryOptions,
+ useMutation,
+ useQuery,
+ useQueryClient,
+} from "@tanstack/react-query";
+import { createFileRoute, ErrorComponent } from "@tanstack/react-router";
+import { NotFoundError } from "@utils/api";
+
+export const Route = createFileRoute("/dead-letter-jobs/$jobId")({
+ parseParams: ({ jobId }) => ({ jobId: BigInt(jobId) }),
+ stringifyParams: ({ jobId }) => ({ jobId: jobId.toString() }),
+ beforeLoad: ({ params: { jobId } }) => ({
+ jobQueryOptions: queryOptions({
+ queryFn: getDeadLetterJob,
+ queryKey: getDeadLetterJobKey(jobId),
+ }),
+ }),
+ loader: ({ context: { queryClient, jobQueryOptions } }) =>
+ queryClient.ensureQueryData(jobQueryOptions),
+ errorComponent: ({ error }) =>
+ error instanceof NotFoundError ? (
+
+ Return to dead letters →
+
+ }
+ description={error.message}
+ heading="Dead letter job not found"
+ title="Dead Letter Job"
+ />
+ ) : (
+
+ ),
+ component: DeadLetterJobComponent,
+});
+
+export function DeadLetterJobComponent() {
+ const { jobId } = Route.useParams();
+ const { jobQueryOptions } = Route.useRouteContext();
+ const navigate = Route.useNavigate();
+ const queryClient = useQueryClient();
+ const { intervalMs } = useRefreshSetting();
+ const retry = useMutation({
+ mutationFn: retryDeadLetterJob,
+ throwOnError: false,
+ onMutate: () =>
+ queryClient.cancelQueries({ queryKey: getDeadLetterJobKey(jobId) }),
+ onError: (error) => {
+ toastError({ message: "Job retry failed", subtext: error.message });
+ },
+ onSuccess: async (job) => {
+ queryClient.setQueryData(getJobKey(job.id), job);
+ toastSuccess({ message: "Job enqueued for retry", duration: 2000 });
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: listDeadLetterJobsKey() }),
+ queryClient.invalidateQueries({ queryKey: listJobsKeyPrefix }),
+ queryClient.invalidateQueries({ queryKey: countsByStateKey() }),
+ ]);
+ await navigate({ to: "/jobs/$jobId", params: { jobId: job.id } });
+ queryClient.removeQueries({ queryKey: getDeadLetterJobKey(jobId) });
+ },
+ });
+ const query = useQuery({
+ ...jobQueryOptions,
+ ...refreshQueryOptions(intervalMs),
+ // Retrying removes this row. Don't poll it while navigating to the new job.
+ enabled: !retry.isPending && !retry.isSuccess,
+ throwOnError: true,
+ });
+
+ if (!query.data) {
+ return (
+ Loading…
+ );
+ }
+ return (
+ retry.mutate(jobId)}
+ retryPending={retry.isPending || retry.isSuccess}
+ />
+ );
+}
diff --git a/src/routes/dead-letter-jobs/index.schema.ts b/src/routes/dead-letter-jobs/index.schema.ts
new file mode 100644
index 00000000..16967472
--- /dev/null
+++ b/src/routes/dead-letter-jobs/index.schema.ts
@@ -0,0 +1,105 @@
+import { Filter, FilterTypeId } from "@components/job-search/JobSearch";
+import { AVAILABLE_FILTERS } from "@components/job-search/types";
+import {
+ DeadLetterJobFilters,
+ defaultDeadLetterJobsLimit,
+ maximumDeadLetterJobsLimit,
+} from "@services/deadLetterJobs";
+import { z } from "zod";
+
+const values = z.union([z.string(), z.array(z.string())]).transform((value) => {
+ const result = [...new Set(Array.isArray(value) ? value : [value])]
+ .filter(Boolean)
+ .sort();
+ return result.length ? result : undefined;
+});
+const strings = values
+ .pipe(z.array(z.string().max(1024)).max(100).optional())
+ .optional();
+const ids = values
+ .pipe(
+ z
+ .array(
+ z
+ .string()
+ .regex(/^[1-9]\d*$/, "Job IDs must be positive integers.")
+ .refine(
+ (id) =>
+ /^[1-9]\d*$/.test(id) &&
+ id.length <= 19 &&
+ BigInt(id) <= 9223372036854775807n,
+ "Job ID is too large.",
+ ),
+ )
+ .max(1000)
+ .optional(),
+ )
+ .optional();
+const priorities = values
+ .pipe(
+ z
+ .array(z.string().regex(/^[1-4]$/, "Priorities must be between 1 and 4."))
+ .max(4)
+ .optional(),
+ )
+ .optional();
+
+export const deadLetterJobSearchSchema = z.object({
+ id: ids,
+ kind: strings,
+ limit: z.coerce
+ .number()
+ .int()
+ .min(defaultDeadLetterJobsLimit)
+ .max(maximumDeadLetterJobsLimit)
+ .default(defaultDeadLetterJobsLimit)
+ .catch(defaultDeadLetterJobsLimit),
+ priority: priorities,
+ queue: strings,
+ tags: strings,
+});
+
+export type DeadLetterJobSearch = z.output;
+
+// The route drops a filter it can't parse, as the Jobs list does, so that a
+// hand-edited or stale URL still opens the list. Edits in the search field
+// use the strict schema above and show the problem instead.
+export const deadLetterJobRouteSearchSchema = deadLetterJobSearchSchema.extend({
+ id: ids.catch(undefined),
+ kind: strings.catch(undefined),
+ priority: priorities.catch(undefined),
+ queue: strings.catch(undefined),
+ tags: strings.catch(undefined),
+});
+
+export const searchToDeadLetterFilters = (
+ search: DeadLetterJobSearch,
+): DeadLetterJobFilters => ({
+ ids: search.id,
+ kinds: search.kind,
+ priorities: search.priority,
+ queues: search.queue,
+ tags: search.tags,
+});
+
+export const searchToInputFilters = (search: DeadLetterJobSearch): Filter[] =>
+ AVAILABLE_FILTERS.flatMap((filter) => {
+ const values = search[filter.id];
+ return values?.length
+ ? [{ id: filter.id, match: filter.match, typeId: filter.id, values }]
+ : [];
+ });
+
+export const inputFiltersToSearch = (filters: Filter[]) => {
+ const result: Record = {
+ id: undefined,
+ kind: undefined,
+ priority: undefined,
+ queue: undefined,
+ tags: undefined,
+ };
+ for (const filter of filters) {
+ if (filter.values.length) result[filter.typeId] = filter.values;
+ }
+ return result;
+};
diff --git a/src/routes/dead-letter-jobs/index.tsx b/src/routes/dead-letter-jobs/index.tsx
new file mode 100644
index 00000000..0212ecf5
--- /dev/null
+++ b/src/routes/dead-letter-jobs/index.tsx
@@ -0,0 +1,108 @@
+import DeadLetterJobList from "@components/DeadLetterJobList";
+import { Filter } from "@components/job-search/JobSearch";
+import { useRefreshSetting } from "@contexts/RefreshSettings.hook";
+import { refreshQueryOptions } from "@contexts/RefreshSettings.query";
+import {
+ defaultDeadLetterJobsLimit,
+ listDeadLetterJobs,
+ listDeadLetterJobsKey,
+ maximumDeadLetterJobsLimit,
+} from "@services/deadLetterJobs";
+import { queryOptions, useQuery } from "@tanstack/react-query";
+import { createFileRoute, stripSearchParams } from "@tanstack/react-router";
+import { useCallback, useMemo, useState } from "react";
+
+import {
+ deadLetterJobRouteSearchSchema,
+ DeadLetterJobSearch,
+ deadLetterJobSearchSchema,
+ inputFiltersToSearch,
+ searchToDeadLetterFilters,
+ searchToInputFilters,
+} from "./index.schema";
+
+const jobsQueryOptions = (search: DeadLetterJobSearch) =>
+ queryOptions({
+ queryFn: listDeadLetterJobs,
+ queryKey: listDeadLetterJobsKey(
+ search.limit,
+ searchToDeadLetterFilters(search),
+ ),
+ });
+
+export const Route = createFileRoute("/dead-letter-jobs/")({
+ validateSearch: deadLetterJobRouteSearchSchema,
+ search: {
+ middlewares: [stripSearchParams({ limit: defaultDeadLetterJobsLimit })],
+ },
+ loaderDeps: ({ search }) => search,
+ loader: ({ context: { queryClient }, deps: search }) =>
+ queryClient.ensureQueryData(jobsQueryOptions(search)),
+ component: DeadLetterJobsIndexComponent,
+});
+
+function DeadLetterJobsIndexComponent() {
+ const search = Route.useLoaderDeps();
+ const { limit } = search;
+ const [filterError, setFilterError] = useState();
+ const initialFilters = useMemo(() => searchToInputFilters(search), [search]);
+ const navigate = Route.useNavigate();
+ const { intervalMs } = useRefreshSetting();
+ const query = useQuery({
+ ...jobsQueryOptions(search),
+ ...refreshQueryOptions(intervalMs),
+ });
+ const onFiltersChange = useCallback(
+ (filters: Filter[]) => {
+ const parsed = deadLetterJobSearchSchema.safeParse({
+ ...search,
+ ...inputFiltersToSearch(filters),
+ });
+ if (!parsed.success) {
+ setFilterError(parsed.error.issues[0]?.message ?? "Invalid filters.");
+ return;
+ }
+ setFilterError(undefined);
+ if (JSON.stringify(parsed.data) === JSON.stringify(search)) return;
+ void navigate({ replace: true, search: parsed.data });
+ },
+ [navigate, search],
+ );
+
+ return (
+ defaultDeadLetterJobsLimit}
+ canShowMore={
+ limit < maximumDeadLetterJobsLimit && (query.data?.length ?? 0) >= limit
+ }
+ filterError={filterError}
+ initialFilters={initialFilters}
+ jobs={query.data ?? []}
+ loadError={
+ query.isError
+ ? `Couldn't refresh dead letter jobs: ${query.error.message}`
+ : undefined
+ }
+ loading={query.isLoading}
+ onFiltersChange={onFiltersChange}
+ showFewer={() =>
+ navigate({
+ replace: true,
+ search: (old) => ({
+ ...old,
+ limit: Math.max(defaultDeadLetterJobsLimit, limit - 20),
+ }),
+ })
+ }
+ showMore={() =>
+ navigate({
+ replace: true,
+ search: (old) => ({
+ ...old,
+ limit: Math.min(maximumDeadLetterJobsLimit, limit + 20),
+ }),
+ })
+ }
+ />
+ );
+}
diff --git a/src/routes/jobs/index.schema.ts b/src/routes/jobs/index.schema.ts
index ab1b4064..f4978cf7 100644
--- a/src/routes/jobs/index.schema.ts
+++ b/src/routes/jobs/index.schema.ts
@@ -5,6 +5,17 @@ export const minimumLimit = 20;
export const defaultLimit = 20;
export const maximumLimit = 200;
+// Search params kept when moving between job states. Other panes clear
+// them when they link to Jobs.
+export const retainedJobSearchKeys = [
+ "id",
+ "kind",
+ "limit",
+ "priority",
+ "queue",
+ "tags",
+] as const;
+
// Define default values
export const defaultValues = {
limit: defaultLimit,
diff --git a/src/routes/jobs/index.tsx b/src/routes/jobs/index.tsx
index 69c4fe0f..268afbce 100644
--- a/src/routes/jobs/index.tsx
+++ b/src/routes/jobs/index.tsx
@@ -6,7 +6,11 @@ import {
refreshQueryOptions,
} from "@contexts/RefreshSettings.query";
import { useRetryJobs } from "@hooks/use-retry-jobs";
-import { defaultValues, jobSearchSchema } from "@routes/jobs/index.schema";
+import {
+ defaultValues,
+ jobSearchSchema,
+ retainedJobSearchKeys,
+} from "@routes/jobs/index.schema";
import {
cancelJobs,
deleteJobs,
@@ -50,7 +54,7 @@ export const Route = createFileRoute("/jobs/")({
search: {
middlewares: [
stripSearchParams(defaultValues),
- retainSearchParams(["id", "kind", "limit", "priority", "queue", "tags"]),
+ retainSearchParams([...retainedJobSearchKeys]),
],
},
beforeLoad: async ({ context }) => {
diff --git a/src/services/deadLetterJobs.test.ts b/src/services/deadLetterJobs.test.ts
new file mode 100644
index 00000000..c4e73566
--- /dev/null
+++ b/src/services/deadLetterJobs.test.ts
@@ -0,0 +1,127 @@
+import { QueryClient } from "@tanstack/react-query";
+import { afterEach, expect, test, vi } from "vitest";
+
+import {
+ fetchDeadLetterSuggestions,
+ getDeadLetterJob,
+ getDeadLetterJobKey,
+ listDeadLetterJobs,
+ listDeadLetterJobsKey,
+ retryDeadLetterJob,
+} from "./deadLetterJobs";
+
+const job = {
+ args: '{"id":1970670598291982290}',
+ attempt: 3,
+ attempted_by: [],
+ created_at: "2026-10-21T00:00:00Z",
+ errors: [],
+ finalized_at: "2026-10-21T10:00:00Z",
+ id: "1970670598291982290",
+ kind: "send_email",
+ max_attempts: 3,
+ metadata: {},
+ priority: 1,
+ queue: "mail",
+ scheduled_at: "2026-10-21T00:00:00Z",
+ state: "discarded",
+ tags: [],
+};
+
+afterEach(() => vi.unstubAllGlobals());
+
+test("requests a bounded list without losing ID precision", async () => {
+ const fetch = vi.fn().mockResolvedValue(Response.json({ data: [job] }));
+ vi.stubGlobal("fetch", fetch);
+ const client = new QueryClient();
+ const jobs = await client.fetchQuery({
+ queryFn: listDeadLetterJobs,
+ queryKey: listDeadLetterJobsKey(40),
+ });
+ expect(jobs[0].id).toBe(1970670598291982290n);
+ expect(jobs[0].finalizedAt).toEqual(new Date(job.finalized_at));
+ expect(fetch).toHaveBeenCalledWith(
+ expect.stringContaining("/pro/dead-letter-jobs?limit=40"),
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
+ );
+});
+
+test("gets a dead letter job and retries it using the Pro endpoints", async () => {
+ const fetch = vi
+ .fn()
+ .mockResolvedValueOnce(Response.json(job))
+ .mockResolvedValueOnce(
+ Response.json({
+ ...job,
+ attempt: 0,
+ id: "1970670598291982291",
+ state: "available",
+ }),
+ );
+ vi.stubGlobal("fetch", fetch);
+ const client = new QueryClient();
+ const original = await client.fetchQuery({
+ queryFn: getDeadLetterJob,
+ queryKey: getDeadLetterJobKey(BigInt(job.id)),
+ });
+ expect(original.createdAt).toEqual(new Date(job.created_at));
+ expect(original.errors).toEqual([]);
+ expect(original.argsRaw).toBe(job.args);
+ const retried = await client
+ .getMutationCache()
+ .build(client, { mutationFn: retryDeadLetterJob })
+ .execute(original.id);
+ expect(retried.id).toBe(1970670598291982291n);
+ expect(fetch).toHaveBeenLastCalledWith(
+ expect.stringContaining(`/pro/dead-letter-jobs/${job.id}/retry`),
+ expect.objectContaining({ method: "POST" }),
+ );
+});
+
+test("sends repeated filter parameters and separates filtered caches", async () => {
+ const fetch = vi
+ .fn()
+ .mockResolvedValue(Response.json({ data: [] }));
+ vi.stubGlobal("fetch", fetch);
+ const client = new QueryClient();
+ const filters = {
+ ids: [job.id, "9223372036854775807"],
+ kinds: ["send_email", "report"],
+ priorities: ["1", "3"],
+ queues: ["mail"],
+ tags: ["urgent", "customer&partner"],
+ };
+ await client.fetchQuery({
+ queryFn: listDeadLetterJobs,
+ queryKey: listDeadLetterJobsKey(40, filters),
+ });
+ const request = fetch.mock.calls[0][0];
+ expect(typeof request).toBe("string");
+ const url = new URL(String(request), "http://localhost");
+ expect(url.searchParams.get("limit")).toBe("40");
+ for (const [key, values] of Object.entries(filters))
+ expect(url.searchParams.getAll(key)).toEqual(values);
+ expect(client.getQueryData(listDeadLetterJobsKey(40))).toBeUndefined();
+ expect(client.getQueryData(listDeadLetterJobsKey(40, filters))).toEqual([]);
+});
+
+test("fetches archive autocomplete with selected values excluded", async () => {
+ const fetch = vi
+ .fn()
+ .mockResolvedValue(Response.json({ data: ["archive_report"] }));
+ vi.stubGlobal("fetch", fetch);
+ expect(
+ await fetchDeadLetterSuggestions("job_kind", "archive_", [
+ "archive_email",
+ "old&job",
+ ]),
+ ).toEqual(["archive_report"]);
+ const url = new URL(String(fetch.mock.calls[0][0]), "http://localhost");
+ expect(url.pathname).toBe("/api/pro/dead-letter-jobs/autocomplete");
+ expect(url.searchParams.get("facet")).toBe("job_kind");
+ expect(url.searchParams.get("match")).toBe("archive_");
+ expect(url.searchParams.getAll("exclude")).toEqual([
+ "archive_email",
+ "old&job",
+ ]);
+});
diff --git a/src/services/deadLetterJobs.ts b/src/services/deadLetterJobs.ts
new file mode 100644
index 00000000..7a8d5b2e
--- /dev/null
+++ b/src/services/deadLetterJobs.ts
@@ -0,0 +1,107 @@
+import type { MutationFunction, QueryFunction } from "@tanstack/react-query";
+
+import { API } from "@utils/api";
+
+import { apiJobToJob, Job, JobFromAPI } from "./jobs";
+import { ListResponse } from "./listResponse";
+
+export type DeadLetterJobFilters = {
+ ids?: string[];
+ kinds?: string[];
+ priorities?: string[];
+ queues?: string[];
+ tags?: string[];
+};
+export type DeadLetterJobListItem = Pick<
+ Job,
+ "attempt" | "finalizedAt" | "id" | "kind" | "maxAttempts" | "queue"
+>;
+
+type DeadLetterJobFromAPI = { id: string } & Omit;
+
+type DeadLetterJobListItemFromAPI = {
+ attempt: number;
+ finalized_at: null | string;
+ id: string;
+ kind: string;
+ max_attempts: number;
+ queue: string;
+};
+
+export const defaultDeadLetterJobsLimit = 20;
+export const maximumDeadLetterJobsLimit = 200;
+
+const toJob = (job: DeadLetterJobFromAPI): Job =>
+ apiJobToJob({ ...job, id: BigInt(job.id) });
+
+export const listDeadLetterJobsKey = (
+ limit?: number,
+ filters: DeadLetterJobFilters = {},
+) =>
+ limit === undefined
+ ? (["listDeadLetterJobs"] as const)
+ : (["listDeadLetterJobs", { ...filters, limit }] as const);
+export const getDeadLetterJobKey = (id: bigint) =>
+ ["getDeadLetterJob", id.toString()] as const;
+
+export const listDeadLetterJobs: QueryFunction<
+ DeadLetterJobListItem[],
+ ReturnType
+> = async ({ queryKey, signal }) => {
+ const limit = queryKey[1]?.limit ?? defaultDeadLetterJobsLimit;
+ const query = new URLSearchParams({ limit: String(limit) });
+ const filters = queryKey[1];
+ for (const key of ["ids", "kinds", "priorities", "queues", "tags"] as const) {
+ filters?.[key]?.forEach((value) => query.append(key, value));
+ }
+ const response = await API.get>(
+ {
+ path: "/pro/dead-letter-jobs",
+ query,
+ },
+ { signal },
+ );
+ return response.data.map((job) => ({
+ attempt: job.attempt,
+ finalizedAt: job.finalized_at ? new Date(job.finalized_at) : undefined,
+ id: BigInt(job.id),
+ kind: job.kind,
+ maxAttempts: job.max_attempts,
+ queue: job.queue,
+ }));
+};
+
+export const getDeadLetterJob: QueryFunction<
+ Job,
+ ReturnType
+> = async ({ queryKey: [, id], signal }) => {
+ return toJob(
+ await API.get(
+ { path: `/pro/dead-letter-jobs/${id}` },
+ { signal },
+ ),
+ );
+};
+
+export const retryDeadLetterJob: MutationFunction = async (id) => {
+ return toJob(
+ await API.post(
+ `/pro/dead-letter-jobs/${id}/retry`,
+ ),
+ );
+};
+
+export async function fetchDeadLetterSuggestions(
+ facet: "job_kind" | "queue_name",
+ match: string,
+ exclude: string[],
+ signal?: AbortSignal,
+): Promise {
+ const query = new URLSearchParams({ facet, match });
+ exclude.forEach((value) => query.append("exclude", value));
+ const response = await API.get>(
+ { path: "/pro/dead-letter-jobs/autocomplete", query },
+ { signal },
+ );
+ return response.data;
+}
diff --git a/src/services/features.test.ts b/src/services/features.test.ts
index e1b64284..a33d9e70 100644
--- a/src/services/features.test.ts
+++ b/src/services/features.test.ts
@@ -6,6 +6,7 @@ describe("apiFeaturesToFeatures", () => {
it("converts API features to frontend features", () => {
const apiFeatures = {
extensions: {
+ dead_letter_jobs: true,
durable_periodic_jobs: true,
has_producer_table: true,
producer_queries: true,
@@ -15,6 +16,7 @@ describe("apiFeaturesToFeatures", () => {
} as const;
const expected = {
+ deadLetterJobs: true,
durablePeriodicJobs: true,
hasProducerTable: true,
hasSequenceTable: false,
@@ -38,6 +40,7 @@ describe("apiFeaturesToFeatures", () => {
} as const;
const expected = {
+ deadLetterJobs: false,
durablePeriodicJobs: false,
hasProducerTable: false,
hasSequenceTable: false,
diff --git a/src/services/features.ts b/src/services/features.ts
index e39ac40e..20254d8f 100644
--- a/src/services/features.ts
+++ b/src/services/features.ts
@@ -16,6 +16,7 @@ type FeaturesFromAPI = {
};
const KNOWN_EXTENSIONS = [
+ "dead_letter_jobs",
"durable_periodic_jobs",
"producer_queries",
"workflow_queries",
diff --git a/src/services/jobs.ts b/src/services/jobs.ts
index 487620e1..26d6396b 100644
--- a/src/services/jobs.ts
+++ b/src/services/jobs.ts
@@ -205,6 +205,9 @@ type ListJobsFilters = {
tags?: string[];
};
+// Matches every job list query, for invalidation.
+export const listJobsKeyPrefix = ["listJobs"] as const;
+
export const listJobsKey = (args: ListJobsFilters): ListJobsKey => {
return [
"listJobs",
diff --git a/src/test/utils/features.ts b/src/test/utils/features.ts
index ca04b79f..aed5d078 100644
--- a/src/test/utils/features.ts
+++ b/src/test/utils/features.ts
@@ -3,6 +3,7 @@ import type { Features } from "@services/features";
export const createFeatures = (
overrides: Partial = {},
): Features => ({
+ deadLetterJobs: false,
durablePeriodicJobs: false,
hasProducerTable: false,
hasSequenceTable: false,