- {queue.name}
+
+ {queue.name}
+
- Available
diff --git a/src/components/TagInput.stories.ts b/src/components/TagInput.stories.ts
new file mode 100644
index 00000000..a51de36d
--- /dev/null
+++ b/src/components/TagInput.stories.ts
@@ -0,0 +1,105 @@
+import type { Meta, StoryObj } from "@storybook/react";
+
+import { type BadgeColor } from "./Badge";
+import TagInput from "./TagInput";
+
+const meta: Meta = {
+ argTypes: {
+ badgeColor: {
+ control: "select",
+ },
+ onChange: { action: "changed" },
+ showHelpText: {
+ control: "boolean",
+ description: "Show the helper text below the input",
+ },
+ },
+ component: TagInput,
+ tags: ["autodocs"],
+ title: "Components/TagInput",
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Empty: Story = {
+ args: {
+ disabled: false,
+ placeholder: "Type and press Enter to add",
+ showHelpText: false,
+ tags: [],
+ },
+};
+
+export const EmptyWithHelp: Story = {
+ args: {
+ disabled: false,
+ placeholder: "Type and press Enter to add",
+ showHelpText: true,
+ tags: [],
+ },
+};
+
+export const WithTags: Story = {
+ args: {
+ badgeColor: "indigo",
+ disabled: false,
+ showHelpText: false,
+ tags: ["customer_id", "region", "user_id"],
+ },
+};
+
+export const WithTagsAndHelp: Story = {
+ args: {
+ badgeColor: "indigo",
+ disabled: false,
+ showHelpText: true,
+ tags: ["customer_id", "region", "user_id"],
+ },
+};
+
+export const WithManyTags: Story = {
+ args: {
+ disabled: false,
+ tags: [
+ "customer_id",
+ "region",
+ "user_id",
+ "order_id",
+ "product_id",
+ "session_id",
+ "long_key_name_with_many_characters",
+ ],
+ },
+};
+
+export const BlueBadges: Story = {
+ args: {
+ badgeColor: "blue" as BadgeColor,
+ disabled: false,
+ tags: ["customer_id", "region", "user_id"],
+ },
+};
+
+export const GreenBadges: Story = {
+ args: {
+ badgeColor: "green" as BadgeColor,
+ disabled: false,
+ tags: ["customer_id", "region", "user_id"],
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ disabled: true,
+ tags: ["customer_id", "region"],
+ },
+};
+
+export const DisabledEmpty: Story = {
+ args: {
+ disabled: true,
+ tags: [],
+ },
+};
diff --git a/src/components/TagInput.tsx b/src/components/TagInput.tsx
new file mode 100644
index 00000000..a90189ed
--- /dev/null
+++ b/src/components/TagInput.tsx
@@ -0,0 +1,117 @@
+import { XMarkIcon } from "@heroicons/react/20/solid";
+import { KeyboardEvent, useEffect, useState } from "react";
+
+import { Badge, type BadgeColor } from "./Badge";
+
+export type TagInputProps = {
+ badgeColor?: BadgeColor;
+ disabled?: boolean;
+ id?: string;
+ name?: string;
+ onChange: (tags: string[]) => void;
+ placeholder?: string;
+ showHelpText?: boolean;
+ tags: string[];
+};
+
+/**
+ * A component for inputting multiple tags or keys with a chip-like UI
+ */
+const TagInput = ({
+ badgeColor = "indigo",
+ disabled = false,
+ id,
+ name,
+ onChange,
+ placeholder = "Type and press Enter to add",
+ showHelpText = false,
+ tags = [],
+}: TagInputProps) => {
+ const [inputValue, setInputValue] = useState("");
+ const [internalTags, setInternalTags] = useState(tags);
+
+ // Update internal tags when external tags change
+ useEffect(() => {
+ setInternalTags(tags);
+ }, [tags]);
+
+ const addTag = (tag: string) => {
+ const trimmedTag = tag.trim();
+ if (trimmedTag && !internalTags.includes(trimmedTag)) {
+ const newTags = [...internalTags, trimmedTag];
+ setInternalTags(newTags);
+ onChange(newTags);
+ }
+ setInputValue("");
+ };
+
+ const removeTag = (tagToRemove: string) => {
+ const newTags = internalTags.filter((tag) => tag !== tagToRemove);
+ setInternalTags(newTags);
+ onChange(newTags);
+ };
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Enter" && inputValue) {
+ e.preventDefault();
+ addTag(inputValue);
+ } else if (
+ e.key === "Backspace" &&
+ !inputValue &&
+ internalTags.length > 0
+ ) {
+ // Remove the last tag when backspace is pressed and input is empty
+ const newTags = [...internalTags];
+ newTags.pop();
+ setInternalTags(newTags);
+ onChange(newTags);
+ }
+ };
+
+ return (
+
+
+ {internalTags.map((tag) => (
+
+
+ {tag}
+ {!disabled && (
+
+ )}
+
+
+ ))}
+ setInputValue(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder={internalTags.length === 0 ? placeholder : ""}
+ type="text"
+ value={inputValue}
+ />
+
+ {showHelpText && (
+
+ Enter multiple keys by typing each one and pressing Enter
+
+ )}
+
+ );
+};
+
+export default TagInput;
diff --git a/src/contexts/Features.hook.tsx b/src/contexts/Features.hook.tsx
new file mode 100644
index 00000000..d0f0b9d1
--- /dev/null
+++ b/src/contexts/Features.hook.tsx
@@ -0,0 +1,11 @@
+import { useContext } from "react";
+
+import { FeaturesContext } from "./Features";
+
+export function useFeatures() {
+ const context = useContext(FeaturesContext);
+ if (context === undefined) {
+ throw new Error("useFeatures must be used within a FeaturesProvider");
+ }
+ return context;
+}
diff --git a/src/contexts/Features.provider.tsx b/src/contexts/Features.provider.tsx
new file mode 100644
index 00000000..94274c17
--- /dev/null
+++ b/src/contexts/Features.provider.tsx
@@ -0,0 +1,24 @@
+import { featuresKey, getFeatures } from "@services/features";
+import { useQuery } from "@tanstack/react-query";
+
+import { FeaturesContext } from "./Features";
+
+export function FeaturesProvider({ children }: { children: React.ReactNode }) {
+ const { data: features, isLoading } = useQuery({
+ queryFn: getFeatures,
+ queryKey: featuresKey(),
+ // Refetch every 30 minutes, these are unlikely to change much:
+ refetchInterval: 30 * 60 * 1000,
+ });
+
+ // Block rendering until features are loaded:
+ if (isLoading || !features) {
+ return ;
+ }
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/contexts/Features.tsx b/src/contexts/Features.tsx
new file mode 100644
index 00000000..4a333624
--- /dev/null
+++ b/src/contexts/Features.tsx
@@ -0,0 +1,10 @@
+import { type Features } from "@services/features";
+import { createContext } from "react";
+
+export interface UseFeaturesProps {
+ features: Features;
+}
+
+export const FeaturesContext = createContext(
+ undefined,
+);
diff --git a/src/providers.tsx b/src/providers.tsx
index a29cabdb..db6d2312 100644
--- a/src/providers.tsx
+++ b/src/providers.tsx
@@ -1,5 +1,6 @@
"use client";
+import { FeaturesProvider } from "@contexts/Features.provider";
import { RefreshSettingProvider } from "@contexts/RefreshSettings.provider";
import { SidebarSettingProvider } from "@contexts/SidebarSetting.provider";
import { queryClient } from "@services/queryClient";
@@ -13,9 +14,11 @@ export function Providers({ children }: { children: React.ReactNode }) {
-
- {children}
-
+
+
+ {children}
+
+
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
index 8c02be46..8e12b44a 100644
--- a/src/routes/__root.tsx
+++ b/src/routes/__root.tsx
@@ -1,12 +1,24 @@
import { Root } from "@components/Root";
+import { type Features, featuresKey, getFeatures } from "@services/features";
import { QueryClient } from "@tanstack/react-query";
import { createRootRouteWithContext } from "@tanstack/react-router";
-export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
- {
- component: RootComponent,
+export const Route = createRootRouteWithContext<{
+ features: Features;
+ queryClient: QueryClient;
+}>()({
+ beforeLoad: async ({ context: { queryClient } }) => {
+ const features = await queryClient.ensureQueryData({
+ queryKey: featuresKey(),
+ queryFn: getFeatures,
+ });
+
+ return {
+ features,
+ };
},
-);
+ component: RootComponent,
+});
function RootComponent() {
return ;
diff --git a/src/routes/jobs/index.tsx b/src/routes/jobs/index.tsx
index 894a7f47..a05130a9 100644
--- a/src/routes/jobs/index.tsx
+++ b/src/routes/jobs/index.tsx
@@ -56,13 +56,7 @@ export const Route = createFileRoute("/jobs/")({
loaderDeps: ({ search: { limit, state } }) => {
return { limit: limit || minimumLimit, state };
},
- loader: async ({ context, deps: { limit, state } }) => {
- if (!context) {
- // workaround for this issue:
- // https://github.com/TanStack/router/issues/1751
- return;
- }
- const { queryClient } = context;
+ loader: async ({ context: { queryClient }, deps: { limit, state } }) => {
// TODO: how to pass abortController.signal into ensureQueryData or queryOptions?
// signal: abortController.signal,
await Promise.all([
diff --git a/src/routes/queues/$name.tsx b/src/routes/queues/$name.tsx
index 10320ad9..2cb8732b 100644
--- a/src/routes/queues/$name.tsx
+++ b/src/routes/queues/$name.tsx
@@ -1,28 +1,52 @@
import QueueDetail from "@components/QueueDetail";
import { useRefreshSetting } from "@contexts/RefreshSettings.hook";
-import { getQueue, getQueueKey } from "@services/queues";
-import { useQuery } from "@tanstack/react-query";
+import { listProducers, listProducersKey } from "@services/producers";
+import {
+ type ConcurrencyConfig,
+ getQueue,
+ getQueueKey,
+ pauseQueue,
+ resumeQueue,
+ updateQueue,
+} from "@services/queues";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createFileRoute, ErrorComponent } from "@tanstack/react-router";
-// import QueueDetail from "@components/QueueDetail";
import { NotFoundError } from "@utils/api";
export const Route = createFileRoute("/queues/$name")({
parseParams: ({ name }) => ({ name }),
stringifyParams: ({ name }) => ({ name: `${name}` }),
- beforeLoad: ({ abortController, params: { name } }) => {
+ beforeLoad: ({
+ abortController,
+ params: { name },
+ context: { features },
+ }) => {
return {
- queryOptions: {
+ producersQueryOptions: {
+ queryKey: listProducersKey(name),
+ queryFn: listProducers,
+ signal: abortController.signal,
+ enabled: features.hasProducerTable,
+ },
+ queueQueryOptions: {
queryKey: getQueueKey(name),
queryFn: getQueue,
- refetchInterval: 2000,
signal: abortController.signal,
},
};
},
- loader: async ({ context: { queryClient, queryOptions } }) => {
- await queryClient.ensureQueryData(queryOptions);
+ loader: async ({
+ context: { queryClient, queueQueryOptions, producersQueryOptions },
+ }) => {
+ await Promise.all([
+ queryClient.ensureQueryData(queueQueryOptions),
+ // Don't wait for or issue the producers query if it's not enabled:
+ ...(producersQueryOptions.enabled
+ ? [queryClient.ensureQueryData(producersQueryOptions)]
+ : []),
+ ]);
},
errorComponent: ({ error }) => {
@@ -38,14 +62,79 @@ export const Route = createFileRoute("/queues/$name")({
function QueueComponent() {
const { name } = Route.useParams();
- const { queryOptions } = Route.useRouteContext();
+ const { queueQueryOptions, producersQueryOptions } = Route.useRouteContext();
const refreshSettings = useRefreshSetting();
- queryOptions.refetchInterval = refreshSettings.intervalMs;
+ const { features } = Route.useRouteContext();
+ const queryClient = useQueryClient();
+
+ const queueQuery = useQuery({
+ ...queueQueryOptions,
+ refetchInterval: refreshSettings.intervalMs,
+ });
+ const producersQuery = useQuery({
+ ...producersQueryOptions,
+ refetchInterval: refreshSettings.intervalMs,
+ });
+
+ const loading =
+ queueQuery.isLoading ||
+ (features.hasProducerTable && producersQuery.isLoading);
+
+ const invalidateQueue = () => {
+ return queryClient.invalidateQueries({
+ queryKey: getQueueKey(name),
+ });
+ };
+
+ // Mutations for queue actions
+ const pauseMutation = useMutation({
+ mutationFn: async (queueName: string) => pauseQueue({ name: queueName }),
+ throwOnError: true,
+ onSuccess: invalidateQueue,
+ });
+
+ const resumeMutation = useMutation({
+ mutationFn: async (queueName: string) => resumeQueue({ name: queueName }),
+ throwOnError: true,
+ onSuccess: invalidateQueue,
+ });
+
+ const updateQueueMutation = useMutation({
+ mutationFn: async ({
+ queueName,
+ concurrencyConfig,
+ }: {
+ concurrencyConfig?: ConcurrencyConfig | null;
+ queueName: string;
+ }) =>
+ updateQueue({
+ name: queueName,
+ concurrency: concurrencyConfig,
+ }),
+ throwOnError: true,
+ onSuccess: invalidateQueue,
+ });
- const queueQuery = useQuery(queryOptions);
- const { data: queue } = queueQuery;
+ // Wrapper for updateQueueConcurrency to match component prop signature
+ const handleUpdateQueueConcurrency = (
+ queueName: string,
+ concurrency?: ConcurrencyConfig | null,
+ ) => {
+ updateQueueMutation.mutate({
+ queueName,
+ concurrencyConfig: concurrency,
+ });
+ };
return (
-
+
);
}
diff --git a/src/services/features.ts b/src/services/features.ts
new file mode 100644
index 00000000..a412865a
--- /dev/null
+++ b/src/services/features.ts
@@ -0,0 +1,32 @@
+import type { QueryFunction } from "@tanstack/react-query";
+
+import { API } from "@utils/api";
+
+import { SnakeToCamelCase } from "./types";
+
+export type Features = {
+ [Key in keyof FeaturesFromAPI as SnakeToCamelCase]: FeaturesFromAPI[Key];
+};
+
+type FeaturesFromAPI = {
+ has_client_table: boolean;
+ has_producer_table: boolean;
+ has_workflows: boolean;
+};
+
+export const featuresKey = () => ["features"] as const;
+export type FeaturesKey = ReturnType;
+
+const apiFeaturesToFeatures = (features: FeaturesFromAPI): Features => ({
+ hasClientTable: features.has_client_table,
+ hasProducerTable: features.has_producer_table,
+ hasWorkflows: features.has_workflows,
+});
+
+export const getFeatures: QueryFunction = async ({
+ signal,
+}) => {
+ return API.get({ path: "/features" }, { signal }).then(
+ apiFeaturesToFeatures,
+ );
+};
diff --git a/src/services/producers.ts b/src/services/producers.ts
new file mode 100644
index 00000000..19b3f780
--- /dev/null
+++ b/src/services/producers.ts
@@ -0,0 +1,56 @@
+import type { QueryFunction } from "@tanstack/react-query";
+
+import { API } from "@utils/api";
+
+import { ListResponse } from "./listResponse";
+import { ConcurrencyConfig } from "./queues";
+import { SnakeToCamelCase, StringEndingWithUnderscoreAt } from "./types";
+
+export type Producer = {
+ [Key in keyof ProducerFromAPI as SnakeToCamelCase]: Key extends
+ | StringEndingWithUnderscoreAt
+ | undefined
+ ? Date | undefined
+ : ProducerFromAPI[Key];
+};
+
+type ProducerFromAPI = {
+ client_id: string;
+ concurrency: ConcurrencyConfig | null;
+ created_at: string;
+ id: number;
+ max_workers: number;
+ paused_at: null | string;
+ queue_name: string;
+ running: number;
+ updated_at: string;
+};
+
+export const listProducersKey = (queueName: string) =>
+ ["listProducers", queueName] as const;
+export type ListProducersKey = ReturnType;
+
+const apiProducerToProducer = (producer: ProducerFromAPI): Producer => ({
+ clientId: producer.client_id,
+ concurrency: producer.concurrency,
+ createdAt: new Date(producer.created_at),
+ id: producer.id,
+ maxWorkers: producer.max_workers,
+ pausedAt: producer.paused_at ? new Date(producer.paused_at) : undefined,
+ queueName: producer.queue_name,
+ running: producer.running,
+ updatedAt: new Date(producer.updated_at),
+});
+
+export const listProducers: QueryFunction<
+ Producer[],
+ ListProducersKey
+> = async ({ queryKey, signal }) => {
+ const [, queueName] = queryKey;
+ const query = new URLSearchParams({ queue_name: queueName });
+
+ return API.get>(
+ { path: "/producers", query },
+ { signal },
+ ).then((response) => response.data.map(apiProducerToProducer));
+};
diff --git a/src/services/queues.ts b/src/services/queues.ts
index e133847c..aaaf1e98 100644
--- a/src/services/queues.ts
+++ b/src/services/queues.ts
@@ -6,6 +6,17 @@ import type { SnakeToCamelCase, StringEndingWithUnderscoreAt } from "./types";
import { ListResponse } from "./listResponse";
+export interface ConcurrencyConfig {
+ global_limit: number;
+ local_limit: number;
+ partition: PartitionConfig;
+}
+
+export interface PartitionConfig {
+ by_args: null | string[];
+ by_kind: null | string[];
+}
+
export type Queue = {
[Key in keyof QueueFromAPI as SnakeToCamelCase]: Key extends
| StringEndingWithUnderscoreAt
@@ -18,20 +29,20 @@ export type Queue = {
// string dates instead of Date objects and keys as snake_case instead of
// camelCase.
export type QueueFromAPI = {
+ concurrency: ConcurrencyConfig | null;
count_available: number;
count_running: number;
created_at: string;
- metadata: object;
name: string;
paused_at?: string;
updated_at: string;
};
export const apiQueueToQueue = (queue: QueueFromAPI): Queue => ({
+ concurrency: queue.concurrency,
countAvailable: queue.count_available,
countRunning: queue.count_running,
createdAt: new Date(queue.created_at),
- metadata: queue.metadata,
name: queue.name,
pausedAt: queue.paused_at ? new Date(queue.paused_at) : undefined,
updatedAt: new Date(queue.updated_at),
@@ -70,7 +81,6 @@ export const listQueues: QueryFunction = async ({
{ signal },
).then(
// Map from QueueFromAPI to Queue:
- // TODO: there must be a cleaner way to do this given the type definitions?
(response) => response.data.map(apiQueueToQueue),
);
};
@@ -90,3 +100,19 @@ export const resumeQueue: MutationFunction = async ({
}) => {
return API.put(`/queues/${name}/resume`);
};
+
+type UpdateQueuePayload = {
+ concurrency?: ConcurrencyConfig | null;
+ name: string;
+};
+
+export const updateQueue: MutationFunction = async ({
+ concurrency,
+ name,
+}) => {
+ return API.patch(`/queues/${name}`, JSON.stringify({ concurrency }), {
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+};
diff --git a/src/stories/QueueDetail.stories.tsx b/src/stories/QueueDetail.stories.tsx
new file mode 100644
index 00000000..b2b4bc5a
--- /dev/null
+++ b/src/stories/QueueDetail.stories.tsx
@@ -0,0 +1,160 @@
+import QueueDetail from "@components/QueueDetail";
+import { type Producer } from "@services/producers";
+import { type ConcurrencyConfig } from "@services/queues";
+import { Meta, StoryObj } from "@storybook/react";
+import { producerFactory } from "@test/factories/producer";
+import { queueFactory } from "@test/factories/queue";
+
+// Mock functions
+const mockPauseQueue = (name: string) => {
+ console.log(`Pausing queue: ${name}`);
+};
+
+const mockResumeQueue = (name: string) => {
+ console.log(`Resuming queue: ${name}`);
+};
+
+const mockUpdateQueueConcurrency = (
+ name: string,
+ concurrency: ConcurrencyConfig | null,
+) => {
+ console.log(`Updating concurrency for queue ${name}:`, concurrency);
+};
+
+// Create consistent producers for stories
+const createProducers = (
+ count: number,
+ queueName: string,
+ options?: {
+ inconsistentConcurrency?: boolean;
+ paused?: boolean;
+ withConcurrency?: boolean;
+ },
+): Producer[] => {
+ return Array.from({ length: count }).map((_, i) => {
+ let producer = producerFactory.params({ queueName }).build();
+
+ if (options?.paused && i % 2 === 0) {
+ producer = producerFactory.paused().params({ queueName }).build();
+ }
+
+ if (options?.withConcurrency) {
+ producer = producerFactory
+ .withConcurrency()
+ .params({ queueName })
+ .build();
+
+ // For inconsistent concurrency, modify some producer settings
+ if (options?.inconsistentConcurrency && i === 1) {
+ producer.concurrency!.global_limit = 20;
+ }
+ }
+
+ return producer;
+ });
+};
+
+const meta: Meta = {
+ args: {
+ loading: false,
+ name: "test-queue",
+ pauseQueue: mockPauseQueue,
+ resumeQueue: mockResumeQueue,
+ updateQueueConcurrency: mockUpdateQueueConcurrency,
+ },
+ component: QueueDetail,
+ parameters: {
+ layout: "fullscreen",
+ },
+ title: "Pages/QueueDetail",
+};
+
+export default meta;
+type Story = StoryObj;
+
+// Loading state
+export const Loading: Story = {
+ args: {
+ loading: true,
+ },
+};
+
+// Queue not found
+export const QueueNotFound: Story = {
+ args: {
+ loading: false,
+ queue: undefined,
+ },
+};
+
+// Active queue with no producers (features disabled)
+export const ActiveQueueWithoutPro: Story = {
+ args: {
+ producers: [],
+ queue: queueFactory.active().build(),
+ },
+ parameters: {
+ features: {
+ hasProducerTable: false,
+ },
+ },
+};
+
+// Active queue with no producers (features enabled)
+export const ActiveQueueNoProducers: Story = {
+ args: {
+ producers: [],
+ queue: queueFactory.active().build(),
+ },
+};
+
+// Paused queue with no producers
+export const PausedQueueNoProducers: Story = {
+ args: {
+ producers: [],
+ queue: queueFactory.paused().build(),
+ },
+};
+
+// Active queue with producers
+export const ActiveQueueWithProducers: Story = {
+ args: {
+ producers: createProducers(5, "test-queue"),
+ queue: queueFactory.active().build(),
+ },
+};
+
+// Paused queue with some paused producers
+export const PausedQueueWithMixedProducers: Story = {
+ args: {
+ producers: createProducers(5, "test-queue", { paused: true }),
+ queue: queueFactory.paused().build(),
+ },
+};
+
+// Queue with concurrency settings
+export const QueueWithConcurrencySettings: Story = {
+ args: {
+ producers: createProducers(3, "test-queue", { withConcurrency: true }),
+ queue: queueFactory.withConcurrency().build(),
+ },
+};
+
+// Queue with inconsistent producer concurrency settings
+export const QueueWithInconsistentConcurrency: Story = {
+ args: {
+ producers: createProducers(3, "test-queue", {
+ inconsistentConcurrency: true,
+ withConcurrency: true,
+ }),
+ queue: queueFactory.withConcurrency().build(),
+ },
+};
+
+// Queue with many producers
+export const QueueWithManyProducers: Story = {
+ args: {
+ producers: createProducers(20, "test-queue", { paused: true }),
+ queue: queueFactory.active().build(),
+ },
+};
diff --git a/src/test/factories/producer.ts b/src/test/factories/producer.ts
new file mode 100644
index 00000000..f3b0d033
--- /dev/null
+++ b/src/test/factories/producer.ts
@@ -0,0 +1,55 @@
+import { faker } from "@faker-js/faker";
+import { type Producer } from "@services/producers";
+import { type ConcurrencyConfig } from "@services/queues";
+import { sub } from "date-fns";
+import { Factory } from "fishery";
+
+class ProducerFactory extends Factory {
+ active() {
+ return this.params({
+ pausedAt: undefined,
+ });
+ }
+
+ paused() {
+ return this.params({
+ pausedAt: sub(new Date(), { minutes: 10 }),
+ });
+ }
+
+ withConcurrency(configOverrides?: Partial) {
+ // Create a valid ConcurrencyConfig with safe defaults
+ const concurrency: ConcurrencyConfig = {
+ global_limit: configOverrides?.global_limit ?? 10,
+ local_limit: configOverrides?.local_limit ?? 5,
+ partition: {
+ by_args: configOverrides?.partition?.by_args ?? [
+ "customer_id",
+ "region",
+ ],
+ by_kind: configOverrides?.partition?.by_kind ?? null,
+ },
+ };
+
+ return this.params({
+ concurrency,
+ });
+ }
+}
+
+export const producerFactory = ProducerFactory.define(({ sequence }) => {
+ const createdAt = faker.date.recent({ days: 1 });
+ const updatedAt = faker.date.recent({ days: 0.5 });
+
+ return {
+ clientId: `client-${sequence}`,
+ concurrency: null,
+ createdAt,
+ id: sequence,
+ maxWorkers: faker.number.int({ max: 50, min: 5 }),
+ pausedAt: undefined,
+ queueName: `queue-${faker.number.int({ max: 5, min: 1 })}`,
+ running: faker.number.int({ max: 20, min: 0 }),
+ updatedAt,
+ };
+});
diff --git a/src/test/factories/queue.ts b/src/test/factories/queue.ts
new file mode 100644
index 00000000..f4522e4c
--- /dev/null
+++ b/src/test/factories/queue.ts
@@ -0,0 +1,72 @@
+import { faker } from "@faker-js/faker";
+import { type ConcurrencyConfig, type Queue } from "@services/queues";
+import { sub } from "date-fns";
+import { Factory } from "fishery";
+
+class QueueFactory extends Factory {
+ active() {
+ return this.params({
+ pausedAt: undefined,
+ });
+ }
+
+ paused() {
+ return this.params({
+ pausedAt: sub(new Date(), { minutes: 30 }),
+ });
+ }
+
+ withConcurrency(configOverrides?: Partial) {
+ // Create a valid ConcurrencyConfig with safe defaults
+ const concurrency: ConcurrencyConfig = {
+ global_limit: configOverrides?.global_limit ?? 10,
+ local_limit: configOverrides?.local_limit ?? 5,
+ partition: {
+ by_args: configOverrides?.partition?.by_args ?? [
+ "customer_id",
+ "region",
+ ],
+ by_kind: configOverrides?.partition?.by_kind ?? null,
+ },
+ };
+
+ return this.params({
+ concurrency,
+ });
+ }
+
+ withoutConcurrency() {
+ return this.params({
+ concurrency: null,
+ });
+ }
+}
+
+export const queueFactory = QueueFactory.define(({ params, sequence }) => {
+ const createdAt = params.createdAt || faker.date.recent({ days: 0.001 });
+ const updatedAt = params.updatedAt || faker.date.recent({ days: 0.0001 });
+
+ // Create a properly typed concurrency config
+ let concurrency: ConcurrencyConfig | null = null;
+ if (params.concurrency) {
+ concurrency = {
+ global_limit: params.concurrency.global_limit || 0,
+ local_limit: params.concurrency.local_limit || 0,
+ partition: {
+ by_args: params.concurrency.partition?.by_args || null,
+ by_kind: params.concurrency.partition?.by_kind || null,
+ },
+ };
+ }
+
+ return {
+ concurrency,
+ countAvailable:
+ params.countAvailable || faker.number.int({ max: 500, min: 0 }),
+ countRunning: params.countRunning || faker.number.int({ max: 100, min: 0 }),
+ createdAt,
+ name: params.name || `queue-${sequence}`,
+ pausedAt: params.pausedAt,
+ updatedAt,
+ };
+});
diff --git a/src/utils/api.ts b/src/utils/api.ts
index bd4332d3..fc1eca88 100644
--- a/src/utils/api.ts
+++ b/src/utils/api.ts
@@ -7,6 +7,12 @@ export const API = {
get: ({ path, query }: GetRequestOpts, config: RequestInit = {}) =>
request(APIUrl(path, query), config),
+ patch: (
+ path: string,
+ body?: TBody,
+ config: RequestInit = {},
+ ) => request(APIUrl(path), { ...config, body, method: "PATCH" }),
+
// Using `extends` to set a type constraint:
post: (
path: string,
|