feat: add BullMQ job queue with admin monitoring UI
- Add BullMQ to backend; mail jobs (verification, password reset) now enqueued instead of sent inline - Mail worker processes jobs with 3-attempt exponential backoff retry - Admin GraphQL resolvers: adminQueues, adminQueueJobs, adminRetryJob, adminRemoveJob, adminPauseQueue, adminResumeQueue - Admin frontend page at /admin/queues: queue cards with counts, job table with status filter, retry/remove/pause actions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -913,6 +913,7 @@ export default {
|
||||
articles: "Articles",
|
||||
comments: "Comments",
|
||||
recordings: "Recordings",
|
||||
queues: "Queues",
|
||||
},
|
||||
common: {
|
||||
save_changes: "Save changes",
|
||||
@@ -1058,6 +1059,36 @@ export default {
|
||||
delete_success: "Recording deleted",
|
||||
delete_error: "Failed to delete recording",
|
||||
},
|
||||
queues: {
|
||||
title: "Job Queues",
|
||||
pause: "Pause",
|
||||
resume: "Resume",
|
||||
paused_badge: "Paused",
|
||||
retry: "Retry",
|
||||
remove: "Remove",
|
||||
retry_success: "Job retried",
|
||||
retry_error: "Failed to retry job",
|
||||
remove_success: "Job removed",
|
||||
remove_error: "Failed to remove job",
|
||||
pause_success: "Queue paused",
|
||||
pause_error: "Failed to pause queue",
|
||||
resume_success: "Queue resumed",
|
||||
resume_error: "Failed to resume queue",
|
||||
col_id: "ID",
|
||||
col_name: "Name",
|
||||
col_status: "Status",
|
||||
col_attempts: "Attempts",
|
||||
col_created: "Created",
|
||||
col_actions: "Actions",
|
||||
no_jobs: "No jobs found",
|
||||
status_all: "All",
|
||||
status_waiting: "Waiting",
|
||||
status_active: "Active",
|
||||
status_completed: "Completed",
|
||||
status_failed: "Failed",
|
||||
status_delayed: "Delayed",
|
||||
failed_reason: "Reason: {reason}",
|
||||
},
|
||||
article_form: {
|
||||
new_title: "New article",
|
||||
edit_title: "Edit article",
|
||||
|
||||
@@ -1876,3 +1876,145 @@ export async function adminDeleteRecording(id: string): Promise<void> {
|
||||
await getGraphQLClient().request(ADMIN_DELETE_RECORDING_MUTATION, { id });
|
||||
});
|
||||
}
|
||||
|
||||
// --- Queues ---
|
||||
|
||||
export type JobCounts = {
|
||||
waiting: number;
|
||||
active: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
delayed: number;
|
||||
paused: number;
|
||||
};
|
||||
|
||||
export type QueueInfo = {
|
||||
name: string;
|
||||
counts: JobCounts;
|
||||
isPaused: boolean;
|
||||
};
|
||||
|
||||
export type Job = {
|
||||
id: string;
|
||||
name: string;
|
||||
queue: string;
|
||||
status: string;
|
||||
data: Record<string, unknown>;
|
||||
result: unknown;
|
||||
failedReason: string | null;
|
||||
attemptsMade: number;
|
||||
createdAt: string;
|
||||
processedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
progress: number | null;
|
||||
};
|
||||
|
||||
const ADMIN_QUEUES_QUERY = gql`
|
||||
query AdminQueues {
|
||||
adminQueues {
|
||||
name
|
||||
isPaused
|
||||
counts {
|
||||
waiting
|
||||
active
|
||||
completed
|
||||
failed
|
||||
delayed
|
||||
paused
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export async function getAdminQueues(
|
||||
fetchFn?: typeof globalThis.fetch,
|
||||
token?: string,
|
||||
): Promise<QueueInfo[]> {
|
||||
return loggedApiCall("getAdminQueues", async () => {
|
||||
const client = token ? getAuthClient(token, fetchFn) : getGraphQLClient(fetchFn);
|
||||
const data = await client.request<{ adminQueues: QueueInfo[] }>(ADMIN_QUEUES_QUERY);
|
||||
return data.adminQueues;
|
||||
});
|
||||
}
|
||||
|
||||
const ADMIN_QUEUE_JOBS_QUERY = gql`
|
||||
query AdminQueueJobs($queue: String!, $status: String, $limit: Int, $offset: Int) {
|
||||
adminQueueJobs(queue: $queue, status: $status, limit: $limit, offset: $offset) {
|
||||
id
|
||||
name
|
||||
queue
|
||||
status
|
||||
data
|
||||
result
|
||||
failedReason
|
||||
attemptsMade
|
||||
createdAt
|
||||
processedAt
|
||||
finishedAt
|
||||
progress
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export async function getAdminQueueJobs(
|
||||
queue: string,
|
||||
status?: string,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
): Promise<Job[]> {
|
||||
return loggedApiCall("getAdminQueueJobs", async () => {
|
||||
const data = await getGraphQLClient().request<{ adminQueueJobs: Job[] }>(
|
||||
ADMIN_QUEUE_JOBS_QUERY,
|
||||
{ queue, status, limit, offset },
|
||||
);
|
||||
return data.adminQueueJobs;
|
||||
});
|
||||
}
|
||||
|
||||
const ADMIN_RETRY_JOB_MUTATION = gql`
|
||||
mutation AdminRetryJob($queue: String!, $jobId: String!) {
|
||||
adminRetryJob(queue: $queue, jobId: $jobId)
|
||||
}
|
||||
`;
|
||||
|
||||
export async function adminRetryJob(queue: string, jobId: string): Promise<void> {
|
||||
return loggedApiCall("adminRetryJob", async () => {
|
||||
await getGraphQLClient().request(ADMIN_RETRY_JOB_MUTATION, { queue, jobId });
|
||||
});
|
||||
}
|
||||
|
||||
const ADMIN_REMOVE_JOB_MUTATION = gql`
|
||||
mutation AdminRemoveJob($queue: String!, $jobId: String!) {
|
||||
adminRemoveJob(queue: $queue, jobId: $jobId)
|
||||
}
|
||||
`;
|
||||
|
||||
export async function adminRemoveJob(queue: string, jobId: string): Promise<void> {
|
||||
return loggedApiCall("adminRemoveJob", async () => {
|
||||
await getGraphQLClient().request(ADMIN_REMOVE_JOB_MUTATION, { queue, jobId });
|
||||
});
|
||||
}
|
||||
|
||||
const ADMIN_PAUSE_QUEUE_MUTATION = gql`
|
||||
mutation AdminPauseQueue($queue: String!) {
|
||||
adminPauseQueue(queue: $queue)
|
||||
}
|
||||
`;
|
||||
|
||||
const ADMIN_RESUME_QUEUE_MUTATION = gql`
|
||||
mutation AdminResumeQueue($queue: String!) {
|
||||
adminResumeQueue(queue: $queue)
|
||||
}
|
||||
`;
|
||||
|
||||
export async function adminPauseQueue(queue: string): Promise<void> {
|
||||
return loggedApiCall("adminPauseQueue", async () => {
|
||||
await getGraphQLClient().request(ADMIN_PAUSE_QUEUE_MUTATION, { queue });
|
||||
});
|
||||
}
|
||||
|
||||
export async function adminResumeQueue(queue: string): Promise<void> {
|
||||
return loggedApiCall("adminResumeQueue", async () => {
|
||||
await getGraphQLClient().request(ADMIN_RESUME_QUEUE_MUTATION, { queue });
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user