Files
triggerdotdev--trigger.dev/apps/webapp/app/models/queueMutation.server.ts
Katia Bulatova 81274f4033 Queue metrics and health UI (#4289)
## Queue pages design overhaul (frontend for the queue-metrics feature)

Builds the dashboards on top of the queue-metrics pipeline (base
branch).

### Queues list

<img width="1440" height="788" alt="Screenshot 2026-07-20 at 01 02 55"
src="https://github.com/user-attachments/assets/0b97dd6c-d05b-4dba-99c9-04266ec22cb3"
/>

### Queue detail

<img width="1440" height="1272" alt="tab-overview"
src="https://github.com/user-attachments/assets/22b33f82-522d-4410-a477-fe5d2c2f7adf"
/>

<img width="1440" height="1532" alt="tab-keys"
src="https://github.com/user-attachments/assets/b05619fa-93a8-43ef-8838-a6d7fc5d789b"
/>


### Under the hood
- Percent-based concurrency overrides (schema + migration + recalc on
env limit change, capped at the env limit) — covered by unit tests
- All recurring refresh hits ClickHouse only (15s blocks / 60s charts,
paused in hidden tabs); Redis/PG serve first paint
- Added `MetricsLayout` that is currently used for `/queues + $id`,
`/agents + $id`, `/settings/usage`

<img width="500" height="auto" alt="Screenshot 2026-07-20 at 01 07 31"
src="https://github.com/user-attachments/assets/e8a798e7-deee-455d-be96-414f7d09a06c"
/>
2026-07-21 19:29:42 +02:00

159 lines
5.4 KiB
TypeScript

import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { getUserById } from "~/models/user.server";
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { concurrencySystem } from "~/v3/services/concurrencySystemInstance.server";
import {
isValidQueueOverridePercent,
MAX_QUEUE_OVERRIDE_PERCENT,
MIN_QUEUE_OVERRIDE_PERCENT,
} from "~/v3/services/concurrencySystem.server";
import { PauseQueueService } from "~/v3/services/pauseQueue.server";
/**
* Handles the per-queue mutating form actions (pause/resume/override/remove-override) shared by the
* Queues list route and the queue detail route. Returns a redirect Response for one of those four
* actions, or `null` if `formData`'s `action` isn't one of them (so the caller can fall through to
* its own action handling). `redirectPath` is where to send the user afterwards — the caller passes
* its own page so a mutation from the detail page stays on the detail page.
*/
export async function handleQueueMutationAction({
request,
environment,
userId,
formData,
redirectPath,
}: {
request: Request;
environment: AuthenticatedEnvironment;
userId: string;
formData: FormData;
redirectPath: string;
}): Promise<Response | null> {
const action = formData.get("action");
switch (action) {
case "queue-pause":
case "queue-resume": {
const friendlyId = formData.get("friendlyId");
if (!friendlyId) {
return redirectWithErrorMessage(redirectPath, request, "Queue ID is required");
}
const queueService = new PauseQueueService();
const result = await queueService.call(
environment,
friendlyId.toString(),
action === "queue-pause" ? "paused" : "resumed"
);
if (!result.success) {
return redirectWithErrorMessage(
redirectPath,
request,
result.error ?? `Failed to ${action === "queue-pause" ? "pause" : "resume"} queue`
);
}
return redirectWithSuccessMessage(
redirectPath,
request,
`Queue ${action === "queue-pause" ? "paused" : "resumed"}`
);
}
case "queue-override": {
const friendlyId = formData.get("friendlyId");
const mode = formData.get("mode") === "percent" ? "percent" : "absolute";
if (!friendlyId) {
return redirectWithErrorMessage(redirectPath, request, "Queue ID is required");
}
// The dialog submits either a `percent` of the environment limit or an absolute `limit`,
// depending on the unit toggle. Build the matching override shape for the service.
let override: number | { limit: number } | { percent: number };
if (mode === "percent") {
const percentValue = formData.get("percent");
if (!percentValue) {
return redirectWithErrorMessage(redirectPath, request, "Percentage is required");
}
const percentNumber = Number(percentValue.toString());
if (!isValidQueueOverridePercent(percentNumber)) {
return redirectWithErrorMessage(
redirectPath,
request,
`Percentage must be greater than ${MIN_QUEUE_OVERRIDE_PERCENT} and less than or equal to ${MAX_QUEUE_OVERRIDE_PERCENT}`
);
}
override = { percent: percentNumber };
} else {
const concurrencyLimit = formData.get("concurrencyLimit");
if (!concurrencyLimit) {
return redirectWithErrorMessage(redirectPath, request, "Concurrency limit is required");
}
const limitNumber = parseInt(concurrencyLimit.toString(), 10);
if (isNaN(limitNumber) || limitNumber < 0) {
return redirectWithErrorMessage(
redirectPath,
request,
"Concurrency limit must be a valid number"
);
}
override = { limit: limitNumber };
}
const user = await getUserById(userId);
if (!user) {
return redirectWithErrorMessage(redirectPath, request, "User not found");
}
const result = await concurrencySystem.queues.overrideQueueConcurrencyLimit(
environment,
friendlyId.toString(),
override,
user
);
if (!result.isOk()) {
// Surface the service's specific message (e.g. the above-cap rejection) instead of a
// generic failure so the user learns why the override was refused.
const error = result.error;
const message =
"message" in error && typeof error.message === "string"
? error.message
: "Failed to override queue concurrency limit";
return redirectWithErrorMessage(redirectPath, request, message);
}
return redirectWithSuccessMessage(
redirectPath,
request,
"Queue concurrency limit overridden"
);
}
case "queue-remove-override": {
const friendlyId = formData.get("friendlyId");
if (!friendlyId) {
return redirectWithErrorMessage(redirectPath, request, "Queue ID is required");
}
const result = await concurrencySystem.queues.resetConcurrencyLimit(
environment,
friendlyId.toString()
);
if (!result.isOk()) {
return redirectWithErrorMessage(
redirectPath,
request,
"Failed to reset queue concurrency limit"
);
}
return redirectWithSuccessMessage(redirectPath, request, "Queue concurrency limit reset");
}
default:
return null;
}
}