feat: add BullMQ job queue with admin monitoring UI
All checks were successful
Build and Push Backend Image / build (push) Successful in 48s
Build and Push Buttplug Image / build (push) Successful in 3m26s
Build and Push Frontend Image / build (push) Successful in 1m11s

- 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:
2026-03-08 18:25:09 +01:00
parent 6dcdc0130b
commit ea23233645
16 changed files with 927 additions and 6 deletions

View File

@@ -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",

View File

@@ -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 });
});
}