chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
@@ -0,0 +1,97 @@
import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { runStore } from "~/v3/runStore.server";
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { requireUser } from "~/services/session.server";
import { impersonate, rootPath, v3RunPath, v3RunSpanPath } from "~/utils/pathBuilder";
import { findBufferedRunRedirectInfo } from "~/v3/mollifier/syntheticRedirectInfo.server";
const ParamsSchema = z.object({
runParam: z.string(),
});
export async function loader({ params, request }: LoaderFunctionArgs) {
const user = await requireUser(request);
const { runParam } = ParamsSchema.parse(params);
const isAdmin = user.admin || user.isImpersonating;
if (!isAdmin) {
return redirectWithErrorMessage(
rootPath(),
request,
"You're not an admin and cannot impersonate",
{
ephemeral: false,
}
);
}
const run = await runStore.findRun(
{
friendlyId: runParam,
},
{
select: {
spanId: true,
runtimeEnvironmentId: true,
},
},
prisma
);
if (!run) {
// Admin impersonation route — bypass org membership so admins can
// open any buffered run by friendlyId, mirroring the existing PG
// behaviour above (no membership filter on the find).
const buffered = await findBufferedRunRedirectInfo({
runFriendlyId: runParam,
userId: user.id,
skipOrgMembershipCheck: true,
});
if (buffered) {
// Preselect the root span so the run-detail trace tree opens with
// the buffered run's span highlighted, matching the sibling
// redirect routes (runs.$runParam.ts, projects.v3.$projectRef…).
const path = buffered.spanId
? v3RunSpanPath(
{ slug: buffered.organizationSlug },
{ slug: buffered.projectSlug },
{ slug: buffered.environmentSlug },
{ friendlyId: runParam },
{ spanId: buffered.spanId }
)
: v3RunPath(
{ slug: buffered.organizationSlug },
{ slug: buffered.projectSlug },
{ slug: buffered.environmentSlug },
{ friendlyId: runParam }
);
return redirect(impersonate(path));
}
return redirectWithErrorMessage(rootPath(), request, "Run doesn't exist", {
ephemeral: false,
});
}
const environment = await controlPlaneResolver.resolveAuthenticatedEnv(run.runtimeEnvironmentId);
if (!environment) {
return redirectWithErrorMessage(rootPath(), request, "Run doesn't exist", {
ephemeral: false,
});
}
const path = v3RunSpanPath(
{ slug: environment.organization.slug },
{ slug: environment.project.slug },
{ slug: environment.slug },
{ friendlyId: runParam },
{ spanId: run.spanId }
);
return redirect(impersonate(path));
}
+6
View File
@@ -0,0 +1,6 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { clearImpersonation } from "~/models/admin.server";
export async function loader({ request, params }: LoaderFunctionArgs) {
return clearImpersonation(request, "/admin");
}
@@ -0,0 +1,82 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "remix-typedjson";
import { $replica } from "~/db.server";
import { clearImpersonation, redirectWithImpersonation } from "~/models/admin.server";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { requireUser } from "~/services/session.server";
import { isSameOriginNavigation } from "~/utils/sameOriginNavigation";
export async function loader({ request, params }: LoaderFunctionArgs) {
const user = await requireUser(request);
// If already impersonating, we need to clear the impersonation
if (user.isImpersonating) {
const url = new URL(request.url);
return clearImpersonation(request, url.pathname);
}
// Only admins can impersonate
if (!user.admin) {
return redirect("/");
}
const path = params["*"];
const organizationSlug = params.organizationSlug;
logger.debug("Impersonating user", { path, organizationSlug });
if (!organizationSlug) {
logger.debug("Exiting impersonation mode");
return clearImpersonation(request, "/admin");
}
// CSRF gate for the SET-impersonation path. Clearing impersonation
// above is benign and stays reachable without the check.
if (!isSameOriginNavigation(request, env.LOGIN_ORIGIN)) {
logger.warn("Refusing cross-site impersonation entry", {
userId: user.id,
organizationSlug,
referer: request.headers.get("referer"),
secFetchSite: request.headers.get("sec-fetch-site"),
});
return redirect("/admin");
}
const org = await $replica.organization.findFirst({
where: {
slug: organizationSlug,
deletedAt: null,
},
select: {
members: {
select: {
user: {
select: {
id: true,
confirmedBasicDetails: true,
},
},
},
},
},
});
if (!org) {
logger.debug("Organization not found", { organizationSlug });
return clearImpersonation(request, "/admin");
}
const firstValidMember = org.members.find((m) => m.user.confirmedBasicDetails);
if (!firstValidMember) {
logger.debug("No valid members found", { organizationSlug });
return clearImpersonation(request, "/admin");
}
return redirectWithImpersonation(
request,
firstValidMember.user.id,
`/orgs/${organizationSlug}/${path}`
);
}
@@ -0,0 +1,52 @@
import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
import { prisma } from "~/db.server";
import { getUsersInvites } from "~/models/member.server";
import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server";
import { requireUser } from "~/services/session.server";
import {
invitesPath,
newOrganizationPath,
newProjectPath,
v3EnvironmentPath,
} from "~/utils/pathBuilder";
//this loader chooses the best project to redirect you to, ideally based on the cookie
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireUser(request);
//if there are invites then we should redirect to the invites page
const invites = await getUsersInvites({ email: user.email });
if (invites.length > 0) {
return redirect(invitesPath());
}
const presenter = new SelectBestEnvironmentPresenter();
try {
const { project, organization, environment } = await presenter.call({
user,
});
//redirect them to the most appropriate project
return redirect(v3EnvironmentPath(organization, project, environment));
} catch (_e) {
const organization = await prisma.organization.findFirst({
where: {
members: {
some: {
userId: user.id,
},
},
deletedAt: null,
},
orderBy: {
createdAt: "desc",
},
});
if (organization) {
return redirect(newProjectPath(organization));
}
//this should only happen if the user has no projects, and no invites
return redirect(newOrganizationPath());
}
};
@@ -0,0 +1,121 @@
import { type LoaderFunctionArgs } from "@remix-run/node";
import { z } from "zod";
import { validateGitHubAppInstallSession } from "~/services/gitHubSession.server";
import { linkGitHubAppInstallation, updateGitHubAppInstallation } from "~/services/gitHub.server";
import { logger } from "~/services/logger.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { tryCatch } from "@trigger.dev/core";
import { $replica } from "~/db.server";
import { requireUser } from "~/services/session.server";
import { sanitizeRedirectPath } from "~/utils";
const QuerySchema = z.discriminatedUnion("setup_action", [
z.object({
setup_action: z.literal("install"),
installation_id: z.coerce.number(),
state: z.string(),
}),
z.object({
setup_action: z.literal("update"),
installation_id: z.coerce.number(),
state: z.string(),
}),
z.object({
setup_action: z.literal("request"),
state: z.string(),
}),
]);
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const queryParams = Object.fromEntries(url.searchParams);
const cookieHeader = request.headers.get("Cookie");
const result = QuerySchema.safeParse(queryParams);
if (!result.success) {
logger.warn("GitHub App callback with invalid params", {
queryParams,
});
return redirectWithErrorMessage("/", request, "Failed to install GitHub app");
}
const callbackData = result.data;
const sessionResult = await validateGitHubAppInstallSession(cookieHeader, callbackData.state);
if (!sessionResult.valid) {
logger.error("GitHub App callback with invalid session", {
callbackData,
error: sessionResult.error,
});
return redirectWithErrorMessage("/", request, "Failed to install GitHub app");
}
const { organizationId, redirectTo: unsafeRedirectTo } = sessionResult;
const redirectTo = sanitizeRedirectPath(unsafeRedirectTo);
const user = await requireUser(request);
const org = await $replica.organization.findFirst({
where: { id: organizationId, members: { some: { userId: user.id } }, deletedAt: null },
orderBy: { createdAt: "desc" },
select: {
id: true,
},
});
if (!org) {
// the secure cookie approach should already protect against this
// just an additional check
logger.error("GitHub app installation attempt on unauthenticated org", {
userId: user.id,
organizationId,
});
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub app");
}
switch (callbackData.setup_action) {
case "install": {
const [error] = await tryCatch(
linkGitHubAppInstallation(callbackData.installation_id, organizationId)
);
if (error) {
logger.error("Failed to link GitHub App installation", {
error,
});
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub app");
}
return redirectWithSuccessMessage(redirectTo, request, "GitHub App installed successfully");
}
case "update": {
const [error] = await tryCatch(updateGitHubAppInstallation(callbackData.installation_id));
if (error) {
logger.error("Failed to update GitHub App installation", {
error,
});
return redirectWithErrorMessage(redirectTo, request, "Failed to update GitHub App");
}
return redirectWithSuccessMessage(redirectTo, request, "GitHub App updated successfully");
}
case "request": {
// This happens when a non-admin user requests installation
// The installation_id won't be available until an admin approves
logger.info("GitHub App installation requested, awaiting approval", {
callbackData,
});
return redirectWithSuccessMessage(redirectTo, request, "GitHub App installation requested");
}
default:
callbackData satisfies never;
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub app");
}
}
@@ -0,0 +1,66 @@
import { redirect } from "remix-typedjson";
import { z } from "zod";
import { $replica } from "~/db.server";
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import { createGitHubAppInstallSession } from "~/services/gitHubSession.server";
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { newOrganizationPath } from "~/utils/pathBuilder";
import { logger } from "~/services/logger.server";
import { sanitizeRedirectPath } from "~/utils";
const QuerySchema = z.object({
org_slug: z.string(),
redirect_to: z.string().refine((value) => value === sanitizeRedirectPath(value), {
message: "Invalid redirect path",
}),
});
export const loader = dashboardLoader(
{
// The org for the auth scope comes from the `org_slug` query param.
context: async (_params, request) => {
const orgSlug = new URL(request.url).searchParams.get("org_slug");
if (!orgSlug) return {};
const organizationId = await resolveOrgIdFromSlug(orgSlug);
return organizationId ? { organizationId } : {};
},
authorization: { action: "write", resource: { type: "github" } },
// Redirect endpoint (no UI): keep redirecting on denial rather than
// throwing the permission panel.
unauthorizedRedirect: "/",
},
async ({ request, user }) => {
const searchParams = new URL(request.url).searchParams;
const parsed = QuerySchema.safeParse(Object.fromEntries(searchParams));
if (!parsed.success) {
logger.warn("GitHub App installation redirect with invalid params", {
searchParams,
error: parsed.error,
});
throw redirect("/");
}
const { org_slug, redirect_to } = parsed.data;
const org = await $replica.organization.findFirst({
where: { slug: org_slug, members: { some: { userId: user.id } }, deletedAt: null },
orderBy: { createdAt: "desc" },
select: {
id: true,
},
});
if (!org) {
throw redirect(newOrganizationPath());
}
const { url, cookieHeader } = await createGitHubAppInstallSession(org.id, redirect_to);
return redirect(url, {
headers: {
"Set-Cookie": cookieHeader,
},
});
}
);
@@ -0,0 +1,32 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { v3BillingPath } from "~/utils/pathBuilder";
const ParamsSchema = z.object({
organizationId: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { organizationId } = ParamsSchema.parse(params);
const org = await prisma.organization.findUnique({
select: {
slug: true,
},
where: {
id: organizationId,
},
});
if (!org) {
throw new Response(null, { status: 404 });
}
return redirectWithErrorMessage(
v3BillingPath({ slug: org.slug }),
request,
"You didn't complete your details on Stripe. Please try again."
);
};
@@ -0,0 +1,46 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "remix-typedjson";
import { z } from "zod";
import { prisma } from "~/db.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { newProjectPath, v3BillingPath } from "~/utils/pathBuilder";
const ParamsSchema = z.object({
organizationId: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { organizationId } = ParamsSchema.parse(params);
const org = await prisma.organization.findFirst({
select: {
slug: true,
_count: {
select: {
projects: true,
},
},
},
where: {
id: organizationId,
},
});
if (!org) {
throw new Response(null, { status: 404 });
}
const hasProject = org._count.projects > 0;
if (hasProject) {
return redirectWithSuccessMessage(
v3BillingPath({ slug: org.slug }),
request,
"Your subscription has been successfully activated."
);
}
return redirect(
newProjectPath({ slug: org.slug }, "Your subscription has been successfully activated.")
);
};
@@ -0,0 +1,34 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { v3BillingPath } from "~/utils/pathBuilder";
const ParamsSchema = z.object({
organizationId: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { organizationId } = ParamsSchema.parse(params);
const org = await prisma.organization.findUnique({
select: {
slug: true,
},
where: {
id: organizationId,
},
});
if (!org) {
throw new Response(null, { status: 404 });
}
const url = new URL(request.url);
const searchParams = new URLSearchParams(url.search);
const reason = searchParams.get("reason");
let errorMessage = reason ? decodeURIComponent(reason) : "Subscribing failed to complete";
return redirectWithErrorMessage(v3BillingPath({ slug: org.slug }), request, errorMessage);
};
@@ -0,0 +1,53 @@
import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
import { prisma } from "~/db.server";
import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server";
import { logger } from "~/services/logger.server";
import { requireUser } from "~/services/session.server";
import {
newOrganizationPath,
newProjectPath,
OrganizationParamsSchema,
v3ProjectPath,
} from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const org = await prisma.organization.findFirst({
where: { slug: organizationSlug, members: { some: { userId: user.id } }, deletedAt: null },
orderBy: { createdAt: "desc" },
select: {
projects: {
where: { deletedAt: null, version: "V3" },
select: {
id: true,
slug: true,
name: true,
updatedAt: true,
},
orderBy: { name: "asc" },
},
},
});
if (!org) {
throw redirect(newOrganizationPath());
}
const selector = new SelectBestEnvironmentPresenter();
const bestProject = await selector.selectBestProjectFromProjects({
user,
projectSlug: undefined,
projects: org.projects,
});
if (!bestProject) {
logger.info("Not Found: project", {
request,
project: bestProject,
});
throw redirect(newProjectPath({ slug: organizationSlug }));
}
return redirect(v3ProjectPath({ slug: organizationSlug }, bestProject));
};
@@ -0,0 +1,411 @@
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import {
ArrowUpCircleIcon,
EnvelopeIcon,
LockOpenIcon,
UserPlusIcon,
} from "@heroicons/react/20/solid";
import { json } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import { Fragment, useRef, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import simplur from "simplur";
import { z } from "zod";
import { MainCenteredContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { FormTitle } from "~/components/primitives/FormTitle";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Select, SelectItem } from "~/components/primitives/Select";
import { $replica } from "~/db.server";
import { env } from "~/env.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { inviteMembers } from "~/models/member.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
import { scheduleEmail } from "~/services/scheduleEmail.server";
import { rbac } from "~/services/rbac.server";
import { ssoController } from "~/services/sso.server";
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { acceptInvitePath, organizationTeamPath, v3BillingPath } from "~/utils/pathBuilder";
import { isAtOrBelow } from "~/utils/inviteRoleLadder";
import { PurchaseSeatsModal } from "../_app.orgs.$organizationSlug.settings.team/route";
const Params = z.object({
organizationSlug: z.string(),
});
export const loader = dashboardLoader(
{
params: Params,
context: async (params) => {
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
return organizationId ? { organizationId } : {};
},
authorization: {
action: "manage",
resource: { type: "members" },
message: "With your current role, you can't invite team members.",
},
},
async ({ user, context, ability }) => {
const organizationId = context.organizationId;
if (!organizationId) {
throw new Response("Not Found", { status: 404 });
}
const userId = user.id;
const presenter = new TeamPresenter();
const result = await presenter.call({
userId,
organizationId,
});
if (!result) {
throw new Response("Not Found", { status: 404 });
}
// Inviter's own role drives the "below their level" filter on the
// dropdown. Plus assignable role IDs already encode the org's plan
// tier — the intersection is what we offer.
const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([
rbac.getUserRole({ userId, organizationId }),
rbac.getAssignableRoleIds(organizationId),
rbac.systemRoles(organizationId),
]);
// Build the dropdown's offerable set server-side: roles that are
// (a) assignable on the current plan AND (b) at or below the
// inviter's own level. The client just renders these — it doesn't
// need to know about the system-role catalogue or the ladder.
const assignableSet = new Set(assignableRoleIds);
const offerableRoleIds = systemRoles
? result.roles
.filter(
(r) =>
assignableSet.has(r.id) && isAtOrBelow(systemRoles, inviterRole?.id ?? null, r.id)
)
.map((r) => r.id)
: [];
// Buying seats is a billing operation: surface whether this user can, so
// the purchase modal disables its trigger (the team action enforces it).
const canManageBilling = ability.can("manage", { type: "billing" });
return typedjson({ ...result, offerableRoleIds, canManageBilling });
}
);
// Sentinel for "no RBAC role attached to invite" — the runtime
// fallback will derive a role from the legacy OrgMember.role write at
// accept time. Used when the org has no RBAC plugin installed (the
// dropdown is hidden) or as a defensive default.
const NO_RBAC_ROLE = "__no_rbac_role__";
const schema = z.object({
emails: z.preprocess((i) => {
if (typeof i === "string") return [i];
if (Array.isArray(i)) {
const emails = i.filter((v) => typeof v === "string" && v !== "");
if (emails.length === 0) {
return [""];
}
return emails;
}
return [""];
}, z.string().email().array().nonempty("At least one email is required")),
rbacRoleId: z.string().optional(),
});
export const action = dashboardAction(
{
params: Params,
context: async (params) => {
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
return organizationId ? { organizationId } : {};
},
authorization: { action: "manage", resource: { type: "members" } },
},
async ({ request, params, user, context }) => {
const userId = user.id;
const { organizationSlug } = params;
const formData = await request.formData();
const submission = parseWithZod(formData, { schema });
if (submission.status !== "success") {
return json(submission.reply());
}
// Directory-managed membership: inviting is disabled (the directory is the
// authority). Enforced here; the Team page also hides the invite button.
if (context.organizationId) {
const policy = await ssoController.getMembershipPolicy(context.organizationId);
if (policy.isOk() && !policy.value.manualMembershipAllowed) {
return json(
{ errors: { body: "Membership is managed by Directory Sync" } },
{ status: 403 }
);
}
}
// Resolve the RBAC role choice. NO_RBAC_ROLE / undefined / unknown
// role → don't pass one through; the runtime fallback handles it.
// Validation: the chosen role must be in the org's assignable set
// (plan-tier) and at or below the inviter's own level.
let resolvedRbacRoleId: string | null = null;
const submittedRbacRoleId = submission.value.rbacRoleId;
if (submittedRbacRoleId && submittedRbacRoleId !== NO_RBAC_ROLE) {
const org = await $replica.organization.findFirst({
where: { slug: organizationSlug },
select: { id: true },
});
if (!org) {
return json({ errors: { body: "Organization not found" } }, { status: 404 });
}
const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([
rbac.getUserRole({ userId, organizationId: org.id }),
rbac.getAssignableRoleIds(org.id),
rbac.systemRoles(org.id),
]);
if (!systemRoles) {
// No plugin installed but the form somehow submitted a role id —
// ignore it (fall through to legacy behaviour rather than 400).
resolvedRbacRoleId = null;
} else {
const assignable = new Set(assignableRoleIds);
if (!assignable.has(submittedRbacRoleId)) {
return json(
{ errors: { body: "You can't invite someone with this role on your current plan" } },
{ status: 400 }
);
}
if (!isAtOrBelow(systemRoles, inviterRole?.id ?? null, submittedRbacRoleId)) {
return json(
{ errors: { body: "You can only invite members at or below your own role" } },
{ status: 403 }
);
}
resolvedRbacRoleId = submittedRbacRoleId;
}
}
try {
const invites = await inviteMembers({
slug: organizationSlug,
emails: submission.value.emails,
userId,
rbacRoleId: resolvedRbacRoleId,
});
for (const invite of invites) {
try {
await scheduleEmail({
email: "invite",
to: invite.email,
orgName: invite.organization.title,
inviterName: invite.inviter.name ?? undefined,
inviterEmail: invite.inviter.email,
inviteLink: `${env.LOGIN_ORIGIN}${acceptInvitePath(invite.token)}`,
});
} catch (error) {
console.error("Failed to send invite email");
console.error(error);
}
}
return redirectWithSuccessMessage(
organizationTeamPath(invites[0].organization),
request,
simplur`${submission.value.emails.length} member[|s] invited`
);
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
}
}
);
export default function Page() {
const {
limits,
canPurchaseSeats,
seatPricing,
extraSeats,
maxSeatQuota,
planSeatLimit,
roles,
offerableRoleIds,
canManageBilling,
} = useTypedLoaderData<typeof loader>();
const [total, setTotal] = useState(limits.used);
const organization = useOrganization();
const lastSubmission = useActionData();
// The loader filtered the catalogue to roles this inviter can
// actually assign (plan tier × strict-below-my-level). With no plugin
// installed, offerableRoleIds is [] and the picker hides entirely.
const offerableSet = new Set(offerableRoleIds);
const offerable = roles.filter((r) => offerableSet.has(r.id));
const showRolePicker = offerable.length > 0;
// Default to the lowest-tier offered role (the loader returns roles
// in its allRoles order, which the plugin emits Owner→Member; the
// last entry is the most restrictive).
const defaultRoleId = showRolePicker ? offerable[offerable.length - 1].id : NO_RBAC_ROLE;
const [selectedRoleId, setSelectedRoleId] = useState(defaultRoleId);
const [form, fields] = useForm<z.infer<typeof schema>>({
id: "invite-members",
// TODO: type this
lastResult: lastSubmission as any,
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
defaultValue: {
emails: [""],
},
});
const { emails } = fields;
const fieldValues = useRef<string[]>([""]);
const emailFields = emails.getFieldList();
return (
<MainCenteredContainer className="max-w-104 rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg">
<div>
<FormTitle
LeadingIcon={<UserPlusIcon className="size-6 text-indigo-500" />}
title="Invite team members"
description={`Invite new team members to ${organization.title}.`}
/>
{total > limits.limit &&
(canPurchaseSeats && seatPricing ? (
<InfoPanel
variant="upgrade"
icon={LockOpenIcon}
iconClassName="text-indigo-500"
title="Need more seats?"
accessory={
<PurchaseSeatsModal
seatPricing={seatPricing}
extraSeats={extraSeats}
usedSeats={limits.used}
maxQuota={maxSeatQuota}
planSeatLimit={planSeatLimit}
canManageBilling={canManageBilling}
triggerButton={<Button variant="primary/small">Purchase more seats</Button>}
/>
}
panelClassName="mb-4"
>
<Paragraph variant="small">
You've used all {limits.limit} of your available team members. Purchase extra seats
to add more.
</Paragraph>
</InfoPanel>
) : (
<InfoPanel
variant="upgrade"
icon={LockOpenIcon}
iconClassName="text-indigo-500"
title="Unlock more team members"
accessory={
<LinkButton
to={v3BillingPath(organization)}
variant="secondary/small"
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="text-indigo-500"
>
Upgrade
</LinkButton>
}
panelClassName="mb-4"
>
<Paragraph variant="small">
You've used all {limits.limit} of your available team members. Upgrade your plan to
add more.
</Paragraph>
</InfoPanel>
))}
<Form method="post" {...getFormProps(form)}>
<Fieldset>
<InputGroup>
<Label htmlFor={emails.id}>Email addresses</Label>
{emailFields.map((email, index) => (
<Fragment key={email.key}>
<Input
{...getInputProps(email, { type: "email" })}
placeholder={index === 0 ? "Enter an email address" : "Add another email"}
icon={EnvelopeIcon}
autoFocus={index === 0}
onChange={(e) => {
fieldValues.current[index] = e.target.value;
const filledFields = fieldValues.current.filter((v) => v !== "");
setTotal(limits.used + filledFields.length);
if (
emailFields.length === fieldValues.current.length &&
fieldValues.current.every((v) => v !== "")
) {
form.insert({ name: emails.name });
}
}}
/>
<FormError id={email.errorId}>{email.errors}</FormError>
</Fragment>
))}
</InputGroup>
{showRolePicker ? (
<InputGroup>
<Label htmlFor="rbacRoleId">Role</Label>
<input type="hidden" name="rbacRoleId" value={selectedRoleId} />
<Select<string, (typeof offerable)[number]>
defaultValue={defaultRoleId}
items={offerable}
variant="tertiary/medium"
dropdownIcon
text={(v) => offerable.find((r) => r.id === v)?.name ?? "Pick a role"}
setValue={(next) => {
if (typeof next === "string") setSelectedRoleId(next);
}}
>
{(items) =>
items.map((role) => (
<SelectItem key={role.id} value={role.id}>
{role.name}
</SelectItem>
))
}
</Select>
<Paragraph variant="extra-small" className="text-text-dimmed">
Invitees join with this role. They can be promoted later from the Team page.
</Paragraph>
</InputGroup>
) : null}
<FormButtons
confirmButton={
<Button type="submit" variant={"primary/small"} disabled={total > limits.limit}>
Send invitations
</Button>
}
cancelButton={
<LinkButton to={organizationTeamPath(organization)} variant={"secondary/small"}>
Cancel
</LinkButton>
}
/>
</Fieldset>
</Form>
</div>
</MainCenteredContainer>
);
}
@@ -0,0 +1,44 @@
import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { prisma } from "~/db.server";
import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server";
import { requireUser } from "~/services/session.server";
import { ProjectParamSchema, v3EnvironmentPath } from "~/utils/pathBuilder";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
const project = await prisma.project.findFirst({
where: {
slug: projectParam,
deletedAt: null,
organization: { slug: organizationSlug, members: { some: { userId: user.id } } },
},
include: {
environments: {
select: {
id: true,
type: true,
slug: true,
parentEnvironmentId: true,
orgMember: {
select: {
userId: true,
},
},
},
},
},
});
if (!project) {
throw new Response(undefined, {
status: 404,
statusText: "Project not found",
});
}
const selector = new SelectBestEnvironmentPresenter();
const environment = await selector.selectBestEnvironment(project.id, user, project.environments);
return redirect(v3EnvironmentPath({ slug: organizationSlug }, project, environment));
};
@@ -0,0 +1,919 @@
import { BookOpenIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import { json, type MetaFunction } from "@remix-run/node";
import { useFetcher, useRevalidator } from "@remix-run/react";
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import type { TaskRunStatus } from "@trigger.dev/database";
import type { PanelHandle } from "@window-splitter/react";
import { Fragment, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ClientOnly } from "remix-utils/client-only";
import { Bar, BarChart, ReferenceLine, Tooltip, type TooltipProps, YAxis } from "recharts";
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
import { BeakerIcon } from "~/assets/icons/BeakerIcon";
import { ClockIcon } from "~/assets/icons/ClockIcon";
import { CubeSparkleIcon } from "~/assets/icons/CubeSparkleIcon";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { PlusIcon } from "~/assets/icons/PlusIcon";
import { QuestionMarkIcon } from "~/assets/icons/QuestionMarkIcon";
import { RunsIcon } from "~/assets/icons/RunsIcon";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { CodeBlock } from "~/components/code/CodeBlock";
import { InlineCode } from "~/components/code/InlineCode";
import { HasNoTasksDeployed, HasNoTasksDev } from "~/components/BlankStatePanels";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Badge } from "~/components/primitives/Badge";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { formatDateTime } from "~/components/primitives/DateTime";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
import { PopoverMenuItem } from "~/components/primitives/Popover";
import {
RESIZABLE_PANEL_ANIMATION,
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
collapsibleHandleClassName,
} from "~/components/primitives/Resizable";
import { SearchInput } from "~/components/primitives/SearchInput";
import SegmentedControl from "~/components/primitives/SegmentedControl";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import { Spinner } from "~/components/primitives/Spinner";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import TooltipPortal from "~/components/primitives/TooltipPortal";
import { TaskFileName } from "~/components/runs/v3/TaskPath";
import { TaskRunStatusCombo } from "~/components/runs/v3/TaskRunStatus";
import {
TaskTriggerSourceIcon,
taskTriggerSourceDescription,
} from "~/components/runs/v3/TaskTriggerSource";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useEventSource } from "~/hooks/useEventSource";
import { useFuzzyFilter } from "~/hooks/useFuzzyFilter";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import {
getUsefulLinksPreference,
setUsefulLinksPreference,
uiPreferencesStorage,
} from "~/services/preferences/uiPreferences.server";
import {
unifiedTaskListPresenter,
type HourlyTaskActivity,
type UnifiedRunningState,
type UnifiedRunningStates,
type UnifiedTaskKind,
type UnifiedTaskListItem,
} from "~/presenters/v3/UnifiedTaskListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { formatNumberCompact } from "~/utils/numberFormatter";
import {
docsPath,
EnvironmentParamSchema,
v3AgentTaskPath,
v3PlaygroundAgentPath,
v3RunsPath,
v3ScheduledTaskPath,
v3StandardTaskPath,
v3TasksStreamingPath,
v3TestTaskPath,
} from "~/utils/pathBuilder";
export const meta: MetaFunction = () => {
return [{ title: `Tasks | Trigger.dev` }];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response(undefined, { status: 404, statusText: "Project not found" });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response(undefined, { status: 404, statusText: "Environment not found" });
}
try {
const { items, hourlyActivity, runningStates } = await unifiedTaskListPresenter.call({
organizationId: project.organizationId,
projectId: project.id,
environmentId: environment.id,
environmentType: environment.type,
});
const usefulLinksPreference = await getUsefulLinksPreference(request);
return typeddefer({ items, hourlyActivity, runningStates, usefulLinksPreference });
} catch (error) {
console.error(error);
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, if this problem persists please contact support.",
});
}
};
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const showUsefulLinks = formData.get("showUsefulLinks") === "true";
const session = await setUsefulLinksPreference(showUsefulLinks, request);
return json(
{ success: true },
{
headers: {
"Set-Cookie": await uiPreferencesStorage.commitSession(session),
},
}
);
}
const KIND_OPTIONS: { value: UnifiedTaskKind; label: string }[] = [
{ value: "AGENT", label: "Agent" },
{ value: "STANDARD", label: "Standard" },
{ value: "SCHEDULED", label: "Scheduled" },
];
const VALID_KINDS = new Set<UnifiedTaskKind>(KIND_OPTIONS.map((o) => o.value));
/** Parse `?types=…` URL values, dropping anything that isn't a known
* `UnifiedTaskKind`. Without this, a shareable URL with a typo would
* produce a filter that matches nothing and the user gets stuck on
* "No tasks match your filters" with no way to recover. */
function parseTypesParam(values: string[]): UnifiedTaskKind[] {
return values.filter((v): v is UnifiedTaskKind => VALID_KINDS.has(v as UnifiedTaskKind));
}
const ALL_TASK_TYPES = "ALL";
type TaskTypeSegment = typeof ALL_TASK_TYPES | UnifiedTaskKind;
/** Segmented control options. "All" shows as a word; the task kinds show as
* icon-only segments. Every segment has a tooltip (label + shortcut).
* Order = shortcut keys 03. */
const TASK_TYPE_SEGMENTS: {
value: TaskTypeSegment;
tooltip: string;
text?: string;
source?: UnifiedTaskKind;
}[] = [
{ value: ALL_TASK_TYPES, tooltip: "All tasks", text: "All" },
{ value: "AGENT", tooltip: "Agent tasks", source: "AGENT" },
{ value: "STANDARD", tooltip: "Standard tasks", source: "STANDARD" },
{ value: "SCHEDULED", tooltip: "Scheduled tasks", source: "SCHEDULED" },
];
const PAGE_SIZE = 25;
export default function Page() {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { items, hourlyActivity, runningStates, usefulLinksPreference } =
useTypedLoaderData<typeof loader>();
const { value, values } = useSearchParams();
// Live-reload on WORKER_CREATED.
const revalidator = useRevalidator();
const streamedEvents = useEventSource(v3TasksStreamingPath(organization, project, environment), {
event: "message",
});
useEffect(() => {
if (streamedEvents !== null) {
revalidator.revalidate();
}
// Don't add `revalidator` to deps — infinite loop.
}, [streamedEvents]); // eslint-disable-line react-hooks/exhaustive-deps
const [showUsefulLinks, setShowUsefulLinks] = useState(usefulLinksPreference ?? true);
// Hide (don't unmount) the charts during the panel animation; 25 reflowing SVGs tank the resize.
const [isPanelAnimating, setIsPanelAnimating] = useState(false);
const animatingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const usefulLinksPanelRef = useRef<PanelHandle>(null);
const fetcher = useFetcher();
const fetcherRef = useRef(fetcher);
fetcherRef.current = fetcher;
const toggleUsefulLinks = useCallback((show: boolean) => {
setShowUsefulLinks(show);
setIsPanelAnimating(true);
if (animatingTimerRef.current) clearTimeout(animatingTimerRef.current);
// 300ms panel anim + 50ms buffer.
animatingTimerRef.current = setTimeout(() => setIsPanelAnimating(false), 350);
if (show) {
usefulLinksPanelRef.current?.expand();
} else {
usefulLinksPanelRef.current?.collapse();
}
fetcherRef.current.submit({ showUsefulLinks: show.toString() }, { method: "post" });
}, []);
const selectedTypes = useMemo(() => {
const raw = parseTypesParam(values("types"));
// Single-select: one kind filters to it; none or legacy multi → all.
return raw.length === 1 ? new Set(raw) : null; // null = all
}, [values]);
const { filteredItems } = useFuzzyFilter<UnifiedTaskListItem>({
items,
keys: ["slug", "filePath", "triggerSource"],
filterText: value("search") ?? "",
});
const visibleItems = useMemo(() => {
if (!selectedTypes) return filteredItems;
return filteredItems.filter((item) => selectedTypes.has(item.kind));
}, [filteredItems, selectedTypes]);
const hasItems = items.length > 0;
// Client-side pagination — presenter returns all tasks; we slice + clamp here.
const totalPages = Math.max(1, Math.ceil(visibleItems.length / PAGE_SIZE));
const requestedPage = Math.max(1, parseInt(value("page") ?? "1", 10) || 1);
const currentPage = Math.min(requestedPage, totalPages);
const pagedItems = useMemo(
() => visibleItems.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE),
[visibleItems, currentPage]
);
return (
<PageContainer>
<NavBar>
<PageTitle title="Tasks" accessory={<TasksHelpTooltip />} />
<PageAccessories>
<LinkButton
variant={"docs/small"}
LeadingIcon={BookOpenIcon}
to={docsPath("/tasks/overview")}
>
Task docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
<ResizablePanel id="tasks-main" min="100px" className="max-h-full">
<div className={cn("grid h-full grid-rows-1")}>
{hasItems ? (
<div className="flex min-w-0 max-w-full flex-col">
<div className="flex h-full flex-col overflow-hidden">
<div className="flex shrink-0 items-center justify-between gap-1.5 p-2">
<div className="flex flex-1 items-center gap-1.5">
<SearchInput placeholder="Search tasks…" resetParams={["page"]} />
<TaskTypeFilter />
</div>
<div className="flex items-center gap-1.5">
{!showUsefulLinks && (
<Button
variant="primary/small"
LeadingIcon={PlusIcon}
leadingIconClassName="mr-[-0.7rem]"
onClick={() => toggleUsefulLinks(true)}
className="pl-1.5"
>
New task
</Button>
)}
<PaginationControls
currentPage={currentPage}
totalPages={totalPages}
showPageNumbers={false}
/>
</div>
</div>
<Table containerClassName="min-h-0 flex-1">
<TableHeader>
<TableRow>
<TableHeaderCell>ID</TableHeaderCell>
<TableHeaderCell
tooltip={
<div className="max-w-sm">
<TaskTypeBreakdown />
</div>
}
disableTooltipHoverableContent
>
Type
</TableHeaderCell>
<TableHeaderCell>File</TableHeaderCell>
<TableHeaderCell>Running</TableHeaderCell>
<TableHeaderCell>Activity (24h)</TableHeaderCell>
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{pagedItems.length > 0 ? (
pagedItems.map((item) => (
<TaskRow
key={item.slug}
item={item}
runningStates={runningStates}
hourlyActivity={hourlyActivity}
organization={organization}
project={project}
environment={environment}
isPanelAnimating={isPanelAnimating}
/>
))
) : (
<TableBlankRow colSpan={6}>
<Paragraph variant="small" className="flex items-center justify-center">
No tasks match your filters
</Paragraph>
</TableBlankRow>
)}
</TableBody>
</Table>
</div>
</div>
) : environment.type === "DEVELOPMENT" ? (
<MainCenteredContainer className="max-w-prose">
<HasNoTasksDev />
</MainCenteredContainer>
) : (
<MainCenteredContainer className="max-w-prose">
<HasNoTasksDeployed environment={environment} />
</MainCenteredContainer>
)}
</div>
</ResizablePanel>
<ResizableHandle
id="tasks-handle"
className={collapsibleHandleClassName(hasItems && showUsefulLinks)}
/>
<ResizablePanel
id="tasks-inspector"
handle={usefulLinksPanelRef}
default="400px"
min="400px"
max="500px"
className="overflow-hidden"
collapsible
collapsed={!hasItems || !showUsefulLinks}
onCollapseChange={() => {}}
collapsedSize="0px"
collapseAnimation={RESIZABLE_PANEL_ANIMATION}
>
<div className="h-full" style={{ minWidth: 400 }}>
{hasItems && <NewTaskPromptsPanel onClose={() => toggleUsefulLinks(false)} />}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</PageBody>
</PageContainer>
);
}
type TaskRowProps = {
item: UnifiedTaskListItem;
runningStates: Promise<UnifiedRunningStates>;
hourlyActivity: Promise<HourlyTaskActivity>;
organization: ReturnType<typeof useOrganization>;
project: ReturnType<typeof useProject>;
environment: ReturnType<typeof useEnvironment>;
isPanelAnimating: boolean;
};
function TaskRow({
item,
runningStates,
hourlyActivity,
organization,
project,
environment,
isPanelAnimating,
}: TaskRowProps) {
const rowPath =
item.kind === "AGENT"
? v3AgentTaskPath(organization, project, environment, item.slug)
: item.kind === "SCHEDULED"
? v3ScheduledTaskPath(organization, project, environment, item.slug)
: v3StandardTaskPath(organization, project, environment, item.slug);
const testPath =
item.kind === "AGENT"
? v3PlaygroundAgentPath(organization, project, environment, item.slug)
: v3TestTaskPath(organization, project, environment, { taskIdentifier: item.slug });
const runsPath = v3RunsPath(organization, project, environment, { tasks: [item.slug] });
return (
<TableRow className="group">
<TableCell to={rowPath} isTabbableCell>
<div className="flex items-center gap-2">
<SimpleTooltip
button={<TaskTriggerSourceIcon source={item.triggerSource} />}
content={taskTriggerSourceDescription(item.triggerSource)}
disableHoverableContent
/>
<span>{item.slug}</span>
</div>
</TableCell>
<TableCell to={rowPath}>
<div className="flex items-center gap-2">
<span>
{item.kind === "AGENT" ? "Agent" : item.kind === "SCHEDULED" ? "Scheduled" : "Standard"}
</span>
{item.kind === "AGENT" && item.agentType && (
<Badge variant="extra-small">{formatAgentType(item.agentType)}</Badge>
)}
</div>
</TableCell>
<TableCell to={rowPath}>
<TaskFileName fileName={item.filePath} variant="extra-extra-small" />
</TableCell>
<TableCell to={rowPath}>
{/* Render the deferred stats client-side. A streamed Suspense boundary still pending at
hydration otherwise bails to client rendering and throws React #421. */}
<ClientOnly fallback={<Spinner color="blue" className="size-3" />}>
{() => (
<Suspense fallback={<Spinner color="blue" className="size-3" />}>
<TypedAwait resolve={runningStates} errorElement={<FailedToLoadStats />}>
{(data) => <RunningCell state={data[item.slug]} />}
</TypedAwait>
</Suspense>
)}
</ClientOnly>
</TableCell>
<TableCell to={rowPath} actionClassName="py-1.5">
<div style={{ width: ACTIVITY_CELL_WIDTH, height: ACTIVITY_CHART_HEIGHT }}>
<div hidden={isPanelAnimating}>
<ClientOnly fallback={<TaskActivityBlankState />}>
{() => (
<Suspense fallback={<TaskActivityBlankState />}>
<TypedAwait resolve={hourlyActivity} errorElement={<FailedToLoadStats />}>
{(data) => {
const taskData = data[item.slug];
return taskData && taskData.length > 0 ? (
<TaskActivityGraph activity={taskData} />
) : (
<TaskActivityBlankState />
);
}}
</TypedAwait>
</Suspense>
)}
</ClientOnly>
</div>
</div>
</TableCell>
<TableCellMenu
isSticky
popoverContent={
<>
<PopoverMenuItem
icon={RunsIcon}
to={runsPath}
title="View runs"
leadingIconClassName="-mx-1 text-runs"
/>
<PopoverMenuItem
icon={BeakerIcon}
to={testPath}
title="Test"
leadingIconClassName="-mx-1 text-tests"
/>
</>
}
hiddenButtons={
<LinkButton
variant="minimal/small"
LeadingIcon={BeakerIcon}
leadingIconClassName="-mx-2.5 text-tests"
to={testPath}
>
<span className="text-text-bright">Test</span>
</LinkButton>
}
/>
</TableRow>
);
}
function RunningCell({ state }: { state: UnifiedRunningState | undefined }) {
if (!state) {
return <span className="text-text-dimmed"></span>;
}
return <>{state.running ?? 0}</>;
}
function TaskTypeFilter() {
const { values, replace } = useSearchParams();
const raw = parseTypesParam(values("types"));
// Single-select: exactly one kind selects it, anything else falls back to All.
const current: TaskTypeSegment = raw.length === 1 ? raw[0] : ALL_TASK_TYPES;
const select = (value: string) => {
// "All" drops the param; a kind filters to just that kind. Always reset page.
replace({ types: value === ALL_TASK_TYPES ? undefined : [value], page: undefined });
};
return (
<>
{TASK_TYPE_SEGMENTS.map((option, index) => (
<TaskTypeShortcut
key={option.value}
shortcut={String(index)}
onSelect={() => select(option.value)}
/>
))}
<SegmentedControl
name="task-type"
value={current}
variant="secondary/small"
onChange={select}
options={TASK_TYPE_SEGMENTS.map((option, index) => ({
value: option.value,
label: <TaskTypeSegmentLabel option={option} shortcut={String(index)} />,
}))}
/>
</>
);
}
// Registers a number-key shortcut that selects one segment.
function TaskTypeShortcut({ shortcut, onSelect }: { shortcut: string; onSelect: () => void }) {
useShortcutKeys({
shortcut: { key: shortcut },
action: (event) => {
event.preventDefault();
onSelect();
},
});
return null;
}
function TaskTypeSegmentLabel({
option,
shortcut,
}: {
option: (typeof TASK_TYPE_SEGMENTS)[number];
shortcut: string;
}) {
return (
<SimpleTooltip
asChild
button={
option.source ? (
// -mx-0.5 tightens the icon segment toward a square button.
<span className="-mx-0.5 flex items-center justify-center">
<TaskTriggerSourceIcon source={option.source} />
<span className="sr-only">{option.tooltip}</span>
</span>
) : (
<span className="flex items-center justify-center">{option.text}</span>
)
}
content={
<div className="flex items-center gap-1">
<span className="text-text-bright">{option.tooltip}</span>
<ShortcutKey shortcut={{ key: shortcut }} variant="small" />
</div>
}
className="px-2 py-1.5 text-xs"
sideOffset={6}
disableHoverableContent
/>
);
}
function formatAgentType(type: string): string {
switch (type) {
case "ai-sdk-chat":
return "AI SDK Chat";
default:
return type;
}
}
const STATUS_BARS: { status: TaskRunStatus; fill: string }[] = [
{ status: "DELAYED", fill: "var(--color-run-delayed)" },
{ status: "PENDING", fill: "var(--color-run-pending)" },
{ status: "PENDING_VERSION", fill: "var(--color-run-pending-version)" },
{ status: "EXECUTING", fill: "var(--color-run-executing)" },
{ status: "RETRYING_AFTER_FAILURE", fill: "var(--color-run-retrying-after-failure)" },
{ status: "WAITING_TO_RESUME", fill: "var(--color-run-waiting-to-resume)" },
{ status: "COMPLETED_SUCCESSFULLY", fill: "var(--color-run-completed-successfully)" },
{ status: "CANCELED", fill: "var(--color-run-canceled)" },
{ status: "COMPLETED_WITH_ERRORS", fill: "var(--color-run-completed-with-errors)" },
{ status: "INTERRUPTED", fill: "var(--color-run-interrupted)" },
{ status: "SYSTEM_FAILURE", fill: "var(--color-run-system-failure)" },
{ status: "PAUSED", fill: "var(--color-run-paused)" },
{ status: "CRASHED", fill: "var(--color-run-crashed)" },
{ status: "EXPIRED", fill: "var(--color-run-expired)" },
{ status: "TIMED_OUT", fill: "var(--color-run-timed-out)" },
];
// Fixed px dims skip ResponsiveContainer's ResizeObserver — otherwise every panel resize re-renders all 25 charts.
const ACTIVITY_CHART_WIDTH = 112;
const ACTIVITY_CHART_HEIGHT = 24;
// chart (112) + gap-1.5 (6) + count min-w (28). Reserved so the column stays put while the chart unmounts.
const ACTIVITY_CELL_WIDTH = 146;
const ACTIVITY_CHART_COUNT_CLASS =
"-mt-1 inline-block min-w-7 text-xxs tabular-nums text-text-dimmed";
function TaskActivityGraph({ activity }: { activity: HourlyTaskActivity[string] }) {
const maxTotal = Math.max(...activity.map((d) => d.total));
return (
<div className="flex items-start gap-1.5">
<div
className="rounded-sm"
style={{ width: ACTIVITY_CHART_WIDTH, height: ACTIVITY_CHART_HEIGHT }}
>
<BarChart
data={activity}
width={ACTIVITY_CHART_WIDTH}
height={ACTIVITY_CHART_HEIGHT}
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
>
<YAxis domain={[0, maxTotal || 1]} hide />
<Tooltip
cursor={{ fill: "rgba(255, 255, 255, 0.06)" }}
content={<TaskActivityTooltip />}
allowEscapeViewBox={{ x: true, y: true }}
wrapperStyle={{ zIndex: 1000 }}
animationDuration={0}
/>
{STATUS_BARS.map(({ status, fill }) => (
<Bar
key={status}
dataKey={status}
stackId="a"
fill={fill}
strokeWidth={0}
isAnimationActive={false}
/>
))}
<ReferenceLine y={0} stroke="var(--color-border-bright)" strokeWidth={1} />
{maxTotal > 0 && (
<ReferenceLine
y={maxTotal}
stroke="var(--color-border-brighter)"
strokeDasharray="4 4"
strokeWidth={1}
/>
)}
</BarChart>
</div>
<SimpleTooltip
asChild
button={<span className={ACTIVITY_CHART_COUNT_CLASS}>{formatNumberCompact(maxTotal)}</span>}
content="Peak runs in a single hour"
/>
</div>
);
}
// SVG line matches recharts' y=0 ReferenceLine anti-aliasing; a CSS border looks too crisp.
function TaskActivityBlankState() {
return (
<div className="flex items-start gap-1.5">
<svg width={ACTIVITY_CHART_WIDTH} height={ACTIVITY_CHART_HEIGHT} className="rounded-sm">
<line
x1={0}
y1={ACTIVITY_CHART_HEIGHT}
x2={ACTIVITY_CHART_WIDTH}
y2={ACTIVITY_CHART_HEIGHT}
stroke="var(--color-border-bright)"
strokeWidth={1}
/>
</svg>
<span className={ACTIVITY_CHART_COUNT_CLASS}>0</span>
</div>
);
}
const TaskActivityTooltip = ({ active, payload }: TooltipProps<number, string>) => {
if (active && payload && payload.length > 0) {
const entry = payload[0].payload as { date: Date; total: number } & Partial<
Record<TaskRunStatus, number>
>;
const date = entry.date instanceof Date ? entry.date : new Date(entry.date);
const formattedDate = formatDateTime(date, "UTC", [], false, true);
const items = STATUS_BARS.filter(({ status }) => (entry[status] ?? 0) > 0).map(
({ status }) => ({ status, value: entry[status] ?? 0 })
);
return (
<TooltipPortal active={active}>
<div className="rounded-sm border border-grid-bright bg-background-dimmed px-3 py-2">
<Header3 className="border-b border-b-border-bright pb-2">{formattedDate}</Header3>
{items.length === 0 ? (
<div className="mt-2 text-xs text-text-dimmed">No runs</div>
) : (
<div className="mt-2 grid grid-cols-[1fr_auto] gap-2 text-xs text-text-bright">
{items.map((item) => (
<Fragment key={item.status}>
<TaskRunStatusCombo status={item.status} />
<p className="tabular-nums">{item.value}</p>
</Fragment>
))}
</div>
)}
</div>
</TooltipPortal>
);
}
return null;
};
function FailedToLoadStats() {
return (
<SimpleTooltip
button={<ExclamationTriangleIcon className="size-4 text-warning" />}
content="We were unable to load the task stats, please try again later."
/>
);
}
function TaskTypeBreakdown() {
return (
<div className="flex flex-col gap-2.5">
<div>
<div className="flex items-center gap-1.5">
<CubeSparkleIcon className="size-4.5 shrink-0 text-agents" />
<Paragraph variant="small/bright">Agent task</Paragraph>
</div>
<Paragraph variant="small" className="mt-1">
A long-lived AI session. Streams LLM responses to your app and keeps context across
messages, page refreshes, and deploys.
</Paragraph>
</div>
<div>
<div className="flex items-center gap-1.5">
<TaskIcon className="size-4.5 shrink-0 text-tasks" />
<Paragraph variant="small/bright">Standard task</Paragraph>
</div>
<Paragraph variant="small" className="mt-1">
A background function you trigger from your code with a payload. Good for AI workflows,
image generation, audio transcription, document processing, and any other long-running
work where reliability matters.
</Paragraph>
</div>
<div>
<div className="flex items-center gap-1.5">
<ClockIcon className="size-4.5 shrink-0 text-schedules" />
<Paragraph variant="small/bright">Scheduled task</Paragraph>
</div>
<Paragraph variant="small" className="mt-1">
Runs automatically on a recurring cron schedule. Use daily, weekly, or any custom interval
you need.
</Paragraph>
</div>
</div>
);
}
function TasksHelpTooltip() {
return (
<SimpleTooltip
button={
<QuestionMarkIcon className="size-4 text-text-dimmed transition hover:text-text-bright" />
}
side="bottom"
className="max-w-sm p-3"
disableHoverableContent
content={
<div className="flex flex-col gap-3">
<div>
<Paragraph variant="small/bright">What is a task?</Paragraph>
<Paragraph variant="small" className="mt-1">
A task is a durable function that runs in the background. It can run for as long as it
needs without timing out, automatically retries on failure, and survives crashes and
deploys.
</Paragraph>
</div>
<div className="border-t border-grid-dimmed pt-3">
<TaskTypeBreakdown />
</div>
</div>
}
/>
);
}
const CHAT_AGENT_CODE = `import { chat } from "@trigger.dev/sdk/ai";
import { streamText, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
export const myChat = chat.agent({
id: "my-chat",
run: async ({ messages, signal }) => {
return streamText({
...chat.toStreamTextOptions(),
model: anthropic("claude-sonnet-4-5"),
messages,
abortSignal: signal,
stopWhen: stepCountIs(15),
});
},
});`;
const STANDARD_TASK_CODE = `import { task } from "@trigger.dev/sdk";
export const helloWorld = task({
id: "hello-world",
run: async (payload: { message: string }) => {
console.log(payload.message);
},
});`;
const SCHEDULED_TASK_CODE = `import { schedules } from "@trigger.dev/sdk";
export const firstScheduledTask = schedules.task({
id: "first-scheduled-task",
run: async (payload) => {
console.log(payload.timestamp);
console.log(payload.lastTimestamp);
},
});`;
function NewTaskPromptsPanel({ onClose }: { onClose: () => void }) {
return (
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden bg-background-bright">
<div className="flex items-center justify-between gap-2 border-b border-grid-dimmed px-3 py-2">
<Header2>Create a new task</Header2>
<Button
onClick={onClose}
variant="minimal/small"
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
className="pl-1"
/>
</div>
<div className="overflow-y-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<Paragraph variant="small/bright" className="mb-6">
Copy any example below into your project's{" "}
<InlineCode variant="extra-small">trigger/</InlineCode> directory and customize it from
there.
</Paragraph>
<PromptCard
icon={<CubeSparkleIcon className="size-4.5 shrink-0 text-agents" />}
title="Chat agent"
description="An AI agent you can chat with from your app. Streams responses, calls tools and keeps context across messages."
code={CHAT_AGENT_CODE}
/>
<PromptCard
icon={<TaskIcon className="size-4.5 shrink-0 text-tasks" />}
title="Standard task"
description="A durable background function you can trigger from your code. Runs as long as it needs without timing out."
code={STANDARD_TASK_CODE}
/>
<PromptCard
icon={<ClockIcon className="size-4.5 shrink-0 text-schedules" />}
title="Scheduled task"
description="A task that runs automatically on a recurring cron schedule: daily, weekly, or any interval you define."
code={SCHEDULED_TASK_CODE}
/>
</div>
</div>
);
}
function PromptCard({
icon,
title,
description,
code,
}: {
icon: React.ReactNode;
title: string;
description: string;
code: string;
}) {
return (
<div className="mb-5">
<div className="mb-1 flex items-center gap-1.5">
{icon}
<Header2>{title}</Header2>
</div>
<Paragraph variant="small" className="mb-2 text-text-dimmed">
{description}
</Paragraph>
<CodeBlock code={code} language="typescript" showCopyButton showLineNumbers={false} />
</div>
);
}
@@ -0,0 +1,601 @@
import { BookOpenIcon } from "@heroicons/react/24/solid";
import { type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Suspense, useMemo, useState } from "react";
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { BeakerIcon } from "~/assets/icons/BeakerIcon";
import { CubeSparkleIcon } from "~/assets/icons/CubeSparkleIcon";
import { PageBody } from "~/components/layout/AppLayout";
import { DirectionSchema, ListPagination } from "~/components/ListPagination";
import { LinkButton } from "~/components/primitives/Buttons";
import { buildActivityTimeAxis } from "~/components/primitives/charts/activityTimeAxis";
import { ChartCard } from "~/components/primitives/charts/ChartCard";
import { Chart, type ChartConfig } from "~/components/primitives/charts/ChartCompound";
import { ChartSyncProvider } from "~/components/primitives/charts/ChartSyncContext";
import { statusColor } from "~/components/primitives/charts/statusColors";
import { CopyableText } from "~/components/primitives/CopyableText";
import { DateTime } from "~/components/primitives/DateTime";
import { Header2 } from "~/components/primitives/Headers";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import { Spinner } from "~/components/primitives/Spinner";
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
import { SessionsTable } from "~/components/sessions/v1/SessionsTable";
import { $replica } from "~/db.server";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useZoomToTimeFilter } from "~/hooks/useZoomToTimeFilter";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import {
AgentDetailPresenter,
type AgentActivity,
type AgentDetail,
} from "~/presenters/v3/AgentDetailPresenter.server";
import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server";
import { SessionListPresenter } from "~/presenters/v3/SessionListPresenter.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { requireUser } from "~/services/session.server";
import {
docsPath,
EnvironmentParamSchema,
v3EnvironmentPath,
v3PlaygroundAgentPath,
} from "~/utils/pathBuilder";
import { parseFiniteInt } from "~/utils/searchParams";
export const meta: MetaFunction<typeof loader> = ({ data }) => {
const slug = (data as { agent?: AgentDetail | null } | undefined)?.agent?.slug;
return [{ title: slug ? `${slug} | Agents | Trigger.dev` : "Agent | Trigger.dev" }];
};
const AgentParamSchema = EnvironmentParamSchema.extend({
agentParam: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const userId = user.id;
const { organizationSlug, projectParam, envParam, agentParam } = AgentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Project not found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response("Environment not found", { status: 404 });
}
const url = new URL(request.url);
const period = url.searchParams.get("period") ?? undefined;
const from = parseFiniteInt(url.searchParams.get("from"));
const to = parseFiniteInt(url.searchParams.get("to"));
const cursor = url.searchParams.get("cursor") ?? undefined;
const directionRaw = url.searchParams.get("direction") ?? undefined;
const direction = directionRaw ? DirectionSchema.parse(directionRaw) : undefined;
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
project.organizationId,
"standard"
);
const presenter = new AgentDetailPresenter($replica, clickhouse);
const agent = await presenter.findAgent({
environmentId: environment.id,
environmentType: environment.type,
agentSlug: agentParam,
});
if (!agent) {
throw new Response("Agent not found", { status: 404 });
}
const time = timeFilterFromTo({ period, from, to, defaultPeriod: "7d" });
const runActivity = presenter
.getActivity({
organizationId: project.organizationId,
projectId: project.id,
environmentId: environment.id,
agentSlug: agent.slug,
from: time.from,
to: time.to,
})
.catch(() => ({ data: [], statuses: [] }) satisfies AgentActivity);
const sessionActivity = presenter
.getSessionActivity({
organizationId: project.organizationId,
projectId: project.id,
environmentId: environment.id,
agentSlug: agent.slug,
from: time.from,
to: time.to,
})
.catch(() => ({ data: [], statuses: [] }) satisfies AgentActivity);
const llmCostActivity = presenter
.getLlmCostActivity({
organizationId: project.organizationId,
projectId: project.id,
environmentId: environment.id,
agentSlug: agent.slug,
from: time.from,
to: time.to,
})
.catch(() => ({ data: [], statuses: [] }) satisfies AgentActivity);
const llmTokenActivity = presenter
.getLlmTokenActivity({
organizationId: project.organizationId,
projectId: project.id,
environmentId: environment.id,
agentSlug: agent.slug,
from: time.from,
to: time.to,
})
.catch(() => ({ data: [], statuses: [] }) satisfies AgentActivity);
const runList = new NextRunListPresenter($replica, clickhouse)
.call(project.organizationId, environment.id, {
userId,
projectId: project.id,
tasks: [agent.slug],
period,
from,
to,
cursor,
direction,
})
.catch(() => null);
const sessionList = new SessionListPresenter($replica, clickhouse)
.call(project.organizationId, environment.id, {
userId,
projectId: project.id,
taskIdentifiers: [agent.slug],
period,
from,
to,
cursor,
direction,
})
.catch(() => null);
return typeddefer({
agent,
runActivity,
sessionActivity,
llmCostActivity,
llmTokenActivity,
runList,
sessionList,
});
};
type AgentTab = "sessions" | "runs";
export default function Page() {
const {
agent,
runActivity,
sessionActivity,
llmCostActivity,
llmTokenActivity,
runList,
sessionList,
} = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const playgroundPath = v3PlaygroundAgentPath(organization, project, environment, agent.slug);
const tasksPath = v3EnvironmentPath(organization, project, environment);
const [tab, setTab] = useState<AgentTab>("sessions");
const zoomToTimeFilter = useZoomToTimeFilter();
const tabLabel = tab === "sessions" ? "Sessions" : "Runs";
return (
<>
<NavBar>
<PageTitle
backButton={{ to: tasksPath, text: "Tasks" }}
title={
<span className="flex items-center gap-1">
<CubeSparkleIcon className="size-4.5 text-agents" />
<span>{agent.slug}</span>
</span>
}
/>
<PageAccessories>
<LinkButton
variant="docs/small"
LeadingIcon={BookOpenIcon}
to={docsPath("ai-chat/overview")}
>
Agents docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
<ResizablePanel id="agent-main" min="300px">
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden">
{/* Top bar — tabs on the left; TimeFilter + pagination on the right.
h-10 matches the right-hand sidebar header height. */}
<div className="flex h-10 items-end border-b border-grid-dimmed bg-background-bright pl-3 pr-2">
<TabContainer className="-mb-px">
<TabButton
isActive={tab === "sessions"}
layoutId="agent-page-tabs"
onClick={() => setTab("sessions")}
>
Sessions
</TabButton>
<TabButton
isActive={tab === "runs"}
layoutId="agent-page-tabs"
onClick={() => setTab("runs")}
>
Runs
</TabButton>
</TabContainer>
<div className="ml-auto flex items-center gap-2 self-center">
<TimeFilter defaultPeriod="7d" labelName={tabLabel} />
{tab === "sessions" ? (
<Suspense fallback={null}>
<TypedAwait resolve={sessionList} errorElement={null}>
{(list) => (list ? <ListPagination list={list} /> : null)}
</TypedAwait>
</Suspense>
) : (
<Suspense fallback={null}>
<TypedAwait resolve={runList} errorElement={null}>
{(list) => (list ? <ListPagination list={list} /> : null)}
</TypedAwait>
</Suspense>
)}
</div>
</div>
<ResizablePanelGroup orientation="vertical" className="max-h-full">
{/* Activity / LLM cost / Token charts */}
<ResizablePanel id="agent-activity" min="220px" default="320px">
<div className="flex h-full flex-col overflow-hidden bg-background p-2">
<ChartSyncProvider onZoom={zoomToTimeFilter}>
<div className="grid min-h-0 flex-1 grid-cols-3 gap-2">
<ChartCard title={tabLabel}>
{tab === "sessions" ? (
<Suspense fallback={<ActivityChartSkeleton />}>
<TypedAwait
resolve={sessionActivity}
errorElement={<ActivityChartSkeleton />}
>
{(result) => <ActivityChart activity={result} />}
</TypedAwait>
</Suspense>
) : (
<Suspense fallback={<ActivityChartSkeleton />}>
<TypedAwait
resolve={runActivity}
errorElement={<ActivityChartSkeleton />}
>
{(result) => <ActivityChart activity={result} />}
</TypedAwait>
</Suspense>
)}
</ChartCard>
<ChartCard title="LLM spend ($)">
<Suspense fallback={<ActivityChartSkeleton />}>
<TypedAwait
resolve={llmCostActivity}
errorElement={<ActivityChartSkeleton />}
>
{(result) => (
<ScalarActivityChart
activity={result}
seriesKey="cost"
label="Spend"
color="var(--color-agents)"
valueFormatter={formatCurrency}
/>
)}
</TypedAwait>
</Suspense>
</ChartCard>
<ChartCard title="Tokens">
<Suspense fallback={<ActivityChartSkeleton />}>
<TypedAwait
resolve={llmTokenActivity}
errorElement={<ActivityChartSkeleton />}
>
{(result) => (
<ScalarActivityChart
activity={result}
seriesKey="tokens"
label="Tokens"
color="#14B8A6"
valueFormatter={formatTokens}
/>
)}
</TypedAwait>
</Suspense>
</ChartCard>
</div>
</ChartSyncProvider>
</div>
</ResizablePanel>
<ResizableHandle id="agent-activity-handle" />
{/* Table */}
<ResizablePanel id="agent-content" min="160px">
<AgentContentArea tab={tab} sessionList={sessionList} runList={runList} />
</ResizablePanel>
</ResizablePanelGroup>
</div>
</ResizablePanel>
<ResizableHandle id="agent-detail-handle" />
<ResizablePanel id="agent-detail" min="280px" default="380px" max="500px" isStaticAtRest>
<AgentDetailSidebar agent={agent} playgroundPath={playgroundPath} />
</ResizablePanel>
</ResizablePanelGroup>
</PageBody>
</>
);
}
type LoaderData = ReturnType<typeof useTypedLoaderData<typeof loader>>;
function AgentContentArea({
tab,
sessionList,
runList,
}: { tab: AgentTab } & Pick<LoaderData, "sessionList" | "runList">) {
return (
<div className="h-full overflow-hidden">
{tab === "sessions" ? (
<Suspense fallback={<TableLoading />}>
<TypedAwait resolve={sessionList} errorElement={<TableLoading />}>
{(list) =>
list ? (
<div className="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<SessionsTable
sessions={list.sessions}
filters={list.filters}
hasFilters={list.hasFilters}
showTopBorder={false}
stickyHeader
/>
</div>
) : (
<TableLoading />
)
}
</TypedAwait>
</Suspense>
) : (
<Suspense fallback={<TableLoading />}>
<TypedAwait resolve={runList} errorElement={<TableLoading />}>
{(list) =>
list ? (
<div className="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<TaskRunsTable
total={list.runs.length}
hasFilters={list.hasFilters}
filters={list.filters}
runs={list.runs}
variant="dimmed"
showTopBorder={false}
stickyHeader
/>
</div>
) : (
<TableLoading />
)
}
</TypedAwait>
</Suspense>
)}
</div>
);
}
function AgentDetailSidebar({
agent,
playgroundPath,
}: {
agent: AgentDetail;
playgroundPath: string;
}) {
const config = (agent.config ?? {}) as Record<string, unknown>;
const agentType = typeof config.type === "string" ? config.type : undefined;
const model = typeof config.model === "string" ? config.model : undefined;
const instructions = typeof config.instructions === "string" ? config.instructions : undefined;
return (
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden bg-background-bright">
<div className="flex min-w-0 items-center gap-2 border-b border-grid-dimmed py-2 pl-3 pr-2">
<Header2 className="flex min-w-0 flex-1 items-center gap-1.5">
<CubeSparkleIcon className="size-4.5 shrink-0 text-agents" />
<span className="truncate">{agent.slug}</span>
</Header2>
<LinkButton
variant="primary/small"
to={playgroundPath}
LeadingIcon={BeakerIcon}
iconSpacing="gap-x-2"
leadingIconClassName="-mx-2"
className="shrink-0"
>
Test agent
</LinkButton>
</div>
<div className="overflow-y-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<Property.Table>
<Property.Item>
<Property.Label>Slug</Property.Label>
<Property.Value>
<CopyableText value={agent.slug} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>File path</Property.Label>
<Property.Value>
<CopyableText value={agent.filePath} />
</Property.Value>
</Property.Item>
{agentType && (
<Property.Item>
<Property.Label>Type</Property.Label>
<Property.Value>
<span className="font-mono text-sm">{agentType}</span>
</Property.Value>
</Property.Item>
)}
{model && (
<Property.Item>
<Property.Label>Model</Property.Label>
<Property.Value>
<span className="font-mono text-sm">{model}</span>
</Property.Value>
</Property.Item>
)}
{instructions && (
<Property.Item className="gap-1">
<Property.Label>Instructions</Property.Label>
<Property.Value>
<Paragraph variant="small" className="whitespace-pre-wrap text-text-dimmed">
{instructions}
</Paragraph>
</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Created</Property.Label>
<Property.Value>
<DateTime date={agent.createdAt} />
</Property.Value>
</Property.Item>
</Property.Table>
</div>
</div>
);
}
function ActivityChart({ activity }: { activity: AgentActivity }) {
const chartConfig: ChartConfig = useMemo(() => {
const cfg: ChartConfig = {};
for (const status of activity.statuses) {
cfg[status] = {
label: status.charAt(0) + status.slice(1).toLowerCase(),
color: statusColor(status),
};
}
return cfg;
}, [activity.statuses]);
const { tickFormatter, tooltipLabelFormatter } = useMemo(
() => buildActivityTimeAxis(activity.data),
[activity.data]
);
return (
<Chart.Root
config={chartConfig}
data={activity.data}
dataKey="bucket"
series={activity.statuses}
fillContainer
>
<Chart.Bar
stackId="status"
barRadius={0}
xAxisProps={{ tickFormatter }}
tooltipLabelFormatter={tooltipLabelFormatter}
/>
</Chart.Root>
);
}
function ActivityChartSkeleton() {
return (
<div className="flex min-h-0 flex-1 items-end gap-px rounded-sm">
{Array.from({ length: 42 }).map((_, i) => (
<div key={i} className="h-full flex-1 bg-background-dimmed" />
))}
</div>
);
}
function TableLoading() {
return (
<div className="flex h-full items-center justify-center">
<Spinner className="size-6" />
</div>
);
}
function ScalarActivityChart({
activity,
seriesKey,
label,
color,
valueFormatter,
}: {
activity: AgentActivity;
seriesKey: string;
label: string;
color: string;
valueFormatter: (value: number) => string;
}) {
const chartConfig: ChartConfig = useMemo(
() => ({ [seriesKey]: { label, color } }),
[seriesKey, label, color]
);
const { tickFormatter, tooltipLabelFormatter } = useMemo(
() => buildActivityTimeAxis(activity.data),
[activity.data]
);
return (
<Chart.Root config={chartConfig} data={activity.data} dataKey="bucket" fillContainer>
<Chart.Bar
barRadius={0}
xAxisProps={{ tickFormatter }}
tooltipLabelFormatter={tooltipLabelFormatter}
tooltipValueFormatter={valueFormatter}
/>
</Chart.Root>
);
}
function formatCurrency(value: number): string {
if (value === 0) return "$0";
if (value < 0.01) return `$${value.toFixed(4)}`;
if (value < 1) return `$${value.toFixed(3)}`;
return `$${value.toFixed(2)}`;
}
function formatTokens(value: number): string {
if (value < 1000) return value.toLocaleString();
if (value < 1_000_000) return `${(value / 1000).toFixed(1)}k`;
return `${(value / 1_000_000).toFixed(1)}M`;
}
@@ -0,0 +1,10 @@
import { Outlet } from "@remix-run/react";
import { PageContainer } from "~/components/layout/AppLayout";
export default function Page() {
return (
<PageContainer>
<Outlet />
</PageContainer>
);
}
@@ -0,0 +1,55 @@
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { prisma } from "~/db.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
import { findProjectBySlug } from "~/models/project.server";
import { requireUserId } from "~/services/session.server";
import {
EnvironmentParamSchema,
v3NewProjectAlertPath,
v3NewProjectAlertPathConnectToSlackPath,
} from "~/utils/pathBuilder";
export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const url = new URL(request.url);
const shouldReinstall = url.searchParams.get("reinstall") === "true";
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Project not found", { status: 404 });
}
// Find an integration for Slack for this org
const integration = await prisma.organizationIntegration.findFirst({
where: {
service: "SLACK",
organizationId: project.organizationId,
deletedAt: null,
},
});
// If integration exists and we're not reinstalling, redirect back to alerts
if (integration && !shouldReinstall) {
return redirectWithSuccessMessage(
`${v3NewProjectAlertPath({ slug: organizationSlug }, project, {
slug: envParam,
})}?option=slack`,
request,
"Successfully connected your Slack workspace"
);
}
// Redirect to Slack for new installation or reinstallation
return await OrgIntegrationRepository.redirectToAuthService(
"SLACK",
project.organizationId,
request,
v3NewProjectAlertPathConnectToSlackPath({ slug: organizationSlug }, project, {
slug: envParam,
})
);
}
@@ -0,0 +1,494 @@
import { getFormProps, getInputProps, getSelectProps, useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { HashtagIcon, LockClosedIcon } from "@heroicons/react/20/solid";
import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/router";
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { SlackIcon } from "@trigger.dev/companyicons";
import { useEffect, useRef, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { InlineCode } from "~/components/code/InlineCode";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout, variantClasses } from "~/components/primitives/Callout";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import SegmentedControl from "~/components/primitives/SegmentedControl";
import { Select, SelectItem } from "~/components/primitives/Select";
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
import { env } from "~/env.server";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { NewAlertChannelPresenter } from "~/presenters/v3/NewAlertChannelPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import {
EnvironmentParamSchema,
ProjectParamSchema,
v3ProjectAlertsPath,
} from "~/utils/pathBuilder";
import {
type CreateAlertChannelOptions,
CreateAlertChannelService,
} from "~/v3/services/alerts/createAlertChannel.server";
import {
assertSafeWebhookUrl,
UnsafeWebhookUrlError,
} from "~/v3/services/alerts/safeWebhookUrl.server";
const FormSchema = z
.object({
alertTypes: z
.array(z.enum(["TASK_RUN", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS"]))
.min(1)
.or(z.enum(["TASK_RUN", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS"])),
environmentTypes: z
.array(z.enum(["STAGING", "PRODUCTION", "PREVIEW"]))
.min(1)
.or(z.enum(["STAGING", "PRODUCTION", "PREVIEW"])),
type: z.enum(["WEBHOOK", "SLACK", "EMAIL"]).default("EMAIL"),
channelValue: z.string().nonempty(),
integrationId: z.string().optional(),
})
.refine(
(value) =>
value.type === "EMAIL" ? z.string().email().safeParse(value.channelValue).success : true,
{
message: "Must be a valid email address",
path: ["channelValue"],
}
)
.refine(
(value) =>
value.type === "WEBHOOK" ? z.string().url().safeParse(value.channelValue).success : true,
{
message: "Must be a valid URL",
path: ["channelValue"],
}
)
.refine(
(value) =>
value.type === "SLACK"
? typeof value.channelValue === "string" && value.channelValue.startsWith("C")
: true,
{
message: "Must select a Slack channel",
path: ["channelValue"],
}
);
function formDataToCreateAlertChannelOptions(
formData: z.infer<typeof FormSchema>
): CreateAlertChannelOptions {
switch (formData.type) {
case "WEBHOOK": {
return {
name: `Webhook to ${new URL(formData.channelValue).hostname}`,
alertTypes: Array.isArray(formData.alertTypes)
? formData.alertTypes
: [formData.alertTypes],
environmentTypes: Array.isArray(formData.environmentTypes)
? formData.environmentTypes
: [formData.environmentTypes],
channel: {
type: "WEBHOOK",
url: formData.channelValue,
},
};
}
case "EMAIL": {
return {
name: `Email to ${formData.channelValue}`,
alertTypes: Array.isArray(formData.alertTypes)
? formData.alertTypes
: [formData.alertTypes],
environmentTypes: Array.isArray(formData.environmentTypes)
? formData.environmentTypes
: [formData.environmentTypes],
channel: {
type: "EMAIL",
email: formData.channelValue,
},
};
}
case "SLACK": {
const [channelId, channelName] = formData.channelValue.split("/");
return {
name: `Slack message to ${channelName}`,
alertTypes: Array.isArray(formData.alertTypes)
? formData.alertTypes
: [formData.alertTypes],
environmentTypes: Array.isArray(formData.environmentTypes)
? formData.environmentTypes
: [formData.environmentTypes],
channel: {
type: "SLACK",
channelId,
channelName,
integrationId: formData.integrationId,
},
};
}
}
}
export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Project not found", { status: 404 });
}
const presenter = new NewAlertChannelPresenter();
const results = await presenter.call(project.id);
const url = new URL(request.url);
const option = url.searchParams.get("option");
const emailAlertsEnabled =
env.ALERT_FROM_EMAIL !== undefined && env.ALERT_RESEND_API_KEY !== undefined;
return typedjson({
...results,
option: option === "slack" ? ("SLACK" as const) : undefined,
emailAlertsEnabled,
});
}
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}
const formData = await request.formData();
const submission = parseWithZod(formData, { schema: FormSchema });
if (submission.status !== "success") {
return json(submission.reply());
}
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
return json(submission.reply({ formErrors: ["Project not found"] }));
}
// Validate the webhook URL before storing it, for an inline field error.
if (submission.value.type === "WEBHOOK") {
try {
await assertSafeWebhookUrl(submission.value.channelValue);
} catch (error) {
if (error instanceof UnsafeWebhookUrlError) {
return json(submission.reply({ fieldErrors: { channelValue: [error.message] } }));
}
throw error;
}
}
const service = new CreateAlertChannelService();
const alertChannel = await service.call(
project.externalRef,
userId,
formDataToCreateAlertChannelOptions(submission.value)
);
if (!alertChannel) {
return json(submission.reply({ formErrors: ["Failed to create alert channel"] }));
}
return redirectWithSuccessMessage(
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }),
request,
`Created ${alertChannel.name} alert`
);
};
export default function Page() {
const [isOpen, setIsOpen] = useState(false);
const { slack, option, emailAlertsEnabled } = useTypedLoaderData<typeof loader>();
const lastSubmission = useActionData();
const navigation = useNavigation();
const navigate = useNavigate();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const [currentAlertChannel, setCurrentAlertChannel] = useState<string | null>(option ?? "EMAIL");
const formRef = useRef<HTMLFormElement>(null);
const [selectedSlackChannelValue, setSelectedSlackChannelValue] = useState<string | undefined>();
const selectedSlackChannel = slack.channels?.find(
(s) => selectedSlackChannelValue === `${s.id}/${s.name}`
);
const isLoading =
navigation.state !== "idle" &&
navigation.formMethod === "post" &&
navigation.formData?.get("action") === "create";
const [
form,
{ channelValue, alertTypes, environmentTypes, type, integrationId: _integrationId },
] = useForm({
id: "create-alert",
// TODO: type this
lastResult: lastSubmission as any,
onValidate({ formData }) {
return parseWithZod(formData, { schema: FormSchema });
},
shouldRevalidate: "onSubmit",
});
useEffect(() => {
setIsOpen(true);
}, []);
useEffect(() => {
if (navigation.state !== "idle") return;
if (lastSubmission !== undefined) return;
formRef.current?.reset();
}, [navigation.state, lastSubmission]);
return (
<Dialog
open={isOpen}
onOpenChange={(o) => {
if (!o) {
navigate(v3ProjectAlertsPath(organization, project, environment));
}
}}
>
<DialogContent>
<DialogHeader>New alert</DialogHeader>
<Form ref={formRef} method="post" {...getFormProps(form)}>
<Fieldset className="mt-2">
<InputGroup fullWidth>
<SegmentedControl
{...getInputProps(type, { type: "text" })}
options={[
{ label: "Email", value: "EMAIL" },
{ label: "Slack", value: "SLACK" },
{ label: "Webhook", value: "WEBHOOK" },
]}
onChange={(value) => {
setCurrentAlertChannel(value);
}}
fullWidth
defaultValue={currentAlertChannel ?? undefined}
/>
</InputGroup>
{currentAlertChannel === "EMAIL" ? (
emailAlertsEnabled ? (
<InputGroup fullWidth>
<Label>Email</Label>
<Input
{...getInputProps(channelValue, { type: "text" })}
placeholder="email@youremail.com"
type="email"
autoFocus
/>
<FormError id={channelValue.errorId}>{channelValue.errors}</FormError>
</InputGroup>
) : (
<Callout variant="warning">
Email integration is not available. Please contact your organization
administrator.
</Callout>
)
) : currentAlertChannel === "SLACK" ? (
<InputGroup fullWidth>
{slack.status === "READY" ? (
<>
<Select
{...getSelectProps(channelValue)}
placeholder="Select a Slack channel"
heading="Filter channels…"
defaultValue={undefined}
dropdownIcon
variant="tertiary/medium"
items={slack.channels}
setValue={(value) => {
typeof value === "string" && setSelectedSlackChannelValue(value);
}}
filter={(channel, search) =>
channel.name?.toLowerCase().includes(search.toLowerCase()) ?? false
}
text={(value) => {
const channel = slack.channels.find((s) => value === `${s.id}/${s.name}`);
if (!channel) return;
return <SlackChannelTitle {...channel} />;
}}
>
{(matches) => (
<>
{matches?.map((channel) => (
<SelectItem key={channel.id} value={`${channel.id}/${channel.name}`}>
<SlackChannelTitle {...channel} />
</SelectItem>
))}
</>
)}
</Select>
{selectedSlackChannel && selectedSlackChannel.is_private && (
<Callout
variant="warning"
className={cn("text-sm", variantClasses.warning.textColor)}
>
To receive alerts in the{" "}
<InlineCode variant="extra-small">{selectedSlackChannel.name}</InlineCode>{" "}
channel, you need to invite the @Trigger.dev Slack Bot. Go to the channel in
Slack and type:{" "}
<InlineCode variant="extra-small">/invite @Trigger.dev</InlineCode>.
</Callout>
)}
<FormError id={channelValue.errorId}>{channelValue.errors}</FormError>
<input type="hidden" name="integrationId" value={slack.integrationId} />
</>
) : slack.status === "NOT_CONFIGURED" ? (
<LinkButton variant="tertiary/large" to="connect-to-slack" fullWidth>
<span className="flex items-center gap-2 text-text-bright">
<SlackIcon className="size-5" /> Connect to Slack
</span>
</LinkButton>
) : slack.status === "TOKEN_REVOKED" || slack.status === "TOKEN_EXPIRED" ? (
<div className="flex flex-col gap-4">
<Callout variant="info">
The Slack integration in your workspace has been revoked or has expired.
Please re-connect your Slack workspace.
</Callout>
<LinkButton
variant="tertiary/large"
to={{
pathname: "connect-to-slack",
search: "?reinstall=true",
}}
fullWidth
>
<span className="flex items-center gap-2 text-text-bright">
<SlackIcon className="size-5" /> Connect to Slack
</span>
</LinkButton>
</div>
) : slack.status === "FAILED_FETCHING_CHANNELS" ? (
<div className="flex flex-col gap-4">
<Callout variant="warning">
Failed loading channels from Slack. Please try again later.
</Callout>
</div>
) : (
<Callout variant="warning">
Slack integration is not available. Please contact your organization
administrator.
</Callout>
)}
</InputGroup>
) : (
<InputGroup fullWidth>
<Label>URL</Label>
<Input
{...getInputProps(channelValue, { type: "text" })}
placeholder="https://foobar.com/webhooks"
type="url"
autoFocus
/>
<FormError id={channelValue.errorId}>{channelValue.errors}</FormError>
<Hint>We'll issue POST requests to this URL with a JSON payload.</Hint>
</InputGroup>
)}
<InputGroup>
<Label>Alert me when</Label>
<div className="flex items-center gap-1">
<CheckboxWithLabel
name={alertTypes.name}
id="TASK_RUN"
value="TASK_RUN"
variant="simple/small"
label="Task runs fail"
defaultChecked
className="pr-0"
/>
<InfoIconTooltip content="You'll receive an alert when a run completely fails." />
</div>
<CheckboxWithLabel
name={alertTypes.name}
id="DEPLOYMENT_FAILURE"
value="DEPLOYMENT_FAILURE"
variant="simple/small"
label="Deployments fail"
defaultChecked
/>
<CheckboxWithLabel
name={alertTypes.name}
id="DEPLOYMENT_SUCCESS"
value="DEPLOYMENT_SUCCESS"
variant="simple/small"
label="Deployments succeed"
defaultChecked
/>
<FormError id={alertTypes.errorId}>{alertTypes.errors}</FormError>
</InputGroup>
<InputGroup>
<Label>Environment</Label>
<input type="hidden" name={environmentTypes.name} value={environment.type} />
<EnvironmentCombo environment={{ type: environment.type }} />
<FormError id={environmentTypes.errorId}>{environmentTypes.errors}</FormError>
</InputGroup>
<FormError>{form.errors}</FormError>
<FormButtons
confirmButton={
<Button variant="primary/medium" disabled={isLoading} name="action" value="create">
{isLoading ? "Saving" : "Save"}
</Button>
}
cancelButton={
<LinkButton
to={v3ProjectAlertsPath(organization, project, environment)}
variant="tertiary/medium"
>
Cancel
</LinkButton>
}
/>
</Fieldset>
</Form>
</DialogContent>
</Dialog>
);
}
function SlackChannelTitle({ name, is_private }: { name?: string; is_private?: boolean }) {
return (
<div className="flex items-center gap-1.5">
{is_private ? <LockClosedIcon className="size-4" /> : <HashtagIcon className="size-4" />}
<span>{name}</span>
</div>
);
}
@@ -0,0 +1,607 @@
import { getFormProps, useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import {
BellAlertIcon,
BellSlashIcon,
BookOpenIcon,
EnvelopeIcon,
GlobeAltIcon,
LockClosedIcon,
PlusIcon,
TrashIcon,
} from "@heroicons/react/20/solid";
import { Form, type MetaFunction, Outlet, useActionData, useNavigation } from "@remix-run/react";
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { SlackIcon } from "@trigger.dev/companyicons";
import type { ProjectAlertChannelType, ProjectAlertType } from "@trigger.dev/database";
import assertNever from "assert-never";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { AlertsNoneDev, AlertsNoneDeployed } from "~/components/BlankStatePanels";
import { Feedback } from "~/components/Feedback";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { DetailCell } from "~/components/primitives/DetailCell";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
Table,
TableBody,
TableCell,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import {
SimpleTooltip,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/primitives/Tooltip";
import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
import { prisma } from "~/db.server";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import {
AlertChannelListPresenter,
type AlertChannelListPresenterRecord,
} from "~/presenters/v3/AlertChannelListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import {
EnvironmentParamSchema,
docsPath,
v3BillingPath,
v3NewProjectAlertPath,
v3ProjectAlertsPath,
} from "~/utils/pathBuilder";
import { alertsWorker } from "~/v3/alertsWorker.server";
export const meta: MetaFunction = () => {
return [
{
title: `Alerts | Trigger.dev`,
},
];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response(undefined, {
status: 404,
statusText: "Project not found",
});
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response(undefined, {
status: 404,
statusText: "Environment not found",
});
}
const presenter = new AlertChannelListPresenter();
const data = await presenter.call(project.id, environment.type);
return typedjson(data);
};
const schema = z.discriminatedUnion("action", [
z.object({ action: z.literal("delete"), id: z.string() }),
z.object({ action: z.literal("disable"), id: z.string() }),
z.object({ action: z.literal("enable"), id: z.string() }),
]);
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}
const formData = await request.formData();
const submission = parseWithZod(formData, { schema });
if (submission.status !== "success") {
return json(submission.reply());
}
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
return json(submission.reply({ formErrors: ["Project not found"] }));
}
switch (submission.value.action) {
case "delete": {
const alertChannel = await prisma.projectAlertChannel.delete({
where: { id: submission.value.id, projectId: project.id },
});
return redirectWithSuccessMessage(
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }),
request,
`Deleted ${alertChannel.name} alert`
);
}
case "disable": {
const alertChannel = await prisma.projectAlertChannel.update({
where: { id: submission.value.id, projectId: project.id },
data: { enabled: false },
});
return redirectWithSuccessMessage(
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }),
request,
`Disabled ${alertChannel.name} alert`
);
}
case "enable": {
const alertChannel = await prisma.projectAlertChannel.update({
where: { id: submission.value.id, projectId: project.id },
data: { enabled: true },
});
if (alertChannel.alertTypes.includes("ERROR_GROUP")) {
await alertsWorker.enqueue({
id: `evaluateErrorAlerts:${project.id}`,
job: "v3.evaluateErrorAlerts",
payload: {
projectId: project.id,
scheduledAt: Date.now(),
},
});
}
return redirectWithSuccessMessage(
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }),
request,
`Enabled ${alertChannel.name} alert`
);
}
}
};
export default function Page() {
const { alertChannels, limits } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const showSelfServe = useShowSelfServe();
const requiresUpgrade = limits.used >= limits.limit;
return (
<PageContainer>
<NavBar>
<PageTitle title="Alerts" />
<PageAccessories>
<LinkButton
LeadingIcon={BookOpenIcon}
to={docsPath("v3/troubleshooting-alerts")}
variant="docs/small"
>
Alerts docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
{alertChannels.length > 0 ? (
<div className="grid max-h-full min-h-full grid-rows-[auto_1fr_auto]">
<div className="flex h-fit items-end justify-between p-2 pl-3">
<Header2 className="">Project alerts</Header2>
{alertChannels.length > 0 && !requiresUpgrade && (
<LinkButton
to={v3NewProjectAlertPath(organization, project, environment)}
variant="primary/small"
LeadingIcon={PlusIcon}
shortcut={{ key: "n" }}
>
New alert
</LinkButton>
)}
</div>
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Alert types</TableHeaderCell>
<TableHeaderCell>Channel</TableHeaderCell>
<TableHeaderCell>Enabled</TableHeaderCell>
<TableHeaderCell>Environments</TableHeaderCell>
<TableHeaderCell hiddenLabel>Actions</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{alertChannels.length > 0 ? (
alertChannels.map((alertChannel) => (
<TableRow key={alertChannel.id}>
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
{alertChannel.name}
</TableCell>
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
{alertChannel.alertTypes.map((type) => alertTypeTitle(type)).join(", ")}
</TableCell>
<TableCell className={cn("py-1", alertChannel.enabled ? "" : "opacity-50")}>
<AlertChannelDetails alertChannel={alertChannel} />
</TableCell>
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
<EnabledStatus
enabled={alertChannel.enabled}
enabledIcon={BellAlertIcon}
disabledIcon={BellSlashIcon}
/>
</TableCell>
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
<div className="flex items-center gap-3">
{alertChannel.environmentTypes.map((environmentType) => (
<EnvironmentCombo
key={environmentType}
environment={{ type: environmentType }}
className="text-xs"
/>
))}
</div>
</TableCell>
<TableCellMenu
isSticky
popoverContent={
<>
{alertChannel.enabled ? (
<DisableAlertChannelButton id={alertChannel.id} />
) : (
<EnableAlertChannelButton id={alertChannel.id} />
)}
<DeleteAlertChannelButton id={alertChannel.id} />
</>
}
className={
alertChannel.enabled
? ""
: "group-hover/table-row:bg-background-bright/50"
}
/>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6}>
<div className="flex flex-col items-center justify-center py-6">
<Header2 spacing className="text-text-bright">
You haven't created any project alerts yet
</Header2>
<Paragraph variant="small" className="mb-4">
Get alerted when runs or deployments fail, or when deployments succeed in
both Prod and Staging environments.
</Paragraph>
<LinkButton
to={v3NewProjectAlertPath(organization, project, environment)}
variant="primary/medium"
LeadingIcon={PlusIcon}
shortcut={{ key: "n" }}
>
New alert
</LinkButton>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<div className="flex h-fit items-stretch gap-3">
<div className="flex w-full items-start justify-between">
<div className="flex h-fit w-full items-center gap-4 border-t border-grid-bright bg-background-bright p-[0.86rem] pl-4">
<SimpleTooltip
button={
<div className="size-6">
<svg className="h-full w-full -rotate-90 overflow-visible">
<circle
className="fill-none stroke-grid-bright"
strokeWidth="4"
r="10"
cx="12"
cy="12"
/>
<circle
className={`fill-none ${
requiresUpgrade ? "stroke-error" : "stroke-success"
}`}
strokeWidth="4"
r="10"
cx="12"
cy="12"
strokeDasharray={`${(limits.used / limits.limit) * 62.8} 62.8`}
strokeDashoffset="0"
strokeLinecap="round"
/>
</svg>
</div>
}
content={`${Math.round((limits.used / limits.limit) * 100)}%`}
/>
<div className="flex w-full items-center justify-between gap-6">
{requiresUpgrade ? (
<Header3 className="text-error">
You've used all {limits.limit} of your available alerts. Upgrade your plan
to enable more.
</Header3>
) : (
<Header3>
You've used {limits.used}/{limits.limit} of your alerts.
</Header3>
)}
{showSelfServe ? (
<LinkButton to={v3BillingPath(organization)} variant="secondary/small">
Upgrade
</LinkButton>
) : (
<Feedback
defaultValue="enterprise"
button={<Button variant="secondary/small">Request more</Button>}
/>
)}
</div>
</div>
</div>
</div>
</div>
) : environment.type === "DEVELOPMENT" ? (
<MainCenteredContainer className="max-w-md">
<AlertsNoneDev />
</MainCenteredContainer>
) : (
<MainCenteredContainer className="max-w-md">
<AlertsNoneDeployed />
</MainCenteredContainer>
)}
<Outlet />
</PageBody>
</PageContainer>
);
}
function DeleteAlertChannelButton(props: { id: string }) {
const lastSubmission = useActionData();
const navigation = useNavigation();
const isLoading =
navigation.state !== "idle" &&
navigation.formMethod === "post" &&
navigation.formData?.get("action") === "delete";
const [form] = useForm({
id: "delete-alert-channel",
// TODO: type this
lastResult: lastSubmission as any,
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
shouldRevalidate: "onSubmit",
});
return (
<Form method="post" {...getFormProps(form)}>
<input type="hidden" name="id" value={props.id} />
<Button
name="action"
value="delete"
fullWidth
textAlignLeft
type="submit"
variant="small-menu-item"
LeadingIcon={TrashIcon}
leadingIconClassName="text-rose-500"
className="text-xs"
>
{isLoading ? "Deleting" : "Delete"}
</Button>
</Form>
);
}
function DisableAlertChannelButton(props: { id: string }) {
const lastSubmission = useActionData();
const navigation = useNavigation();
const isLoading =
navigation.state !== "idle" &&
navigation.formMethod === "post" &&
navigation.formData?.get("action") === "delete";
const [form] = useForm({
id: "disable-alert-channel",
// TODO: type this
lastResult: lastSubmission as any,
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
shouldRevalidate: "onSubmit",
});
return (
<Form method="post" {...getFormProps(form)}>
<input type="hidden" name="id" value={props.id} />
<Button
name="action"
value="disable"
type="submit"
fullWidth
textAlignLeft
variant="small-menu-item"
LeadingIcon={BellSlashIcon}
leadingIconClassName="text-dimmed"
className="text-xs"
>
{isLoading ? "Disabling" : "Disable"}
</Button>
</Form>
);
}
function EnableAlertChannelButton(props: { id: string }) {
const lastSubmission = useActionData();
const navigation = useNavigation();
const isLoading =
navigation.state !== "idle" &&
navigation.formMethod === "post" &&
navigation.formData?.get("action") === "delete";
const [form] = useForm({
id: "enable-alert-channel",
// TODO: type this
lastResult: lastSubmission as any,
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
shouldRevalidate: "onSubmit",
});
return (
<Form method="post" {...getFormProps(form)}>
<input type="hidden" name="id" value={props.id} />
<Button
name="action"
value="enable"
type="submit"
fullWidth
textAlignLeft
variant="small-menu-item"
LeadingIcon={BellAlertIcon}
leadingIconClassName="text-success"
className="text-xs"
>
{isLoading ? "Enabling" : "Enable"}
</Button>
</Form>
);
}
function AlertChannelDetails({ alertChannel }: { alertChannel: AlertChannelListPresenterRecord }) {
switch (alertChannel.properties?.type) {
case "EMAIL": {
return (
<DetailCell
leadingIcon={
<AlertChannelTypeIcon
channelType={alertChannel.type}
className="size-5 text-text-dimmed"
/>
}
leadingIconClassName="text-text-dimmed"
label={"Email"}
description={alertChannel.properties.email}
boxClassName="group-hover/table-row:bg-background-bright"
className="h-12"
/>
);
}
case "WEBHOOK": {
return (
<DetailCell
leadingIcon={
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<AlertChannelTypeIcon
channelType={alertChannel.type}
className="size-5 text-text-dimmed"
/>
</TooltipTrigger>
<TooltipContent className="flex items-center gap-1">Webhook</TooltipContent>
</Tooltip>
</TooltipProvider>
}
leadingIconClassName="text-text-dimmed"
label={alertChannel.properties.url}
description={
<ClipboardField
value={alertChannel.properties.secret}
variant="secondary/small"
icon={<LockClosedIcon className="size-4" />}
iconButton
secure={"•".repeat(alertChannel.properties.secret.length)}
className="mt-1 w-80"
/>
}
boxClassName="group-hover/table-row:bg-background-bright"
/>
);
}
case "SLACK": {
return (
<DetailCell
leadingIcon={
<AlertChannelTypeIcon
channelType={alertChannel.type}
className="size-5 text-text-dimmed"
/>
}
leadingIconClassName="text-text-dimmed"
label={"Slack"}
description={`#${alertChannel.properties.channelName}`}
boxClassName="group-hover/table-row:bg-background-bright"
/>
);
}
}
return null;
}
export function alertTypeTitle(alertType: ProjectAlertType): string {
switch (alertType) {
case "TASK_RUN":
return "Task run failure";
case "TASK_RUN_ATTEMPT":
return "Task attempt failure";
case "DEPLOYMENT_FAILURE":
return "Deployment failure";
case "DEPLOYMENT_SUCCESS":
return "Deployment success";
case "ERROR_GROUP":
return "Error group";
default: {
throw new Error(`Unknown alertType: ${alertType}`);
}
}
}
export function AlertChannelTypeIcon({
channelType,
className,
}: {
channelType: ProjectAlertChannelType;
className: string;
}) {
switch (channelType) {
case "EMAIL":
return <EnvelopeIcon className={className} />;
case "SLACK":
return <SlackIcon className={className} />;
case "WEBHOOK":
return <GlobeAltIcon className={className} />;
default: {
assertNever(channelType);
}
}
}
@@ -0,0 +1,223 @@
import { BookOpenIcon } from "@heroicons/react/20/solid";
import { type MetaFunction } from "@remix-run/react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { CodeBlock } from "~/components/code/CodeBlock";
import { InlineCode } from "~/components/code/InlineCode";
import {
EnvironmentCombo,
environmentFullTitle,
environmentTextClassName,
} from "~/components/environments/EnvironmentLabel";
import { RegenerateApiKeyModal } from "~/components/environments/RegenerateApiKeyModal";
import {
MainHorizontallyCenteredContainer,
PageBody,
PageContainer,
} from "~/components/layout/AppLayout";
import { PermissionDenied } from "~/components/PermissionDenied";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "~/components/primitives/Accordion";
import { LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { Header2 } from "~/components/primitives/Headers";
import { Hint } from "~/components/primitives/Hint";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import * as Property from "~/components/primitives/PropertyTable";
import { useOrganization } from "~/hooks/useOrganizations";
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import { ApiKeysPresenter } from "~/presenters/v3/ApiKeysPresenter.server";
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { cn } from "~/utils/cn";
import { docsPath, EnvironmentParamSchema } from "~/utils/pathBuilder";
export const meta: MetaFunction = () => {
return [
{
title: `API keys | Trigger.dev`,
},
];
};
export const loader = dashboardLoader(
{
params: EnvironmentParamSchema,
context: async (params) => {
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
return organizationId ? { organizationId } : {};
},
// No hard authorization: anyone with project access can open the page.
// Reading the secret key is gated per environment tier below — a role
// that can't read this tier's keys gets the info panel, not the key.
},
async ({ params, user, ability }) => {
const { projectParam, envParam } = params;
try {
const presenter = new ApiKeysPresenter();
const { environment, hasVercelIntegration } = await presenter.call({
userId: user.id,
projectSlug: projectParam,
environmentSlug: envParam,
});
const canReadApiKeys =
!environment || ability.can("read", { type: "apiKeys", envType: environment.type });
return typedjson({
// Never serialize the secret key to the client when the role can't
// read it for this environment tier.
environment: environment && !canReadApiKeys ? { ...environment, apiKey: "" } : environment,
hasVercelIntegration,
canReadApiKeys,
});
} catch (error) {
console.error(error);
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, if this problem persists please contact support.",
});
}
}
);
export default function Page() {
const { environment, hasVercelIntegration, canReadApiKeys } = useTypedLoaderData<typeof loader>();
const _organization = useOrganization();
if (!environment) {
throw new Response(undefined, {
status: 404,
statusText: "Environment not found",
});
}
let envBlock = `TRIGGER_SECRET_KEY="${environment.apiKey}"`;
if (environment.branchName) {
envBlock += `\nTRIGGER_PREVIEW_BRANCH="${environment.branchName}"`;
}
return (
<PageContainer>
<NavBar>
<PageTitle title="API keys" />
<PageAccessories>
<AdminDebugTooltip>
<Property.Table>
<Property.Item key={environment.id}>
<Property.Label>{environment.slug}</Property.Label>
<Property.Value>{environment.id}</Property.Value>
</Property.Item>
</Property.Table>
</AdminDebugTooltip>
<LinkButton
variant={"docs/small"}
LeadingIcon={BookOpenIcon}
to={docsPath("/v3/apikeys")}
>
API keys docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody>
<MainHorizontallyCenteredContainer>
<div className="mb-3 border-b border-grid-dimmed pb-1">
<Header2
className={cn(
"inline-flex items-center gap-1 font-normal",
environmentTextClassName(environment)
)}
>
<EnvironmentCombo
environment={environment}
className="text-base"
iconClassName="size-5"
/>
API keys
</Header2>
</div>
{canReadApiKeys ? (
<div className="flex flex-col gap-6">
<InputGroup fullWidth>
<div className="flex w-full items-center justify-between">
<Label>Secret key</Label>
<RegenerateApiKeyModal
id={environment.parentEnvironment?.id ?? environment.id}
title={environmentFullTitle(environment)}
hasVercelIntegration={hasVercelIntegration}
isDevelopment={environment.type === "DEVELOPMENT"}
/>
</div>
<ClipboardField
className="w-full max-w-none"
secure={`tr_${environment.apiKey.split("_")[1]}_••••••••`}
value={environment.apiKey}
variant={"secondary/small"}
/>
<Hint>
Set this as your <InlineCode variant="extra-small">TRIGGER_SECRET_KEY</InlineCode>{" "}
env var in your backend.
</Hint>
</InputGroup>
{environment.branchName && (
<InputGroup fullWidth>
<Label>Branch name</Label>
<ClipboardField
className="w-full max-w-none"
value={environment.branchName}
variant={"secondary/small"}
/>
<Hint>
Set this as your{" "}
<InlineCode variant="extra-small">TRIGGER_PREVIEW_BRANCH</InlineCode> env var in
your backend.
</Hint>
</InputGroup>
)}
{environment.type === "DEVELOPMENT" && (
<Callout variant="info">
Every team member gets their own dev Secret key. Make sure you're using the one
above otherwise you will trigger runs on your team member's machine.
</Callout>
)}
<Accordion type="single" collapsible>
<AccordionItem value="item-1">
<AccordionTrigger>How to set these environment variables</AccordionTrigger>
<AccordionContent>
<div className="flex flex-col gap-2">
<div>
You need to set these environment variables in your backend. This allows the
SDK to authenticate with Trigger.dev.
</div>
<CodeBlock
language="javascript"
code={envBlock}
showOpenInModal={false}
showLineNumbers={false}
/>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
) : (
<PermissionDenied
message={`With your current role, you can't view the API keys for ${environmentFullTitle(
environment
)}.`}
/>
)}
</MainHorizontallyCenteredContainer>
</PageBody>
</PageContainer>
);
}
@@ -0,0 +1,301 @@
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core";
import { motion } from "framer-motion";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { RunsIcon } from "~/assets/icons/RunsIcon";
import { LinkButton } from "~/components/primitives/Buttons";
import { CopyableText } from "~/components/primitives/CopyableText";
import { DateTime } from "~/components/primitives/DateTime";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import { BatchStatusCombo, descriptionForBatchStatus } from "~/components/runs/v3/BatchStatus";
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { BatchPresenter } from "~/presenters/v3/BatchPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { formatNumber } from "~/utils/numberFormatter";
import { EnvironmentParamSchema, v3BatchesPath, v3BatchRunsPath } from "~/utils/pathBuilder";
const BatchParamSchema = EnvironmentParamSchema.extend({
batchParam: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam, batchParam } = BatchParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Not Found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response("Not Found", { status: 404 });
}
try {
const presenter = new BatchPresenter();
const [error, data] = await tryCatch(
presenter.call({
environmentId: environment.id,
batchId: batchParam,
userId,
})
);
if (error) {
throw new Error(error.message);
}
return typedjson({ batch: data });
} catch (error) {
console.error(error);
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, if this problem persists please contact support.",
});
}
};
export default function Page() {
const { batch } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
// Auto-reload when batch is still in progress
useAutoRevalidate({
interval: 1000,
onFocus: true,
disabled: batch.hasFinished,
});
const showProgressMeter =
batch.isV2 && (batch.status === "PROCESSING" || batch.status === "PARTIAL_FAILED");
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_2.5rem_1fr_3.25rem] overflow-hidden bg-background-bright">
{/* Header */}
<div className="mx-3 flex items-center justify-between gap-2 overflow-x-hidden border-b border-grid-dimmed">
<Header2 className={cn("truncate whitespace-nowrap")}>{batch.friendlyId}</Header2>
<LinkButton
to={v3BatchesPath(organization, project, environment)}
variant="minimal/small"
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
className="pl-1"
/>
</div>
{/* Status bar */}
<div className="flex items-center justify-between gap-2 border-b border-grid-dimmed px-3 text-sm">
<BatchStatusCombo status={batch.status} />
<Paragraph variant="extra-small" className="text-text-dimmed">
{descriptionForBatchStatus(batch.status)}
</Paragraph>
</div>
{/* Scrollable content */}
<div className="overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<div className="space-y-3">
{/* Progress meter for v2 batches */}
{showProgressMeter && (
<div className="px-3 pt-3">
<BatchProgressMeter
successCount={batch.successfulRunCount}
failureCount={batch.failedRunCount}
totalCount={batch.runCount}
/>
</div>
)}
{/* Properties */}
<div className="px-3 py-3">
<Property.Table>
<Property.Item>
<Property.Label>ID</Property.Label>
<Property.Value>
<CopyableText value={batch.friendlyId} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Status</Property.Label>
<Property.Value>
<BatchStatusCombo status={batch.status} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Version</Property.Label>
<Property.Value>{batch.isV2 ? "v2 (Run Engine)" : "v1 (Legacy)"}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Total runs</Property.Label>
<Property.Value>{formatNumber(batch.runCount)}</Property.Value>
</Property.Item>
{batch.isV2 && (
<>
<Property.Item>
<Property.Label>Successfully created</Property.Label>
<Property.Value className="text-success">
{formatNumber(batch.successfulRunCount)}
</Property.Value>
</Property.Item>
{batch.failedRunCount > 0 && (
<Property.Item>
<Property.Label>Failed to create</Property.Label>
<Property.Value className="text-error">
{formatNumber(batch.failedRunCount)}
</Property.Value>
</Property.Item>
)}
</>
)}
{batch.idempotencyKey && (
<Property.Item>
<Property.Label>Idempotency key</Property.Label>
<Property.Value>
<CopyableText value={batch.idempotencyKey} className="font-mono text-xs" />
</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Created</Property.Label>
<Property.Value>
<DateTime date={batch.createdAt} />
</Property.Value>
</Property.Item>
{batch.processingStartedAt && (
<Property.Item>
<Property.Label>Processing started</Property.Label>
<Property.Value>
<DateTime date={batch.processingStartedAt} />
</Property.Value>
</Property.Item>
)}
{batch.processingCompletedAt && (
<Property.Item>
<Property.Label>Processing completed</Property.Label>
<Property.Value>
<DateTime date={batch.processingCompletedAt} />
</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Finished</Property.Label>
<Property.Value>
{batch.finishedAt ? <DateTime date={batch.finishedAt} /> : ""}
</Property.Value>
</Property.Item>
</Property.Table>
</div>
{/* Errors section */}
{batch.errors.length > 0 && (
<div className="px-3 pb-3">
<Header3 className="mb-2 flex items-center gap-1.5 text-warning">
<ExclamationTriangleIcon className="size-4" />
Run creation errors ({batch.errors.length})
</Header3>
<div className="divide-y divide-grid-dimmed rounded-md border border-grid-dimmed bg-background-deep">
{batch.errors.map((error) => (
<div key={error.id} className="px-3 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-text-dimmed">
Item #{error.index}
</span>
<span className="text-sm text-text-bright">{error.taskIdentifier}</span>
</div>
{error.errorCode && (
<span className="rounded bg-background-hover px-1.5 py-0.5 font-mono text-xs text-text-dimmed">
{error.errorCode}
</span>
)}
</div>
<Paragraph variant="small" className="mt-1 text-error">
{error.error}
</Paragraph>
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 border-t border-grid-dimmed px-2">
<LinkButton
variant="secondary/medium"
to={v3BatchRunsPath(organization, project, environment, batch)}
trailingIconClassName="text-runs"
TrailingIcon={RunsIcon}
className="text-text-bright"
>
View runs
</LinkButton>
</div>
</div>
);
}
type BatchProgressMeterProps = {
successCount: number;
failureCount: number;
totalCount: number;
};
function BatchProgressMeter({ successCount, failureCount, totalCount }: BatchProgressMeterProps) {
const processedCount = successCount + failureCount;
const successPercentage = totalCount === 0 ? 0 : (successCount / totalCount) * 100;
const failurePercentage = totalCount === 0 ? 0 : (failureCount / totalCount) * 100;
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<Paragraph variant="small/bright">Run creation progress</Paragraph>
<Paragraph variant="extra-small">
{formatNumber(processedCount)}/{formatNumber(totalCount)}
</Paragraph>
</div>
<div className="relative h-4 w-full overflow-hidden rounded-sm bg-background-deep">
<motion.div
className="absolute left-0 top-0 h-full bg-success"
initial={{ width: `${successPercentage}%` }}
animate={{ width: `${successPercentage}%` }}
transition={{ duration: 0.3, ease: "easeOut" }}
/>
<motion.div
className="absolute top-0 h-full bg-error"
initial={{ width: `${failurePercentage}%`, left: `${successPercentage}%` }}
animate={{ width: `${failurePercentage}%`, left: `${successPercentage}%` }}
transition={{ duration: 0.3, ease: "easeOut" }}
/>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<div className="h-2 w-2 rounded-[1px] bg-success" />
<Paragraph variant="extra-small">{formatNumber(successCount)} created</Paragraph>
</div>
{failureCount > 0 && (
<div className="flex items-center gap-1">
<div className="h-2 w-2 rounded-[1px] bg-error" />
<Paragraph variant="extra-small">{formatNumber(failureCount)} failed</Paragraph>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,330 @@
import { ExclamationCircleIcon } from "@heroicons/react/20/solid";
import { BookOpenIcon } from "@heroicons/react/24/solid";
import { type MetaFunction, Outlet, useLocation, useNavigation, useParams } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { formatDuration } from "@trigger.dev/core/v3/utils/durations";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { RunsIcon } from "~/assets/icons/RunsIcon";
import { BatchesNone } from "~/components/BlankStatePanels";
import { ListPagination } from "~/components/ListPagination";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
collapsibleHandleClassName,
RESIZABLE_PANEL_ANIMATION,
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import { Spinner } from "~/components/primitives/Spinner";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
CopyableTableCell,
} from "~/components/primitives/Table";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { BatchFilters, BatchListFilters } from "~/components/runs/v3/BatchFilters";
import {
allBatchStatuses,
BatchStatusCombo,
descriptionForBatchStatus,
} from "~/components/runs/v3/BatchStatus";
import { LiveTimer } from "~/components/runs/v3/LiveTimer";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithErrorMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { type BatchList, BatchListPresenter } from "~/presenters/v3/BatchListPresenter.server";
import { requireUserId } from "~/services/session.server";
import {
$replica,
runOpsNewReplicaClient,
runOpsLegacyReplica,
runOpsSplitReadEnabled,
type PrismaClientOrTransaction,
} from "~/db.server";
import {
docsPath,
EnvironmentParamSchema,
v3BatchPath,
v3BatchRunsPath,
} from "~/utils/pathBuilder";
import { throwNotFound } from "~/utils/httpErrors";
export const meta: MetaFunction = () => {
return [
{
title: `Batches | Trigger.dev`,
},
];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
return redirectWithErrorMessage("/", request, "Project not found");
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throwNotFound("Environment not found");
}
const url = new URL(request.url);
const s = {
cursor: url.searchParams.get("cursor") ?? undefined,
direction: url.searchParams.get("direction") ?? undefined,
statuses: url.searchParams.getAll("statuses"),
period: url.searchParams.get("period") ?? undefined,
from: url.searchParams.get("from") ?? undefined,
to: url.searchParams.get("to") ?? undefined,
id: url.searchParams.get("id") ?? undefined,
};
const filters = BatchListFilters.parse(s);
const presenter = new BatchListPresenter(undefined, undefined, {
runOpsNew: runOpsNewReplicaClient as unknown as PrismaClientOrTransaction,
runOpsLegacyReplica: runOpsLegacyReplica as unknown as PrismaClientOrTransaction,
controlPlaneReplica: $replica as unknown as PrismaClientOrTransaction,
splitEnabled: runOpsSplitReadEnabled,
});
const list = await presenter.call({
userId,
projectId: project.id,
...filters,
friendlyId: filters.id,
environmentId: environment.id,
});
return typedjson(list);
};
export default function Page() {
const { batches, hasFilters, hasAnyBatches, filters, pagination } =
useTypedLoaderData<typeof loader>();
const { batchParam } = useParams();
const isShowingInspector = batchParam !== undefined;
return (
<PageContainer>
<NavBar>
<PageTitle title="Batches" />
<PageAccessories>
<AdminDebugTooltip />
<LinkButton
variant={"docs/small"}
LeadingIcon={BookOpenIcon}
to={docsPath("/triggering")}
>
Batches docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
{!hasAnyBatches ? (
<MainCenteredContainer className="max-w-md">
<BatchesNone />
</MainCenteredContainer>
) : (
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
<ResizablePanel id="batches-main" min={"100px"}>
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
<div className="flex items-start justify-between gap-x-2 p-2">
<BatchFilters hasFilters={hasFilters} />
<div className="flex items-center justify-end gap-x-2">
<ListPagination list={{ pagination }} />
</div>
</div>
<BatchesTable
batches={batches}
filters={filters}
hasFilters={hasFilters}
pagination={pagination}
hasAnyBatches={hasAnyBatches}
/>
</div>
</ResizablePanel>
<ResizableHandle
id="batches-handle"
className={collapsibleHandleClassName(isShowingInspector)}
/>
<ResizablePanel
id="batches-inspector"
min="370px"
default="370px"
className="overflow-hidden"
collapsible
collapsed={!isShowingInspector}
onCollapseChange={() => {}}
collapsedSize="0px"
collapseAnimation={RESIZABLE_PANEL_ANIMATION}
>
<div className="h-full" style={{ minWidth: 370 }}>
<Outlet />
</div>
</ResizablePanel>
</ResizablePanelGroup>
)}
</PageBody>
</PageContainer>
);
}
function BatchesTable({ batches, hasFilters, filters }: BatchList) {
const navigation = useNavigation();
const location = useLocation();
const isLoading =
navigation.state !== "idle" && navigation.location?.pathname === location.pathname;
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { batchParam } = useParams();
return (
<Table className="max-h-full overflow-y-auto">
<TableHeader>
<TableRow>
<TableHeaderCell>ID</TableHeaderCell>
<TableHeaderCell
tooltip={
<div className="flex flex-col divide-y divide-grid-dimmed">
{allBatchStatuses.map((status) => (
<div
key={status}
className="grid grid-cols-[8rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1"
>
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
<BatchStatusCombo status={status} />
</div>
<Paragraph variant="extra-small" className="text-wrap! text-text-dimmed">
{descriptionForBatchStatus(status)}
</Paragraph>
</div>
))}
</div>
}
>
Status
</TableHeaderCell>
<TableHeaderCell>Runs</TableHeaderCell>
<TableHeaderCell>Duration</TableHeaderCell>
<TableHeaderCell>Created</TableHeaderCell>
<TableHeaderCell>Finished</TableHeaderCell>
<TableHeaderCell>
<span className="sr-only">Go to batch</span>
</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{batches.length === 0 ? (
<TableBlankRow colSpan={8}>
<div className="flex items-center justify-center">
<Paragraph className="w-auto">No batches match these filters</Paragraph>
</div>
</TableBlankRow>
) : (
batches.map((batch) => {
const basePath = v3BatchPath(organization, project, environment, batch);
const inspectorPath = `${basePath}${location.search}`;
const runsPath = v3BatchRunsPath(organization, project, environment, batch);
const isSelected = batchParam === batch.friendlyId;
return (
<TableRow key={batch.id} className={isSelected ? "bg-grid-dimmed" : undefined}>
<CopyableTableCell value={batch.friendlyId} to={inspectorPath} isTabbableCell>
{batch.friendlyId}
</CopyableTableCell>
<TableCell to={inspectorPath}>
{batch.batchVersion === "v1" ? (
<SimpleTooltip
content="Upgrade to the latest SDK for batch statuses to appear."
disableHoverableContent
button={
<span className="flex items-center gap-1">
<ExclamationCircleIcon className="size-4 text-text-dimmed" />
<span>Legacy batch</span>
</span>
}
/>
) : (
<SimpleTooltip
content={descriptionForBatchStatus(batch.status)}
disableHoverableContent
button={<BatchStatusCombo status={batch.status} />}
/>
)}
</TableCell>
<TableCell to={inspectorPath}>{batch.runCount}</TableCell>
<TableCell
to={inspectorPath}
className="w-[1%]"
actionClassName="pr-0 tabular-nums"
>
{batch.finishedAt ? (
formatDuration(new Date(batch.createdAt), new Date(batch.finishedAt), {
style: "short",
})
) : (
<LiveTimer startTime={new Date(batch.createdAt)} />
)}
</TableCell>
<TableCell to={inspectorPath}>
<DateTime date={batch.createdAt} />
</TableCell>
<TableCell to={inspectorPath}>
{batch.finishedAt ? <DateTime date={batch.finishedAt} /> : ""}
</TableCell>
<BatchActionsCell runsPath={runsPath} />
</TableRow>
);
})
)}
{isLoading && (
<TableBlankRow
colSpan={8}
className="absolute left-0 top-0 flex h-full w-full items-center justify-center gap-2 bg-background-dimmed/90"
>
<Spinner /> <span className="text-text-dimmed">Loading</span>
</TableBlankRow>
)}
</TableBody>
</Table>
);
}
function BatchActionsCell({ runsPath }: { runsPath: string }) {
return (
<TableCellMenu
isSticky
hiddenButtons={
<LinkButton
to={runsPath}
variant="minimal/small"
TrailingIcon={RunsIcon}
trailingIconClassName="text-runs"
className="text-text-bright"
>
<span>View runs</span>
</LinkButton>
}
/>
);
}
@@ -0,0 +1,873 @@
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { ArrowUpCircleIcon, CheckIcon, EnvelopeIcon, PlusIcon } from "@heroicons/react/20/solid";
import { BookOpenIcon } from "@heroicons/react/24/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import { useFetcher, useSearchParams } from "@remix-run/react";
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core/v3";
import { useCallback, useEffect, useState } from "react";
import { SearchInput } from "~/components/primitives/SearchInput";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
import { BranchesNoBranchableEnvironment, BranchesNoBranches } from "~/components/BlankStatePanels";
import { Feedback } from "~/components/Feedback";
import { GitMetadata } from "~/components/GitMetadata";
import { V4Title } from "~/components/V4Badge";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Badge } from "~/components/primitives/Badge";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { CopyableText } from "~/components/primitives/CopyableText";
import { DateTime } from "~/components/primitives/DateTime";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTrigger,
} from "~/components/primitives/Dialog";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header3 } from "~/components/primitives/Headers";
import { InputGroup } from "~/components/primitives/InputGroup";
import { InputNumberStepper } from "~/components/primitives/InputNumberStepper";
import { Label } from "~/components/primitives/Label";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
import { PopoverMenuItem } from "~/components/primitives/Popover";
import * as Property from "~/components/primitives/PropertyTable";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import { Switch } from "~/components/primitives/Switch";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { findProjectBySlug } from "~/models/project.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { BranchesPresenter } from "~/presenters/v3/BranchesPresenter.server";
import { logger } from "~/services/logger.server";
import { getCurrentPlan, getSelfServePurchaseBlockReason } from "~/services/platform.v3.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import {
branchesPath,
docsPath,
EnvironmentParamSchema,
ProjectParamSchema,
v3BillingPath,
} from "~/utils/pathBuilder";
import { formatCurrency, formatNumber } from "~/utils/numberFormatter";
import { SetBranchesAddOnService } from "~/v3/services/setBranchesAddOn.server";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { ArchiveButton } from "../resources.branches.archive";
import { NewBranchPanel } from "~/routes/resources.branches.create";
import { BranchesOptions } from "~/utils/branches";
import { IconArrowBearRight2 } from "@tabler/icons-react";
const PurchaseSchema = z.discriminatedUnion("action", [
z.object({
action: z.literal("purchase"),
amount: z.coerce.number().int("Must be a whole number").min(0, "Amount must be 0 or more"),
}),
z.object({
action: z.literal("quota-increase"),
amount: z.coerce.number().int("Must be a whole number").min(1, "Amount must be greater than 0"),
}),
]);
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam } = ProjectParamSchema.parse(params);
const searchParams = new URL(request.url).searchParams;
const parsedSearchParams = BranchesOptions.safeParse(Object.fromEntries(searchParams));
const options = parsedSearchParams.success ? parsedSearchParams.data : {};
try {
const presenter = new BranchesPresenter();
const result = await presenter.call({
userId,
projectSlug: projectParam,
env: "preview",
...options,
});
return typedjson(result);
} catch (error) {
logger.error("Error loading preview branches page", { error });
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, if this problem persists please contact support.",
});
}
};
export async function action({ request, params }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const formData = await request.formData();
const formType = formData.get("_formType");
if (formType === "purchase-branches") {
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
const redirectPath = branchesPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam }
);
if (!project) {
throw await redirectWithErrorMessage(redirectPath, request, "Project not found");
}
const currentPlan = await getCurrentPlan(project.organizationId);
const purchaseBlockReason = getSelfServePurchaseBlockReason(currentPlan);
if (purchaseBlockReason === "plan_unavailable") {
return json(
{ ok: false, error: "Unable to verify billing status. Please try again." } as const,
{ status: 503 }
);
}
if (purchaseBlockReason === "managed_billing") {
return json({ ok: false, error: "Contact us to request more branches." } as const, {
status: 403,
});
}
const submission = parseWithZod(formData, { schema: PurchaseSchema });
if (submission.status !== "success") {
return json(submission.reply());
}
const service = new SetBranchesAddOnService();
const [error, result] = await tryCatch(
service.call({
userId,
organizationId: project.organizationId,
action: submission.value.action,
amount: submission.value.amount,
})
);
if (error) {
return json(
submission.reply({
fieldErrors: { amount: [error instanceof Error ? error.message : "Unknown error"] },
})
);
}
if (!result.success) {
return json(submission.reply({ fieldErrors: { amount: [result.error] } }));
}
return json({ ok: true } as const);
}
// Branch creation is handled by the `resources.branches.create` resource
// route; this action only services the purchase flow above.
return json({ ok: false, error: "Unsupported action" } as const, { status: 400 });
}
export default function Page() {
const {
branchableEnvironment,
branches,
hasFilters: _hasFilters,
limits,
currentPage,
totalPages,
hasBranches,
canPurchaseBranches,
extraBranches,
branchPricing,
maxBranchQuota,
planBranchLimit,
} = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const plan = useCurrentPlan();
const showSelfServe = useShowSelfServe();
const requiresUpgrade =
plan?.v3Subscription?.plan &&
limits.used >= plan.v3Subscription.plan.limits.branches.number &&
!plan.v3Subscription.plan.limits.branches.canExceed;
const canUpgrade =
plan?.v3Subscription?.plan && !plan.v3Subscription.plan.limits.branches.canExceed;
const atBranchLimit = limits.used >= limits.limit;
const usageRatio = limits.limit > 0 ? Math.min(limits.used / limits.limit, 1) : 0;
if (!branchableEnvironment) {
return (
<PageContainer>
<NavBar>
<PageTitle title={<V4Title>Preview branches</V4Title>} />
</NavBar>
<PageBody>
<MainCenteredContainer className="max-w-md">
<BranchesNoBranchableEnvironment showSelfServe={showSelfServe} />
</MainCenteredContainer>
</PageBody>
</PageContainer>
);
}
return (
<PageContainer>
<NavBar>
<PageTitle title={<V4Title>Preview branches</V4Title>} />
<PageAccessories>
<AdminDebugTooltip>
<Property.Table>
{branches.map((branch) => (
<Property.Item key={branch.id}>
<Property.Label>{branch.branchName}</Property.Label>
<Property.Value>{branch.id}</Property.Value>
</Property.Item>
))}
</Property.Table>
</AdminDebugTooltip>
<LinkButton
variant={"docs/small"}
LeadingIcon={BookOpenIcon}
to={docsPath("deployment/preview-branches")}
>
Branches docs
</LinkButton>
{limits.isAtLimit ? (
<UpgradePanel
limits={limits}
canUpgrade={canUpgrade ?? false}
canPurchaseBranches={canPurchaseBranches}
branchPricing={branchPricing}
extraBranches={extraBranches}
maxBranchQuota={maxBranchQuota}
planBranchLimit={planBranchLimit}
/>
) : (
<NewBranchPanel
button={
<Button
variant="primary/small"
shortcut={{ key: "n" }}
LeadingIcon={PlusIcon}
leadingIconClassName="text-white"
fullWidth
textAlignLeft
>
New branch
</Button>
}
env="preview"
/>
)}
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<div className="grid max-h-full min-h-full grid-rows-[auto_1fr_auto]">
{!hasBranches ? (
<MainCenteredContainer className="max-w-md">
<BranchesNoBranches
env="preview"
limits={limits}
canUpgrade={canUpgrade ?? false}
showSelfServe={showSelfServe}
/>
</MainCenteredContainer>
) : (
<>
<div className="flex items-center justify-between gap-x-1.5 p-2">
<BranchFilters />
<PaginationControls
currentPage={currentPage}
totalPages={totalPages}
showPageNumbers={false}
/>
</div>
<div className="grid max-h-full min-h-full grid-rows-[1fr] overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Branch</TableHeaderCell>
<TableHeaderCell>Created</TableHeaderCell>
<TableHeaderCell>Git</TableHeaderCell>
<TableHeaderCell>Archived</TableHeaderCell>
<TableHeaderCell>
<span className="sr-only">Actions</span>
</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{branches.length === 0 ? (
<TableBlankRow colSpan={5}>
<Paragraph>There are no matches for your filters</Paragraph>
</TableBlankRow>
) : (
branches.map((branch) => {
const path = branchesPath(organization, project, branch);
const cellClass = branch.archivedAt ? "opacity-50" : "";
const isSelected = branch.id === environment.id;
return (
<TableRow key={branch.id}>
<TableCell isTabbableCell className={cellClass}>
<div className="flex items-center gap-1">
<BranchEnvironmentIconSmall
className={cn("size-4", isSelected && "text-preview")}
/>
<CopyableText
value={branch.branchName ?? ""}
className={cn(isSelected && "text-preview")}
/>
{isSelected && <Badge variant="extra-small">Current</Badge>}
</div>
</TableCell>
<TableCell className={cellClass}>
<DateTime date={branch.createdAt} />
</TableCell>
<TableCell className={cellClass}>
<div className="-ml-1 flex items-center">
<GitMetadata git={branch.git} />
</div>
</TableCell>
<TableCell className={cellClass}>
{branch.archivedAt ? (
<CheckIcon className="size-4 text-text-dimmed" />
) : (
""
)}
</TableCell>
<TableCellMenu
className="pl-32"
isSticky
hiddenButtons={
isSelected ? null : (
<LinkButton
to={path}
variant="secondary/small"
LeadingIcon={IconArrowBearRight2}
leadingIconClassName="text-blue-500 -mr-2"
className="pl-1.5"
>
Switch to branch
</LinkButton>
)
}
popoverContent={
!isSelected || !branch.archivedAt ? (
<>
{isSelected ? null : (
<PopoverMenuItem
to={path}
icon={IconArrowBearRight2}
leadingIconClassName="text-blue-500 -mr-0.5 -ml-1"
title="Switch to branch"
/>
)}
{!branch.archivedAt ? (
<ArchiveButton environment={branch} />
) : null}
</>
) : null
}
/>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
<div className="flex w-full items-start justify-between">
<div className="flex h-fit w-full items-center gap-4 border-t border-grid-bright bg-background-bright p-[0.86rem] pl-4">
<SimpleTooltip
button={
<div className="size-6">
<svg className="h-full w-full -rotate-90 overflow-visible">
<circle
className="fill-none stroke-grid-bright"
strokeWidth="4"
r="10"
cx="12"
cy="12"
/>
<circle
className={`fill-none ${
atBranchLimit ? "stroke-error" : "stroke-success"
}`}
strokeWidth="4"
r="10"
cx="12"
cy="12"
strokeDasharray={`${usageRatio * 62.8} 62.8`}
strokeDashoffset="0"
strokeLinecap="round"
/>
</svg>
</div>
}
content={`${Math.round(usageRatio * 100)}%`}
/>
<div className="flex w-full items-center justify-between gap-6">
{requiresUpgrade ? (
<Header3 className="text-error">
You've used all {limits.limit} of your branches. Archive one or upgrade your
plan to enable more.
</Header3>
) : (
<div className="flex items-center gap-1">
<Header3 className={atBranchLimit ? "text-error" : undefined}>
You've used {limits.used}/{limits.limit} of your branches
</Header3>
<InfoIconTooltip content="Archived branches don't count towards your limit." />
</div>
)}
{canPurchaseBranches && branchPricing ? (
<PurchaseBranchesModal
branchPricing={branchPricing}
extraBranches={extraBranches}
activeBranches={limits.used}
maxQuota={maxBranchQuota}
planBranchLimit={planBranchLimit}
/>
) : canUpgrade ? (
showSelfServe ? (
<div className="flex items-center gap-3">
<Paragraph variant="small" className="whitespace-nowrap text-text-dimmed">
Upgrade plan for more Preview Branches
</Paragraph>
<LinkButton
to={v3BillingPath(organization)}
variant="secondary/small"
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="text-indigo-500"
>
Upgrade
</LinkButton>
</div>
) : (
<Feedback
defaultValue="enterprise"
button={<Button variant="secondary/small">Request more</Button>}
/>
)
) : null}
</div>
</div>
</div>
</>
)}
</div>
</PageBody>
</PageContainer>
);
}
export function BranchFilters() {
const [searchParams, setSearchParams] = useSearchParams();
const { showArchived } = BranchesOptions.parse(Object.fromEntries(searchParams.entries()));
const handleArchivedChange = useCallback((checked: boolean) => {
setSearchParams((s) => {
if (checked) {
s.set("showArchived", "true");
} else {
s.delete("showArchived");
}
s.delete("page");
return s;
});
}, []);
return (
<div className="flex w-full items-center justify-between gap-2">
<SearchInput placeholder="Search branch name…" resetParams={["page"]} />
<Switch
checked={showArchived ?? false}
onCheckedChange={handleArchivedChange}
label="Show archived"
variant="secondary/small"
/>
</div>
);
}
function UpgradePanel({
limits,
canUpgrade,
canPurchaseBranches,
branchPricing,
extraBranches,
maxBranchQuota,
planBranchLimit,
}: {
limits: {
used: number;
limit: number;
};
canUpgrade: boolean;
canPurchaseBranches: boolean;
branchPricing: { stepSize: number; centsPerStep: number } | null;
extraBranches: number;
maxBranchQuota: number;
planBranchLimit: number;
}) {
const organization = useOrganization();
const showSelfServe = useShowSelfServe();
if (canPurchaseBranches && branchPricing) {
return (
<PurchaseBranchesModal
branchPricing={branchPricing}
extraBranches={extraBranches}
activeBranches={limits.used}
maxQuota={maxBranchQuota}
planBranchLimit={planBranchLimit}
triggerButton={
<Button
LeadingIcon={PlusIcon}
leadingIconClassName="text-white"
variant="primary/small"
shortcut={{ key: "n" }}
>
Purchase more
</Button>
}
/>
);
}
return (
<Dialog>
<DialogTrigger asChild>
<Button
LeadingIcon={PlusIcon}
leadingIconClassName="text-white"
variant="primary/small"
shortcut={{ key: "n" }}
>
New branch
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>You've exceeded your limit</DialogHeader>
<div className="mt-2">
<Paragraph spacing>
You've used {limits.used}/{limits.limit} of your branches.
</Paragraph>
<Paragraph>You can archive one or upgrade your plan for more.</Paragraph>
</div>
<DialogFooter>
{canUpgrade ? (
showSelfServe ? (
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
Upgrade
</LinkButton>
) : (
<Feedback
defaultValue="enterprise"
button={<Button variant="secondary/small">Request more</Button>}
/>
)
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function PurchaseBranchesModal({
branchPricing,
extraBranches,
activeBranches,
maxQuota,
planBranchLimit,
triggerButton,
}: {
branchPricing: {
stepSize: number;
centsPerStep: number;
};
extraBranches: number;
activeBranches: number;
maxQuota: number;
planBranchLimit: number;
triggerButton?: React.ReactNode;
}) {
const showSelfServe = useShowSelfServe();
const fetcher = useFetcher();
const lastSubmission =
fetcher.data && typeof fetcher.data === "object" && "status" in fetcher.data
? fetcher.data
: undefined;
const [form, { amount }] = useForm({
id: "purchase-branches",
lastResult: lastSubmission as any,
onValidate({ formData }) {
return parseWithZod(formData, { schema: PurchaseSchema });
},
shouldRevalidate: "onSubmit",
});
const [amountValue, setAmountValue] = useState(extraBranches);
useEffect(() => {
setAmountValue(extraBranches);
}, [extraBranches]);
const isLoading = fetcher.state !== "idle";
const [open, setOpen] = useState(false);
useEffect(() => {
const data = fetcher.data;
if (
fetcher.state === "idle" &&
data !== null &&
typeof data === "object" &&
"ok" in data &&
data.ok
) {
setOpen(false);
}
}, [fetcher.state, fetcher.data]);
const state = updateBranchState({
value: amountValue,
existingValue: extraBranches,
quota: maxQuota,
activeBranches,
planBranchLimit,
});
const changeClassName =
state === "decrease" ? "text-error" : state === "increase" ? "text-success" : undefined;
const pricePerBranch = branchPricing.centsPerStep / branchPricing.stepSize / 100;
const title = extraBranches === 0 ? "Purchase extra branches…" : "Add/remove extra branches…";
if (!showSelfServe) {
return (
<Feedback
defaultValue="enterprise"
button={<Button variant="secondary/small">Request more</Button>}
/>
);
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{triggerButton ?? (
<Button variant="primary/small" onClick={() => setOpen(true)}>
{title}
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>{title}</DialogHeader>
<fetcher.Form method="post" {...getFormProps(form)}>
<input type="hidden" name="_formType" value="purchase-branches" />
<div className="flex flex-col gap-4 pt-2">
<div className="flex flex-col gap-1">
<Paragraph variant="small/bright">
Purchase extra preview branches at {formatCurrency(pricePerBranch, false)}/month per
branch. Reducing the number of branches will take effect at the start of the next
billing cycle (1st of the month).
</Paragraph>
</div>
<Fieldset>
<InputGroup fullWidth>
<Label htmlFor="amount" className="text-text-dimmed">
Total extra branches
</Label>
<InputNumberStepper
{...getInputProps(amount, { type: "number" })}
step={branchPricing.stepSize}
min={0}
max={undefined}
value={amountValue}
onChange={(e) => setAmountValue(Number(e.target.value))}
disabled={isLoading}
/>
<FormError id={amount.errorId}>{amount.errors}</FormError>
<FormError>{form.errors}</FormError>
</InputGroup>
</Fieldset>
{state === "need_to_archive" ? (
<div className="flex flex-col pb-3">
<Paragraph variant="small" className="text-warning" spacing>
You need to archive{" "}
{formatNumber(activeBranches - (planBranchLimit + amountValue))} more{" "}
{activeBranches - (planBranchLimit + amountValue) === 1 ? "branch" : "branches"}{" "}
before you can reduce to this level.
</Paragraph>
</div>
) : state === "above_quota" ? (
<div className="flex flex-col pb-3">
<Paragraph variant="small" className="text-warning" spacing>
Currently you can only have up to {maxQuota} extra preview branches. Send a
request below to lift your current limit. We'll get back to you soon.
</Paragraph>
</div>
) : (
<div className="flex flex-col pb-3 tabular-nums">
<div className="grid grid-cols-2 border-b border-grid-dimmed pb-1">
<Header3 className="font-normal text-text-dimmed">Summary</Header3>
<Header3 className="justify-self-end font-normal text-text-dimmed">Total</Header3>
</div>
<div className="grid grid-cols-2 pt-2">
<Header3 className="pb-0 font-normal text-text-dimmed">
<span className="text-text-bright">{formatNumber(extraBranches)}</span> current
extra
</Header3>
<Header3 className="justify-self-end font-normal text-text-bright">
{formatCurrency(extraBranches * pricePerBranch, true)}
</Header3>
</div>
<div className="grid grid-cols-2 text-xs">
<span className="text-text-dimmed">
({extraBranches} {extraBranches === 1 ? "branch" : "branches"})
</span>
<span className="justify-self-end text-text-dimmed">/mth</span>
</div>
<div className="grid grid-cols-2 pt-2">
<Header3 className={cn("pb-0 font-normal", changeClassName)}>
{state === "increase" ? "+" : null}
{formatNumber(amountValue - extraBranches)}
</Header3>
<Header3 className={cn("justify-self-end font-normal", changeClassName)}>
{state === "increase" ? "+" : null}
{formatCurrency((amountValue - extraBranches) * pricePerBranch, true)}
</Header3>
</div>
<div className="grid grid-cols-2 text-xs">
<span className="text-text-dimmed">
({Math.abs(amountValue - extraBranches)}{" "}
{Math.abs(amountValue - extraBranches) === 1 ? "branch" : "branches"} @{" "}
{formatCurrency(pricePerBranch, true)}/mth)
</span>
<span className="justify-self-end text-text-dimmed">/mth</span>
</div>
<div className="grid grid-cols-2 pt-2">
<Header3 className="pb-0 font-normal text-text-dimmed">
<span className="text-text-bright">{formatNumber(amountValue)}</span> new total
</Header3>
<Header3 className="justify-self-end font-normal text-text-bright">
{formatCurrency(amountValue * pricePerBranch, true)}
</Header3>
</div>
<div className="grid grid-cols-2 text-xs">
<span className="text-text-dimmed">
({amountValue} {amountValue === 1 ? "branch" : "branches"})
</span>
<span className="justify-self-end text-text-dimmed">/mth</span>
</div>
</div>
)}
</div>
<FormButtons
confirmButton={
state === "above_quota" ? (
<>
<input type="hidden" name="action" value="quota-increase" />
<Button
LeadingIcon={isLoading ? SpinnerWhite : EnvelopeIcon}
variant="primary/medium"
type="submit"
disabled={isLoading}
>
<span className="tabular-nums text-text-bright">{`Send request for ${formatNumber(
amountValue
)}`}</span>
</Button>
</>
) : state === "decrease" || state === "need_to_archive" ? (
<>
<input type="hidden" name="action" value="purchase" />
<Button
variant="danger/medium"
type="submit"
disabled={isLoading || state === "need_to_archive"}
LeadingIcon={isLoading ? SpinnerWhite : undefined}
>
<span className="tabular-nums text-text-bright">{`Remove ${formatNumber(
extraBranches - amountValue
)} ${extraBranches - amountValue === 1 ? "branch" : "branches"}`}</span>
</Button>
</>
) : (
<>
<input type="hidden" name="action" value="purchase" />
<Button
variant="primary/medium"
type="submit"
disabled={isLoading || state === "no_change"}
LeadingIcon={isLoading ? SpinnerWhite : undefined}
>
<span className="tabular-nums text-text-bright">{`Purchase ${formatNumber(
amountValue - extraBranches
)} ${amountValue - extraBranches === 1 ? "branch" : "branches"}`}</span>
</Button>
</>
)
}
cancelButton={
<DialogClose asChild>
<Button variant="secondary/medium" disabled={isLoading}>
Cancel
</Button>
</DialogClose>
}
/>
</fetcher.Form>
</DialogContent>
</Dialog>
);
}
function updateBranchState({
value,
existingValue,
quota,
activeBranches,
planBranchLimit,
}: {
value: number;
existingValue: number;
quota: number;
activeBranches: number;
planBranchLimit: number;
}): "no_change" | "increase" | "decrease" | "above_quota" | "need_to_archive" {
if (value === existingValue) return "no_change";
if (value < existingValue) {
const newTotalLimit = planBranchLimit + value;
if (activeBranches > newTotalLimit) {
return "need_to_archive";
}
return "decrease";
}
if (value > quota) return "above_quota";
return "increase";
}
@@ -0,0 +1,381 @@
import { ArrowPathIcon } from "@heroicons/react/20/solid";
import { NoSymbolIcon } from "@heroicons/react/24/solid";
import { tryCatch } from "@trigger.dev/core";
import type { BulkActionType } from "@trigger.dev/database";
import { motion } from "framer-motion";
import { useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { RunsIcon } from "~/assets/icons/RunsIcon";
import { BulkActionFilterSummary } from "~/components/BulkActionFilterSummary";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { CopyableText } from "~/components/primitives/CopyableText";
import { DateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { Header2 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import { AbortBulkActionDialog } from "~/components/runs/v3/AbortBulkActionDialog";
import { BulkActionStatusCombo, BulkActionTypeCombo } from "~/components/runs/v3/BulkAction";
import { UserAvatar } from "~/components/UserProfilePhoto";
import { env } from "~/env.server";
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { BulkActionPresenter } from "~/presenters/v3/BulkActionPresenter.server";
import { logger } from "~/services/logger.server";
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { checkPermissions } from "~/services/routeBuilders/permissions.server";
import { cn } from "~/utils/cn";
import { formatNumber } from "~/utils/numberFormatter";
import {
EnvironmentParamSchema,
v3BulkActionPath,
v3BulkActionsPath,
v3CreateBulkActionPath,
v3RunsPath,
} from "~/utils/pathBuilder";
import { BulkActionService } from "~/v3/services/bulk/BulkActionV2.server";
const BulkActionParamSchema = EnvironmentParamSchema.extend({
bulkActionParam: z.string(),
});
export const loader = dashboardLoader(
{
params: BulkActionParamSchema,
context: async (params) => {
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
return organizationId ? { organizationId } : {};
},
authorization: { action: "read", resource: { type: "runs" } },
},
async ({ params, user, ability }) => {
const { organizationSlug, projectParam, envParam, bulkActionParam } = params;
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
throw new Response("Not Found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
if (!environment) {
throw new Response("Not Found", { status: 404 });
}
try {
const presenter = new BulkActionPresenter();
const [error, data] = await tryCatch(
presenter.call({
environmentId: environment.id,
bulkActionId: bulkActionParam,
})
);
if (error) {
throw new Error(error.message);
}
const autoReloadPollIntervalMs = env.BULK_ACTION_AUTORELOAD_POLL_INTERVAL_MS;
// Display flag for the Abort button — the action enforces write:runs.
const { canAbort } = checkPermissions(ability, {
canAbort: { action: "write", resource: { type: "runs" } },
});
return typedjson({ bulkAction: data, autoReloadPollIntervalMs, canAbort });
} catch (error) {
console.error(error);
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, if this problem persists please contact support.",
});
}
}
);
export const action = dashboardAction(
{
params: BulkActionParamSchema,
context: async (params) => {
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
return organizationId ? { organizationId } : {};
},
authorization: { action: "write", resource: { type: "runs" } },
},
async ({ request, params, user }) => {
const { organizationSlug, projectParam, envParam, bulkActionParam } = params;
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
throw new Response("Not Found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
if (!environment) {
throw new Response("Not Found", { status: 404 });
}
const service = new BulkActionService();
const [error, _result] = await tryCatch(service.abort(bulkActionParam, environment.id));
if (error) {
logger.error("Failed to abort bulk action", {
error,
});
return redirectWithErrorMessage(
v3BulkActionPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam },
{ friendlyId: bulkActionParam }
),
request,
`Failed to abort bulk action: ${error.message}`
);
}
return redirectWithSuccessMessage(
v3BulkActionPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam },
{ friendlyId: bulkActionParam }
),
request,
"Bulk action aborted"
);
}
);
export default function Page() {
const { bulkAction, autoReloadPollIntervalMs, canAbort } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
useAutoRevalidate({
interval: autoReloadPollIntervalMs,
onFocus: true,
disabled: bulkAction.status !== "PENDING",
});
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_2.5rem_1fr_3.25rem] overflow-hidden bg-background-bright">
<div className="mx-3 flex items-center justify-between gap-2 overflow-x-hidden border-b border-grid-dimmed">
<Header2 className={cn("truncate whitespace-nowrap")}>
{bulkAction.name || bulkAction.friendlyId}
</Header2>
<LinkButton
to={v3BulkActionsPath(organization, project, environment)}
variant="minimal/small"
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
className="pl-1"
/>
</div>
<div className="flex items-center justify-between gap-2 border-b border-grid-dimmed px-3 text-sm">
<BulkActionStatusCombo status={bulkAction.status} />
{bulkAction.status === "PENDING" ? (
<ControlledAbortBulkActionDialog
canAbort={canAbort}
formAction={v3BulkActionPath(organization, project, environment, bulkAction)}
/>
) : null}
</div>
<div className="overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<div className="space-y-3">
<div className="px-3 pt-3">
<Meter
type={bulkAction.type}
successCount={bulkAction.successCount}
failureCount={bulkAction.failureCount}
totalCount={bulkAction.totalCount}
/>
</div>
<div className="px-3 pb-3">
<Property.Table>
<Property.Item>
<Property.Label>ID</Property.Label>
<Property.Value>
<CopyableText value={bulkAction.friendlyId} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Bulk action</Property.Label>
<Property.Value>
<BulkActionTypeCombo type={bulkAction.type} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>User</Property.Label>
<Property.Value>
{bulkAction.user ? (
<div className="flex items-center gap-1">
<UserAvatar
name={bulkAction.user.name}
avatarUrl={bulkAction.user.avatarUrl}
className="h-4 w-4"
/>
<Paragraph variant="extra-small">{bulkAction.user.name}</Paragraph>
</div>
) : (
""
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Created</Property.Label>
<Property.Value>
<DateTime date={bulkAction.createdAt} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Completed</Property.Label>
<Property.Value>
{bulkAction.completedAt ? <DateTime date={bulkAction.completedAt} /> : ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Summary</Property.Label>
<Property.Value>
<BulkActionFilterSummary
selected={bulkAction.totalCount}
mode={bulkAction.mode}
action={bulkAction.type === "REPLAY" ? "replay" : "cancel"}
filters={bulkAction.filters}
final={true}
/>
</Property.Value>
</Property.Item>
</Property.Table>
</div>
</div>
</div>
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-2">
<LinkButton
to={v3CreateBulkActionPath(
organization,
project,
environment,
{
bulkId: bulkAction.friendlyId,
},
undefined,
"replay"
)}
variant="tertiary/medium"
LeadingIcon={ArrowPathIcon}
leadingIconClassName="text-indigo-500"
>
Replay runs
</LinkButton>
<LinkButton
variant="tertiary/medium"
to={v3RunsPath(organization, project, environment, {
bulkId: bulkAction.friendlyId,
})}
LeadingIcon={RunsIcon}
leadingIconClassName="text-indigo-500"
>
View runs
</LinkButton>
</div>
</div>
);
}
type MeterProps = {
type: BulkActionType;
successCount: number;
failureCount: number;
totalCount: number;
};
function Meter({ type, successCount, failureCount, totalCount }: MeterProps) {
const successPercentage = totalCount === 0 ? 0 : (successCount / totalCount) * 100;
const failurePercentage = totalCount === 0 ? 0 : (failureCount / totalCount) * 100;
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<Paragraph variant="small/bright">Runs</Paragraph>
<Paragraph variant="extra-small">
{formatNumber(successCount + failureCount)}/{formatNumber(totalCount)}
</Paragraph>
</div>
<div className="relative h-4 w-full overflow-hidden rounded-sm bg-background-deep">
<motion.div
className="absolute left-0 top-0 h-full w-full bg-success"
initial={{ width: `${successPercentage}%` }}
animate={{ width: `${successPercentage}%` }}
transition={{ duration: 0.3, ease: "easeOut" }}
/>
<motion.div
className="absolute top-0 h-full w-full bg-surface-control-hover"
initial={{ width: `${failurePercentage}%`, left: `${successPercentage}%` }}
animate={{ width: `${failurePercentage}%`, left: `${successPercentage}%` }}
transition={{ duration: 0.3, ease: "easeOut" }}
/>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<div className="h-2 w-2 rounded-[1px] bg-success" />
<Paragraph variant="extra-small">
{formatNumber(successCount)} {typeText(type)} successfully
</Paragraph>
</div>
<div className="flex items-center gap-1">
<div className="h-2 w-2 rounded-[1px] bg-surface-control-hover" />
<Paragraph variant="extra-small">
{formatNumber(failureCount)} {typeText(type)} failed{" "}
{type === "CANCEL" ? " (already finished)" : ""}
</Paragraph>
</div>
</div>
</div>
);
}
function typeText(type: BulkActionType) {
switch (type) {
case "CANCEL":
return "canceled";
case "REPLAY":
return "replayed";
}
}
function ControlledAbortBulkActionDialog({
canAbort,
formAction,
}: {
canAbort: boolean;
formAction: string;
}) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="danger/small"
LeadingIcon={NoSymbolIcon}
disabled={!canAbort}
tooltip={canAbort ? undefined : "You don't have permission to abort bulk actions"}
>
Abort
</Button>
</DialogTrigger>
<AbortBulkActionDialog formAction={formAction} onAbortSubmitted={() => setOpen(false)} />
</Dialog>
);
}
@@ -0,0 +1,392 @@
import { BookOpenIcon, PlusIcon } from "@heroicons/react/20/solid";
import { NoSymbolIcon } from "@heroicons/react/24/solid";
import { Outlet, useParams, type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { BulkActionsNone } from "~/components/BlankStatePanels";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
RESIZABLE_PANEL_ANIMATION,
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
collapsibleHandleClassName,
} from "~/components/primitives/Resizable";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue";
import { AbortBulkActionDialog } from "~/components/runs/v3/AbortBulkActionDialog";
import { BulkActionStatusCombo, BulkActionTypeCombo } from "~/components/runs/v3/BulkAction";
import { UserAvatar } from "~/components/UserProfilePhoto";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import {
type BulkActionListItem,
BulkActionListPresenter,
} from "~/presenters/v3/BulkActionListPresenter.server";
import { rbac } from "~/services/rbac.server";
import { checkPermissions } from "~/services/routeBuilders/permissions.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import {
docsPath,
EnvironmentParamSchema,
v3BulkActionPath,
v3CreateBulkActionPath,
} from "~/utils/pathBuilder";
export const meta: MetaFunction = () => {
return [
{
title: `Bulk actions | Trigger.dev`,
},
];
};
const SearchParamsSchema = z.object({
page: z.coerce.number().optional(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Not Found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response("Not Found", { status: 404 });
}
try {
const url = new URL(request.url);
const { page } = SearchParamsSchema.parse(Object.fromEntries(url.searchParams));
const presenter = new BulkActionListPresenter();
const [error, data] = await tryCatch(
presenter.call({
environmentId: environment.id,
page,
})
);
if (error) {
throw new Error(error.message);
}
// Display flag for the row-menu Abort control. The abort action route
// enforces write:runs independently. Permissive in OSS.
const bulkAuth = await rbac.authenticateSession(request, {
userId,
organizationId: project.organizationId,
});
const { canAbort } = bulkAuth.ok
? checkPermissions(bulkAuth.ability, {
canAbort: { action: "write", resource: { type: "runs" } },
})
: { canAbort: true };
return typedjson({ ...data, canAbort });
} catch (error) {
console.error(error);
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, if this problem persists please contact support.",
});
}
};
export default function Page() {
const {
bulkActions,
currentPage,
totalPages,
totalCount: _totalCount,
canAbort,
} = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { bulkActionParam } = useParams();
const isShowingInspector = bulkActionParam !== undefined;
return (
<PageContainer>
<NavBar>
<PageTitle title="Bulk actions" />
<PageAccessories>
<AdminDebugTooltip />
<LinkButton
variant={"docs/small"}
LeadingIcon={BookOpenIcon}
to={docsPath("/bulk-actions")}
>
Bulk actions docs
</LinkButton>
<LinkButton
variant="primary/small"
LeadingIcon={PlusIcon}
to={v3CreateBulkActionPath(organization, project, environment)}
shortcut={{
key: "n",
}}
>
New bulk action
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
{bulkActions.length === 0 ? (
<MainCenteredContainer className="max-w-prose">
<BulkActionsNone />
</MainCenteredContainer>
) : (
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
<ResizablePanel id="bulk-actions-main" min={"100px"}>
<div
className={cn(
"grid max-h-full min-h-full overflow-x-auto",
totalPages > 1 ? "grid-rows-[auto_1fr_auto]" : "grid-rows-[1fr]"
)}
>
{totalPages > 1 && (
<div className="flex items-center justify-end gap-x-2 p-2">
<PaginationControls
currentPage={currentPage}
totalPages={totalPages}
showPageNumbers={false}
/>
</div>
)}
<BulkActionsTable
bulkActions={bulkActions}
totalPages={totalPages}
canAbort={canAbort}
/>
{totalPages > 1 && (
<div
className={cn(
"flex min-h-full",
totalPages > 1 && "justify-end border-t border-grid-dimmed px-2 py-3"
)}
>
<PaginationControls currentPage={currentPage} totalPages={totalPages} />
</div>
)}
</div>
</ResizablePanel>
<ResizableHandle
id="bulk-actions-handle"
className={collapsibleHandleClassName(isShowingInspector)}
/>
<ResizablePanel
id="bulk-actions-inspector"
default="500px"
min="500px"
max="800px"
className="overflow-hidden"
collapsible
collapsed={!isShowingInspector}
onCollapseChange={() => {}}
collapsedSize="0px"
collapseAnimation={RESIZABLE_PANEL_ANIMATION}
>
<div className="h-full" style={{ minWidth: 500 }}>
<Outlet />
</div>
</ResizablePanel>
</ResizablePanelGroup>
)}
</PageBody>
</PageContainer>
);
}
function BulkActionsTable({
bulkActions,
totalPages,
canAbort,
}: {
bulkActions: BulkActionListItem[];
totalPages: number;
canAbort: boolean;
}) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { bulkActionParam } = useParams();
return (
<Table containerClassName={cn(totalPages === 1 && "border-t-0")}>
<TableHeader>
<TableRow>
<TableHeaderCell>ID</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell
tooltip={
<div className="flex flex-col divide-y divide-grid-dimmed">
<div className="grid grid-cols-[8rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1">
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
<BulkActionStatusCombo status="PENDING" />
</div>
<Paragraph variant="extra-small" className="text-wrap! text-text-dimmed">
The bulk action is currently in progress. They can take some time if there are
lots of runs.
</Paragraph>
</div>
<div className="grid grid-cols-[8rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1">
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
<BulkActionStatusCombo status="COMPLETED" />
</div>
<Paragraph variant="extra-small" className="text-wrap! text-text-dimmed">
The bulk action has completed successfully.
</Paragraph>
</div>
<div className="grid grid-cols-[8rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1">
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
<BulkActionStatusCombo status="ABORTED" />
</div>
<Paragraph variant="extra-small" className="text-wrap! text-text-dimmed">
The bulk action was aborted.
</Paragraph>
</div>
</div>
}
>
Status
</TableHeaderCell>
<TableHeaderCell>Bulk action</TableHeaderCell>
<TableHeaderCell>Runs</TableHeaderCell>
<TableHeaderCell>User</TableHeaderCell>
<TableHeaderCell>Created</TableHeaderCell>
<TableHeaderCell>Completed</TableHeaderCell>
<TableHeaderCell>
<span className="sr-only">Actions</span>
</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{bulkActions.length === 0 ? (
<TableBlankRow colSpan={9}>There are no matching bulk actions</TableBlankRow>
) : (
bulkActions.map((bulkAction) => {
const path = v3BulkActionPath(organization, project, environment, bulkAction);
const isSelected = bulkActionParam === bulkAction.friendlyId;
return (
<TableRow
key={bulkAction.friendlyId}
className={isSelected ? "bg-grid-dimmed" : undefined}
>
<TableCell to={path} isTabbableCell>
<TruncatedCopyableValue value={bulkAction.friendlyId} />
</TableCell>
<TableCell to={path}>{bulkAction.name || ""}</TableCell>
<TableCell to={path}>
<BulkActionStatusCombo status={bulkAction.status} />
</TableCell>
<TableCell to={path}>
<BulkActionTypeCombo type={bulkAction.type} />
</TableCell>
<TableCell to={path}>{bulkAction.totalCount}</TableCell>
<TableCell to={path}>
{bulkAction.user ? (
<div className="flex items-center gap-1">
<UserAvatar
name={bulkAction.user.name}
avatarUrl={bulkAction.user.avatarUrl}
className="h-4 w-4"
/>
<Paragraph variant="extra-small">{bulkAction.user.name}</Paragraph>
</div>
) : (
""
)}
</TableCell>
<TableCell to={path}>
<DateTime date={bulkAction.createdAt} />
</TableCell>
<TableCell to={path}>
{bulkAction.completedAt ? <DateTime date={bulkAction.completedAt} /> : ""}
</TableCell>
<BulkActionActionsCell bulkAction={bulkAction} path={path} canAbort={canAbort} />
</TableRow>
);
})
)}
</TableBody>
</Table>
);
}
function BulkActionActionsCell({
bulkAction,
path,
canAbort,
}: {
bulkAction: BulkActionListItem;
path: string;
canAbort: boolean;
}) {
// Abort is the only action, and only while the bulk action is still running.
if (bulkAction.status !== "PENDING") {
return <TableCell to={path}>{""}</TableCell>;
}
return (
<TableCellMenu
isSticky
hiddenButtons={
canAbort ? (
<Dialog>
<DialogTrigger asChild>
<Button
variant="minimal/small"
LeadingIcon={NoSymbolIcon}
leadingIconClassName="text-error"
>
<span className="text-text-bright">Abort</span>
</Button>
</DialogTrigger>
<AbortBulkActionDialog formAction={path} />
</Dialog>
) : (
<Button
variant="minimal/small"
LeadingIcon={NoSymbolIcon}
leadingIconClassName="text-error"
disabled
tooltip="You don't have permission to abort bulk actions"
>
<span className="text-text-bright">Abort</span>
</Button>
)
}
/>
);
}
@@ -0,0 +1,885 @@
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import {
ArrowDownIcon,
EnvelopeIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
} from "@heroicons/react/20/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import {
Form,
useActionData,
useNavigation,
useSearchParams,
type MetaFunction,
} from "@remix-run/react";
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core";
import { useEffect, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import simplur from "simplur";
import { z } from "zod";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
import { Feedback } from "~/components/Feedback";
import {
MainHorizontallyCenteredContainer,
PageBody,
PageContainer,
} from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { InputNumberStepper } from "~/components/primitives/InputNumberStepper";
import { Label } from "~/components/primitives/Label";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import {
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
import { useFeatures } from "~/hooks/useFeatures";
import { useOrganization } from "~/hooks/useOrganizations";
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import {
ManageConcurrencyPresenter,
type ConcurrencyResult,
type EnvironmentWithConcurrency,
} from "~/presenters/v3/ManageConcurrencyPresenter.server";
import {
getCurrentPlan,
getPlans,
getSelfServePurchaseBlockReason,
} from "~/services/platform.v3.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { formatCurrency, formatNumber } from "~/utils/numberFormatter";
import { concurrencyPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder";
import { AllocateConcurrencyService } from "~/v3/services/allocateConcurrency.server";
import { SetConcurrencyAddOnService } from "~/v3/services/setConcurrencyAddOn.server";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
export const meta: MetaFunction = () => {
return [
{
title: `Manage concurrency | Trigger.dev`,
},
];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const {
organizationSlug,
projectParam,
envParam: _envParam,
} = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response(undefined, {
status: 404,
statusText: "Project not found",
});
}
const presenter = new ManageConcurrencyPresenter();
const [error, result] = await tryCatch(
presenter.call({
userId: userId,
projectId: project.id,
organizationId: project.organizationId,
})
);
if (error) {
throw new Response(undefined, {
status: 400,
statusText: error.message,
});
}
const plans = await tryCatch(getPlans());
if (!plans) {
throw new Response(null, { status: 404, statusText: "Plans not found" });
}
return typedjson(result);
};
const FormSchema = z.discriminatedUnion("action", [
z.object({
action: z.enum(["purchase"]),
amount: z.coerce.number().min(0, "Amount must be 0 or more"),
}),
z.object({
action: z.enum(["quota-increase"]),
amount: z.coerce.number().min(1, "Amount must be greater than 0"),
}),
z.object({
action: z.enum(["allocate"]),
// It will only update environments that are passed in
environments: z.array(
z.object({
id: z.string(),
amount: z.coerce.number().min(0, "Amount must be 0 or more"),
})
),
}),
]);
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
const redirectPath = concurrencyPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam }
);
if (!project) {
throw await redirectWithErrorMessage(redirectPath, request, "Project not found");
}
const formData = await request.formData();
const submission = parseWithZod(formData, { schema: FormSchema });
if (submission.status !== "success") {
return json(submission.reply());
}
if (submission.value.action === "allocate") {
const allocate = new AllocateConcurrencyService();
const [error, result] = await tryCatch(
allocate.call({
userId,
projectId: project.id,
organizationId: project.organizationId,
environments: submission.value.environments,
})
);
if (error) {
return json(
submission.reply({
fieldErrors: {
environments: [error instanceof Error ? error.message : "Unknown error"],
},
})
);
}
if (!result.success) {
return json(submission.reply({ fieldErrors: { environments: [result.error] } }));
}
return redirectWithSuccessMessage(
`${redirectPath}?success=true`,
request,
"Concurrency allocated successfully"
);
}
const currentPlan = await getCurrentPlan(project.organizationId);
const purchaseBlockReason = getSelfServePurchaseBlockReason(currentPlan);
if (purchaseBlockReason === "plan_unavailable") {
return json(
submission.reply({
fieldErrors: { amount: ["Unable to verify billing status. Please try again."] },
}),
{ status: 503 }
);
}
if (purchaseBlockReason === "managed_billing") {
return json(
submission.reply({ fieldErrors: { amount: ["Contact us to request more concurrency."] } }),
{ status: 403 }
);
}
const service = new SetConcurrencyAddOnService();
const [error, result] = await tryCatch(
service.call({
userId,
projectId: project.id,
organizationId: project.organizationId,
action: submission.value.action,
amount: submission.value.amount,
})
);
if (error) {
return json(
submission.reply({
fieldErrors: { amount: [error instanceof Error ? error.message : "Unknown error"] },
})
);
}
if (!result.success) {
return json(submission.reply({ fieldErrors: { amount: [result.error] } }));
}
return redirectWithSuccessMessage(
`${redirectPath}?success=true`,
request,
submission.value.action === "purchase"
? "Concurrency updated successfully"
: "Requested extra concurrency, we'll get back to you soon."
);
};
export default function Page() {
const {
canAddConcurrency,
extraConcurrency,
extraAllocatedConcurrency,
extraUnallocatedConcurrency,
environments,
concurrencyPricing,
maxQuota,
} = useTypedLoaderData<typeof loader>();
return (
<PageContainer>
<NavBar>
<PageTitle title="Concurrency" />
<PageAccessories>
<AdminDebugTooltip>
<Property.Table>
{environments.map((environment) => (
<Property.Item key={environment.id}>
<Property.Label>
{environment.type}{" "}
{environment.branchName ? ` (${environment.branchName})` : ""}
</Property.Label>
<Property.Value>{environment.id}</Property.Value>
</Property.Item>
))}
</Property.Table>
</AdminDebugTooltip>
</PageAccessories>
</NavBar>
<PageBody scrollable={true}>
<MainHorizontallyCenteredContainer>
{canAddConcurrency ? (
<Upgradable
canAddConcurrency={canAddConcurrency}
extraConcurrency={extraConcurrency}
extraAllocatedConcurrency={extraAllocatedConcurrency}
extraUnallocatedConcurrency={extraUnallocatedConcurrency}
environments={environments}
concurrencyPricing={concurrencyPricing}
maxQuota={maxQuota}
/>
) : (
<NotUpgradable environments={environments} />
)}
</MainHorizontallyCenteredContainer>
</PageBody>
</PageContainer>
);
}
function initialAllocation(environments: ConcurrencyResult["environments"]) {
return new Map<string, number>(
environments
.filter((e) => e.type !== "DEVELOPMENT")
.map((e) => [e.id, Math.max(0, e.maximumConcurrencyLimit - e.planConcurrencyLimit)])
);
}
function allocationTotal(environments: ConcurrencyResult["environments"]) {
const allocation = initialAllocation(environments);
return Array.from(allocation.values()).reduce((e, acc) => e + acc, 0);
}
function Upgradable({
extraConcurrency,
extraAllocatedConcurrency,
extraUnallocatedConcurrency,
environments,
concurrencyPricing,
maxQuota,
}: ConcurrencyResult) {
const lastSubmission = useActionData();
const [form, fields] = useForm({
id: "allocate-concurrency",
// TODO: type this
lastResult: lastSubmission as any,
onValidate({ formData }) {
return parseWithZod(formData, { schema: FormSchema });
},
shouldRevalidate: "onSubmit",
});
const { environments: formEnvironments } = fields;
const navigation = useNavigation();
const isLoading = navigation.state !== "idle" && navigation.formMethod === "POST";
const [allocation, setAllocation] = useState(initialAllocation(environments));
const allocatedInProject = Array.from(allocation.values()).reduce((e, acc) => e + acc, 0);
const initialAllocationInProject = allocationTotal(environments);
const changeInAllocation = allocatedInProject - initialAllocationInProject;
const unallocated = extraUnallocatedConcurrency - changeInAllocation;
const allocationModified = changeInAllocation !== 0;
return (
<div className="flex flex-col gap-3">
<div className="border-b border-grid-dimmed pb-1">
<Header2>Manage your concurrency</Header2>
</div>
<Paragraph variant="small">
Concurrency limits determine how many runs you can execute at the same time. You can add
extra concurrency to your organization which you can allocate to environments in your
projects.
</Paragraph>
<div className="mt-3 flex flex-col gap-6">
<div className="flex flex-col gap-2">
<div className="flex items-center first-letter:pb-1">
<Header3 className="grow">Extra concurrency</Header3>
<PurchaseConcurrencyModal
concurrencyPricing={concurrencyPricing}
extraConcurrency={extraConcurrency}
extraUnallocatedConcurrency={extraUnallocatedConcurrency}
maxQuota={maxQuota}
disabled={unallocated < 0 ? false : allocationModified}
/>
</div>
<Table variant="bright/no-hover">
<TableBody>
<TableRow>
<TableCell className="pl-0 text-text-bright">Extra concurrency purchased</TableCell>
<TableCell alignment="right" className="tabular-nums text-text-bright">
{extraConcurrency}
</TableCell>
</TableRow>
<TableRow>
<TableCell>Allocated concurrency</TableCell>
<TableCell alignment="right" className={"tabular-nums text-text-bright"}>
{allocationModified ? (
<>
<span className="text-text-dimmed line-through">
{extraAllocatedConcurrency}
</span>{" "}
{extraAllocatedConcurrency + changeInAllocation}
</>
) : (
extraAllocatedConcurrency
)}
</TableCell>
</TableRow>
<TableRow>
<TableCell>Unallocated concurrency</TableCell>
<TableCell
alignment="right"
className={cn(
"tabular-nums",
unallocated > 0
? "text-success"
: unallocated < 0
? "text-error"
: "text-text-bright"
)}
>
{allocationModified ? (
<>
<span className="text-text-dimmed line-through">
{extraUnallocatedConcurrency}
</span>{" "}
{extraUnallocatedConcurrency - changeInAllocation}
</>
) : (
extraUnallocatedConcurrency
)}
</TableCell>
</TableRow>
<TableRow
className={
allocationModified || unallocated > 0 ? undefined : "after:bg-transparent"
}
>
<TableCell
colSpan={2}
className={cn("py-0", (unallocated > 0 || allocationModified) && "pr-0")}
>
<div className="flex h-10 items-center">
{allocationModified ? (
unallocated < 0 ? (
<div className="flex items-center gap-1">
<ExclamationTriangleIcon className="size-4 text-error" />
<span className="text-error">
You're trying to allocate more concurrency than your total purchased
amount.
</span>
</div>
) : (
<div className="flex w-full items-center justify-between gap-3">
<div className="flex items-center gap-1">
<InformationCircleIcon className="size-4 text-text-dimmed" />
<span>
Save your changes or{" "}
<button
className="inline text-indigo-500 hover:text-indigo-300"
onClick={() => {
setAllocation(initialAllocation(environments));
}}
>
reset
</button>
.
</span>
</div>
<Button
variant="primary/small"
type="submit"
form="allocate"
disabled={unallocated < 0 || isLoading}
LeadingIcon={isLoading ? SpinnerWhite : undefined}
>
Save
</Button>
</div>
)
) : unallocated > 0 ? (
<div className="flex h-full w-full items-center justify-between bg-success/10 px-2.5">
<div className="flex items-center justify-start gap-1">
<InformationCircleIcon className="size-4 text-success" />
<span className="text-success">
You have {unallocated} extra concurrency available to allocate below.
</span>
</div>
<ArrowDownIcon className="size-4 animate-bounce text-success" />
</div>
) : (
<></>
)}
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
<FormError id={formEnvironments.id}>{formEnvironments.errors}</FormError>
</div>
<Form className="flex flex-col gap-2" method="post" {...getFormProps(form)} id="allocate">
<input type="hidden" name="action" value="allocate" />
<div className="flex items-center pb-1">
<Header3 className="grow">Concurrency allocation</Header3>
</div>
<Table variant="bright/no-hover">
<TableHeader>
<TableRow>
<TableHeaderCell className="pl-0">Environment</TableHeaderCell>
<TableHeaderCell alignment="right">
<span className="flex items-center justify-end gap-x-1">
Included{" "}
<InfoIconTooltip content="This is the included concurrency based on your plan." />
</span>
</TableHeaderCell>
<TableHeaderCell alignment="right">Extra concurrency</TableHeaderCell>
<TableHeaderCell alignment="right">Total</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{environments.map((environment, index) => (
<TableRow key={environment.id}>
<TableCell>
<EnvironmentCombo
environment={environment}
className="max-w-[18ch]"
tooltipSideOffset={6}
tooltipSide="top"
/>
</TableCell>
<TableCell alignment="right">{environment.planConcurrencyLimit}</TableCell>
<TableCell alignment="right">
<div className="flex items-center justify-end">
{environment.type === "DEVELOPMENT" ? (
Math.max(
0,
environment.maximumConcurrencyLimit - environment.planConcurrencyLimit
)
) : (
<>
<input
type="hidden"
name={`environments[${index}].id`}
value={environment.id}
/>
<Input
name={`environments[${index}].amount`}
type="number"
variant="outline/small"
className="text-right"
containerClassName="w-16"
fullWidth={false}
value={allocation.get(environment.id)}
onChange={(e) => {
const value = e.target.value === "" ? 0 : Number(e.target.value);
setAllocation(new Map(allocation).set(environment.id, value));
}}
min={0}
/>
</>
)}
</div>
</TableCell>
<TableCell alignment="right">
{environment.planConcurrencyLimit + (allocation.get(environment.id) ?? 0)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Form>
</div>
</div>
);
}
function NotUpgradable({ environments }: { environments: EnvironmentWithConcurrency[] }) {
const { isManagedCloud } = useFeatures();
const plan = useCurrentPlan();
const organization = useOrganization();
const showSelfServe = useShowSelfServe();
return (
<div className="flex flex-col gap-3">
<div className="border-b border-grid-dimmed pb-1">
<Header2>Your concurrency</Header2>
</div>
{isManagedCloud ? (
<>
<Paragraph variant="small">
Concurrency limits determine how many runs you can execute at the same time. You can
upgrade your plan to get more concurrency. You are currently on the{" "}
{plan?.v3Subscription?.plan?.title ?? "Free"} plan.
</Paragraph>
{showSelfServe ? (
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
Upgrade for more concurrency
</LinkButton>
) : (
<Feedback
defaultValue="enterprise"
button={<Button variant="secondary/small">Contact us</Button>}
/>
)}
</>
) : null}
<div className="mt-3 flex flex-col gap-3">
<Table variant="bright/no-hover">
<TableHeader>
<TableRow>
<TableHeaderCell className="pl-0">Environment</TableHeaderCell>
<TableHeaderCell alignment="right">Concurrency limit</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{environments.map((environment) => (
<TableRow key={environment.id}>
<TableCell className="pl-0">
<EnvironmentCombo environment={environment} />
</TableCell>
<TableCell alignment="right">{environment.maximumConcurrencyLimit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}
function PurchaseConcurrencyModal({
concurrencyPricing,
extraConcurrency,
extraUnallocatedConcurrency,
maxQuota,
disabled,
}: {
concurrencyPricing: {
stepSize: number;
centsPerStep: number;
};
extraConcurrency: number;
extraUnallocatedConcurrency: number;
maxQuota: number;
disabled: boolean;
}) {
const showSelfServe = useShowSelfServe();
const lastSubmission = useActionData();
const [form, fields] = useForm({
id: "purchase-concurrency",
// TODO: type this
lastResult: lastSubmission as any,
onValidate({ formData }) {
return parseWithZod(formData, { schema: FormSchema });
},
shouldRevalidate: "onSubmit",
});
const { amount } = fields;
const [amountValue, setAmountValue] = useState(extraConcurrency);
const navigation = useNavigation();
const isLoading = navigation.state !== "idle" && navigation.formMethod === "POST";
// Close the panel, when we've succeeded
// This is required because a redirect to the same path doesn't clear state
const [searchParams, setSearchParams] = useSearchParams();
const [open, setOpen] = useState(false);
useEffect(() => {
const success = searchParams.get("success");
if (success) {
setOpen(false);
setSearchParams((s) => {
s.delete("success");
return s;
});
}
}, [searchParams.get("success")]);
const state = updateState({
value: amountValue,
existingValue: extraConcurrency,
quota: maxQuota,
extraUnallocatedConcurrency,
});
const changeClassName =
state === "decrease" ? "text-error" : state === "increase" ? "text-success" : undefined;
const title = extraConcurrency === 0 ? "Purchase extra concurrency" : "Add/remove concurrency";
if (!showSelfServe) {
return (
<Feedback
defaultValue="enterprise"
button={<Button variant="secondary/small">Request more</Button>}
/>
);
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="primary/small"
disabled={disabled}
onClick={() => {
setOpen(true);
}}
>
{title}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>{title}</DialogHeader>
<Form method="post" {...getFormProps(form)}>
<div className="flex flex-col gap-4 pt-2">
<Paragraph variant="base/bright" spacing>
You can purchase bundles of {concurrencyPricing.stepSize} concurrency for{" "}
{formatCurrency(concurrencyPricing.centsPerStep / 100, false)}/month. Or you can
remove any extra concurrency after you have unallocated it from your environments
first.
</Paragraph>
<Fieldset>
<InputGroup fullWidth>
<Label htmlFor="amount" className="text-text-dimmed">
Total extra concurrency
</Label>
<InputNumberStepper
{...getInputProps(amount, { type: "number" })}
step={concurrencyPricing.stepSize}
min={0}
max={undefined}
value={amountValue}
onChange={(e) => setAmountValue(Number(e.target.value))}
disabled={isLoading}
/>
<FormError id={amount.errorId}>{amount.errors}</FormError>
<FormError>{form.errors}</FormError>
</InputGroup>
</Fieldset>
{state === "need_to_increase_unallocated" ? (
<div className="flex flex-col pb-3">
<Paragraph variant="small" className="text-warning" spacing>
You need to unallocate{" "}
{formatNumber(extraConcurrency - amountValue - extraUnallocatedConcurrency)} more
concurrency from your environments in order to remove{" "}
{formatNumber(extraConcurrency - amountValue)} concurrency from your account.
</Paragraph>
</div>
) : state === "above_quota" ? (
<div className="flex flex-col pb-3">
<Paragraph variant="small" className="text-warning" spacing>
Currently you can only have up to {maxQuota} extra concurrency. Send a request
below to lift your current limit. We'll get back to you soon.
</Paragraph>
</div>
) : (
<div className="flex flex-col pb-3 tabular-nums">
<div className="grid grid-cols-2 border-b border-grid-dimmed pb-1">
<Header3 className="font-normal text-text-dimmed">Summary</Header3>
<Header3 className="justify-self-end font-normal text-text-dimmed">Total</Header3>
</div>
<div className="grid grid-cols-2 pt-2">
<Header3 className="pb-0 font-normal text-text-dimmed">
<span className="text-text-bright">{formatNumber(extraConcurrency)}</span>{" "}
current total
</Header3>
<Header3 className="justify-self-end font-normal text-text-bright">
{formatCurrency(
(extraConcurrency * concurrencyPricing.centsPerStep) /
concurrencyPricing.stepSize /
100,
true
)}
</Header3>
</div>
<div className="grid grid-cols-2 text-xs">
<span className="text-text-dimmed">
({simplur`${extraConcurrency / concurrencyPricing.stepSize} bundle[|s]`})
</span>
<span className="justify-self-end text-text-dimmed">/mth</span>
</div>
<div className="grid grid-cols-2 pt-2">
<Header3 className={cn("pb-0 font-normal", changeClassName)}>
{state === "increase" ? "+" : null}
{formatNumber(amountValue - extraConcurrency)}
</Header3>
<Header3 className={cn("justify-self-end font-normal", changeClassName)}>
{state === "increase" ? "+" : null}
{formatCurrency(
((amountValue - extraConcurrency) * concurrencyPricing.centsPerStep) /
concurrencyPricing.stepSize /
100,
true
)}
</Header3>
</div>
<div className="grid grid-cols-2 text-xs">
<span className="text-text-dimmed">
(
{simplur`${
(amountValue - extraConcurrency) / concurrencyPricing.stepSize
} bundle[|s]`}{" "}
@ {formatCurrency(concurrencyPricing.centsPerStep / 100, true)}/mth)
</span>
<span className="justify-self-end text-text-dimmed">/mth</span>
</div>
<div className="grid grid-cols-2 pt-2">
<Header3 className="pb-0 font-normal text-text-dimmed">
<span className="text-text-bright">{formatNumber(amountValue)}</span> new total
</Header3>
<Header3 className="justify-self-end font-normal text-text-bright">
{formatCurrency(
(amountValue * concurrencyPricing.centsPerStep) /
concurrencyPricing.stepSize /
100,
true
)}
</Header3>
</div>
<div className="grid grid-cols-2 text-xs">
<span className="text-text-dimmed">
({simplur`${amountValue / concurrencyPricing.stepSize} bundle[|s]`})
</span>
<span className="justify-self-end text-text-dimmed">/mth</span>
</div>
</div>
)}
</div>
<FormButtons
confirmButton={
state === "above_quota" ? (
<>
<input type="hidden" name="action" value="quota-increase" />
<Button
LeadingIcon={isLoading ? SpinnerWhite : EnvelopeIcon}
variant="primary/medium"
type="submit"
disabled={isLoading}
>
{`Send request for ${formatNumber(amountValue)}`}
</Button>
</>
) : state === "decrease" || state === "need_to_increase_unallocated" ? (
<>
<input type="hidden" name="action" value="purchase" />
<Button
variant="danger/medium"
type="submit"
disabled={isLoading || state === "need_to_increase_unallocated"}
LeadingIcon={isLoading ? SpinnerWhite : undefined}
>
{`Remove ${formatNumber(extraConcurrency - amountValue)} concurrency`}
</Button>
</>
) : (
<>
<input type="hidden" name="action" value="purchase" />
<Button
variant="primary/medium"
type="submit"
disabled={isLoading || state === "no_change"}
LeadingIcon={isLoading ? SpinnerWhite : undefined}
>
{`Purchase ${formatNumber(amountValue - extraConcurrency)} concurrency`}
</Button>
</>
)
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium" disabled={isLoading}>
Cancel
</Button>
</DialogClose>
}
/>
</Form>
</DialogContent>
</Dialog>
);
}
function updateState({
value,
existingValue,
quota,
extraUnallocatedConcurrency,
}: {
value: number;
existingValue: number;
quota: number;
extraUnallocatedConcurrency: number;
}): "no_change" | "increase" | "decrease" | "above_quota" | "need_to_increase_unallocated" {
if (value === existingValue) return "no_change";
if (value < existingValue) {
const difference = existingValue - value;
if (difference > extraUnallocatedConcurrency) {
return "need_to_increase_unallocated";
}
return "decrease";
}
if (value > quota) return "above_quota";
return "increase";
}
@@ -0,0 +1,440 @@
import { XMarkIcon } from "@heroicons/react/20/solid";
import { type LoaderFunctionArgs } from "@remix-run/node";
import { Form } from "@remix-run/react";
import type { TaskTriggerSource } from "@trigger.dev/database";
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import ReactGridLayout from "react-grid-layout";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { LogsTaskFilter } from "~/components/logs/LogsTaskFilter";
import { ModelsFilter, type ModelOption } from "~/components/metrics/ModelsFilter";
import { OperationsFilter } from "~/components/metrics/OperationsFilter";
import { PromptsFilter } from "~/components/metrics/PromptsFilter";
import { ProvidersFilter } from "~/components/metrics/ProvidersFilter";
import { type WidgetData } from "~/components/metrics/QueryWidget";
import { QueuesFilter } from "~/components/metrics/QueuesFilter";
import { ScopeFilter } from "~/components/metrics/ScopeFilter";
import { TitleWidget } from "~/components/metrics/TitleWidget";
import { CreateDashboardPageButton } from "~/components/navigation/DashboardDialogs";
import { Button } from "~/components/primitives/Buttons";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { TimeFilter } from "~/components/runs/v3/SharedFilters";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { getTaskIdentifiers } from "~/models/task.server";
import {
type BuiltInDashboardFilter,
type LayoutItem,
type Widget,
MetricDashboardPresenter,
} from "~/presenters/v3/MetricDashboardPresenter.server";
import { PromptPresenter } from "~/presenters/v3/PromptPresenter.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { requireUser } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { QueryScopeSchema } from "~/v3/querySchemas";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { MetricWidget } from "../resources.metric";
const ParamSchema = EnvironmentParamSchema.extend({
dashboardKey: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { projectParam, organizationSlug, envParam, dashboardKey } = ParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
throw new Response(undefined, {
status: 404,
statusText: "Project not found",
});
}
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
if (!environment) {
throw new Response(undefined, {
status: 404,
statusText: "Environment not found",
});
}
const presenter = new MetricDashboardPresenter();
const [dashboard, possibleTasks] = await Promise.all([
presenter.builtInDashboard({
organizationId: project.organizationId,
key: dashboardKey,
}),
getTaskIdentifiers(environment.id),
]);
const filters = dashboard.filters ?? ["tasks", "queues"];
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
project.organizationId,
"standard"
);
// Load distinct models from ClickHouse if the dashboard has a models filter
let possibleModels: { model: string; system: string }[] = [];
if (filters.includes("models")) {
const queryFn = clickhouse.reader.query({
name: "getDistinctModels",
query: `SELECT response_model, any(gen_ai_system) AS gen_ai_system FROM trigger_dev.llm_metrics_v1 WHERE organization_id = {organizationId: String} AND project_id = {projectId: String} AND environment_id = {environmentId: String} AND response_model != '' GROUP BY response_model ORDER BY response_model`,
params: z.object({
organizationId: z.string(),
projectId: z.string(),
environmentId: z.string(),
}),
schema: z.object({ response_model: z.string(), gen_ai_system: z.string() }),
});
const [error, rows] = await queryFn({
organizationId: project.organizationId,
projectId: project.id,
environmentId: environment.id,
});
if (!error) {
possibleModels = rows.map((r) => ({ model: r.response_model, system: r.gen_ai_system }));
}
}
const promptPresenter = new PromptPresenter(clickhouse);
const [possiblePrompts, possibleOperations, possibleProviders] = await Promise.all([
filters.includes("prompts")
? promptPresenter.getDistinctPromptSlugs(project.organizationId, project.id, environment.id)
: ([] as string[]),
filters.includes("operations")
? promptPresenter.getDistinctOperations(project.organizationId, project.id, environment.id)
: ([] as string[]),
filters.includes("providers")
? promptPresenter.getDistinctProviders(project.organizationId, project.id, environment.id)
: ([] as string[]),
]);
return typedjson({
...dashboard,
filters,
possibleTasks,
possibleModels,
possiblePrompts,
possibleOperations,
possibleProviders,
});
};
export default function Page() {
const {
key,
title,
layout: dashboardLayout,
defaultPeriod,
filters,
possibleTasks,
possibleModels,
possiblePrompts,
possibleOperations,
possibleProviders,
} = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
return (
<PageContainer>
<NavBar>
<PageTitle title={title} />
</NavBar>
<PageBody scrollable={false}>
<div className="h-full">
<MetricDashboard
key={key}
layout={dashboardLayout.layout}
widgets={dashboardLayout.widgets}
defaultPeriod={defaultPeriod}
editable={false}
filters={filters}
possibleTasks={possibleTasks}
possibleModels={possibleModels}
possiblePrompts={possiblePrompts}
possibleOperations={possibleOperations}
possibleProviders={possibleProviders}
filterAccessories={
<CreateDashboardPageButton
organization={organization}
project={project}
environment={environment}
shortcut={{ key: "n" }}
/>
}
/>
</div>
</PageBody>
</PageContainer>
);
}
export function MetricDashboard({
layout,
widgets,
defaultPeriod,
editable,
filters: filterConfig,
possibleTasks,
possibleModels,
possiblePrompts,
possibleOperations,
possibleProviders,
onLayoutChange,
onEditWidget,
onRenameWidget,
onDeleteWidget,
onDuplicateWidget,
filterAccessories,
}: {
/** The layout items (positions/sizes) - fully controlled from parent */
layout: LayoutItem[];
/** The widget configurations keyed by widget ID - fully controlled from parent */
widgets: Record<string, Widget>;
defaultPeriod: string;
editable: boolean;
/** Which filters to show. Defaults to ["tasks", "queues"]. */
filters?: BuiltInDashboardFilter[];
/** Possible tasks for filtering */
possibleTasks?: {
slug: string;
triggerSource: TaskTriggerSource;
isInLatestDeployment: boolean;
}[];
/** Possible models for filtering */
possibleModels?: ModelOption[];
/** Possible prompt slugs for filtering */
possiblePrompts?: string[];
/** Possible operations for filtering */
possibleOperations?: string[];
/** Possible providers for filtering */
possibleProviders?: string[];
onLayoutChange?: (layout: LayoutItem[]) => void;
onEditWidget?: (widgetId: string, widget: WidgetData) => void;
onRenameWidget?: (widgetId: string, newTitle: string) => void;
onDeleteWidget?: (widgetId: string) => void;
onDuplicateWidget?: (widgetId: string, widget: WidgetData) => void;
filterAccessories?: ReactNode;
}) {
const { value, values } = useSearchParams();
const { width, containerRef, mounted } = useContainerWidth();
const [resizingItemId, setResizingItemId] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const isInteracting = resizingItemId !== null || isDragging;
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const plan = useCurrentPlan();
const maxPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number;
const period = value("period");
const from = value("from");
const to = value("to");
const parsedScope = QueryScopeSchema.safeParse(value("scope") ?? "environment");
const scope = parsedScope.success ? parsedScope.data : "environment";
const tasks = values("tasks").filter((v) => v !== "");
const queues = values("queues").filter((v) => v !== "");
const models = values("models").filter((v) => v !== "");
const prompts = values("prompts").filter((v) => v !== "");
const operations = values("operations").filter((v) => v !== "");
const providers = values("providers").filter((v) => v !== "");
const activeFilters = filterConfig ?? ["tasks", "queues"];
const hasAppliedFilters =
tasks.length > 0 ||
queues.length > 0 ||
models.length > 0 ||
prompts.length > 0 ||
operations.length > 0 ||
providers.length > 0;
const handleLayoutChange = useCallback(
(newLayout: readonly LayoutItem[]) => {
const mutableLayout = [...newLayout];
onLayoutChange?.(mutableLayout);
},
[onLayoutChange]
);
// Apply constraints for title widgets: fixed height of 2, allow horizontal resize only
const constrainedLayout = useMemo(
() =>
layout.map((item) => {
const widget = widgets[item.i];
if (widget?.display.type === "title") {
return { ...item, h: 2, minH: 2, maxH: 2 };
}
return item;
}),
[layout, widgets]
);
return (
<div className="grid max-h-full grid-rows-[auto_1fr] overflow-hidden">
<div className="flex items-center justify-between gap-x-2 border-b border-b-grid-bright py-2 pl-2 pr-3">
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1">
<ScopeFilter shortcut={{ key: "s" }} />
{activeFilters.includes("tasks") && (
<LogsTaskFilter possibleTasks={possibleTasks ?? []} />
)}
{activeFilters.includes("queues") && <QueuesFilter />}
{activeFilters.includes("models") && (
<ModelsFilter possibleModels={possibleModels ?? []} />
)}
{activeFilters.includes("prompts") && (
<PromptsFilter possiblePrompts={possiblePrompts ?? []} />
)}
{activeFilters.includes("operations") && (
<OperationsFilter possibleOperations={possibleOperations ?? []} />
)}
{activeFilters.includes("providers") && (
<ProvidersFilter possibleProviders={possibleProviders ?? []} />
)}
<TimeFilter
defaultPeriod={defaultPeriod}
labelName="Period"
hideLabel
maxPeriodDays={maxPeriodDays}
valueClassName="text-text-bright"
shortcut={{ key: "d" }}
/>
{hasAppliedFilters && (
<Form className="-ml-1 h-6">
<Button
variant="minimal/small"
LeadingIcon={XMarkIcon}
tooltip="Clear all filters"
className="group-hover/button:bg-transparent"
leadingIconClassName="group-hover/button:text-text-bright"
/>
</Form>
)}
</div>
{filterAccessories && <div className="flex shrink-0 items-center">{filterAccessories}</div>}
</div>
<div
ref={containerRef}
className={cn(
"overflow-y-auto scrollbar-thin scrollbar-track-background-bright scrollbar-thumb-background-raised",
isInteracting && "select-none"
)}
>
{mounted && (
<ReactGridLayout
layout={constrainedLayout}
width={width}
gridConfig={{ cols: 12, rowHeight: 30 }}
resizeConfig={{
enabled: editable,
handles: ["se"],
}}
dragConfig={{ enabled: editable, handle: ".drag-handle" }}
onLayoutChange={handleLayoutChange}
onResizeStart={(_layout, oldItem) => setResizingItemId(oldItem?.i ?? null)}
onResizeStop={() => setResizingItemId(null)}
onDragStart={() => setIsDragging(true)}
onDragStop={() => setIsDragging(false)}
>
{Object.entries(widgets).map(([key, widget]) => (
<div key={key}>
{widget.display.type === "title" ? (
<TitleWidget
title={widget.title}
isDraggable={editable}
isResizing={resizingItemId === key}
onRename={
onRenameWidget ? (newTitle) => onRenameWidget(key, newTitle) : undefined
}
onDelete={onDeleteWidget ? () => onDeleteWidget(key) : undefined}
/>
) : (
<MetricWidget
widgetKey={key}
title={widget.title}
query={widget.query}
scope={scope}
period={period ?? defaultPeriod}
from={from ?? null}
to={to ?? null}
taskIdentifiers={tasks.length > 0 ? tasks : undefined}
queues={queues.length > 0 ? queues : undefined}
responseModels={models.length > 0 ? models : undefined}
promptSlugs={prompts.length > 0 ? prompts : undefined}
operations={operations.length > 0 ? operations : undefined}
providers={providers.length > 0 ? providers : undefined}
config={widget.display}
organizationId={organization.id}
projectId={project.id}
environmentId={environment.id}
refreshIntervalMs={60_000}
isResizing={resizingItemId === key}
isDraggable={editable}
onEdit={
onEditWidget
? (resultData) => onEditWidget(key, { ...widget, resultData })
: undefined
}
onRename={
onRenameWidget ? (newTitle) => onRenameWidget(key, newTitle) : undefined
}
onDelete={onDeleteWidget ? () => onDeleteWidget(key) : undefined}
onDuplicate={
onDuplicateWidget
? (resultData) => onDuplicateWidget(key, { ...widget, resultData })
: undefined
}
/>
)}
</div>
))}
</ReactGridLayout>
)}
</div>
</div>
);
}
function useContainerWidth(initialWidth = 1280) {
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(initialWidth);
const [mounted, setMounted] = useState(false);
const measureWidth = useCallback(() => {
if (containerRef.current) {
setWidth(containerRef.current.offsetWidth);
}
}, []);
useEffect(() => {
measureWidth();
setMounted(true);
const element = containerRef.current;
if (!element) return;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
setWidth(entry.contentRect.width);
}
});
resizeObserver.observe(element);
return () => resizeObserver.disconnect();
}, [measureWidth]);
return { width, containerRef, mounted };
}
@@ -0,0 +1,144 @@
import { PlusIcon } from "@heroicons/react/20/solid";
import { Link, type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { AIMetricsIcon } from "~/assets/icons/AIMetricsIcon";
import { RunsIcon } from "~/assets/icons/RunsIcon";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { CreateDashboardPageButton } from "~/components/navigation/DashboardDialogs";
import { Header1, Header2 } from "~/components/primitives/Headers";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { EnvironmentParamSchema, v3BuiltInDashboardPath } from "~/utils/pathBuilder";
export const meta: MetaFunction = () => {
return [{ title: "Dashboards | Trigger.dev" }];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) throw new Response("Project not found", { status: 404 });
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) throw new Response("Environment not found", { status: 404 });
return typedjson({ ok: true });
};
export default function Page() {
useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const runsPath = v3BuiltInDashboardPath(organization, project, environment, "overview");
const agentsPath = v3BuiltInDashboardPath(organization, project, environment, "llm");
return (
<PageContainer>
<NavBar>
<PageTitle title="Dashboards" />
</NavBar>
<PageBody scrollable={true}>
<div className="mx-auto mt-8 flex w-full max-w-5xl flex-col gap-4 p-6">
<div>
<Header1 spacing className="text-text-bright">
Select a dashboard
</Header1>
<Paragraph variant="small" className="text-text-dimmed">
Browse the built-in dashboards or create your own custom dashboard with your own
charts and widgets.
</Paragraph>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<DashboardCta
to={runsPath}
icon={<RunsIcon className="size-7 text-runs" />}
title="Run metrics"
description="Overview of runs, latency, throughput and queue health for this environment."
/>
<DashboardCta
to={agentsPath}
icon={<AIMetricsIcon className="size-7 text-aiMetrics" />}
title="AI metrics"
description="LLM-level metrics: cost, tokens, latency, and per-model usage for your AI agents."
/>
<CreateDashboardPageButton
organization={organization}
project={project}
environment={environment}
>
<DashboardCta
icon={<PlusIcon className="size-7 text-text-bright" />}
title="Create your own dashboard"
description="Build a dashboard with custom widgets and queries."
/>
</CreateDashboardPageButton>
</div>
</div>
</PageBody>
</PageContainer>
);
}
const CTA_CLASSNAME = cn(
"group flex h-full min-h-40 flex-col gap-3 rounded-lg border border-grid-bright bg-background-bright p-5 text-left transition-colors",
"hover:border-border-bright hover:bg-background-hover"
);
type DashboardCtaProps = {
icon: ReactNode;
title: string;
description: string;
};
type DashboardCtaLinkProps = DashboardCtaProps & { to: string };
type DashboardCtaButtonProps = DashboardCtaProps &
ButtonHTMLAttributes<HTMLButtonElement> & { to?: undefined };
const DashboardCta = forwardRef<HTMLElement, DashboardCtaLinkProps | DashboardCtaButtonProps>(
function DashboardCta({ icon, title, description, ...rest }, ref) {
const body = (
<>
<div className="flex items-center justify-between">{icon}</div>
<div className="flex flex-col gap-1">
<Header2 className="text-text-bright">{title}</Header2>
<Paragraph variant="small" className="text-text-dimmed">
{description}
</Paragraph>
</div>
</>
);
if ("to" in rest && rest.to) {
return (
<Link ref={ref as React.Ref<HTMLAnchorElement>} to={rest.to} className={CTA_CLASSNAME}>
{body}
</Link>
);
}
const { to: _to, ...buttonProps } = rest as DashboardCtaButtonProps;
return (
<button
ref={ref as React.Ref<HTMLButtonElement>}
type="button"
className={CTA_CLASSNAME}
{...buttonProps}
>
{body}
</button>
);
}
);
@@ -0,0 +1,778 @@
import { ArrowUpCircleIcon, PlusIcon, TrashIcon } from "@heroicons/react/20/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { Form, useNavigation } from "@remix-run/react";
import { IconChartHistogram, IconEdit } from "@tabler/icons-react";
import { Type } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { defaultChartConfig } from "~/components/code/ChartConfigPanel";
import { Feedback } from "~/components/Feedback";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTrigger,
} from "~/components/primitives/Dialog";
import { FormButtons } from "~/components/primitives/FormButtons";
import { Header3 } from "~/components/primitives/Headers";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
Popover,
PopoverContent,
PopoverVerticalEllipseTrigger,
} from "~/components/primitives/Popover";
import { Sheet, SheetContent } from "~/components/primitives/SheetV3";
import { useToast } from "~/components/primitives/Toast";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { QueryEditor, type QueryEditorSaveData } from "~/components/query/QueryEditor";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { useDashboardEditor } from "~/hooks/useDashboardEditor";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization, useWidgetLimitPerDashboard } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { getTaskIdentifiers } from "~/models/task.server";
import { MetricDashboardPresenter } from "~/presenters/v3/MetricDashboardPresenter.server";
import { QueryPresenter } from "~/presenters/v3/QueryPresenter.server";
import { requireUser, requireUserId } from "~/services/session.server";
import {
EnvironmentParamSchema,
queryPath,
v3BillingPath,
v3BuiltInDashboardPath,
} from "~/utils/pathBuilder";
import { MetricDashboard } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardKey/route";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
const ParamSchema = EnvironmentParamSchema.extend({
dashboardId: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { projectParam, organizationSlug, envParam, dashboardId } = ParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
throw new Response(undefined, {
status: 404,
statusText: "Project not found",
});
}
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
if (!environment) {
throw new Response(undefined, {
status: 404,
statusText: "Environment not found",
});
}
const dashboardPresenter = new MetricDashboardPresenter();
const queryPresenter = new QueryPresenter();
const [dashboard, { defaultQuery, history }, possibleTasks] = await Promise.all([
dashboardPresenter.customDashboard({
friendlyId: dashboardId,
organizationId: project.organizationId,
}),
queryPresenter.call({
organizationId: project.organizationId,
}),
getTaskIdentifiers(environment.id),
]);
// Admins and impersonating users can use EXPLAIN
const isAdmin = user.admin || user.isImpersonating;
// Compute widget count from dashboard layout
const widgetCount = Object.keys(dashboard.layout.widgets).length;
return typedjson({
...dashboard,
// Query editor data
queryDefaultQuery: defaultQuery,
queryHistory: history,
isAdmin,
maxRows: env.QUERY_CLICKHOUSE_MAX_RETURNED_ROWS,
possibleTasks,
widgetCount,
});
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, envParam, dashboardId } = ParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Project not found", { status: 404 });
}
// Load the dashboard
const dashboard = await prisma.metricsDashboard.findFirst({
where: {
friendlyId: dashboardId,
organizationId: project.organizationId,
},
});
if (!dashboard) {
throw new Response("Dashboard not found", { status: 404 });
}
const formData = await request.formData();
const action = formData.get("action");
switch (action) {
case "delete": {
await prisma.metricsDashboard.delete({
where: { id: dashboard.id },
});
return redirectWithSuccessMessage(
v3BuiltInDashboardPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam },
"overview"
),
request,
`Deleted "${dashboard.title}" dashboard`
);
}
case "rename": {
const newTitle = formData.get("title");
if (typeof newTitle !== "string" || newTitle.trim().length === 0) {
throw new Response("Title is required", { status: 400 });
}
await prisma.metricsDashboard.update({
where: { id: dashboard.id },
data: { title: newTitle.trim() },
});
return typedjson({ success: true });
}
default: {
throw new Response("Invalid action", { status: 400 });
}
}
};
export default function Page() {
const {
friendlyId,
title,
layout: dashboardLayout,
defaultPeriod,
queryDefaultQuery,
queryHistory,
isAdmin,
maxRows,
possibleTasks,
widgetCount: _initialWidgetCount,
} = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const plan = useCurrentPlan();
const maxPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number;
// Widget limits
const widgetLimitPerDashboard = useWidgetLimitPerDashboard();
const planLimits = (plan?.v3Subscription?.plan?.limits as any)?.metricWidgetsPerDashboard;
const canExceedWidgets = typeof planLimits === "object" && planLimits.canExceed === true;
// Build the action URLs - both use the resource route to avoid full page renders on POST
const widgetActionUrl = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${friendlyId}/widgets`;
const layoutActionUrl = widgetActionUrl;
const toast = useToast();
const handleSyncError = useCallback(
(error: Error, action: string) => {
const actionMessages: Record<string, string> = {
add: "Failed to add widget",
update: "Failed to update widget",
delete: "Failed to delete widget",
duplicate: "Failed to duplicate widget",
layout: "Failed to save layout",
};
const message = actionMessages[action] || "Failed to save changes";
toast.error(`${message}. Your changes may not be saved.`, { title: "Sync Error" });
},
[toast]
);
// Add title dialog state
const [showAddTitleDialog, setShowAddTitleDialog] = useState(false);
const [newTitleValue, setNewTitleValue] = useState("");
// Widget limit dialog state (triggered when hook blocks add/duplicate)
const [showWidgetLimitDialog, setShowWidgetLimitDialog] = useState(false);
const handleWidgetLimitReached = useCallback(() => {
setShowWidgetLimitDialog(true);
}, []);
// Use the dashboard editor hook for all state management
const { state, actions } = useDashboardEditor({
initialData: dashboardLayout,
widgetActionUrl,
layoutActionUrl,
widgetLimit: canExceedWidgets ? undefined : widgetLimitPerDashboard,
onSyncError: handleSyncError,
onWidgetLimitReached: handleWidgetLimitReached,
});
// Reactive widget count from editor state (title widgets don't count against limits)
const currentWidgetCount = Object.values(state.widgets).filter(
(w) => w.display.type !== "title"
).length;
const totalWidgetCount = Object.keys(state.widgets).length;
const widgetLimits = { used: currentWidgetCount, limit: widgetLimitPerDashboard };
const widgetIsAtLimit = currentWidgetCount >= widgetLimitPerDashboard;
const widgetLimitRatio =
widgetLimits.limit > 0 ? widgetLimits.used / widgetLimits.limit : widgetLimits.used > 0 ? 1 : 0;
const widgetLimitPercent = Math.min(100, Math.max(0, Math.round(widgetLimitRatio * 100)));
const widgetCanUpgrade = plan?.v3Subscription?.plan && !canExceedWidgets;
// Build the query action URL for the editor
const queryActionUrl = queryPath(
{ slug: organization.slug },
{ slug: project.slug },
{ slug: environment.slug }
);
// Handle save from the QueryEditor
const handleSave = useCallback(
(data: QueryEditorSaveData) => {
if (state.editorMode?.type === "add") {
actions.addWidget(data.title, data.query, data.config);
} else if (state.editorMode?.type === "edit") {
actions.updateWidget(state.editorMode.widgetId, data.title, data.query, data.config);
}
},
[state.editorMode, actions]
);
// Render save button for the QueryEditor
const renderSaveForm = useCallback(
(data: QueryEditorSaveData) => {
const isAdd = state.editorMode?.type === "add";
return (
<Button
type="button"
variant="primary/small"
disabled={!data.query}
onClick={() => handleSave(data)}
>
{isAdd ? "Add to dashboard" : "Save changes"}
</Button>
);
},
[state.editorMode, handleSave]
);
// Prepare editor props when in editor mode
const editorProps = state.editorMode
? (() => {
const mode =
state.editorMode.type === "add"
? { type: "dashboard-add" as const, dashboardId: friendlyId, dashboardName: title }
: {
type: "dashboard-edit" as const,
dashboardId: friendlyId,
dashboardName: title,
widgetId: state.editorMode.widgetId,
widgetName: state.editorMode.widget.title,
};
// For edit mode, use the widget's existing values as defaults
const editorDefaultQuery =
state.editorMode.type === "edit" ? state.editorMode.widget.query : queryDefaultQuery;
const editorDefaultChartConfig =
state.editorMode.type === "edit" && state.editorMode.widget.display.type === "chart"
? {
chartType: state.editorMode.widget.display.chartType,
xAxisColumn: state.editorMode.widget.display.xAxisColumn,
yAxisColumns: state.editorMode.widget.display.yAxisColumns,
groupByColumn: state.editorMode.widget.display.groupByColumn,
stacked: state.editorMode.widget.display.stacked,
sortByColumn: state.editorMode.widget.display.sortByColumn,
sortDirection: state.editorMode.widget.display.sortDirection,
aggregation: state.editorMode.widget.display.aggregation,
seriesColors: state.editorMode.widget.display.seriesColors,
}
: defaultChartConfig;
const editorDefaultBigNumberConfig =
state.editorMode.type === "edit" && state.editorMode.widget.display.type === "bignumber"
? {
column: state.editorMode.widget.display.column,
aggregation: state.editorMode.widget.display.aggregation,
sortDirection: state.editorMode.widget.display.sortDirection,
abbreviate: state.editorMode.widget.display.abbreviate,
prefix: state.editorMode.widget.display.prefix,
suffix: state.editorMode.widget.display.suffix,
}
: undefined;
const editorDefaultResultsView =
state.editorMode.type === "edit" ? state.editorMode.widget.display.type : "table";
// Pass the existing result data when editing
const editorDefaultData =
state.editorMode.type === "edit" ? state.editorMode.widget.resultData : undefined;
return {
mode,
editorDefaultQuery,
editorDefaultChartConfig,
editorDefaultBigNumberConfig,
editorDefaultResultsView,
editorDefaultData,
};
})()
: null;
const dashboardMenu = (
<Popover>
<PopoverVerticalEllipseTrigger variant="secondary" />
<PopoverContent className="w-fit min-w-40 p-1" align="end">
<div className="flex flex-col gap-1">
<RenameDashboardDialog title={title} />
<DeleteDashboardDialog title={title} />
</div>
</PopoverContent>
</Popover>
);
const filterAccessories = (
<div className="flex items-center gap-x-1.5">
{widgetIsAtLimit ? (
<>
<Dialog>
<DialogTrigger asChild>
<Button variant="primary/small" LeadingIcon={PlusIcon} shortcut={{ key: "c" }}>
Add chart
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>You've exceeded your widget limit</DialogHeader>
<DialogDescription>
You've used {widgetLimits.used}/{widgetLimits.limit} widgets on this dashboard.
</DialogDescription>
<DialogFooter>
{widgetCanUpgrade ? (
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
Upgrade
</LinkButton>
) : (
<Feedback
button={<Button variant="primary/small">Request more</Button>}
defaultValue="help"
/>
)}
</DialogFooter>
</DialogContent>
</Dialog>
<Button
variant="secondary/small"
LeadingIcon={Type}
className="pl-1.5"
iconSpacing="gap-x-1"
tooltip="Add section title"
shortcut={{ key: "h" }}
onClick={() => {
setNewTitleValue("");
setShowAddTitleDialog(true);
}}
>
Add title
</Button>
</>
) : (
<>
<Button
variant="primary/small"
LeadingIcon={IconChartHistogram}
className="pl-1.5"
iconSpacing="gap-x-1.5"
shortcut={{ key: "c" }}
onClick={actions.openAddEditor}
>
Add chart
</Button>
<Button
variant="secondary/small"
LeadingIcon={Type}
className="pl-1.5"
iconSpacing="gap-x-1"
tooltip="Add section title"
shortcut={{ key: "h" }}
onClick={() => {
setNewTitleValue("");
setShowAddTitleDialog(true);
}}
>
Add title
</Button>
</>
)}
{dashboardMenu}
</div>
);
return (
<PageContainer>
<NavBar>
<PageTitle title={title} />
<PageAccessories>{totalWidgetCount === 0 && dashboardMenu}</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<div className="flex h-full flex-col">
<div className="min-h-0 flex-1">
{totalWidgetCount === 0 ? (
<MainCenteredContainer className="max-w-md">
<InfoPanel
icon={IconChartHistogram}
iconClassName="text-metrics"
panelClassName="max-full"
title="Add your first chart"
accessory={
<Button
variant="primary/small"
LeadingIcon={PlusIcon}
onClick={actions.openAddEditor}
>
Add chart
</Button>
}
>
<Paragraph variant="small">
Charts let you visualize your task metrics. Write a query to pull data from your
runs, then choose how to display it on this dashboard.
</Paragraph>
</InfoPanel>
</MainCenteredContainer>
) : (
<MetricDashboard
key={friendlyId}
layout={state.layout}
widgets={state.widgets}
defaultPeriod={defaultPeriod}
editable={true}
possibleTasks={possibleTasks}
onLayoutChange={actions.updateLayout}
onEditWidget={actions.openEditEditor}
onRenameWidget={actions.renameWidget}
onDeleteWidget={actions.deleteWidget}
onDuplicateWidget={actions.duplicateWidget}
filterAccessories={filterAccessories}
/>
)}
</div>
<div className="flex w-full items-start justify-between">
<div className="flex h-fit w-full items-center gap-4 border-t border-grid-bright bg-background-bright p-[0.86rem] pl-4">
<SimpleTooltip
button={
<div className="size-6">
<svg className="h-full w-full -rotate-90 overflow-visible">
<circle
className="fill-none stroke-grid-bright"
strokeWidth="4"
r="10"
cx="12"
cy="12"
/>
<circle
className={`fill-none ${
widgetIsAtLimit ? "stroke-error" : "stroke-success"
}`}
strokeWidth="4"
r="10"
cx="12"
cy="12"
strokeDasharray={`${widgetLimitRatio * 62.8} 62.8`}
strokeDashoffset="0"
strokeLinecap="round"
/>
</svg>
</div>
}
content={`${widgetLimitPercent}%`}
/>
<div className="flex w-full items-center justify-between gap-6">
{widgetIsAtLimit ? (
<Header3 className="text-error">
You've used all {widgetLimits.limit} of your available widgets. Upgrade your
plan to enable more.
</Header3>
) : (
<Header3>
You've used {widgetLimits.used}/{widgetLimits.limit} of your charts
</Header3>
)}
{widgetCanUpgrade ? (
<LinkButton
to={v3BillingPath(organization)}
variant="secondary/small"
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="text-indigo-500"
>
Upgrade
</LinkButton>
) : (
<Feedback
button={<Button variant="secondary/small">Request more</Button>}
defaultValue="help"
/>
)}
</div>
</div>
</div>
</div>
</PageBody>
{/* Query Editor Sheet - opens on top of the dashboard */}
<Sheet open={!!state.editorMode} onOpenChange={(open) => !open && actions.closeEditor()}>
<SheetContent
side="right"
className="w-[90vw] max-w-none rounded-l-lg border-l border-grid-dimmed p-0 sm:max-w-none"
>
{editorProps && (
<QueryEditor
defaultQuery={editorProps.editorDefaultQuery}
defaultScope="environment"
defaultPeriod={defaultPeriod}
defaultResultsView={
editorProps.editorDefaultResultsView === "chart"
? "graph"
: editorProps.editorDefaultResultsView === "bignumber"
? "bignumber"
: "table"
}
defaultChartConfig={editorProps.editorDefaultChartConfig}
defaultBigNumberConfig={editorProps.editorDefaultBigNumberConfig}
defaultData={editorProps.editorDefaultData}
history={queryHistory}
isAdmin={isAdmin}
maxRows={maxRows}
queryActionUrl={queryActionUrl}
mode={editorProps.mode}
maxPeriodDays={maxPeriodDays}
save={renderSaveForm}
onClose={actions.closeEditor}
/>
)}
</SheetContent>
</Sheet>
{/* Add title dialog */}
<Dialog open={showAddTitleDialog} onOpenChange={setShowAddTitleDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>Add title</DialogHeader>
<form
className="space-y-4 pt-3"
onSubmit={(e) => {
e.preventDefault();
if (newTitleValue.trim()) {
actions.addTitleWidget(newTitleValue.trim());
setShowAddTitleDialog(false);
}
}}
>
<InputGroup>
<Label>Title</Label>
<Input
value={newTitleValue}
onChange={(e) => setNewTitleValue(e.target.value)}
placeholder="Section title"
autoFocus
/>
</InputGroup>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Button type="submit" variant="primary/medium" disabled={!newTitleValue.trim()}>
Add
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Widget limit dialog - triggered by hook when add/duplicate is blocked */}
<Dialog open={showWidgetLimitDialog} onOpenChange={setShowWidgetLimitDialog}>
<DialogContent>
<DialogHeader>You've exceeded your widget limit</DialogHeader>
<DialogDescription>
You've used {widgetLimits.used}/{widgetLimits.limit} widgets on this dashboard.
</DialogDescription>
<DialogFooter>
{widgetCanUpgrade ? (
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
Upgrade
</LinkButton>
) : (
<Feedback
button={<Button variant="primary/small">Request more</Button>}
defaultValue="help"
/>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</PageContainer>
);
}
function RenameDashboardDialog({ title }: { title: string }) {
const navigation = useNavigation();
const [isOpen, setIsOpen] = useState(false);
const [newTitle, setNewTitle] = useState(title);
const isRenaming =
navigation.state !== "idle" &&
navigation.formMethod === "post" &&
navigation.formData?.get("action") === "rename";
// Close dialog when navigation completes
useEffect(() => {
if (navigation.state === "idle") {
setIsOpen(false);
}
}, [navigation.state]);
// Sync newTitle state when title changes (after successful rename)
useEffect(() => {
setNewTitle(title);
}, [title]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={IconEdit}
fullWidth
textAlignLeft
className="pl-0.5 pr-3"
leadingIconClassName="gap-x-0"
>
Rename dashboard
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-sm">
<DialogHeader>Rename dashboard</DialogHeader>
<Form method="post" className="space-y-4">
<input type="hidden" name="action" value="rename" />
<InputGroup>
<Label>Title</Label>
<Input
name="title"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="Dashboard title"
required
autoFocus
/>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
variant="primary/medium"
disabled={isRenaming || !newTitle.trim()}
>
{isRenaming ? "Saving…" : "Save"}
</Button>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</Form>
</DialogContent>
</Dialog>
);
}
function DeleteDashboardDialog({ title }: { title: string }) {
const navigation = useNavigation();
const [isOpen, setIsOpen] = useState(false);
const isDeleting =
navigation.state !== "idle" &&
navigation.formMethod === "post" &&
navigation.formData?.get("action") === "delete";
// Close dialog when navigation completes
useEffect(() => {
if (navigation.state === "idle") {
setIsOpen(false);
}
}, [navigation.state]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={TrashIcon}
leadingIconClassName="text-rose-500"
fullWidth
textAlignLeft
>
Delete dashboard
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>Delete dashboard</DialogHeader>
<div className="mb-2 mt-4 flex flex-col gap-2">
<Paragraph>
Are you sure you want to delete <strong>"{title}"</strong>? This action cannot be undone
and all widgets on this dashboard will be permanently removed.
</Paragraph>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary/medium">Cancel</Button>
</DialogClose>
<Form method="post">
<Button
type="submit"
name="action"
value="delete"
variant="danger/medium"
LeadingIcon={TrashIcon}
disabled={isDeleting}
>
{isDeleting ? "Deleting…" : "Delete"}
</Button>
</Form>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,753 @@
import { Link, useLocation } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { useEffect, useState, useRef, useCallback } from "react";
import { S2, S2Error } from "@s2-dev/streamstore";
import {
Clipboard,
ClipboardCheck,
ChevronDown,
ChevronUp,
TerminalSquareIcon,
LayoutDashboardIcon,
GitBranchIcon,
ServerIcon,
} from "lucide-react";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { GitMetadata } from "~/components/GitMetadata";
import { VercelLink } from "~/components/integrations/VercelLink";
import { RuntimeIcon } from "~/components/RuntimeIcon";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
import { Badge } from "~/components/primitives/Badge";
import { LinkButton } from "~/components/primitives/Buttons";
import { DateTimeAccurate } from "~/components/primitives/DateTime";
import { Header2 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import {
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { DeploymentError } from "~/components/runs/v3/DeploymentError";
import { DeploymentStatus } from "~/components/runs/v3/DeploymentStatus";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/primitives/Tooltip";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { DeploymentPresenter } from "~/presenters/v3/DeploymentPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { v3DeploymentParams, v3DeploymentsPath, v3RunsPath } from "~/utils/pathBuilder";
import { capitalizeWord } from "~/utils/string";
import { UserTag } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route";
import { DeploymentEventFromString } from "@trigger.dev/core/v3/schemas";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam, deploymentParam } =
v3DeploymentParams.parse(params);
try {
const presenter = new DeploymentPresenter();
const { deployment, eventStream } = await presenter.call({
userId,
organizationSlug,
projectSlug: projectParam,
environmentSlug: envParam,
deploymentShortCode: deploymentParam,
});
return typedjson({ deployment, eventStream });
} catch (error) {
console.error(error);
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, if this problem persists please contact support.",
});
}
};
type LogEntry = {
message: string;
timestamp: Date;
level: "info" | "error" | "warn" | "debug";
};
function getTriggeredViaDisplay(triggeredVia: string | null | undefined): {
icon: React.ReactNode;
label: string;
} | null {
if (!triggeredVia) return null;
const iconClass = "size-4 text-text-dimmed";
switch (triggeredVia) {
case "cli:manual":
return {
icon: <TerminalSquareIcon className={iconClass} />,
label: "CLI (Manual)",
};
case "cli:github_actions":
return {
icon: <GitBranchIcon className={iconClass} />,
label: "CLI (GitHub Actions)",
};
case "cli:gitlab_ci":
return {
icon: <ServerIcon className={iconClass} />,
label: "CLI (GitLab CI)",
};
case "cli:circleci":
return {
icon: <ServerIcon className={iconClass} />,
label: "CLI (CircleCI)",
};
case "cli:jenkins":
return {
icon: <ServerIcon className={iconClass} />,
label: "CLI (Jenkins)",
};
case "cli:azure_pipelines":
return {
icon: <ServerIcon className={iconClass} />,
label: "CLI (Azure Pipelines)",
};
case "cli:bitbucket_pipelines":
return {
icon: <ServerIcon className={iconClass} />,
label: "CLI (Bitbucket Pipelines)",
};
case "cli:travis_ci":
return {
icon: <ServerIcon className={iconClass} />,
label: "CLI (Travis CI)",
};
case "cli:buildkite":
return {
icon: <ServerIcon className={iconClass} />,
label: "CLI (Buildkite)",
};
case "cli:ci_other":
return {
icon: <ServerIcon className={iconClass} />,
label: "CLI (CI)",
};
case "git_integration:github":
return {
icon: <GitBranchIcon className={iconClass} />,
label: "GitHub Integration",
};
case "dashboard":
return {
icon: <LayoutDashboardIcon className={iconClass} />,
label: "Dashboard",
};
default:
// Handle any unknown values gracefully
if (triggeredVia.startsWith("cli:")) {
return {
icon: <TerminalSquareIcon className={iconClass} />,
label: `CLI (${triggeredVia.replace("cli:", "")})`,
};
}
return {
icon: <ServerIcon className={iconClass} />,
label: triggeredVia,
};
}
}
export default function Page() {
const { deployment, eventStream } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const location = useLocation();
const page = new URLSearchParams(location.search).get("page");
const logsDisabled = eventStream === undefined;
const [logs, setLogs] = useState<LogEntry[]>([]);
const [isStreaming, setIsStreaming] = useState(true);
const [streamError, setStreamError] = useState<string | null>(null);
const isPending = deployment.status === "PENDING";
useEffect(() => {
if (logsDisabled) return;
const abortController = new AbortController();
setLogs([]);
setStreamError(null);
setIsStreaming(true);
const streamLogs = async () => {
try {
const s2 = new S2({ accessToken: eventStream.s2.accessToken });
const basin = s2.basin(eventStream.s2.basin);
const stream = basin.stream(eventStream.s2.stream);
const readSession = await stream.readSession(
{
start: { from: { seqNum: 0 }, clamp: true },
stop: { waitSecs: 60 },
},
{ signal: abortController.signal }
);
for await (const record of readSession) {
const decoded = record.body;
const result = DeploymentEventFromString.safeParse(decoded);
if (!result.success) {
// fallback to the previous format in s2 logs for compatibility
try {
const headers: Record<string, string> = {};
if (record.headers) {
for (const [name, value] of record.headers) {
headers[name] = value;
}
}
const level = (headers["level"]?.toLowerCase() as LogEntry["level"]) ?? "info";
setLogs((prevLogs) => [
...prevLogs,
{
timestamp: new Date(record.timestamp),
message: decoded,
level,
},
]);
} catch (err) {
console.error("Failed to parse log record:", err);
}
continue;
}
const event = result.data;
if (event.type !== "log") {
continue;
}
setLogs((prevLogs) => [
...prevLogs,
{
timestamp: new Date(record.timestamp),
message: event.data.message,
level: event.data.level,
},
]);
}
} catch (error) {
if (abortController.signal.aborted) return;
const isNotFoundError =
error instanceof S2Error &&
error.code &&
["permission_denied", "stream_not_found"].includes(error.code);
if (isNotFoundError) return;
console.error("Failed to stream logs:", error);
setStreamError("Failed to stream logs");
} finally {
if (!abortController.signal.aborted) {
setIsStreaming(false);
}
}
};
streamLogs();
return () => {
abortController.abort();
};
}, [eventStream?.s2?.basin, eventStream?.s2?.stream, eventStream?.s2?.accessToken, isPending]);
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden bg-background-bright">
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
<Header2 className={cn("whitespace-nowrap")}>Deploy: {deployment.shortCode}</Header2>
<AdminDebugTooltip>
<Property.Table>
<Property.Item>
<Property.Label>ID</Property.Label>
<Property.Value>{deployment.id}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Project ID</Property.Label>
<Property.Value>{deployment.projectId}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Org ID</Property.Label>
<Property.Value>{deployment.organizationId}</Property.Value>
</Property.Item>
{deployment.imageReference && (
<Property.Item>
<Property.Label>Image</Property.Label>
<Property.Value>{deployment.imageReference}</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Platform</Property.Label>
<Property.Value>{deployment.imagePlatform}</Property.Value>
</Property.Item>
{deployment.externalBuildData && (
<Property.Item>
<Property.Label>Build Server</Property.Label>
<Property.Value>
<Link
to={`/resources/${deployment.projectId}/deployments/${deployment.id}/logs`}
className="extra-small/bright/mono underline"
>
{deployment.externalBuildData.buildId}
</Link>
</Property.Value>
</Property.Item>
)}
</Property.Table>
</AdminDebugTooltip>
<LinkButton
to={`${v3DeploymentsPath(organization, project, environment)}${
page ? `?page=${page}` : ""
}`}
variant="minimal/small"
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
className="pl-1"
/>
</div>
<div className="overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<div className="flex flex-col">
<div className="p-3">
<Property.Table>
<Property.Item>
<Property.Label>Deploy</Property.Label>
<Property.Value className="flex items-center gap-2">
<span>{deployment.shortCode}</span>
{deployment.label && (
<Badge variant="extra-small" className="capitalize">
{deployment.label}
</Badge>
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Environment</Property.Label>
<Property.Value>
<EnvironmentCombo environment={deployment.environment} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Version</Property.Label>
<Property.Value>{deployment.version}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Status</Property.Label>
<Property.Value>
<DeploymentStatus
status={deployment.status}
isBuilt={deployment.isBuilt}
className="text-sm"
/>
</Property.Value>
</Property.Item>
{!logsDisabled && (
<Property.Item>
<Property.Label>Logs</Property.Label>
<LogsDisplay
logs={logs}
isStreaming={isStreaming}
streamError={streamError}
initialCollapsed={(
["PENDING", "DEPLOYED", "TIMED_OUT"] satisfies (typeof deployment.status)[]
).includes(deployment.status)}
/>
</Property.Item>
)}
{deployment.canceledAt && (
<Property.Item>
<Property.Label>Canceled at</Property.Label>
<Property.Value>
<>
<DateTimeAccurate date={deployment.canceledAt} /> UTC
</>
</Property.Value>
</Property.Item>
)}
{deployment.canceledReason && (
<Property.Item>
<Property.Label>Cancelation reason</Property.Label>
<Property.Value>{deployment.canceledReason}</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Tasks</Property.Label>
<Property.Value>{deployment.tasks ? deployment.tasks.length : ""}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>SDK Version</Property.Label>
<Property.Value>
{deployment.sdkVersion ? deployment.sdkVersion : ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>CLI Version</Property.Label>
<Property.Value>
{deployment.cliVersion ? deployment.cliVersion : ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Runtime</Property.Label>
<Property.Value>
<RuntimeIcon
runtime={deployment.runtime}
runtimeVersion={deployment.runtimeVersion}
withLabel
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Worker type</Property.Label>
<Property.Value>{capitalizeWord(deployment.type)}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Started at</Property.Label>
<Property.Value>
{deployment.startedAt ? (
<>
<DateTimeAccurate date={deployment.startedAt} /> UTC
</>
) : (
""
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Installed at</Property.Label>
<Property.Value>
{deployment.installedAt ? (
<>
<DateTimeAccurate date={deployment.installedAt} /> UTC
</>
) : (
""
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Built at</Property.Label>
<Property.Value>
{deployment.builtAt ? (
<>
<DateTimeAccurate date={deployment.builtAt} /> UTC
</>
) : (
""
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Deployed at</Property.Label>
<Property.Value>
{deployment.deployedAt ? (
<>
<DateTimeAccurate date={deployment.deployedAt} /> UTC
</>
) : (
""
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Git</Property.Label>
<Property.Value>
<div className="-ml-1 mt-0.5 flex flex-col">
<GitMetadata git={deployment.git} />
</div>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Deployed by</Property.Label>
<Property.Value>
{deployment.git?.source === "trigger_github_app" ? (
<UserTag
name={deployment.git.ghUsername ?? "GitHub Integration"}
avatarUrl={deployment.git.ghUserAvatarUrl}
/>
) : deployment.deployedBy ? (
<UserTag
name={deployment.deployedBy.name ?? deployment.deployedBy.displayName ?? ""}
avatarUrl={deployment.deployedBy.avatarUrl ?? undefined}
/>
) : (
""
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Triggered via</Property.Label>
<Property.Value>
{(() => {
const display = getTriggeredViaDisplay(deployment.triggeredVia);
if (!display) return "";
return (
<span className="flex items-center gap-1.5">
{display.icon}
{display.label}
</span>
);
})()}
</Property.Value>
</Property.Item>
{deployment.vercelDeploymentUrl && (
<Property.Item>
<Property.Label>Linked</Property.Label>
<Property.Value>
<div className="-ml-1 mt-0.5 flex flex-col">
<VercelLink vercelDeploymentUrl={deployment.vercelDeploymentUrl} />
</div>
</Property.Value>
</Property.Item>
)}
</Property.Table>
</div>
{deployment.errorData && <DeploymentError errorData={deployment.errorData} />}
{deployment.tasks && (
<div className="divide-y divide-background-bright overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<Table variant="bright">
<TableHeader>
<TableRow>
<TableHeaderCell className="px-2">Task</TableHeaderCell>
<TableHeaderCell className="px-2">File path</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{deployment.tasks.map((t) => {
const path = v3RunsPath(organization, project, environment, {
tasks: [t.slug],
});
return (
<TableRow key={t.slug}>
<TableCell to={path}>
<div className="inline-flex flex-col gap-0.5">
<Paragraph variant="extra-small" className="text-text-dimmed">
{t.slug}
</Paragraph>
</div>
</TableCell>
<TableCell to={path}>{t.filePath}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
</div>
</div>
);
}
function LogsDisplay({
logs,
isStreaming,
streamError,
initialCollapsed = false,
}: {
logs: LogEntry[];
isStreaming: boolean;
streamError: string | null;
initialCollapsed?: boolean;
}) {
const [copied, setCopied] = useState(false);
const [mouseOver, setMouseOver] = useState(false);
const [collapsed, setCollapsed] = useState(initialCollapsed);
const logsContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setCollapsed(initialCollapsed);
}, [initialCollapsed]);
// auto-scroll log container to bottom when new logs arrive
useEffect(() => {
if (logsContainerRef.current) {
logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight;
}
}, [logs]);
const onCopyLogs = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
const logsText = logs.map((log) => log.message).join("\n");
navigator.clipboard.writeText(logsText);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1500);
},
[logs]
);
const errorCount = logs.filter((log) => log.level === "error").length;
const warningCount = logs.filter((log) => log.level === "warn").length;
return (
<div className="mt-1.5 overflow-hidden rounded-md border border-grid-bright">
<div className="flex items-center justify-between border-b border-grid-dimmed px-3 py-2">
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5">
<div
className={cn(
"h-2 w-2 rounded-full",
errorCount > 0 ? "bg-error/80" : "bg-surface-control"
)}
/>
<Paragraph variant="extra-small/dimmed/mono" className="w-[ch-10]">
{`${errorCount} ${errorCount === 1 ? "error" : "errors"}`}
</Paragraph>
</div>
<div className="flex items-center gap-1.5">
<div
className={cn(
"h-2 w-2 rounded-full",
warningCount > 0 ? "bg-warning/80" : "bg-surface-control"
)}
/>
<Paragraph variant="extra-small/dimmed/mono">
{`${warningCount} ${warningCount === 1 ? "warning" : "warnings"}`}
</Paragraph>
</div>
</div>
{logs.length > 0 && (
<div className="flex items-center gap-3">
<TooltipProvider>
<Tooltip open={copied || mouseOver} disableHoverableContent>
<TooltipTrigger
onClick={onCopyLogs}
onMouseEnter={() => setMouseOver(true)}
onMouseLeave={() => setMouseOver(false)}
className={cn(
"transition-colors duration-100 focus-custom hover:cursor-pointer",
copied ? "text-success" : "text-text-dimmed hover:text-text-bright"
)}
>
<div className="size-4 shrink-0">
{copied ? (
<ClipboardCheck className="size-full" />
) : (
<Clipboard className="size-full" />
)}
</div>
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
{copied ? "Copied" : "Copy"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip disableHoverableContent>
<TooltipTrigger
onClick={() => setCollapsed(!collapsed)}
className={cn(
"transition-colors duration-100 focus-custom hover:cursor-pointer",
"text-text-dimmed hover:text-text-bright"
)}
>
{collapsed ? (
<ChevronDown className="size-4" />
) : (
<ChevronUp className="size-4" />
)}
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
{collapsed ? "Expand" : "Collapse"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</div>
<div className="relative">
<div
ref={logsContainerRef}
className={cn(
"grow overflow-x-auto overflow-y-scroll font-mono text-xs transition-all duration-200 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control",
collapsed ? "h-16" : "h-64"
)}
>
<div className="flex w-fit min-w-full flex-col">
{logs.length === 0 && (
<div className="flex gap-x-2.5 border-l-2 border-transparent px-2.5 py-1">
{streamError ? (
<span className="text-error">Failed fetching logs</span>
) : (
<span className="text-text-dimmed">
{isStreaming ? "Waiting for logs..." : "No logs yet"}
</span>
)}
</div>
)}
{logs.map((log, index) => {
return (
<div
key={index}
className={cn(
"flex w-full gap-x-2.5 border-l-2 px-2.5 py-1",
log.level === "error" && "border-error/60 bg-error/15 hover:bg-error/25",
log.level === "warn" && "border-warning/60 bg-warning/20 hover:bg-warning/30",
log.level === "info" && "border-transparent hover:bg-background-hover"
)}
>
<span
className={cn(
"select-none whitespace-nowrap py-px",
log.level === "error" && "text-error/80",
log.level === "warn" && "text-warning/70",
log.level === "info" && "text-text-dimmed"
)}
>
<DateTimeAccurate date={log.timestamp} hideDate hour12={false} />
</span>
<span
className={cn(
"whitespace-nowrap",
log.level === "error" && "text-error",
log.level === "warn" && "text-warning",
log.level === "info" && "text-text-bright"
)}
>
{log.message}
</span>
</div>
);
})}
</div>
</div>
{collapsed && (
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-8 bg-linear-to-t from-background-bright/90 to-transparent" />
)}
</div>
</div>
);
}
@@ -0,0 +1,732 @@
import {
ArrowPathIcon,
ArrowUturnLeftIcon,
BookOpenIcon,
NoSymbolIcon,
} from "@heroicons/react/20/solid";
import {
Form,
type MetaFunction,
Outlet,
useLocation,
useNavigate,
useNavigation,
useParams,
} from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { CogIcon, GitBranchIcon } from "lucide-react";
import { useEffect } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { PromoteIcon } from "~/assets/icons/PromoteIcon";
import { VercelLink } from "~/components/integrations/VercelLink";
import { DeploymentsNone, DeploymentsNoneDev } from "~/components/BlankStatePanels";
import { OctoKitty } from "~/components/GitHubLoginButton";
import { GitMetadata } from "~/components/GitMetadata";
import { RuntimeIcon } from "~/components/RuntimeIcon";
import { UserAvatar } from "~/components/UserProfilePhoto";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Badge } from "~/components/primitives/Badge";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import {
Dialog,
DialogDescription,
DialogContent,
DialogHeader,
DialogTrigger,
DialogFooter,
} from "~/components/primitives/Dialog";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
RESIZABLE_PANEL_ANIMATION,
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
collapsibleHandleClassName,
} from "~/components/primitives/Resizable";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import {
DeploymentStatus,
deploymentStatusDescription,
deploymentStatuses,
} from "~/components/runs/v3/DeploymentStatus";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import {
type DeploymentListItem,
DeploymentListPresenter,
} from "~/presenters/v3/DeploymentListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { rbac } from "~/services/rbac.server";
import { checkPermissions } from "~/services/routeBuilders/permissions.server";
import { titleCase } from "~/utils";
import { cn } from "~/utils/cn";
import {
EnvironmentParamSchema,
docsPath,
v3DeploymentPath,
v3ProjectSettingsIntegrationsPath,
} from "~/utils/pathBuilder";
import { createSearchParams } from "~/utils/searchParams";
import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions";
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
import { env } from "~/env.server";
import { DialogClose } from "@radix-ui/react-dialog";
export const meta: MetaFunction = () => {
return [
{
title: `Deployments | Trigger.dev`,
},
];
};
const SearchParams = z.object({
page: z.coerce.number().optional(),
version: z.string().optional(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const searchParams = createSearchParams(request.url, SearchParams);
let page = searchParams.success ? Number(searchParams.params.get("page") ?? 1) : 1;
const version = searchParams.success ? searchParams.params.get("version")?.toString() : undefined;
const presenter = new DeploymentListPresenter();
// If we have a version, find its page
if (version) {
try {
page = await presenter.findPageForVersion({
userId,
organizationSlug,
projectSlug: projectParam,
environmentSlug: envParam,
version,
});
} catch (error) {
console.error("Error finding page for version", error);
// Carry on, we'll just show the selected page
}
}
try {
const result = await presenter.call({
userId,
organizationSlug,
projectSlug: projectParam,
environmentSlug: envParam,
page,
});
// If we have a version, find the deployment
const selectedDeployment = version
? result.deployments.find((d) => d.version === version)
: undefined;
const autoReloadPollIntervalMs = env.DEPLOYMENTS_AUTORELOAD_POLL_INTERVAL_MS;
// Display flag for the rollback/promote/cancel controls — the action
// routes enforce write:deployments independently. Permissive in OSS.
const orgId = await resolveOrgIdFromSlug(organizationSlug);
const deploymentAuth = orgId
? await rbac.authenticateSession(request, { userId, organizationId: orgId })
: null;
const canWriteDeployments =
deploymentAuth && deploymentAuth.ok
? checkPermissions(deploymentAuth.ability, {
canWriteDeployments: { action: "write", resource: { type: "deployments" } },
}).canWriteDeployments
: true;
return typedjson({
...result,
selectedDeployment,
autoReloadPollIntervalMs,
canWriteDeployments,
});
} catch (error) {
console.error(error);
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, if this problem persists please contact support.",
});
}
};
export default function Page() {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const {
deployments,
currentPage,
totalPages,
selectedDeployment,
connectedGithubRepository,
environmentGitHubBranch,
autoReloadPollIntervalMs,
hasVercelIntegration,
canWriteDeployments,
} = useTypedLoaderData<typeof loader>();
const hasDeployments = totalPages > 0;
const { deploymentParam } = useParams();
const location = useLocation();
const navigate = useNavigate();
useAutoRevalidate({ interval: autoReloadPollIntervalMs, onFocus: true });
// If we have a selected deployment from the version param, show it
useEffect(() => {
if (selectedDeployment && !deploymentParam) {
const searchParams = new URLSearchParams(location.search);
searchParams.delete("version");
searchParams.set("page", currentPage.toString());
navigate(`${location.pathname}/${selectedDeployment.shortCode}?${searchParams.toString()}`);
}
}, [selectedDeployment, deploymentParam, location.search]);
const currentDeployment = deployments.find((d) => d.isCurrent);
return (
<PageContainer>
<NavBar>
<PageTitle title="Deployments" />
<PageAccessories>
<LinkButton
variant={"docs/small"}
LeadingIcon={BookOpenIcon}
to={docsPath("/cli-deploy")}
>
Deployments docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<ResizablePanelGroup orientation="horizontal" className="h-full max-h-full">
<ResizablePanel id="deployments-main" min="100px" className="max-h-full">
{hasDeployments ? (
<div className="flex h-full max-h-full flex-col">
<Table containerClassName="border-t-0 grow">
<TableHeader>
<TableRow>
<TableHeaderCell>Deploy</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell
tooltip={
<div className="flex flex-col divide-y divide-grid-dimmed">
{deploymentStatuses.map((status) => (
<div
key={status}
className="grid grid-cols-[8rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1"
>
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
<DeploymentStatus status={status} isBuilt={false} />
</div>
<Paragraph
variant="extra-small"
className="text-wrap! text-text-dimmed"
>
{deploymentStatusDescription(status)}
</Paragraph>
</div>
))}
</div>
}
>
Status
</TableHeaderCell>
<TableHeaderCell>Runtime</TableHeaderCell>
<TableHeaderCell>Tasks</TableHeaderCell>
<TableHeaderCell>Deployed at</TableHeaderCell>
<TableHeaderCell>Deployed by</TableHeaderCell>
<TableHeaderCell>Git</TableHeaderCell>
{hasVercelIntegration && <TableHeaderCell>Linked</TableHeaderCell>}
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{deployments.length > 0 ? (
deployments.map((deployment) => {
const path = v3DeploymentPath(
organization,
project,
environment,
deployment,
currentPage
);
const isSelected = deploymentParam === deployment.shortCode;
return (
<TableRow key={deployment.id} className="group" isSelected={isSelected}>
<TableCell to={path} isTabbableCell isSelected={isSelected}>
<div className="flex items-center gap-2">
<Paragraph
variant="extra-small"
className="group-hover/table-row:text-text-bright"
>
{deployment.shortCode}
</Paragraph>
{deployment.label && (
<Badge variant="extra-small">{titleCase(deployment.label)}</Badge>
)}
</div>
</TableCell>
<TableCell to={path} isSelected={isSelected}>
{deployment.version}
</TableCell>
<TableCell to={path} isSelected={isSelected}>
<DeploymentStatus
status={deployment.status}
isBuilt={deployment.isBuilt}
/>
</TableCell>
<TableCell to={path} isSelected={isSelected}>
<RuntimeIcon
runtime={deployment.runtime}
runtimeVersion={deployment.runtimeVersion}
/>
</TableCell>
<TableCell to={path} isSelected={isSelected}>
{deployment.tasksCount !== null ? deployment.tasksCount : ""}
</TableCell>
<TableCell to={path} isSelected={isSelected}>
{deployment.deployedAt ? (
<DateTime date={deployment.deployedAt} />
) : (
""
)}
</TableCell>
<TableCell to={path} isSelected={isSelected}>
{deployment.git?.source === "trigger_github_app" ? (
<UserTag
name={deployment.git.ghUsername ?? "GitHub Integration"}
avatarUrl={deployment.git.ghUserAvatarUrl}
/>
) : deployment.deployedBy ? (
<UserTag
name={
deployment.deployedBy.name ??
deployment.deployedBy.displayName ??
""
}
avatarUrl={deployment.deployedBy.avatarUrl ?? undefined}
/>
) : (
""
)}
</TableCell>
<TableCell isSelected={isSelected}>
<div className="-ml-1 flex items-center">
<GitMetadata git={deployment.git} />
</div>
</TableCell>
{hasVercelIntegration && (
<TableCell isSelected={isSelected}>
{deployment.vercelDeploymentUrl ? (
<div
className="-ml-1 flex items-center"
onClick={(e) => e.stopPropagation()}
>
<VercelLink
vercelDeploymentUrl={deployment.vercelDeploymentUrl}
/>
</div>
) : (
""
)}
</TableCell>
)}
<DeploymentActionsCell
deployment={deployment}
path={path}
isSelected={isSelected}
currentDeployment={currentDeployment}
canWriteDeployments={canWriteDeployments}
/>
</TableRow>
);
})
) : (
<TableBlankRow colSpan={hasVercelIntegration ? 9 : 8}>
<Paragraph className="flex items-center justify-center">
No deploys match your filters
</Paragraph>
</TableBlankRow>
)}
</TableBody>
</Table>
<div
className={cn(
"-mt-px flex flex-wrap justify-end gap-2 border-t border-grid-dimmed px-3 pb-[7px] pt-[6px]",
connectedGithubRepository && environmentGitHubBranch && "justify-between"
)}
>
{connectedGithubRepository && environmentGitHubBranch && (
<div className="flex flex-nowrap items-center gap-2 whitespace-nowrap text-sm">
<OctoKitty className="size-4" />
Automatically triggered by pushes to{" "}
<div className="flex max-w-32 items-center gap-1 truncate rounded bg-grid-dimmed px-1 font-mono">
<GitBranchIcon className="size-3 shrink-0" />
<span className="max-w-28 truncate">{environmentGitHubBranch}</span>
</div>{" "}
in
<a
href={connectedGithubRepository.repository.htmlUrl}
target="_blank"
rel="noreferrer noopener"
className="max-w-52 truncate text-sm text-text-dimmed underline transition-colors hover:text-text-bright"
>
{connectedGithubRepository.repository.fullName}
</a>
<LinkButton
variant="minimal/small"
LeadingIcon={CogIcon}
to={v3ProjectSettingsIntegrationsPath(organization, project, environment)}
/>
</div>
)}
<PaginationControls currentPage={currentPage} totalPages={totalPages} />
</div>
</div>
) : environment.type === "DEVELOPMENT" ? (
<MainCenteredContainer className="max-w-prose">
<DeploymentsNoneDev />
</MainCenteredContainer>
) : (
<MainCenteredContainer className="max-w-prose">
<DeploymentsNone />
</MainCenteredContainer>
)}
</ResizablePanel>
<ResizableHandle
id="deployments-handle"
className={collapsibleHandleClassName(!!deploymentParam)}
/>
<ResizablePanel
id="deployments-inspector"
default="400px"
min="400px"
max="800px"
className="overflow-hidden"
collapsible
collapsed={!deploymentParam}
onCollapseChange={() => {}}
collapsedSize="0px"
collapseAnimation={RESIZABLE_PANEL_ANIMATION}
>
<div className="h-full" style={{ minWidth: 400 }}>
<Outlet />
</div>
</ResizablePanel>
</ResizablePanelGroup>
</PageBody>
</PageContainer>
);
}
export function UserTag({ name, avatarUrl }: { name: string; avatarUrl?: string }) {
return (
<div className="flex items-center gap-1">
<UserAvatar
avatarUrl={avatarUrl}
name={name}
className="h-4 w-4 group-hover/table-row:text-text-bright"
/>
<Paragraph variant="extra-small" className="group-hover/table-row:text-text-bright">
{name}
</Paragraph>
</div>
);
}
function DeploymentActionsCell({
deployment,
path,
isSelected,
currentDeployment,
canWriteDeployments,
}: {
deployment: DeploymentListItem;
path: string;
isSelected: boolean;
currentDeployment?: DeploymentListItem;
canWriteDeployments: boolean;
}) {
const location = useLocation();
const project = useProject();
const canBeMadeCurrent = !deployment.isCurrent && deployment.isDeployed;
const canBeRolledBack =
canBeMadeCurrent &&
currentDeployment?.version &&
compareDeploymentVersions(deployment.version, currentDeployment.version) === -1;
const canBePromoted = canBeMadeCurrent && !canBeRolledBack;
const finalStatuses = ["CANCELED", "DEPLOYED", "FAILED", "TIMED_OUT"];
const canBeCanceled = !finalStatuses.includes(deployment.status);
if (!canBeRolledBack && !canBePromoted && !canBeCanceled) {
return (
<TableCell to={path} isSelected={isSelected}>
{""}
</TableCell>
);
}
return (
<TableCellMenu
isSticky
isSelected={isSelected}
popoverContent={
<>
{canBeRolledBack &&
(canWriteDeployments ? (
<Dialog>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={ArrowUturnLeftIcon}
leadingIconClassName="text-blue-500"
fullWidth
textAlignLeft
>
Rollback
</Button>
</DialogTrigger>
<RollbackDeploymentDialog
projectId={project.id}
deploymentShortCode={deployment.shortCode}
redirectPath={`${location.pathname}${location.search}`}
/>
</Dialog>
) : (
<Button
variant="small-menu-item"
LeadingIcon={ArrowUturnLeftIcon}
leadingIconClassName="text-blue-500"
fullWidth
textAlignLeft
disabled
tooltip="You don't have permission to roll back deployments"
>
Rollback
</Button>
))}
{canBePromoted &&
(canWriteDeployments ? (
<Dialog>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={PromoteIcon}
leadingIconClassName="text-blue-500"
fullWidth
textAlignLeft
>
Promote
</Button>
</DialogTrigger>
<PromoteDeploymentDialog
projectId={project.id}
deploymentShortCode={deployment.shortCode}
redirectPath={`${location.pathname}${location.search}`}
/>
</Dialog>
) : (
<Button
variant="small-menu-item"
LeadingIcon={PromoteIcon}
leadingIconClassName="text-blue-500"
fullWidth
textAlignLeft
disabled
tooltip="You don't have permission to promote deployments"
>
Promote
</Button>
))}
{canBeCanceled &&
(canWriteDeployments ? (
<Dialog>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={NoSymbolIcon}
leadingIconClassName="text-error"
fullWidth
textAlignLeft
>
Cancel
</Button>
</DialogTrigger>
<CancelDeploymentDialog
projectId={project.id}
deploymentShortCode={deployment.shortCode}
redirectPath={`${location.pathname}${location.search}`}
/>
</Dialog>
) : (
<Button
variant="small-menu-item"
LeadingIcon={NoSymbolIcon}
leadingIconClassName="text-error"
fullWidth
textAlignLeft
disabled
tooltip="You don't have permission to cancel deployments"
>
Cancel
</Button>
))}
</>
}
/>
);
}
type RollbackDeploymentDialogProps = {
projectId: string;
deploymentShortCode: string;
redirectPath: string;
};
function RollbackDeploymentDialog({
projectId,
deploymentShortCode,
redirectPath,
}: RollbackDeploymentDialogProps) {
const navigation = useNavigation();
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/rollback`;
const isLoading = navigation.formAction === formAction;
return (
<DialogContent key="rollback">
<DialogHeader>Rollback to this deployment?</DialogHeader>
<DialogDescription>
This deployment will become the default for all future runs. Tasks triggered but not
included in this deploy will remain queued until you roll back to or create a new deployment
with these tasks included.
</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Form
action={`/resources/${projectId}/deployments/${deploymentShortCode}/rollback`}
method="post"
>
<Button
type="submit"
name="redirectUrl"
value={redirectPath}
variant="primary/medium"
LeadingIcon={isLoading ? SpinnerWhite : ArrowPathIcon}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Rolling back..." : "Rollback deployment"}
</Button>
</Form>
</DialogFooter>
</DialogContent>
);
}
function PromoteDeploymentDialog({
projectId,
deploymentShortCode,
redirectPath,
}: RollbackDeploymentDialogProps) {
const navigation = useNavigation();
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/promote`;
const isLoading = navigation.formAction === formAction;
return (
<DialogContent key="promote">
<DialogHeader>Promote this deployment?</DialogHeader>
<DialogDescription>
This deployment will become the default for all future runs not explicitly tied to a
specific deployment.
</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Form
action={`/resources/${projectId}/deployments/${deploymentShortCode}/promote`}
method="post"
>
<Button
type="submit"
name="redirectUrl"
value={redirectPath}
variant="primary/medium"
LeadingIcon={isLoading ? SpinnerWhite : ArrowPathIcon}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Promoting..." : "Promote deployment"}
</Button>
</Form>
</DialogFooter>
</DialogContent>
);
}
function CancelDeploymentDialog({
projectId,
deploymentShortCode,
redirectPath,
}: RollbackDeploymentDialogProps) {
const navigation = useNavigation();
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/cancel`;
const isLoading = navigation.formAction === formAction;
return (
<DialogContent key="cancel">
<DialogHeader>Cancel this deployment?</DialogHeader>
<DialogDescription>Canceling a deployment cannot be undone. Are you sure?</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Back</Button>
</DialogClose>
<Form action={formAction} method="post">
<Button
type="submit"
name="redirectUrl"
value={redirectPath}
variant="danger/medium"
LeadingIcon={isLoading ? SpinnerWhite : NoSymbolIcon}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Canceling..." : "Cancel deployment"}
</Button>
</Form>
</DialogFooter>
</DialogContent>
);
}
@@ -0,0 +1,357 @@
import { CheckIcon, PlusIcon } from "@heroicons/react/20/solid";
import { BookOpenIcon } from "@heroicons/react/24/solid";
import { useSearchParams } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { useCallback } from "react";
import { SearchInput } from "~/components/primitives/SearchInput";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
import { V4Title } from "~/components/V4Badge";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Badge } from "~/components/primitives/Badge";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { CopyableText } from "~/components/primitives/CopyableText";
import { DateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { Header3 } from "~/components/primitives/Headers";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
import { PopoverMenuItem } from "~/components/primitives/Popover";
import * as Property from "~/components/primitives/PropertyTable";
import { Switch } from "~/components/primitives/Switch";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { BranchesPresenter } from "~/presenters/v3/BranchesPresenter.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { branchesDevPath, docsPath, ProjectParamSchema } from "~/utils/pathBuilder";
import { ArchiveButton } from "../resources.branches.archive";
import { NewBranchPanel } from "~/routes/resources.branches.create";
import { BranchesOptions } from "~/utils/branches";
import { IconArrowBearRight2 } from "@tabler/icons-react";
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam } = ProjectParamSchema.parse(params);
const searchParams = new URL(request.url).searchParams;
const parsedSearchParams = BranchesOptions.safeParse(Object.fromEntries(searchParams));
const options = parsedSearchParams.success ? parsedSearchParams.data : {};
try {
const presenter = new BranchesPresenter();
const result = await presenter.call({
userId,
projectSlug: projectParam,
env: "development",
...options,
});
return typedjson(result);
} catch (error) {
logger.error("Error loading dev branches page", { error });
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, if this problem persists please contact support.",
});
}
};
export default function Page() {
const { branches, limits, currentPage, totalPages } = useTypedLoaderData<typeof loader>();
useAutoRevalidate({ interval: 5000 });
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const atBranchLimit = limits.used >= limits.limit;
const usageRatio = limits.limit > 0 ? Math.min(limits.used / limits.limit, 1) : 0;
return (
<PageContainer>
<NavBar>
<PageTitle title={<V4Title>Dev branches</V4Title>} />
<PageAccessories>
<AdminDebugTooltip>
<Property.Table>
{branches.map((branch) => (
<Property.Item key={branch.id}>
<Property.Label>{branch.branchName}</Property.Label>
<Property.Value>{branch.id}</Property.Value>
</Property.Item>
))}
</Property.Table>
</AdminDebugTooltip>
<LinkButton
variant={"docs/small"}
LeadingIcon={BookOpenIcon}
to={docsPath("deployment/dev-branches")}
>
Dev branches docs
</LinkButton>
{limits.isAtLimit ? (
<BranchLimitReachedDialog limits={limits} />
) : (
<NewBranchPanel
button={
<Button
variant="primary/small"
shortcut={{ key: "n" }}
LeadingIcon={PlusIcon}
leadingIconClassName="text-white"
fullWidth
textAlignLeft
>
New branch
</Button>
}
env="development"
/>
)}
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<div className="grid max-h-full min-h-full grid-rows-[auto_1fr_auto]">
<div className="flex items-center justify-between gap-x-1.5 p-2">
<BranchFilters />
<PaginationControls
currentPage={currentPage}
totalPages={totalPages}
showPageNumbers={false}
/>
</div>
<div className="grid max-h-full min-h-full grid-rows-[1fr] overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Branch</TableHeaderCell>
<TableHeaderCell>Created</TableHeaderCell>
<TableHeaderCell>Last active</TableHeaderCell>
<TableHeaderCell>Archived</TableHeaderCell>
<TableHeaderCell>
<span className="sr-only">Actions</span>
</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{branches.length === 0 ? (
<TableBlankRow colSpan={5}>
<Paragraph>There are no matches for your filters</Paragraph>
</TableBlankRow>
) : (
branches.map((branch) => {
const path = branchesDevPath(organization, project, branch);
const cellClass = branch.archivedAt ? "opacity-50" : "";
const isSelected = branch.id === environment.id;
return (
<TableRow key={branch.id}>
<TableCell isTabbableCell className={cellClass}>
<div className="flex items-center gap-1">
<BranchEnvironmentIconSmall
className={cn("size-4", isSelected && "text-dev")}
/>
<CopyableText
value={branch.branchName}
className={cn(isSelected && "text-dev")}
/>
{isSelected && <Badge variant="extra-small">Current</Badge>}
</div>
</TableCell>
<TableCell className={cellClass}>
<DateTime date={branch.createdAt} />
</TableCell>
<TableCell className={cellClass}>
{branch.isConnected ? (
<>Online now</>
) : branch.lastActivity ? (
<DateTime date={branch.lastActivity} />
) : null}
</TableCell>
<TableCell className={cellClass}>
{branch.archivedAt ? (
<CheckIcon className="size-4 text-text-dimmed" />
) : (
""
)}
</TableCell>
<TableCellMenu
className="pl-32"
isSticky
hiddenButtons={
isSelected ? null : (
<LinkButton
to={path}
variant="secondary/small"
LeadingIcon={IconArrowBearRight2}
leadingIconClassName="text-blue-500 -mr-2"
className="pl-1.5"
>
Switch to branch
</LinkButton>
)
}
popoverContent={
!isSelected || !branch.archivedAt ? (
<>
{isSelected ? null : (
<PopoverMenuItem
to={path}
icon={IconArrowBearRight2}
leadingIconClassName="text-blue-500 -mr-0.5 -ml-1"
title="Switch to branch"
/>
)}
{!branch.archivedAt ? (
<ArchiveButton
environment={branch}
// The root dev env (no parent) is the default
// branch and can't be archived — matches the
// guard in ArchiveBranchService.
disabled={!branch.parentEnvironmentId}
/>
) : null}
</>
) : null
}
/>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
<div className="flex w-full items-start justify-between">
<div className="flex h-fit w-full items-center gap-4 border-t border-grid-bright bg-background-bright p-[0.86rem] pl-4">
<SimpleTooltip
button={
<div className="size-6">
<svg className="h-full w-full -rotate-90 overflow-visible">
<circle
className="fill-none stroke-grid-bright"
strokeWidth="4"
r="10"
cx="12"
cy="12"
/>
<circle
className={`fill-none ${atBranchLimit ? "stroke-error" : "stroke-success"}`}
strokeWidth="4"
r="10"
cx="12"
cy="12"
strokeDasharray={`${usageRatio * 62.8} 62.8`}
strokeDashoffset="0"
strokeLinecap="round"
/>
</svg>
</div>
}
content={`${Math.round(usageRatio * 100)}%`}
/>
<div className="flex w-full items-center justify-between gap-6">
{atBranchLimit ? (
<Header3 className="text-error">
You've used all {limits.limit} of your branches. Archive one to free up space.
</Header3>
) : (
<div className="flex items-center gap-1">
<Header3>
You've used {limits.used}/{limits.limit} of your branches
</Header3>
<InfoIconTooltip content="Archived branches don't count towards your limit." />
</div>
)}
</div>
</div>
</div>
</div>
</PageBody>
</PageContainer>
);
}
export function BranchFilters() {
const [searchParams, setSearchParams] = useSearchParams();
const { showArchived } = BranchesOptions.parse(Object.fromEntries(searchParams.entries()));
const handleArchivedChange = useCallback((checked: boolean) => {
setSearchParams((s) => {
if (checked) {
s.set("showArchived", "true");
} else {
s.delete("showArchived");
}
s.delete("page");
return s;
});
}, []);
return (
<div className="flex w-full items-center justify-between gap-2">
<SearchInput placeholder="Search branch name…" resetParams={["page"]} />
<Switch
checked={showArchived ?? false}
onCheckedChange={handleArchivedChange}
label="Show archived"
variant="secondary/small"
/>
</div>
);
}
function BranchLimitReachedDialog({
limits,
}: {
limits: {
used: number;
limit: number;
};
}) {
return (
<Dialog>
<DialogTrigger asChild>
<Button
LeadingIcon={PlusIcon}
leadingIconClassName="text-white"
variant="primary/small"
shortcut={{ key: "n" }}
>
New branch
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>You've exceeded your limit</DialogHeader>
<div className="mt-2">
<Paragraph spacing>
You've used {limits.used}/{limits.limit} of your branches.
</Paragraph>
<Paragraph>You can archive a branch to free up space.</Paragraph>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,678 @@
import { getFormProps, useForm, type FieldMetadata, type FormMetadata } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import {
LockClosedIcon,
LockOpenIcon,
NoSymbolIcon,
PlusIcon,
XMarkIcon,
} from "@heroicons/react/20/solid";
import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react";
import { json } from "@remix-run/server-runtime";
import dotenv from "dotenv";
import { useCallback, useState } from "react";
import { redirect } from "remix-typedjson";
import invariant from "tiny-invariant";
import { z } from "zod";
import { EnvironmentLabel, environmentFullTitle } from "~/components/environments/EnvironmentLabel";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Select, SelectItem } from "~/components/primitives/Select";
import { Switch } from "~/components/primitives/Switch";
import { TextLink } from "~/components/primitives/TextLink";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/primitives/Tooltip";
import { prisma } from "~/db.server";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useList } from "~/hooks/useList";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useTypedMatchesData } from "~/hooks/useTypedMatchData";
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import {
environmentVariablesRouteId,
type loader as environmentVariablesLoader,
} from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route";
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
import { cn } from "~/utils/cn";
import {
EnvironmentParamSchema,
v3BillingPath,
v3EnvironmentVariablesPath,
} from "~/utils/pathBuilder";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import { EnvironmentVariableKey } from "~/v3/environmentVariables/repository";
const Variable = z.object({
key: EnvironmentVariableKey,
value: z.string().nonempty("Value is required"),
});
type Variable = z.infer<typeof Variable>;
const schema = z.object({
override: z.preprocess((i) => {
if (i === "true") return true;
if (i === "false") return false;
return;
}, z.boolean()),
isSecret: z.preprocess((i) => {
if (i === "true") return true;
return false;
}, z.boolean()),
environmentIds: z.preprocess(
(i) => {
if (typeof i === "string") return [i];
if (Array.isArray(i)) {
const ids = i.filter((v) => typeof v === "string" && v !== "");
if (ids.length === 0) {
return;
}
return ids;
}
return;
},
z.array(z.string(), { required_error: "At least one environment is required" })
),
variables: z.preprocess((i) => {
if (!Array.isArray(i)) {
return [];
}
return i;
}, Variable.array().nonempty("At least one variable is required")),
});
export const action = dashboardAction(
{
params: EnvironmentParamSchema,
context: async (params) => {
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
return organizationId ? { organizationId } : {};
},
// Per-environment write:envvars is enforced in the handler — the target
// environments come from the submission, not the route params.
},
async ({ request, params, user, ability }) => {
const userId = user.id;
const { organizationSlug, projectParam, envParam } = params;
if (request.method.toUpperCase() !== "POST") {
throw new Response("Method Not Allowed", { status: 405 });
}
const formData = await request.formData();
const submission = parseWithZod(formData, { schema });
if (submission.status !== "success") {
return json(submission.reply());
}
// Enforce env-tier write:envvars for every targeted environment, so a role
// that can't write a deployed tier can't create vars there via a direct
// POST (the disabled checkboxes are not the boundary).
const targetEnvironments = await prisma.runtimeEnvironment.findMany({
where: { id: { in: submission.value.environmentIds } },
select: { type: true },
});
const hasDeniedEnvironment = targetEnvironments.some(
(env) => !ability.can("write", { type: "envvars", envType: env.type })
);
if (hasDeniedEnvironment) {
return json(
submission.reply({
fieldErrors: {
environmentIds: [
"You don't have permission to manage environment variables in one of the selected environments.",
],
},
})
);
}
const project = await prisma.project.findUnique({
where: {
slug: params.projectParam,
organization: {
members: {
some: {
userId,
},
},
},
},
select: {
id: true,
},
});
if (!project) {
return json(submission.reply({ formErrors: ["Project not found"] }));
}
const repository = new EnvironmentVariablesRepository(prisma);
const result = await repository.create(project.id, {
...submission.value,
lastUpdatedBy: {
type: "user",
userId,
},
});
if (!result.success) {
const fieldErrors: Record<string, string[]> = {};
if (result.variableErrors) {
for (const { key, error } of result.variableErrors) {
const index = submission.value.variables.findIndex((v) => v.key === key);
if (index !== -1) {
fieldErrors[`variables[${index}].key`] = [error];
}
}
} else {
fieldErrors.variables = [result.error];
}
return json(submission.reply({ fieldErrors }));
}
return redirect(
v3EnvironmentVariablesPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam }
)
);
}
);
export default function Page() {
const [isOpen, _setIsOpen] = useState(true);
const parentData = useTypedMatchesData<typeof environmentVariablesLoader>({
id: environmentVariablesRouteId,
});
invariant(
parentData,
"Environment variables page loader data must be defined when rendering the create dialog"
);
const { environments, hasStaging, writableEnvironmentIds } = parentData;
// Creating a variable is a write, so gate the targets on write access.
const writableEnvironmentIdSet = new Set(writableEnvironmentIds);
const lastSubmission = useActionData();
const navigation = useNavigation();
const navigate = useNavigate();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState<Set<string>>(new Set());
const [selectedBranchId, setSelectedBranchId] = useState<string | undefined>(undefined);
// TODO for no we only support branch-specific env vars for Preview environments
// Mostly to keep the UX for setting consistent env-vars across Dev/Staging/Prod easier
const previewBranches = environments.filter(
(env) => env.type === "PREVIEW" && env.parentEnvironmentId !== null
);
const nonBranchEnvironments = environments.filter((env) => env.parentEnvironmentId === null);
const selectedEnvironments = environments.filter((env) => selectedEnvironmentIds.has(env.id));
const previewIsSelected = selectedEnvironments.some((env) => env.type === "PREVIEW");
const isLoading = navigation.state !== "idle" && navigation.formMethod === "post";
const [form, fields] = useForm<z.infer<typeof schema>>({
id: "create-environment-variables",
// TODO: type this
lastResult: lastSubmission as any,
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
shouldRevalidate: "onSubmit",
defaultValue: {
variables: [{ key: "", value: "" }],
},
});
const { environmentIds, variables } = fields;
const handleEnvironmentChange = (
environmentId: string,
isChecked: boolean,
environmentType?: string
) => {
setSelectedEnvironmentIds((prev) => {
const newSet = new Set(prev);
if (isChecked) {
if (environmentType === "PREVIEW") {
// If PREVIEW is checked, clear all other selections including branches
newSet.clear();
newSet.add(environmentId);
} else {
// If a non-PREVIEW environment is checked, remove PREVIEW if it's selected
const previewEnv = environments.find((env) => env.type === "PREVIEW");
if (previewEnv) {
newSet.delete(previewEnv.id);
}
newSet.add(environmentId);
setSelectedBranchId(undefined);
}
} else {
newSet.delete(environmentId);
}
return newSet;
});
};
const handleBranchChange = (branchId: string) => {
if (branchId === "all") {
setSelectedBranchId(undefined);
} else {
setSelectedBranchId(branchId);
}
};
const [revealAll, setRevealAll] = useState(true);
return (
<Dialog
open={isOpen}
onOpenChange={(o) => {
if (!o) {
navigate(v3EnvironmentVariablesPath(organization, project, environment));
}
}}
>
<DialogContent className="p-0 pt-2.5 md:max-w-2xl lg:max-w-3xl">
<DialogHeader className="px-4">New environment variables</DialogHeader>
<Form method="post" {...getFormProps(form)}>
<Fieldset className="max-h-[70vh] overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<InputGroup fullWidth>
<Label>Environments</Label>
{selectedBranchId ? (
<input type="hidden" name="environmentIds" value={selectedBranchId} />
) : (
Array.from(selectedEnvironmentIds).map((id) => (
<input key={id} type="hidden" name="environmentIds" value={id} />
))
)}
<div className="flex items-center gap-2">
{nonBranchEnvironments.map((environment) =>
writableEnvironmentIdSet.has(environment.id) ? (
<CheckboxWithLabel
key={environment.id}
id={environment.id}
value={environment.id}
defaultChecked={selectedEnvironmentIds.has(environment.id)}
onChange={(isChecked) =>
handleEnvironmentChange(environment.id, isChecked, environment.type)
}
label={<EnvironmentLabel environment={environment} className="text-sm" />}
variant="button"
/>
) : (
<TooltipProvider key={environment.id}>
<Tooltip>
<TooltipTrigger asChild>
<div>
<CheckboxWithLabel
id={environment.id}
value={environment.id}
disabled
defaultChecked={false}
label={
<EnvironmentLabel environment={environment} className="text-sm" />
}
variant="button"
/>
</div>
</TooltipTrigger>
<TooltipContent className="flex items-center gap-2">
<NoSymbolIcon className="size-4 text-text-dimmed" />
With your current role, you can't manage{" "}
{environmentFullTitle(environment)} environment variables.
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
)}
{!hasStaging && (
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<TextLink
to={v3BillingPath(organization)}
className="flex w-fit cursor-pointer items-center gap-2 rounded border border-dashed border-border-bright py-2.5 pl-3 pr-4 transition hover:border-border-brightest hover:bg-background-dimmed"
>
<LockClosedIcon className="size-4 text-text-faint" />
<EnvironmentLabel
environment={{ type: "STAGING" }}
className="text-sm"
/>
</TextLink>
</TooltipTrigger>
<TooltipContent className="flex items-center gap-2">
<LockOpenIcon className="size-4 text-indigo-500" />
Upgrade your plan to add a Staging environment.
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<TextLink
to={v3BillingPath(organization)}
className="flex w-fit cursor-pointer items-center gap-2 rounded border border-dashed border-border-bright py-2.5 pl-3 pr-4 transition hover:border-border-brightest hover:bg-background-dimmed"
>
<LockClosedIcon className="size-4 text-text-faint" />
<EnvironmentLabel
environment={{ type: "PREVIEW" }}
className="text-sm"
/>
</TextLink>
</TooltipTrigger>
<TooltipContent className="flex items-center gap-2">
<LockOpenIcon className="size-4 text-indigo-500" />
Upgrade your plan to add Preview branches.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
)}
</div>
<FormError id={environmentIds.errorId}>{environmentIds.errors}</FormError>
<Hint>
Dev environment variables specified here will be overridden by ones in your .env
file when running locally.
</Hint>
</InputGroup>
{previewIsSelected && (
<InputGroup fullWidth>
<Label>Select branch</Label>
<div className="flex items-center gap-1">
<Select
variant="tertiary/medium"
value={selectedBranchId ?? "all"}
setValue={handleBranchChange}
placeholder="All branches"
items={[{ id: "all", branchName: "All branches" }, ...previewBranches]}
className="w-fit min-w-52"
filter={{
keys: [
(item) => item.branchName?.replace(/\//g, " ").replace(/_/g, " ") ?? "",
],
}}
text={(val) =>
val ? previewBranches.find((b) => b.id === val)?.branchName : null
}
dropdownIcon
>
{(matches) =>
matches?.map((env) => (
<SelectItem key={env.id} value={env.id}>
{env.branchName}
</SelectItem>
))
}
</Select>
{selectedBranchId !== "all" && selectedBranchId !== undefined && (
<Button
variant="minimal/medium"
type="button"
onClick={() => setSelectedBranchId(undefined)}
LeadingIcon={XMarkIcon}
/>
)}
</div>
<Hint>Select a branch to override variables in the Preview environment.</Hint>
</InputGroup>
)}
<InputGroup className="w-auto">
<Switch
name="isSecret"
variant="medium"
defaultChecked={false}
value="true"
className="-ml-2 inline-flex w-fit"
label={<span className="text-text-bright">Secret value</span>}
/>
<Hint className="-mt-1">
If enabled, you and your team will not be able to view the values after creation.
</Hint>
</InputGroup>
<InputGroup fullWidth>
<FieldLayout>
<Label>Keys</Label>
<div className="flex justify-between gap-1">
<Label>Values</Label>
<Switch
variant="small"
label="Reveal"
checked={revealAll}
onCheckedChange={(e) => setRevealAll(e.valueOf())}
/>
</div>
</FieldLayout>
<VariableFields
revealValues={revealAll}
formId={form.id}
form={form}
variablesFields={variables}
/>
<FormError id={variables.errorId}>{variables.errors}</FormError>
</InputGroup>
<FormError>{form.errors}</FormError>
</Fieldset>
<FormButtons
className="px-4 pb-4"
confirmButton={
<div className="flex flex-row-reverse items-center gap-2">
<Button
type="submit"
variant="primary/medium"
disabled={isLoading}
name="override"
value="false"
>
{isLoading ? "Saving" : "Save"}
</Button>
<Button
variant="secondary/medium"
disabled={isLoading}
name="override"
value="true"
>
{isLoading ? "Overriding" : "Override"}
</Button>
</div>
}
cancelButton={
<LinkButton
to={v3EnvironmentVariablesPath(organization, project, environment)}
variant="tertiary/medium"
>
Cancel
</LinkButton>
}
/>
</Form>
</DialogContent>
</Dialog>
);
}
function FieldLayout({ children }: { children: React.ReactNode }) {
return <div className="grid w-full grid-cols-[1fr_1fr] gap-1.5">{children}</div>;
}
function VariableFields({
revealValues,
formId,
variablesFields,
form,
}: {
revealValues: boolean;
formId?: string;
variablesFields: FieldMetadata<Variable[]>;
form: FormMetadata<any>;
}) {
const {
items,
append,
update,
delete: remove,
insertAfter,
} = useList<Variable>([{ key: "", value: "" }]);
const handlePaste = useCallback((index: number, e: React.ClipboardEvent<HTMLInputElement>) => {
const clipboardData = e.clipboardData;
if (!clipboardData) return;
let text = clipboardData.getData("text");
if (!text) return;
const variables = dotenv.parse(text);
const keyValuePairs = Object.entries(variables).map(([key, value]) => ({ key, value }));
//do the default paste
if (keyValuePairs.length === 0) return;
//prevent default pasting
e.preventDefault();
const [firstPair, ...rest] = keyValuePairs;
update(index, firstPair);
for (const _pair of rest) {
form.insert({ name: variablesFields.name });
}
insertAfter(index, rest);
}, []);
const fields = variablesFields.getFieldList();
return (
<>
{fields.map((field, index) => {
const item = items[index];
return (
<VariableField
formId={formId}
key={index}
index={index}
value={item}
onChange={(value) => update(index, value)}
onPaste={(e) => handlePaste(index, e)}
onDelete={() => {
form.remove({ name: variablesFields.name, index });
remove(index);
}}
showDeleteButton={items.length > 1}
showValue={revealValues}
config={field}
/>
);
})}
<div className="flex items-center justify-between gap-4">
<Paragraph variant="extra-small">
Tip: Paste all your .env values at once into this form to populate it.
</Paragraph>
<Button
variant="tertiary/medium"
className="w-fit"
type="button"
onClick={() => {
form.insert({ name: variablesFields.name });
append([{ key: "", value: "" }]);
}}
LeadingIcon={PlusIcon}
>
Add another
</Button>
</div>
</>
);
}
function VariableField({
formId,
index,
value,
onChange,
onPaste,
onDelete,
showDeleteButton,
showValue,
config,
}: {
formId?: string;
index: number;
value: Variable;
onChange: (value: Variable) => void;
onPaste: (e: React.ClipboardEvent<HTMLInputElement>) => void;
onDelete: () => void;
showDeleteButton: boolean;
showValue: boolean;
config: FieldMetadata<Variable>;
}) {
const fields = config.getFieldset();
const baseFieldName = `variables[${index}]`;
return (
<fieldset>
<FieldLayout>
<div className="space-y-2">
<Input
id={`${formId}-${baseFieldName}.key`}
name={`${baseFieldName}.key`}
placeholder="e.g. CLIENT_KEY"
value={value.key}
onChange={(e) => onChange({ ...value, key: e.currentTarget.value })}
autoFocus={index === 0}
onPaste={onPaste}
/>
<FormError id={fields.key.errorId}>{fields.key.errors}</FormError>
</div>
<div className={cn("flex items-start gap-1")}>
<div className="grow space-y-2">
<Input
id={`${formId}-${baseFieldName}.value`}
name={`${baseFieldName}.value`}
type={showValue ? "text" : "password"}
placeholder="Not set"
value={value.value}
onChange={(e) => onChange({ ...value, value: e.currentTarget.value })}
/>
<FormError id={fields.value.errorId}>{fields.value.errors}</FormError>
</div>
{showDeleteButton && (
<Button
variant="minimal/medium"
type="button"
onClick={() => onDelete()}
LeadingIcon={XMarkIcon}
/>
)}
</div>
</FieldLayout>
</fieldset>
);
}
@@ -0,0 +1,971 @@
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import {
BookOpenIcon,
InformationCircleIcon,
LockClosedIcon,
NoSymbolIcon,
PencilSquareIcon,
PlusIcon,
TrashIcon,
} from "@heroicons/react/20/solid";
import {
Form,
Outlet,
useActionData,
useFetcher,
useNavigation,
useRevalidator,
type MetaFunction,
} from "@remix-run/react";
import { json } from "@remix-run/server-runtime";
import { useVirtualizer } from "@tanstack/react-virtual";
import { fromPromise } from "neverthrow";
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { UserAvatar } from "~/components/UserProfilePhoto";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
import { VercelLogo } from "~/components/integrations/VercelLogo";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { CopyableText } from "~/components/primitives/CopyableText";
import { DateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header2 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { SearchInput } from "~/components/primitives/SearchInput";
import { Switch } from "~/components/primitives/Switch";
import {
Table,
TableBody,
TableCell,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { prisma } from "~/db.server";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useFuzzyFilter } from "~/hooks/useFuzzyFilter";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import {
EnvironmentVariablesPresenter,
type EnvironmentVariableWithSetValues,
} from "~/presenters/v3/EnvironmentVariablesPresenter.server";
import { type EnvironmentVariablesEnvironment } from "~/presenters/v3/environmentVariablesEnvironments.server";
import { logger } from "~/services/logger.server";
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { VercelIntegrationService } from "~/services/vercelIntegration.server";
import { cn } from "~/utils/cn";
import {
EnvironmentParamSchema,
docsPath,
v3EnvironmentVariablesPath,
v3NewEnvironmentVariablesPath,
} from "~/utils/pathBuilder";
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import {
DeleteEnvironmentVariableValue,
EditEnvironmentVariableValue,
} from "~/v3/environmentVariables/repository";
import {
isPullEnvVarsEnabledForEnvironment,
shouldSyncEnvVar,
type TriggerEnvironmentType,
} from "~/v3/vercel/vercelProjectIntegrationSchema";
export const meta: MetaFunction = () => {
return [
{
title: `Environment variables | Trigger.dev`,
},
];
};
type PageVercelIntegration = NonNullable<
Awaited<ReturnType<EnvironmentVariablesPresenter["call"]>>["vercelIntegration"]
>;
// A value the current role can't read for its environment tier is masked
// server-side: the value is withheld and the cell renders "Permission denied".
export type MaskedEnvironmentVariable = EnvironmentVariableWithSetValues & {
permissionDenied?: boolean;
};
export type EnvironmentVariablesPageLoaderData = {
environmentVariables: MaskedEnvironmentVariable[];
environments: EnvironmentVariablesEnvironment[];
hasStaging: boolean;
vercelIntegration: PageVercelIntegration | null;
// Environment ids whose env vars the current role can read.
accessibleEnvironmentIds: string[];
// Environment ids whose env vars the current role can write (create/edit/delete).
writableEnvironmentIds: string[];
};
export const environmentVariablesRouteId =
"routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables";
export const loader = dashboardLoader(
{
params: EnvironmentParamSchema,
context: async (params) => {
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
return organizationId ? { organizationId } : {};
},
// No hard authorization: the page lists every environment. Values in
// environments the role can't read are masked per-tier below.
},
async ({ params, user, ability }) => {
const { projectParam } = params;
try {
const presenter = new EnvironmentVariablesPresenter();
const { environmentVariables, environments, hasStaging, vercelIntegration } =
await presenter.call({
userId: user.id,
projectSlug: projectParam,
});
const accessibleEnvironmentIds = environments
.filter((env) => ability.can("read", { type: "envvars", envType: env.type }))
.map((env) => env.id);
const accessible = new Set(accessibleEnvironmentIds);
// Write access is a separate grant from read: gate write controls (edit,
// delete, create) on this set, not on read-accessibility.
const writableEnvironmentIds = environments
.filter((env) => ability.can("write", { type: "envvars", envType: env.type }))
.map((env) => env.id);
// Withhold values (and the "who/when" metadata) for environments the
// role can't read — never serialize them to the client.
const masked: MaskedEnvironmentVariable[] = environmentVariables.map((variable) =>
accessible.has(variable.environment.id)
? variable
: {
...variable,
value: "",
isSecret: false,
permissionDenied: true,
lastUpdatedBy: null,
updatedByUser: null,
}
);
return typedjson({
environmentVariables: masked,
environments,
hasStaging,
vercelIntegration,
accessibleEnvironmentIds,
writableEnvironmentIds,
});
} catch (error) {
console.error(error);
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, if this problem persists please contact support.",
});
}
}
);
const schema = z.discriminatedUnion("action", [
z.object({ action: z.literal("edit"), ...EditEnvironmentVariableValue.shape }),
z.object({
action: z.literal("delete"),
key: z.string(),
...DeleteEnvironmentVariableValue.shape,
}),
z.object({
action: z.literal("update-vercel-sync"),
key: z.string(),
environmentType: z.enum(["PRODUCTION", "STAGING", "PREVIEW", "DEVELOPMENT"]),
syncEnabled: z
.union([z.literal("true"), z.literal("false")])
.transform((val) => val === "true"),
}),
]);
export const action = dashboardAction(
{
params: EnvironmentParamSchema,
context: async (params) => {
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
return organizationId ? { organizationId } : {};
},
// Per-environment write:envvars is enforced in the handler — the target
// environment tier comes from the submission, not the route params.
},
async ({ request, params, user, ability }) => {
const userId = user.id;
const { organizationSlug, projectParam, envParam } = params;
if (request.method.toUpperCase() !== "POST") {
throw new Response("Method Not Allowed", { status: 405 });
}
const formData = await request.formData();
const submission = parseWithZod(formData, { schema });
if (submission.status !== "success") {
return json(submission.reply());
}
// Enforce env-tier write:envvars on the targeted environment, so a role
// that can't write a deployed tier can't mutate it via a direct POST.
const targetEnvType =
submission.value.action === "update-vercel-sync"
? submission.value.environmentType
: (
await prisma.runtimeEnvironment.findFirst({
where: { id: submission.value.environmentId },
select: { type: true },
})
)?.type;
if (targetEnvType && !ability.can("write", { type: "envvars", envType: targetEnvType })) {
return json(
submission.reply({
formErrors: [
"You don't have permission to manage environment variables in this environment.",
],
})
);
}
const project = await prisma.project.findUnique({
where: {
slug: params.projectParam,
organization: {
members: {
some: {
userId,
},
},
},
},
select: {
id: true,
},
});
if (!project) {
return json(submission.reply({ formErrors: ["Project not found"] }));
}
switch (submission.value.action) {
case "edit": {
const repository = new EnvironmentVariablesRepository(prisma);
const result = await repository.editValue(project.id, {
...submission.value,
lastUpdatedBy: {
type: "user",
userId,
},
});
if (!result.success) {
return json(submission.reply({ formErrors: [result.error] }));
}
return json({ ...submission.reply(), success: true });
}
case "delete": {
const repository = new EnvironmentVariablesRepository(prisma);
const result = await repository.deleteValue(project.id, submission.value);
if (!result.success) {
return json(submission.reply({ formErrors: [result.error] }));
}
// Clean up syncEnvVarsMapping if Vercel integration exists (best-effort)
const { environmentId, key } = submission.value;
const vercelService = new VercelIntegrationService();
await fromPromise(
(async () => {
const integration = await vercelService.getVercelProjectIntegration(project.id);
if (integration) {
const runtimeEnv = await prisma.runtimeEnvironment.findUnique({
where: { id: environmentId },
select: { type: true },
});
if (runtimeEnv) {
await vercelService.removeSyncEnvVarForEnvironment(
project.id,
key,
runtimeEnv.type as TriggerEnvironmentType
);
}
}
})(),
(error) => error
).mapErr((error) => {
logger.error("Failed to remove Vercel sync mapping", { error });
return error;
});
return redirectWithSuccessMessage(
v3EnvironmentVariablesPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam }
),
request,
`Deleted ${submission.value.key} environment variable`
);
}
case "update-vercel-sync": {
const vercelService = new VercelIntegrationService();
const integration = await vercelService.getVercelProjectIntegration(project.id);
if (!integration) {
return json(submission.reply({ formErrors: ["Vercel integration not found"] }));
}
// Update the sync mapping for the specific env var and environment
await vercelService.updateSyncEnvVarForEnvironment(
project.id,
submission.value.key,
submission.value.environmentType,
submission.value.syncEnabled
);
return json({ success: true });
}
}
}
);
const SSR_ROW_WINDOW = 50;
const ROW_ESTIMATE_HEIGHT = 44;
const VIRTUAL_OVERSCAN = 10;
type GroupedEnvironmentVariable = MaskedEnvironmentVariable & {
isFirstTime: boolean;
isLastTime: boolean;
occurences: number;
};
export default function Page() {
const loaderData = useTypedLoaderData<EnvironmentVariablesPageLoaderData>();
return <EnvironmentVariablesListPage loaderData={loaderData} />;
}
function EnvironmentVariablesListPage({
loaderData,
}: {
loaderData: EnvironmentVariablesPageLoaderData;
}) {
const [revealAll, setRevealAll] = useState(false);
const { environmentVariables, vercelIntegration } = loaderData;
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { value } = useSearchParams();
const urlSearch = value("search") ?? "";
const { filteredItems } = useFuzzyFilter<EnvironmentVariableWithSetValues>({
items: environmentVariables,
keys: ["key", "value", "environment.type", "environment.branchName"],
filterText: urlSearch,
});
const tableScrollRef = useRef<HTMLDivElement>(null);
// Add isFirst and isLast to each environment variable
// They're set based on if they're the first or last time that `key` has been seen in the list
const groupedEnvironmentVariables = useMemo((): GroupedEnvironmentVariable[] => {
// Create a map to track occurrences of each key
const keyOccurrences = new Map<string, number>();
// First pass: count total occurrences of each key
filteredItems.forEach((variable) => {
keyOccurrences.set(variable.key, (keyOccurrences.get(variable.key) || 0) + 1);
});
// Second pass: add isFirstTime, isLastTime, and occurrences flags
const seenKeys = new Set<string>();
const currentOccurrences = new Map<string, number>();
return filteredItems.map((variable) => {
// Track current occurrence number for this key
const currentCount = (currentOccurrences.get(variable.key) || 0) + 1;
currentOccurrences.set(variable.key, currentCount);
const totalOccurrences = keyOccurrences.get(variable.key) || 1;
const isFirstTime = !seenKeys.has(variable.key);
const isLastTime = currentCount === totalOccurrences;
if (isFirstTime) {
seenKeys.add(variable.key);
}
return {
...variable,
isFirstTime,
isLastTime,
occurences: totalOccurrences,
};
});
}, [filteredItems]);
const shouldVirtualize = groupedEnvironmentVariables.length > SSR_ROW_WINDOW;
const [isVirtualized, setIsVirtualized] = useState(false);
useLayoutEffect(() => {
setIsVirtualized(shouldVirtualize);
}, [shouldVirtualize]);
const staticRows = useMemo(() => {
if (shouldVirtualize) {
return groupedEnvironmentVariables.slice(0, SSR_ROW_WINDOW);
}
return groupedEnvironmentVariables;
}, [groupedEnvironmentVariables, shouldVirtualize]);
const vercelColumnCount = vercelIntegration?.enabled ? 6 : 5;
return (
<PageContainer>
<NavBar>
<PageTitle title="Environment variables" />
<PageAccessories>
<LinkButton
LeadingIcon={BookOpenIcon}
to={docsPath("v3/deploy-environment-variables")}
variant="docs/small"
>
Environment variables docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<div className={cn("flex h-full min-h-0 flex-col")}>
{environmentVariables.length > 0 && (
<div className="flex items-center justify-between gap-2 px-2 py-2">
<SearchInput placeholder="Search variables…" autoFocus />
<div className="flex items-center justify-end gap-1.5">
<Switch
variant="secondary/small"
label="Reveal values"
checked={revealAll}
onCheckedChange={(e) => setRevealAll(e.valueOf())}
/>
<LinkButton
to={v3NewEnvironmentVariablesPath(organization, project, environment)}
variant="primary/small"
LeadingIcon={PlusIcon}
shortcut={{ key: "n" }}
>
Add new
</LinkButton>
</div>
</div>
)}
<div
ref={tableScrollRef}
className="min-h-0 flex-1 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
>
<Table
containerClassName={cn(
filteredItems.length === 0 && "border-t-0",
"overflow-visible"
)}
>
<TableHeader>
<TableRow>
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[22%]" : "w-[25%]"}>
Key
</TableHeaderCell>
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[32%]" : "w-[37%]"}>
Value
</TableHeaderCell>
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[13%]" : "w-[15%]"}>
<SimpleTooltip
button={
<span className="flex items-center gap-1">
Environment
<InformationCircleIcon className="size-4 text-text-dimmed" />
</span>
}
content="Dev environment variables specified here will be overridden by ones in your .env file when running locally."
className="max-w-60"
/>
</TableHeaderCell>
{vercelIntegration?.enabled && (
<TableHeaderCell className="w-[8%]">
<SimpleTooltip
button={
<span className="flex items-center gap-1">
Sync
<InformationCircleIcon className="size-4 text-text-dimmed" />
</span>
}
content="When enabled, this variable will be pulled from Vercel during builds. Requires 'Pull env vars before build' to be enabled in settings."
/>
</TableHeaderCell>
)}
<TableHeaderCell className={vercelIntegration?.enabled ? "w-[24%]" : "w-[22%]"}>
Updated
</TableHeaderCell>
<TableHeaderCell hiddenLabel className="w-0">
Actions
</TableHeaderCell>
</TableRow>
</TableHeader>
{groupedEnvironmentVariables.length > 0 ? (
isVirtualized && shouldVirtualize ? (
<EnvironmentVariablesVirtualTableBody
groupedEnvironmentVariables={groupedEnvironmentVariables}
scrollRef={tableScrollRef}
revealAll={revealAll}
vercelIntegration={vercelIntegration}
columnCount={vercelColumnCount}
/>
) : (
<TableBody>
{staticRows.map((variable) => (
<EnvironmentVariableTableRow
key={`${variable.id}-${variable.environment.id}`}
variable={variable}
revealAll={revealAll}
vercelIntegration={vercelIntegration}
/>
))}
</TableBody>
)
) : (
<TableBody>
<TableRow>
<TableCell colSpan={vercelColumnCount}>
{environmentVariables.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-y-4 py-8">
<Header2>You haven't set any environment variables yet.</Header2>
<LinkButton
to={v3NewEnvironmentVariablesPath(organization, project, environment)}
variant="primary/medium"
LeadingIcon={PlusIcon}
shortcut={{ key: "n" }}
>
Add new
</LinkButton>
</div>
) : (
<div className="flex flex-col items-center justify-center gap-y-4 py-8">
<Paragraph>No variables match your search.</Paragraph>
</div>
)}
</TableCell>
</TableRow>
</TableBody>
)}
</Table>
</div>
</div>
</PageBody>
<Outlet />
</PageContainer>
);
}
function getBorderedCellClassName(variable: GroupedEnvironmentVariable) {
if (variable.occurences <= 1) {
return "";
}
if (variable.isLastTime) {
return "";
}
return "relative after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright group-hover/table-row:after:bg-grid-bright group-hover/table-row:before:bg-grid-bright";
}
function EnvironmentVariableTableRow({
variable,
revealAll,
vercelIntegration,
}: {
variable: GroupedEnvironmentVariable;
revealAll: boolean;
vercelIntegration: PageVercelIntegration | null;
}) {
const cellClassName = "py-2";
const borderedCellClassName = getBorderedCellClassName(variable);
return (
<TableRow className={variable.isLastTime ? "after:bg-surface-control" : "after:bg-transparent"}>
<TableCell className={cellClassName}>
{variable.isFirstTime ? <CopyableText value={variable.key} className="font-mono" /> : null}
</TableCell>
<TableCell className={cn(cellClassName, borderedCellClassName, "after:left-3")}>
{variable.permissionDenied ? (
<SimpleTooltip
button={
<div className="flex items-center gap-x-1">
<NoSymbolIcon className="size-3 text-text-dimmed" />
<span className="text-xs text-text-dimmed">Permission denied</span>
</div>
}
content="With your current role, you can't view this environment's variables."
/>
) : variable.isSecret ? (
<SimpleTooltip
button={
<div className="flex items-center gap-x-1">
<LockClosedIcon className="size-3 text-text-dimmed" />
<span className="text-xs text-text-dimmed">Secret</span>
</div>
}
content="This variable is secret and cannot be revealed."
/>
) : (
<ClipboardField
secure={!revealAll}
value={variable.value}
variant={"secondary/small"}
fullWidth={true}
/>
)}
</TableCell>
<TableCell className={cn(cellClassName, borderedCellClassName)}>
<EnvironmentCombo environment={variable.environment} className="text-sm" />
</TableCell>
{vercelIntegration?.enabled && (
<TableCell className={cn(cellClassName, borderedCellClassName)}>
{variable.environment.type !== "DEVELOPMENT" && (
<VercelSyncCheckbox
envVarKey={variable.key}
environmentType={variable.environment.type as TriggerEnvironmentType}
syncEnabled={shouldSyncEnvVar(
vercelIntegration.syncEnvVarsMapping,
variable.key,
variable.environment.type as TriggerEnvironmentType
)}
pullEnvVarsEnabledForEnv={isPullEnvVarsEnabledForEnvironment(
vercelIntegration.pullEnvVarsBeforeBuild,
variable.environment.type as TriggerEnvironmentType
)}
/>
)}
</TableCell>
)}
<TableCell className={cn(cellClassName, borderedCellClassName)}>
<div className="flex items-center gap-3">
{variable.updatedAt ? (
<span className="shrink-0 text-sm tabular-nums text-text-dimmed">
<DateTime date={variable.updatedAt} includeSeconds={false} />
</span>
) : null}
{variable.updatedByUser ? (
<div className="flex min-w-0 items-center gap-2">
<UserAvatar
avatarUrl={variable.updatedByUser.avatarUrl}
name={variable.updatedByUser.name}
className="size-5 shrink-0"
/>
<span className="truncate text-sm">{variable.updatedByUser.name}</span>
</div>
) : variable.lastUpdatedBy?.type === "integration" &&
variable.lastUpdatedBy?.integration === "vercel" ? (
<div className="flex min-w-0 items-center gap-2">
<VercelLogo className="size-4 shrink-0 text-text-dimmed transition-colors group-hover/table-row:text-text-bright" />
<span className="truncate text-sm capitalize text-text-dimmed transition-colors group-hover/table-row:text-text-bright">
{variable.lastUpdatedBy.integration}
</span>
</div>
) : null}
</div>
</TableCell>
<TableCellMenu
isSticky
className="[&:has([data-hidden-buttons])]:w-auto w-0"
hiddenButtons={
// No edit/delete for environments the role can't manage the value
// is withheld, and the action enforces write:envvars independently.
variable.permissionDenied ? undefined : (
<>
<EditEnvironmentVariablePanel variable={variable} revealAll={revealAll} />
<DeleteEnvironmentVariableButton variable={variable} />
</>
)
}
/>
</TableRow>
);
}
function EnvironmentVariablesVirtualTableBody({
groupedEnvironmentVariables,
scrollRef,
revealAll,
vercelIntegration,
columnCount,
}: {
groupedEnvironmentVariables: GroupedEnvironmentVariable[];
scrollRef: RefObject<HTMLDivElement | null>;
revealAll: boolean;
vercelIntegration: PageVercelIntegration | null;
columnCount: number;
}) {
const rowVirtualizer = useVirtualizer({
count: groupedEnvironmentVariables.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => ROW_ESTIMATE_HEIGHT,
overscan: VIRTUAL_OVERSCAN,
});
const virtualItems = rowVirtualizer.getVirtualItems();
const topSpacerHeight = virtualItems[0]?.start ?? 0;
const bottomSpacerHeight = rowVirtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0);
return (
<TableBody>
{topSpacerHeight > 0 && (
<tr aria-hidden style={{ height: topSpacerHeight }}>
<td colSpan={columnCount} />
</tr>
)}
{virtualItems.map((virtualRow) => {
const variable = groupedEnvironmentVariables[virtualRow.index];
if (!variable) {
return null;
}
return (
<EnvironmentVariableTableRow
key={`${variable.id}-${variable.environment.id}`}
variable={variable}
revealAll={revealAll}
vercelIntegration={vercelIntegration}
/>
);
})}
{bottomSpacerHeight > 0 && (
<tr aria-hidden style={{ height: bottomSpacerHeight }}>
<td colSpan={columnCount} />
</tr>
)}
</TableBody>
);
}
function EditEnvironmentVariablePanel({
variable,
revealAll,
}: {
variable: EnvironmentVariableWithSetValues;
revealAll: boolean;
}) {
const [isOpen, setIsOpen] = useState(false);
const fetcher = useFetcher<typeof action>();
const lastSubmission = fetcher.data as any;
const isLoading = fetcher.state !== "idle";
// Close dialog on successful submission
useEffect(() => {
if (lastSubmission?.success && fetcher.state === "idle") {
setIsOpen(false);
}
}, [lastSubmission?.success, fetcher.state]);
const [form, { id, environmentId, value }] = useForm({
id: `edit-environment-variable-${variable.id}-${variable.environment.id}`,
// TODO: type this
lastResult: lastSubmission as any,
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
shouldRevalidate: "onSubmit",
});
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={PencilSquareIcon}
fullWidth
textAlignLeft
></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>Edit environment variable</DialogHeader>
<fetcher.Form method="post" {...getFormProps(form)}>
<input type="hidden" name="action" value="edit" />
<input {...getInputProps(id, { type: "hidden" })} value={variable.id} />
<input
{...getInputProps(environmentId, { type: "hidden" })}
value={variable.environment.id}
/>
<FormError id={id.errorId}>{id.errors}</FormError>
<FormError id={environmentId.errorId}>{environmentId.errors}</FormError>
<Fieldset>
<InputGroup fullWidth className="mt-2 gap-0">
<Label>Key</Label>
<CopyableText value={variable.key} className="w-fit font-mono text-sm" />
</InputGroup>
<InputGroup fullWidth>
<Label>Environment</Label>
<EnvironmentCombo environment={variable.environment} className="text-sm" />
</InputGroup>
<InputGroup fullWidth>
<Label>Value</Label>
<Input
{...getInputProps(value, { type: "text" })}
placeholder={variable.isSecret ? "Set new secret value" : "Not set"}
defaultValue={variable.value}
type={"text"}
/>
<FormError id={value.errorId}>{value.errors}</FormError>
</InputGroup>
<FormError>{form.errors}</FormError>
<FormButtons
confirmButton={
<Button type="submit" variant="primary/medium" disabled={isLoading}>
{isLoading ? "Saving…" : "Save"}
</Button>
}
cancelButton={
<Button onClick={() => setIsOpen(false)} variant="tertiary/medium" type="button">
Cancel
</Button>
}
/>
</Fieldset>
</fetcher.Form>
</DialogContent>
</Dialog>
);
}
function DeleteEnvironmentVariableButton({
variable,
}: {
variable: EnvironmentVariableWithSetValues;
}) {
const lastSubmission = useActionData();
const navigation = useNavigation();
const isLoading =
navigation.state !== "idle" &&
navigation.formMethod === "post" &&
navigation.formData?.get("action") === "delete";
const [form] = useForm({
id: "delete-environment-variable",
// TODO: type this
lastResult: lastSubmission as any,
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
shouldRevalidate: "onSubmit",
});
return (
<Form method="post" {...getFormProps(form)}>
<input type="hidden" name="id" value={variable.id} />
<input type="hidden" name="key" value={variable.key} />
<input type="hidden" name="environmentId" value={variable.environment.id} />
<Button
name="action"
value="delete"
type="submit"
variant="small-menu-item"
fullWidth
textAlignLeft
LeadingIcon={TrashIcon}
leadingIconClassName="text-rose-500 group-hover/button:text-text-bright transition-colors"
className="ml-0.5 transition-colors group-hover/button:bg-error"
>
{isLoading ? "Deleting" : ""}
</Button>
</Form>
);
}
/**
* Toggle component for controlling whether an environment variable is pulled from Vercel.
*
* When enabled, the variable will be pulled from Vercel during builds.
* By default, all variables are pulled unless explicitly disabled.
*
* Note: If the env slug is missing from syncEnvVarsMapping, all vars are pulled by default.
* Only when syncEnvVarsMapping[envSlug][envVarName] = false, the env var is skipped during builds.
*/
function VercelSyncCheckbox({
envVarKey,
environmentType,
syncEnabled,
pullEnvVarsEnabledForEnv,
}: {
envVarKey: string;
environmentType: "PRODUCTION" | "STAGING" | "PREVIEW" | "DEVELOPMENT";
syncEnabled: boolean;
pullEnvVarsEnabledForEnv: boolean;
}) {
const fetcher = useFetcher();
const revalidator = useRevalidator();
const isLoading = fetcher.state !== "idle";
// Revalidate loader data after successful submission (without full page reload)
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data) {
const data = fetcher.data as { success?: boolean };
if (data.success) {
revalidator.revalidate();
}
}
}, [fetcher.state, fetcher.data, revalidator]);
const handleChange = (checked: boolean) => {
fetcher.submit(
{
action: "update-vercel-sync",
key: envVarKey,
environmentType,
syncEnabled: checked.toString(),
},
{ method: "post" }
);
};
// If pull env vars is disabled for this environment, show disabled state
if (!pullEnvVarsEnabledForEnv) {
return (
<SimpleTooltip
button={<Switch variant="small" checked={false} disabled onCheckedChange={() => {}} />}
content="Enable 'Pull env vars before build' for this environment in Vercel settings."
/>
);
}
return (
<Switch
variant="small"
checked={syncEnabled}
disabled={isLoading}
onCheckedChange={handleChange}
/>
);
}
@@ -0,0 +1,966 @@
import { parseWithZod } from "@conform-to/zod";
import { BellAlertIcon } from "@heroicons/react/20/solid";
import { type MetaFunction, useFetcher, useRevalidator } from "@remix-run/react";
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { IconAlarmSnooze as IconAlarmSnoozeBase, IconCircleDotted } from "@tabler/icons-react";
import { ErrorId } from "@trigger.dev/core/v3/isomorphic";
import { isPast } from "date-fns";
import { AnimatePresence, motion } from "framer-motion";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
type TooltipProps,
XAxis,
YAxis,
} from "recharts";
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { BugIcon } from "~/assets/icons/BugIcon";
import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon";
import { RunsIcon } from "~/assets/icons/RunsIcon";
import { CodeBlock } from "~/components/code/CodeBlock";
import { ErrorStatusBadge } from "~/components/errors/ErrorStatusBadge";
import {
CustomIgnoreDialog,
ErrorStatusMenuItems,
statusActionToastMessage,
} from "~/components/errors/ErrorStatusMenu";
import { PageBody } from "~/components/layout/AppLayout";
import { DirectionSchema, ListPagination } from "~/components/ListPagination";
import { LogsVersionFilter } from "~/components/logs/LogsVersionFilter";
import { LinkButton } from "~/components/primitives/Buttons";
import { PermissionLink } from "~/components/primitives/PermissionLink";
import { Callout } from "~/components/primitives/Callout";
import { CopyableText } from "~/components/primitives/CopyableText";
import { DateTime, RelativeDateTime } from "~/components/primitives/DateTime";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Popover, PopoverArrowTrigger, PopoverContent } from "~/components/primitives/Popover";
import * as Property from "~/components/primitives/PropertyTable";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import { Spinner } from "~/components/primitives/Spinner";
import { useToast } from "~/components/primitives/Toast";
import TooltipPortal from "~/components/primitives/TooltipPortal";
import type { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
import { $replica } from "~/db.server";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import {
type ErrorGroupActivity,
type ErrorGroupActivityVersions,
type ErrorGroupOccurrences,
ErrorGroupPresenter,
type ErrorGroupState,
type ErrorGroupSummary,
} from "~/presenters/v3/ErrorGroupPresenter.server";
import { type NextRunList } from "~/presenters/v3/NextRunListPresenter.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { requireUser, requireUserId } from "~/services/session.server";
import { rbac } from "~/services/rbac.server";
import { checkPermissions } from "~/services/routeBuilders/permissions.server";
import { cn } from "~/utils/cn";
import {
EnvironmentParamSchema,
v3CreateBulkActionPath,
v3ErrorsPath,
v3RunsPath,
} from "~/utils/pathBuilder";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { ErrorGroupActions } from "~/v3/services/errorGroupActions.server";
export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [
{
title: `Error Details | Trigger.dev`,
},
];
};
const emptyStringToUndefined = z.preprocess(
(v) => (v === "" ? undefined : v),
z.coerce.number().positive().optional()
);
const actionSchema = z.discriminatedUnion("action", [
z.object({
action: z.literal("resolve"),
taskIdentifier: z.string().min(1),
resolvedInVersion: z.string().optional(),
}),
z.object({
action: z.literal("ignore"),
taskIdentifier: z.string().min(1),
duration: emptyStringToUndefined,
occurrenceRate: emptyStringToUndefined,
totalOccurrences: emptyStringToUndefined,
reason: z.preprocess((v) => (v === "" ? undefined : v), z.string().optional()),
}),
z.object({
action: z.literal("unresolve"),
taskIdentifier: z.string().min(1),
}),
]);
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const fingerprint = params.fingerprint;
if (!fingerprint) {
return json({ error: "Fingerprint parameter is required" }, { status: 400 });
}
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
return json({ error: "Project not found" }, { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
return json({ error: "Environment not found" }, { status: 404 });
}
const formData = await request.formData();
const submission = parseWithZod(formData, { schema: actionSchema });
if (submission.status !== "success") {
return json(submission.reply());
}
const actions = new ErrorGroupActions();
const identifier = {
organizationId: project.organizationId,
projectId: project.id,
environmentId: environment.id,
taskIdentifier: submission.value.taskIdentifier,
errorFingerprint: fingerprint,
};
switch (submission.value.action) {
case "resolve": {
await actions.resolveError(identifier, {
userId,
resolvedInVersion: submission.value.resolvedInVersion,
});
return json({ ok: true });
}
case "ignore": {
let occurrenceCountAtIgnoreTime: number | undefined;
if (submission.value.totalOccurrences) {
const clickhouseClient = await clickhouseFactory.getClickhouseForOrganization(
environment.organizationId,
"query"
);
const qb = clickhouseClient.errors.listQueryBuilder();
qb.where("organization_id = {organizationId: String}", {
organizationId: project.organizationId,
});
qb.where("project_id = {projectId: String}", { projectId: project.id });
qb.where("environment_id = {environmentId: String}", {
environmentId: environment.id,
});
qb.where("error_fingerprint = {fingerprint: String}", { fingerprint });
qb.where("task_identifier = {taskIdentifier: String}", {
taskIdentifier: submission.value.taskIdentifier,
});
qb.groupBy("error_fingerprint, task_identifier");
const [err, results] = await qb.execute();
if (err || !results || results.length === 0) {
return json(
{ error: "Failed to fetch current occurrence count. Please try again." },
{ status: 500 }
);
}
occurrenceCountAtIgnoreTime = results[0].occurrence_count;
}
await actions.ignoreError(identifier, {
userId,
duration: submission.value.duration,
occurrenceRateThreshold: submission.value.occurrenceRate,
totalOccurrencesThreshold: submission.value.totalOccurrences,
occurrenceCountAtIgnoreTime,
reason: submission.value.reason,
});
return json({ ok: true });
}
case "unresolve": {
await actions.unresolveError(identifier);
return json({ ok: true });
}
}
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const userId = user.id;
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
const fingerprint = params.fingerprint;
if (!fingerprint) {
throw new Response("Fingerprint parameter is required", { status: 400 });
}
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Project not found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response("Environment not found", { status: 404 });
}
const url = new URL(request.url);
const period = url.searchParams.get("period") ?? undefined;
const fromStr = url.searchParams.get("from");
const toStr = url.searchParams.get("to");
const from = fromStr ? parseInt(fromStr, 10) : undefined;
const to = toStr ? parseInt(toStr, 10) : undefined;
const versions = url.searchParams.getAll("versions").filter((v) => v.length > 0);
const cursor = url.searchParams.get("cursor") ?? undefined;
const directionRaw = url.searchParams.get("direction") ?? undefined;
const direction = directionRaw ? DirectionSchema.parse(directionRaw) : undefined;
const [logsClickhouseClient, clickhouseClient] = await Promise.all([
clickhouseFactory.getClickhouseForOrganization(environment.organizationId, "logs"),
clickhouseFactory.getClickhouseForOrganization(environment.organizationId, "standard"),
]);
const presenter = new ErrorGroupPresenter($replica, logsClickhouseClient, clickhouseClient);
const detailPromise = presenter
.call(project.organizationId, environment.id, {
userId,
projectId: project.id,
fingerprint,
versions: versions.length > 0 ? versions : undefined,
period,
from,
to,
cursor,
direction,
})
.catch((error) => {
if (error instanceof ServiceValidationError) {
return { error: error.message };
}
throw error;
});
const time = timeFilterFromTo({ period, from, to, defaultPeriod: "7d" });
const activityPromise = presenter
.getOccurrences(
project.organizationId,
project.id,
environment.id,
fingerprint,
time.from,
time.to,
versions.length > 0 ? versions : undefined
)
.catch(() => ({ data: [] as ErrorGroupActivity, versions: [] as string[] }));
// Display flags for the row-menu and bulk-replay controls — the cancel/
// replay action routes enforce write:runs independently. Permissive in OSS.
const runAuth = await rbac.authenticateSession(request, {
userId,
organizationId: project.organizationId,
});
const runPermissions = runAuth.ok
? checkPermissions(runAuth.ability, {
canCancelRuns: { action: "write", resource: { type: "runs" } },
canReplayRuns: { action: "write", resource: { type: "runs" } },
})
: { canCancelRuns: true, canReplayRuns: true };
return typeddefer({
data: detailPromise,
activity: activityPromise,
organizationSlug,
projectParam,
envParam,
fingerprint,
...runPermissions,
});
};
export default function Page() {
const {
data,
activity,
organizationSlug,
projectParam,
envParam,
fingerprint,
canCancelRuns,
canReplayRuns,
} = useTypedLoaderData<typeof loader>();
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const errorsPath = useMemo(() => {
const base = v3ErrorsPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam }
);
const carry = new URLSearchParams();
const period = searchParams.get("period");
const from = searchParams.get("from");
const to = searchParams.get("to");
if (period) carry.set("period", period);
if (from) carry.set("from", from);
if (to) carry.set("to", to);
for (const v of searchParams.getAll("versions")) {
if (v) carry.append("versions", v);
}
const qs = carry.toString();
return qs ? `${base}?${qs}` : base;
}, [organizationSlug, projectParam, envParam, searchParams.toString()]);
const alertsHref = useMemo(() => {
const params = new URLSearchParams(location.search);
params.set("alerts", "true");
return `?${params.toString()}`;
}, [location.search]);
return (
<>
<NavBar>
<PageTitle
backButton={{
to: errorsPath,
text: "Errors",
}}
title={<span className="font-mono text-xs">{ErrorId.toFriendlyId(fingerprint)}</span>}
/>
<PageAccessories>
<LinkButton
to={alertsHref}
variant="secondary/small"
LeadingIcon={BellAlertIcon}
leadingIconClassName="text-alerts"
>
Configure alerts
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<div className="flex items-center gap-2">
<Spinner />
<Paragraph variant="small">Loading error details</Paragraph>
</div>
</div>
}
>
<TypedAwait
resolve={data}
errorElement={
<div className="flex items-center justify-center px-3 py-12">
<Callout variant="error" className="max-w-fit">
Unable to load error details. Please refresh the page or try again in a moment.
</Callout>
</div>
}
>
{(result) => {
if ("error" in result) {
return (
<div className="flex items-center justify-center px-3 py-12">
<Callout variant="error" className="max-w-fit">
{result.error}
</Callout>
</div>
);
}
return (
<ErrorGroupDetail
errorGroup={result.errorGroup}
runList={result.runList}
activity={activity}
organizationSlug={organizationSlug}
projectParam={projectParam}
envParam={envParam}
fingerprint={fingerprint}
canCancelRuns={canCancelRuns}
canReplayRuns={canReplayRuns}
/>
);
}}
</TypedAwait>
</Suspense>
</PageBody>
</>
);
}
function ErrorGroupDetail({
errorGroup,
runList,
activity,
organizationSlug,
projectParam,
envParam,
fingerprint,
canCancelRuns,
canReplayRuns,
}: {
errorGroup: ErrorGroupSummary | undefined;
runList: NextRunList | undefined;
activity: Promise<ErrorGroupOccurrences>;
organizationSlug: string;
projectParam: string;
envParam: string;
fingerprint: string;
canCancelRuns: boolean;
canReplayRuns: boolean;
}) {
const { value, values } = useSearchParams();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
if (!errorGroup) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<Header3 className="mb-2">Error not found</Header3>
<Paragraph variant="small">
This error group does not exist or has no instances.
</Paragraph>
</div>
</div>
);
}
const fromValue = value("from") ?? undefined;
const toValue = value("to") ?? undefined;
const selectedVersions = values("versions").filter((v) => v !== "");
const filters: TaskRunListSearchFilters = {
period: value("period") ?? undefined,
from: fromValue ? parseInt(fromValue, 10) : undefined,
to: toValue ? parseInt(toValue, 10) : undefined,
versions: selectedVersions.length > 0 ? selectedVersions : undefined,
rootOnly: false,
errorId: ErrorId.toFriendlyId(fingerprint),
};
return (
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
{/* Main content: chart + runs */}
<ResizablePanel id="error-main" min="300px">
<div className="grid h-full grid-rows-[12rem_1fr] overflow-hidden">
{/* Activity chart */}
<div className="flex flex-col gap-3 overflow-hidden border-b border-grid-bright bg-background-bright py-2 pl-2 pr-4">
<div className="flex items-center gap-2">
<TimeFilter defaultPeriod="7d" labelName="Occurred" />
<LogsVersionFilter />
</div>
<Suspense fallback={<ActivityChartBlankState />}>
<TypedAwait resolve={activity} errorElement={<ActivityChartBlankState />}>
{(result) => {
if (result.data.length > 0 && result.versions.length > 0) {
return <ActivityChart activity={result.data} versions={result.versions} />;
}
return <ActivityChartBlankState />;
}}
</TypedAwait>
</Suspense>
</div>
{/* Runs Table */}
<div className="flex flex-col gap-1 overflow-y-hidden">
<div className="flex items-center justify-between pl-3 pr-2 pt-1">
<Header3 className="mb-1 mt-2">Runs</Header3>
{runList && (
<div className="flex items-center gap-2">
<LinkButton
variant="secondary/small"
to={v3RunsPath(organization, project, environment, filters)}
LeadingIcon={RunsIcon}
>
View all runs
</LinkButton>
<PermissionLink
hasPermission={canReplayRuns}
noPermissionTooltip="You don't have permission to replay runs"
variant="secondary/small"
to={v3CreateBulkActionPath(
organization,
project,
environment,
filters,
"filter",
"replay"
)}
LeadingIcon={ListCheckedIcon}
>
Bulk replay
</PermissionLink>
<ListPagination list={runList} />
</div>
)}
</div>
{runList ? (
<TaskRunsTable
total={runList.runs.length}
hasFilters={selectedVersions.length > 0}
filters={{
tasks: [],
versions: selectedVersions,
statuses: [],
from: undefined,
to: undefined,
}}
runs={runList.runs}
isLoading={false}
variant="dimmed"
additionalTableState={{ errorId: ErrorId.toFriendlyId(fingerprint) }}
canCancelRuns={canCancelRuns}
canReplayRuns={canReplayRuns}
/>
) : (
<div className="flex flex-1 flex-col items-center justify-center gap-3">
<BugIcon className="size-16 text-secondary" />
<Paragraph className="max-w-32 text-center text-text-dimmed">
No runs found for this error.
</Paragraph>
</div>
)}
</div>
</div>
</ResizablePanel>
{/* Right-hand detail sidebar */}
<ResizableHandle id="error-detail-handle" />
<ResizablePanel id="error-detail" min="280px" default="380px" max="500px" isStaticAtRest>
<ErrorDetailSidebar errorGroup={errorGroup} fingerprint={fingerprint} />
</ResizablePanel>
</ResizablePanelGroup>
);
}
function ErrorDetailSidebar({
errorGroup,
fingerprint,
}: {
errorGroup: ErrorGroupSummary;
fingerprint: string;
}) {
return (
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden bg-background-bright">
<div className="border-b border-grid-dimmed px-3 py-2">
<Header2 className="truncate">Details</Header2>
</div>
<div className="overflow-y-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<div className="flex flex-col gap-4">
<Property.Table>
{/* Status */}
<Property.Item className="gap-1">
<Property.Label>Error status</Property.Label>
<Property.Value>
<div className="flex items-center justify-between">
<ErrorStatusBadge status={errorGroup.state.status} className="w-fit" />
<ErrorStatusDropdown
state={errorGroup.state}
taskIdentifier={errorGroup.taskIdentifier}
/>
</div>
<AnimatePresence>
{errorGroup.state.status === "IGNORED" && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15, ease: "easeInOut" }}
className="overflow-hidden"
>
<IgnoredDetails
className="mt-2"
state={errorGroup.state}
totalOccurrences={errorGroup.count}
/>
</motion.div>
)}
</AnimatePresence>
</Property.Value>
</Property.Item>
{/* Error message */}
<Property.Item className="gap-1">
<Property.Label>Error</Property.Label>
<Property.Value>
<CodeBlock
code={errorGroup.errorMessage}
showCopyButton
showLineNumbers={false}
showOpenInModal={false}
language="typescript"
wrap
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>ID</Property.Label>
<Property.Value>
<CopyableText value={ErrorId.toFriendlyId(errorGroup.fingerprint)} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Task</Property.Label>
<Property.Value>
<CopyableText value={errorGroup.taskIdentifier} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Occurrences</Property.Label>
<Property.Value>
<span className="tabular-nums">{errorGroup.count.toLocaleString()}</span>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>First seen</Property.Label>
<Property.Value>
<DateTime date={errorGroup.firstSeen} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Last seen</Property.Label>
<Property.Value>
<RelativeDateTime date={errorGroup.lastSeen} />
</Property.Value>
</Property.Item>
{errorGroup.affectedVersions.length > 0 && (
<Property.Item>
<Property.Label>Versions</Property.Label>
<Property.Value>
<span className="font-mono text-sm">
{errorGroup.affectedVersions.join(", ")}
</span>
</Property.Value>
</Property.Item>
)}
</Property.Table>
</div>
</div>
</div>
);
}
function IgnoredDetails({
state,
totalOccurrences,
className,
}: {
state: ErrorGroupState;
totalOccurrences: number;
className?: string;
}) {
if (state.status !== "IGNORED") {
return null;
}
const hasConditions =
state.ignoredUntil || state.ignoredUntilOccurrenceRate || state.ignoredUntilTotalOccurrences;
const ignoredForever = !hasConditions;
const occurrencesSinceIgnore =
state.ignoredUntilTotalOccurrences && state.ignoredAtOccurrenceCount !== null
? totalOccurrences - state.ignoredAtOccurrenceCount
: null;
return (
<div
className={cn(
"flex flex-col gap-1.5 rounded border border-text-dimmed/20 bg-text-dimmed/5 px-3 py-2.5 text-sm",
className
)}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1">
<IconAlarmSnoozeBase className="size-5 text-blue-500" />
<Header3 className="text-text-bright">
{ignoredForever ? "Ignored permanently" : "Ignored with conditions"}
</Header3>
</div>
{(state.ignoredByUserDisplayName || state.ignoredAt) && (
<Paragraph variant="extra-small/dimmed">
{state.ignoredByUserDisplayName && <>Configured by {state.ignoredByUserDisplayName}</>}
{state.ignoredByUserDisplayName && state.ignoredAt && " "}
{state.ignoredAt && <RelativeDateTime date={state.ignoredAt} capitalize={false} />}
</Paragraph>
)}
</div>
{state.ignoredReason && (
<Paragraph variant="extra-small/dimmed">Reason: {state.ignoredReason}</Paragraph>
)}
{hasConditions && (
<div className="flex flex-col gap-1 text-xs text-text-dimmed">
{state.ignoredUntil && (
<span>
Will revert to "Unresolved" at:{" "}
<span className="text-text-bright">
<DateTime date={state.ignoredUntil} />
</span>
{isPast(state.ignoredUntil) && <span className="ml-1 text-warning">(expired)</span>}
</span>
)}
{state.ignoredUntilOccurrenceRate !== null && state.ignoredUntilOccurrenceRate > 0 && (
<span>
Will revert to "Unresolved" when: Occurrence rate exceeds{" "}
<span className="tabular-nums text-text-bright">
{state.ignoredUntilOccurrenceRate}/min
</span>
</span>
)}
{state.ignoredUntilTotalOccurrences !== null &&
state.ignoredUntilTotalOccurrences > 0 && (
<span>
Will revert to "Unresolved" when: Total occurrences exceed{" "}
<span className="tabular-nums text-text-bright">
{state.ignoredUntilTotalOccurrences.toLocaleString()}
</span>
{occurrencesSinceIgnore !== null && (
<span className="ml-1 tabular-nums text-text-dimmed">
({occurrencesSinceIgnore.toLocaleString()} since ignored)
</span>
)}
</span>
)}
</div>
)}
</div>
);
}
function ErrorStatusDropdown({
state,
taskIdentifier,
}: {
state: ErrorGroupState;
taskIdentifier: string;
}) {
const fetcher = useFetcher<{ ok?: boolean }>();
const revalidator = useRevalidator();
const [popoverOpen, setPopoverOpen] = useState(false);
const [customIgnoreOpen, setCustomIgnoreOpen] = useState(false);
const isSubmitting = fetcher.state !== "idle";
const toast = useToast();
const pendingToast = useRef<string | undefined>();
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data?.ok && pendingToast.current) {
toast.success(pendingToast.current);
pendingToast.current = undefined;
revalidator.revalidate();
}
}, [fetcher.state, fetcher.data, toast, revalidator]);
const act = (data: Record<string, string>) => {
setPopoverOpen(false);
pendingToast.current = statusActionToastMessage(data);
fetcher.submit(data, { method: "post" });
};
return (
<>
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverArrowTrigger variant="primary" disabled={isSubmitting} className="items-center">
<IconCircleDotted className="-ml-1 mr-1 size-3.5 text-text-bright" />
Mark error as
</PopoverArrowTrigger>
<PopoverContent className="inline-flex min-w-0! flex-col p-1" align="end">
<ErrorStatusMenuItems
status={state.status}
taskIdentifier={taskIdentifier}
onAction={act}
onCustomIgnore={() => {
setPopoverOpen(false);
setCustomIgnoreOpen(true);
}}
/>
</PopoverContent>
</Popover>
<CustomIgnoreDialog
open={customIgnoreOpen}
onOpenChange={setCustomIgnoreOpen}
taskIdentifier={taskIdentifier}
/>
</>
);
}
function ActivityChart({
activity,
versions,
}: {
activity: ErrorGroupActivity;
versions: ErrorGroupActivityVersions;
}) {
const ERROR_CHART_COLORS = ["#6c5ce7", "#ec4899"];
const colors = useMemo(
() => versions.map((_, i) => ERROR_CHART_COLORS[i % ERROR_CHART_COLORS.length]),
[versions]
);
const data = useMemo(
() =>
activity.map((d) => ({
...d,
__timestamp: d.date instanceof Date ? d.date.getTime() : new Date(d.date).getTime(),
})),
[activity]
);
const midnightTicks = useMemo(() => {
const ticks: number[] = [];
for (const d of data) {
const date = new Date(d.__timestamp);
if (date.getHours() === 0 && date.getMinutes() === 0) {
ticks.push(d.__timestamp);
}
}
return ticks;
}, [data]);
const xAxisFormatter = useMemo(() => {
return (value: number) => {
const date = new Date(value);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
};
}, []);
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 4, right: 4, left: 0, bottom: 0 }}>
<CartesianGrid vertical={false} stroke="var(--color-grid-bright)" strokeDasharray="3 3" />
<XAxis
dataKey="__timestamp"
tickFormatter={xAxisFormatter}
ticks={midnightTicks}
height={24}
axisLine={false}
tickLine={false}
tick={{ fontSize: 11, fill: "var(--color-text-dimmed)" }}
/>
<YAxis
width={30}
tickMargin={4}
axisLine={false}
tickLine={false}
tick={{ fontSize: 11, fill: "var(--color-text-dimmed)" }}
domain={["auto", (dataMax: number) => dataMax * 1.15]}
/>
<Tooltip
cursor={{ fill: "rgba(255, 255, 255, 0.06)" }}
content={<ActivityTooltip versions={versions} colors={colors} />}
allowEscapeViewBox={{ x: true, y: true }}
wrapperStyle={{ zIndex: 1000 }}
animationDuration={0}
/>
{versions.map((version, i) => (
<Bar
key={version}
dataKey={version}
stackId="versions"
fill={colors[i]}
strokeWidth={0}
isAnimationActive={false}
/>
))}
</BarChart>
</ResponsiveContainer>
);
}
const ActivityTooltip = ({
active,
payload,
versions,
colors,
}: TooltipProps<number, string> & { versions: string[]; colors: string[] }) => {
if (!active || !payload?.length) return null;
const timestamp = payload[0]?.payload?.__timestamp as number | undefined;
if (!timestamp) return null;
const date = new Date(timestamp);
const formattedDate = date.toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
return (
<TooltipPortal active={active}>
<div className="rounded-sm border border-grid-bright bg-background-dimmed px-3 py-2">
<Header3 className="border-b border-b-border-bright pb-2">{formattedDate}</Header3>
<div className="mt-2 flex flex-col gap-1">
{payload.map((entry, i) => {
const value = (entry.value as number) ?? 0;
return (
<div key={entry.dataKey} className="flex items-center gap-2 text-xs">
<div className="size-2 rounded-[2px]" style={{ backgroundColor: entry.color }} />
<span className="text-text-dimmed">{entry.dataKey}</span>
<span className="tabular-nums text-text-bright">{value}</span>
</div>
);
})}
</div>
</div>
</TooltipPortal>
);
};
function ActivityChartBlankState() {
return (
<div className="flex min-h-0 flex-1 items-end gap-px rounded-sm">
{[...Array(42)].map((_, i) => (
<div key={i} className="h-full flex-1 bg-background-dimmed" />
))}
</div>
);
}
@@ -0,0 +1,777 @@
import * as Ariakit from "@ariakit/react";
import { BellAlertIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { Form, useFetcher, useRevalidator, type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { ErrorId } from "@trigger.dev/core/v3/isomorphic";
import { type ErrorGroupStatus } from "@trigger.dev/database";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
Bar,
BarChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
YAxis,
type TooltipProps,
} from "recharts";
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
import { BugIcon } from "~/assets/icons/BugIcon";
import { ErrorStatusBadge } from "~/components/errors/ErrorStatusBadge";
import {
CustomIgnoreDialog,
ErrorStatusMenuItems,
statusActionToastMessage,
} from "~/components/errors/ErrorStatusMenu";
import { PageBody } from "~/components/layout/AppLayout";
import { ListPagination } from "~/components/ListPagination";
import { LogsTaskFilter } from "~/components/logs/LogsTaskFilter";
import { LogsVersionFilter } from "~/components/logs/LogsVersionFilter";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { formatDateTime, RelativeDateTime } from "~/components/primitives/DateTime";
import { Header3 } from "~/components/primitives/Headers";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { PopoverSectionHeader } from "~/components/primitives/Popover";
import { SearchInput } from "~/components/primitives/SearchInput";
import {
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
SelectTrigger,
} from "~/components/primitives/Select";
import { Spinner } from "~/components/primitives/Spinner";
import {
CopyableTableCell,
Table,
TableBody,
TableCell,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { useToast } from "~/components/primitives/Toast";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import TooltipPortal from "~/components/primitives/TooltipPortal";
import { appliedSummary, FilterMenuProvider, TimeFilter } from "~/components/runs/v3/SharedFilters";
import { $replica } from "~/db.server";
import { useInterval } from "~/hooks/useInterval";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useSearchParams } from "~/hooks/useSearchParam";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import {
ErrorsListPresenter,
type ErrorGroup,
type ErrorOccurrenceActivity,
type ErrorOccurrences,
type ErrorsList as ErrorsListData,
} from "~/presenters/v3/ErrorsListPresenter.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { getCurrentPlan } from "~/services/platform.v3.server";
import { requireUser } from "~/services/session.server";
import { formatNumberCompact } from "~/utils/numberFormatter";
import { EnvironmentParamSchema, v3ErrorPath } from "~/utils/pathBuilder";
import { ServiceValidationError } from "~/v3/services/baseService.server";
export const meta: MetaFunction = () => {
return [
{
title: `Errors | Trigger.dev`,
},
];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const userId = user.id;
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Project not found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response("Environment not found", { status: 404 });
}
const url = new URL(request.url);
const tasks = url.searchParams.getAll("tasks").filter((t) => t.length > 0);
const versions = url.searchParams.getAll("versions").filter((v) => v.length > 0);
const statuses = url.searchParams
.getAll("status")
.filter(
(s): s is ErrorGroupStatus => s === "UNRESOLVED" || s === "RESOLVED" || s === "IGNORED"
);
const search = url.searchParams.get("search") ?? undefined;
const period = url.searchParams.get("period") ?? undefined;
const fromStr = url.searchParams.get("from");
const toStr = url.searchParams.get("to");
const from = fromStr ? parseInt(fromStr, 10) : undefined;
const to = toStr ? parseInt(toStr, 10) : undefined;
const cursor = url.searchParams.get("cursor") ?? undefined;
const directionRaw = url.searchParams.get("direction");
const direction =
directionRaw === "forward" || directionRaw === "backward" ? directionRaw : undefined;
const plan = await getCurrentPlan(project.organizationId);
const retentionLimitDays = plan?.v3Subscription?.plan?.limits.logRetentionDays.number ?? 30;
const logsClickhouseClient = await clickhouseFactory.getClickhouseForOrganization(
project.organizationId,
"logs"
);
const presenter = new ErrorsListPresenter($replica, logsClickhouseClient);
const listPromise = presenter
.call(project.organizationId, environment.id, {
userId,
projectId: project.id,
tasks: tasks.length > 0 ? tasks : undefined,
versions: versions.length > 0 ? versions : undefined,
statuses: statuses.length > 0 ? statuses : undefined,
search,
period,
from,
to,
cursor,
direction,
defaultPeriod: "1d",
retentionLimitDays,
})
.catch((error) => {
if (error instanceof ServiceValidationError) {
return { error: error.message };
}
throw error;
});
const occurrencesPromise = listPromise.then((result) => {
if ("error" in result) return { data: {} };
const fingerprints = result.errorGroups.map((g) => g.fingerprint);
if (fingerprints.length === 0) return { data: {} };
return presenter.getOccurrences(
project.organizationId,
project.id,
environment.id,
fingerprints,
result.filters.from,
result.filters.to
);
});
return typeddefer({
data: listPromise,
occurrences: occurrencesPromise,
defaultPeriod: "1d",
retentionLimitDays,
organizationSlug,
projectParam,
envParam,
});
};
export default function Page() {
const {
data,
occurrences,
defaultPeriod,
retentionLimitDays,
organizationSlug,
projectParam,
envParam,
} = useTypedLoaderData<typeof loader>();
const revalidator = useRevalidator();
useInterval({
interval: 60_000,
onLoad: false,
callback: useCallback(() => {
if (revalidator.state === "idle") {
revalidator.revalidate();
}
}, [revalidator]),
});
const location = useOptimisticLocation();
const alertsHref = useMemo(() => {
const params = new URLSearchParams(location.search);
params.set("alerts", "true");
return `?${params.toString()}`;
}, [location.search]);
return (
<>
<NavBar>
<PageTitle title="Errors" />
</NavBar>
<PageBody scrollable={false}>
<Suspense
fallback={
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto] overflow-hidden">
<div className="border-b border-grid-bright" />
<div className="my-2 flex items-center justify-center">
<div className="mx-auto flex items-center gap-2">
<Spinner />
<Paragraph variant="small">Loading errors</Paragraph>
</div>
</div>
</div>
}
>
<TypedAwait
resolve={data}
errorElement={
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto_1fr] overflow-hidden">
<FiltersBar
defaultPeriod={defaultPeriod}
retentionLimitDays={retentionLimitDays}
alertsHref={alertsHref}
/>
<div className="flex items-center justify-center px-3 py-12">
<Callout variant="error" className="max-w-fit">
Unable to load errors. Please refresh the page or try again in a moment.
</Callout>
</div>
</div>
}
>
{(result) => {
if ("error" in result) {
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto_1fr] overflow-hidden">
<FiltersBar
defaultPeriod={defaultPeriod}
retentionLimitDays={retentionLimitDays}
alertsHref={alertsHref}
/>
<div className="flex items-center justify-center px-3 py-12">
<Callout variant="error" className="max-w-fit">
{result.error}
</Callout>
</div>
</div>
);
}
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden">
<FiltersBar
list={result}
defaultPeriod={defaultPeriod}
retentionLimitDays={retentionLimitDays}
alertsHref={alertsHref}
/>
<ErrorsList
errorGroups={result.errorGroups}
occurrences={occurrences}
organizationSlug={organizationSlug}
projectParam={projectParam}
envParam={envParam}
/>
</div>
);
}}
</TypedAwait>
</Suspense>
</PageBody>
</>
);
}
const errorStatusOptions = [
{ value: "UNRESOLVED", label: "Unresolved" },
{ value: "RESOLVED", label: "Resolved" },
{ value: "IGNORED", label: "Ignored" },
] as const;
const statusIcon = <BugIcon className="size-4" />;
const statusShortcut = { key: "s" };
const timeShortcut = { key: "d" };
const alertsShortcut = { key: "c" };
function StatusFilter() {
const { values, del } = useSearchParams();
const selectedStatuses = values("status");
if (selectedStatuses.length === 0 || selectedStatuses.every((v) => v === "")) {
return (
<FilterMenuProvider>
{(search, setSearch) => (
<ErrorStatusDropdown
trigger={
<SelectTrigger
icon={statusIcon}
variant="secondary/small"
shortcut={statusShortcut}
tooltipTitle="Filter by status"
className="pl-1.5"
>
<span className="ml-1">Status</span>
</SelectTrigger>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<ErrorStatusDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Status"
icon={statusIcon}
value={appliedSummary(
selectedStatuses.map((s) => {
const opt = errorStatusOptions.find((o) => o.value === s);
return opt ? opt.label : s;
})
)}
onRemove={() => del(["status", "cursor", "direction"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
function ErrorStatusDropdown({
trigger,
clearSearchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({
status: values.length > 0 ? values : undefined,
cursor: undefined,
direction: undefined,
});
};
return (
<SelectProvider value={values("status")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<SelectList>
{errorStatusOptions.map((item) => (
<SelectItem key={item.value} value={item.value}>
<ErrorStatusBadge status={item.value} />
</SelectItem>
))}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
function FiltersBar({
list,
defaultPeriod,
retentionLimitDays,
alertsHref,
}: {
list?: ErrorsListData;
defaultPeriod?: string;
retentionLimitDays: number;
alertsHref: string;
}) {
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const hasFilters =
searchParams.has("status") ||
searchParams.has("tasks") ||
searchParams.has("versions") ||
searchParams.has("search") ||
searchParams.has("period") ||
searchParams.has("from") ||
searchParams.has("to");
return (
<div className="flex items-start justify-between gap-x-2 border-b border-grid-bright p-2">
<div className="flex flex-row flex-wrap items-center gap-1.5">
{list ? (
<>
<SearchInput placeholder="Search errors…" />
<StatusFilter />
<LogsTaskFilter possibleTasks={list.filters.possibleTasks} />
<LogsVersionFilter />
<TimeFilter
defaultPeriod={defaultPeriod}
maxPeriodDays={retentionLimitDays}
labelName="Occurred"
shortcut={timeShortcut}
/>
{hasFilters && (
<Form className="-ml-1 h-6">
<Button
variant="minimal/small"
LeadingIcon={XMarkIcon}
tooltip="Clear all filters"
className="group-hover/button:bg-transparent"
leadingIconClassName="group-hover/button:text-text-bright"
/>
</Form>
)}
</>
) : (
<>
<SearchInput placeholder="Search errors…" />
<StatusFilter />
<LogsTaskFilter possibleTasks={[]} />
<LogsVersionFilter />
<TimeFilter
defaultPeriod={defaultPeriod}
maxPeriodDays={retentionLimitDays}
shortcut={timeShortcut}
/>
{hasFilters && (
<Form className="-ml-1 h-6">
<Button
variant="minimal/small"
LeadingIcon={XMarkIcon}
tooltip="Clear all filters"
className="group-hover/button:bg-transparent"
leadingIconClassName="group-hover/button:text-text-bright"
/>
</Form>
)}
</>
)}
</div>
<div className="flex shrink-0 items-center gap-1.5">
<LinkButton
to={alertsHref}
variant="secondary/small"
LeadingIcon={BellAlertIcon}
leadingIconClassName="text-alerts"
shortcut={alertsShortcut}
tooltip="Configure alerts"
>
Configure alerts
</LinkButton>
{list && <ListPagination list={list} />}
</div>
</div>
);
}
function ErrorsList({
errorGroups,
occurrences,
organizationSlug,
projectParam,
envParam,
}: {
errorGroups: ErrorGroup[];
occurrences: Promise<ErrorOccurrences>;
organizationSlug: string;
projectParam: string;
envParam: string;
}) {
if (errorGroups.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3">
<BugIcon className="size-16 text-secondary" />
<Paragraph className="text-center text-text-dimmed">
No errors found for this time period.
</Paragraph>
</div>
);
}
return (
<Table containerClassName="max-h-full pb-10" showTopBorder={false}>
<TableHeader>
<TableRow>
<TableHeaderCell>ID</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Task</TableHeaderCell>
<TableHeaderCell>Error</TableHeaderCell>
<TableHeaderCell>Occurrences</TableHeaderCell>
<TableHeaderCell>Activity</TableHeaderCell>
<TableHeaderCell>First seen</TableHeaderCell>
<TableHeaderCell>Last seen</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{errorGroups.map((errorGroup) => (
<ErrorGroupRow
key={`${errorGroup.taskIdentifier}::${errorGroup.fingerprint}`}
errorGroup={errorGroup}
occurrences={occurrences}
organizationSlug={organizationSlug}
projectParam={projectParam}
envParam={envParam}
/>
))}
</TableBody>
</Table>
);
}
function ErrorGroupRow({
errorGroup,
occurrences,
organizationSlug,
projectParam,
envParam,
}: {
errorGroup: ErrorGroup;
occurrences: Promise<ErrorOccurrences>;
organizationSlug: string;
projectParam: string;
envParam: string;
}) {
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const errorPath = useMemo(() => {
const base = v3ErrorPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam },
{ fingerprint: errorGroup.fingerprint }
);
const carry = new URLSearchParams();
const period = searchParams.get("period");
const from = searchParams.get("from");
const to = searchParams.get("to");
if (period) carry.set("period", period);
if (from) carry.set("from", from);
if (to) carry.set("to", to);
for (const v of searchParams.getAll("versions")) {
if (v) carry.append("versions", v);
}
const qs = carry.toString();
return qs ? `${base}?${qs}` : base;
}, [organizationSlug, projectParam, envParam, errorGroup.fingerprint, searchParams.toString()]);
const errorMessage = `${errorGroup.errorMessage}`;
return (
<TableRow>
<CopyableTableCell to={errorPath} value={ErrorId.toFriendlyId(errorGroup.fingerprint)}>
{errorGroup.fingerprint.slice(-8)}
</CopyableTableCell>
<TableCell to={errorPath}>
<ErrorStatusBadge status={errorGroup.status} />
</TableCell>
<TableCell to={errorPath}>{errorGroup.taskIdentifier}</TableCell>
<CopyableTableCell to={errorPath} className="font-mono" value={errorMessage}>
{errorMessage.length > 128 ? `${errorMessage.slice(0, 128)}` : errorMessage}
</CopyableTableCell>
<TableCell to={errorPath}>
<span className="tabular-nums">{errorGroup.count.toLocaleString()}</span>
</TableCell>
<TableCell to={errorPath} actionClassName="py-1.5">
<Suspense fallback={<ErrorActivityBlankState />}>
<TypedAwait resolve={occurrences} errorElement={<ErrorActivityBlankState />}>
{(result) => {
const activity = result.data[errorGroup.fingerprint];
return activity ? (
<ErrorActivityGraph activity={activity} />
) : (
<ErrorActivityBlankState />
);
}}
</TypedAwait>
</Suspense>
</TableCell>
<TableCell to={errorPath} className="tabular-nums">
<RelativeDateTime date={errorGroup.firstSeen} />
</TableCell>
<TableCell to={errorPath} className="tabular-nums">
<RelativeDateTime date={errorGroup.lastSeen} />
</TableCell>
<ErrorActionsCell
errorGroup={errorGroup}
organizationSlug={organizationSlug}
projectParam={projectParam}
envParam={envParam}
/>
</TableRow>
);
}
function ErrorActionsCell({
errorGroup,
organizationSlug,
projectParam,
envParam,
}: {
errorGroup: ErrorGroup;
organizationSlug: string;
projectParam: string;
envParam: string;
}) {
const fetcher = useFetcher<{ ok?: boolean }>();
const revalidator = useRevalidator();
const [customIgnoreOpen, setCustomIgnoreOpen] = useState(false);
const toast = useToast();
const pendingToast = useRef<string | undefined>();
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data?.ok && pendingToast.current) {
toast.success(pendingToast.current);
pendingToast.current = undefined;
revalidator.revalidate();
}
}, [fetcher.state, fetcher.data, toast, revalidator]);
const actionUrl = v3ErrorPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam },
{ fingerprint: errorGroup.fingerprint }
);
return (
<>
<TableCellMenu
isSticky
popoverContent={(close) => (
<>
<PopoverSectionHeader title="Mark error as…" />
<div className="flex flex-col gap-1 p-1">
<ErrorStatusMenuItems
status={errorGroup.status}
taskIdentifier={errorGroup.taskIdentifier}
onAction={(data) => {
close();
pendingToast.current = statusActionToastMessage(data);
fetcher.submit(data, { method: "post", action: actionUrl });
}}
onCustomIgnore={() => {
close();
setCustomIgnoreOpen(true);
}}
/>
</div>
</>
)}
/>
<CustomIgnoreDialog
open={customIgnoreOpen}
onOpenChange={setCustomIgnoreOpen}
taskIdentifier={errorGroup.taskIdentifier}
formAction={actionUrl}
/>
</>
);
}
function ErrorActivityGraph({ activity }: { activity: ErrorOccurrenceActivity }) {
const maxCount = Math.max(...activity.map((d) => d.count));
return (
<div className="flex items-start gap-1.5">
<div className="h-6 w-28 rounded-sm">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={activity} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
<YAxis domain={[0, maxCount || 1]} hide />
<Tooltip
cursor={{ fill: "rgba(255, 255, 255, 0.06)" }}
content={<ErrorActivityTooltip />}
allowEscapeViewBox={{ x: true, y: true }}
wrapperStyle={{ zIndex: 1000 }}
animationDuration={0}
/>
<Bar
dataKey="count"
fill="#6366F1"
strokeWidth={0}
isAnimationActive={false}
minPointSize={1}
/>
<ReferenceLine y={0} stroke="var(--color-border-bright)" strokeWidth={1} />
{maxCount > 0 && (
<ReferenceLine
y={maxCount}
stroke="var(--color-border-brighter)"
strokeDasharray="4 4"
strokeWidth={1}
/>
)}
</BarChart>
</ResponsiveContainer>
</div>
<SimpleTooltip
asChild
button={
<span className="-mt-1 text-xxs tabular-nums text-text-dimmed">
{formatNumberCompact(maxCount)}
</span>
}
content="Peak occurrences in a single time bucket"
/>
</div>
);
}
const ErrorActivityTooltip = ({ active, payload }: TooltipProps<number, string>) => {
if (active && payload && payload.length > 0) {
const entry = payload[0].payload as { date: Date; count: number };
const date = entry.date instanceof Date ? entry.date : new Date(entry.date);
const formattedDate = formatDateTime(date, "UTC", [], false, true);
return (
<TooltipPortal active={active}>
<div className="rounded-sm border border-grid-bright bg-background-dimmed px-3 py-2">
<Header3 className="border-b border-b-border-bright pb-2">{formattedDate}</Header3>
<div className="mt-2 text-xs text-text-bright">
<span className="tabular-nums">{entry.count}</span>{" "}
<span className="text-text-dimmed">
{entry.count === 1 ? "occurrence" : "occurrences"}
</span>
</div>
</div>
</TooltipPortal>
);
}
return null;
};
function ErrorActivityBlankState() {
return (
<div className="flex h-6 w-28 items-end gap-px rounded-sm">
{[...Array(24)].map((_, i) => (
<div key={i} className="h-full flex-1 bg-background-hover" />
))}
</div>
);
}
@@ -0,0 +1,48 @@
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { prisma } from "~/db.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
import { findProjectBySlug } from "~/models/project.server";
import { requireUserId } from "~/services/session.server";
import {
EnvironmentParamSchema,
v3ErrorsPath,
v3ErrorsConnectToSlackPath,
} from "~/utils/pathBuilder";
export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const url = new URL(request.url);
const shouldReinstall = url.searchParams.get("reinstall") === "true";
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Project not found", { status: 404 });
}
const integration = await prisma.organizationIntegration.findFirst({
where: {
service: "SLACK",
organizationId: project.organizationId,
deletedAt: null,
},
});
if (integration && !shouldReinstall) {
return redirectWithSuccessMessage(
`${v3ErrorsPath({ slug: organizationSlug }, project, { slug: envParam })}?alerts`,
request,
"Successfully connected your Slack workspace"
);
}
return await OrgIntegrationRepository.redirectToAuthService(
"SLACK",
project.organizationId,
request,
v3ErrorsConnectToSlackPath({ slug: organizationSlug }, project, { slug: envParam })
);
}
@@ -0,0 +1,216 @@
import { parseWithZod } from "@conform-to/zod";
import { Outlet, useNavigate } from "@remix-run/react";
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { useCallback } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { PageContainer } from "~/components/layout/AppLayout";
import {
ConfigureErrorAlerts,
ErrorAlertsFormSchema,
} from "~/components/errors/ConfigureErrorAlerts";
import { Sheet, SheetContent } from "~/components/primitives/SheetV3";
import { prisma } from "~/db.server";
import { ErrorAlertChannelPresenter } from "~/presenters/v3/ErrorAlertChannelPresenter.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { requireUserId } from "~/services/session.server";
import { env } from "~/env.server";
import {
EnvironmentParamSchema,
v3ErrorsConnectToSlackPath,
v3ErrorsPath,
} from "~/utils/pathBuilder";
import {
type CreateAlertChannelOptions,
CreateAlertChannelService,
} from "~/v3/services/alerts/createAlertChannel.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useSearchParams } from "~/hooks/useSearchParam";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Project not found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response("Environment not found", { status: 404 });
}
const presenter = new ErrorAlertChannelPresenter();
const alertData = await presenter.call(project.id, environment.type);
const connectToSlackHref = v3ErrorsConnectToSlackPath({ slug: organizationSlug }, project, {
slug: envParam,
});
const errorsPath = v3ErrorsPath({ slug: organizationSlug }, project, { slug: envParam });
return typedjson({
alertData,
projectRef: project.externalRef,
projectId: project.id,
environmentType: environment.type,
connectToSlackHref,
errorsPath,
});
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
if (request.method.toUpperCase() !== "POST") {
return json({ status: 405, error: "Method Not Allowed" }, { status: 405 });
}
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
return json({ error: "Project not found" }, { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
return json({ error: "Environment not found" }, { status: 404 });
}
const formData = await request.formData();
const submission = parseWithZod(formData, { schema: ErrorAlertsFormSchema });
if (submission.status !== "success") {
return json(submission.reply());
}
const { emails, webhooks, slackChannel, slackIntegrationId } = submission.value;
const emailEnabled = env.ALERT_FROM_EMAIL !== undefined && env.ALERT_RESEND_API_KEY !== undefined;
const slackEnabled = !!slackIntegrationId;
const existingChannels = await prisma.projectAlertChannel.findMany({
where: {
projectId: project.id,
alertTypes: { has: "ERROR_GROUP" },
environmentTypes: { has: environment.type },
},
});
const service = new CreateAlertChannelService();
const environmentTypes = [environment.type];
const processedChannelIds = new Set<string>();
if (emailEnabled) {
for (const email of emails) {
const options: CreateAlertChannelOptions = {
name: `Error alert to ${email}`,
alertTypes: ["ERROR_GROUP"],
environmentTypes,
deduplicationKey: `error-email:${email}:${environment.type}`,
channel: { type: "EMAIL", email },
};
const channel = await service.call(project.externalRef, userId, options);
processedChannelIds.add(channel.id);
}
}
if (slackEnabled && slackChannel) {
const [channelId, channelName] = slackChannel.split("/");
if (channelId && channelName) {
const options: CreateAlertChannelOptions = {
name: `Error alert to #${channelName}`,
alertTypes: ["ERROR_GROUP"],
environmentTypes,
deduplicationKey: `error-slack:${environment.type}`,
channel: {
type: "SLACK",
channelId,
channelName,
integrationId: slackIntegrationId,
},
};
const channel = await service.call(project.externalRef, userId, options);
processedChannelIds.add(channel.id);
}
}
for (const url of webhooks) {
const options: CreateAlertChannelOptions = {
name: `Error alert to ${new URL(url).hostname}`,
alertTypes: ["ERROR_GROUP"],
environmentTypes,
deduplicationKey: `error-webhook:${url}:${environment.type}`,
channel: { type: "WEBHOOK", url },
};
try {
const channel = await service.call(project.externalRef, userId, options);
processedChannelIds.add(channel.id);
} catch (error) {
// CreateAlertChannelService rejects unsafe webhook URLs.
if (error instanceof ServiceValidationError) {
return json(submission.reply({ fieldErrors: { webhooks: [error.message] } }));
}
throw error;
}
}
const editableTypes = new Set<string>(["WEBHOOK"]);
if (emailEnabled) {
editableTypes.add("EMAIL");
}
if (slackEnabled) {
editableTypes.add("SLACK");
}
const channelsToDelete = existingChannels.filter(
(ch) =>
!processedChannelIds.has(ch.id) &&
editableTypes.has(ch.type) &&
ch.alertTypes.length === 1 &&
ch.alertTypes[0] === "ERROR_GROUP"
);
for (const ch of channelsToDelete) {
await prisma.projectAlertChannel.delete({ where: { id: ch.id } });
}
return json({ ok: true });
};
export default function Page() {
const { alertData, connectToSlackHref, errorsPath } = useTypedLoaderData<typeof loader>();
const { has } = useSearchParams();
const showAlerts = has("alerts") ?? false;
const navigate = useNavigate();
const location = useOptimisticLocation();
const closeAlerts = useCallback(() => {
const params = new URLSearchParams(location.search);
params.delete("alerts");
const qs = params.toString();
navigate(qs ? `?${qs}` : location.pathname, { replace: true });
}, [location.search, location.pathname, navigate]);
return (
<PageContainer>
<Outlet />
<Sheet open={showAlerts} onOpenChange={(open) => !open && closeAlerts()}>
<SheetContent
side="right"
className="w-[420px] min-w-[320px] max-w-[560px] p-0 sm:max-w-[560px]"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<ConfigureErrorAlerts
{...alertData}
connectToSlackHref={connectToSlackHref}
formAction={errorsPath}
/>
</SheetContent>
</Sheet>
</PageContainer>
);
}
@@ -0,0 +1,889 @@
import { CheckIcon, BookOpenIcon } from "@heroicons/react/20/solid";
import { type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { IconCardsFilled, IconDiamondFilled, IconTallymark4 } from "@tabler/icons-react";
import { tryCatch } from "@trigger.dev/core";
import { Gauge } from "lucide-react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { Feedback } from "~/components/Feedback";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { EnvironmentSelector } from "~/components/navigation/EnvironmentSelector";
import { AnimatedNumber } from "~/components/primitives/AnimatedNumber";
import { Badge } from "~/components/primitives/Badge";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Header2 } from "~/components/primitives/Headers";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import * as Property from "~/components/primitives/PropertyTable";
import {
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import {
LimitsPresenter,
type FeatureInfo,
type LimitsResult,
type QuotaInfo,
type RateLimitInfo,
} from "~/presenters/v3/LimitsPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { formatNumber } from "~/utils/numberFormatter";
import {
concurrencyPath,
docsPath,
EnvironmentParamSchema,
organizationBillingPath,
} from "~/utils/pathBuilder";
export const meta: MetaFunction = () => {
return [
{
title: `Limits | Trigger.dev`,
},
];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response(undefined, {
status: 404,
statusText: "Project not found",
});
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response(undefined, {
status: 404,
statusText: "Environment not found",
});
}
const presenter = new LimitsPresenter();
const [error, result] = await tryCatch(
presenter.call({
organizationId: project.organizationId,
projectId: project.id,
environmentId: environment.id,
environmentType: environment.type,
environmentApiKey: environment.apiKey,
})
);
if (error) {
throw new Response(error.message, {
status: 400,
});
}
// Match the queues page pattern: pass a poll interval from the loader
const autoReloadPollIntervalMs = 5000;
return typedjson({
...result,
autoReloadPollIntervalMs,
});
};
export default function Page() {
const data = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
// Auto-revalidate using the loader-provided interval and refresh on focus
useAutoRevalidate({ interval: data.autoReloadPollIntervalMs, onFocus: true });
return (
<PageContainer>
<NavBar>
<PageTitle title="Limits" />
<PageAccessories>
<AdminDebugTooltip>
<Property.Table>
<Property.Item>
<Property.Label>Plan</Property.Label>
<Property.Value>{data.planName ?? "No plan"}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Organization ID</Property.Label>
<Property.Value>{data.organizationId}</Property.Value>
</Property.Item>
</Property.Table>
</AdminDebugTooltip>
<LinkButton variant={"docs/small"} LeadingIcon={BookOpenIcon} to={docsPath("/limits")}>
Limits docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={true}>
<div className="mx-auto mt-10 max-w-2xl p-4">
<div className="flex flex-col gap-8">
{/* Current Plan Section */}
{data.planName && (
<CurrentPlanSection
planName={data.planName}
isOnTopPlan={data.isOnTopPlan}
billingPath={organizationBillingPath(organization)}
/>
)}
{/* Concurrency Section */}
<ConcurrencySection
concurrencyPath={concurrencyPath(organization, project, environment)}
/>
{/* Rate Limits Section */}
<RateLimitsSection
rateLimits={data.rateLimits}
isOnTopPlan={data.isOnTopPlan}
billingPath={organizationBillingPath(organization)}
organization={organization}
project={project}
environment={environment}
/>
{/* Quotas Section */}
<QuotasSection
quotas={data.quotas}
isOnTopPlan={data.isOnTopPlan}
billingPath={organizationBillingPath(organization)}
/>
{/* Features Section */}
<FeaturesSection
features={data.features}
isOnTopPlan={data.isOnTopPlan}
billingPath={organizationBillingPath(organization)}
/>
</div>
</div>
</PageBody>
</PageContainer>
);
}
function CurrentPlanSection({
planName,
isOnTopPlan,
billingPath,
}: {
planName: string;
isOnTopPlan: boolean;
billingPath: string;
}) {
const showSelfServe = useShowSelfServe();
return (
<div className="flex flex-col gap-3">
<Header2 className="flex items-center gap-1">
<IconCardsFilled className="size-5 text-amber-400" />
Current plan
</Header2>
<Table variant="bright/no-hover">
<TableBody>
<TableRow>
<TableCell className="w-full text-sm">{planName}</TableCell>
<TableCell alignment="right">
{isOnTopPlan ? (
<Feedback
button={<Button variant="secondary/small">Request Enterprise</Button>}
defaultValue="help"
/>
) : showSelfServe ? (
<LinkButton to={billingPath} variant="secondary/small">
View plans
</LinkButton>
) : (
<Feedback
defaultValue="enterprise"
button={<Button variant="secondary/small">Contact us</Button>}
/>
)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
);
}
function ConcurrencySection({ concurrencyPath }: { concurrencyPath: string }) {
return (
<div className="flex flex-col gap-3">
<Header2 className="flex items-center gap-1.5">
<ConcurrencyIcon className="size-5 text-orange-500" />
Concurrency limits
<InfoIconTooltip
content="Concurrency limits control how many runs execute at the same time."
disableHoverableContent
/>
</Header2>
<Table variant="bright/no-hover">
<TableBody>
<TableRow>
<TableCell className="w-full text-sm">Concurrency</TableCell>
<TableCell alignment="right">
<LinkButton to={concurrencyPath} variant="secondary/small">
Manage concurrency
</LinkButton>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
);
}
function RateLimitsSection({
rateLimits,
isOnTopPlan,
billingPath,
organization,
project,
environment,
}: {
rateLimits: LimitsResult["rateLimits"];
isOnTopPlan: boolean;
billingPath: string;
organization: ReturnType<typeof useOrganization>;
project: ReturnType<typeof useProject>;
environment: ReturnType<typeof useEnvironment>;
}) {
const showSelfServe = useShowSelfServe();
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<Header2 className="flex items-center gap-1.5">
<Gauge className="size-5 text-indigo-500" />
Rate limits
<InfoIconTooltip
content="Rate limits control how many API requests can be made within a time window. They are tracked per API key."
disableHoverableContent
/>
</Header2>
<EnvironmentSelector
organization={organization}
project={project}
environment={environment}
className="w-auto"
/>
</div>
<Table variant="bright/no-hover">
<TableHeader>
<TableRow>
<TableHeaderCell>Rate limit</TableHeaderCell>
<TableHeaderCell alignment="right">
<span className="flex items-center justify-end gap-x-1">
Type
<InfoIconTooltip
content={
<div className="flex max-w-xs flex-col gap-3 py-1">
<div className="flex flex-col items-start gap-1.5">
<RateLimitTypeBadge type="tokenBucket" />
<span className="pl-0.5 text-text-bright">
Requests consume tokens from a bucket that refills over time. When empty,
requests are rate limited.
</span>
</div>
<div className="flex flex-col items-start gap-1.5">
<RateLimitTypeBadge type="fixedWindow" />
<span className="pl-0.5 text-text-bright">
Allows a set number of requests per time window. The window resets at
fixed intervals.
</span>
</div>
<div className="flex flex-col items-start gap-1.5">
<RateLimitTypeBadge type="slidingWindow" />
<span className="pl-0.5 text-text-bright">
Allows a set number of requests per rolling time window. The limit is
continuously evaluated.
</span>
</div>
</div>
}
disableHoverableContent
/>
</span>
</TableHeaderCell>
<TableHeaderCell alignment="right">Configuration</TableHeaderCell>
<TableHeaderCell alignment="right">
<span className="flex items-center justify-end gap-x-1">
Available
<InfoIconTooltip
content="Current available tokens for this environment's API key"
disableHoverableContent
/>
</span>
</TableHeaderCell>
{showSelfServe ? <TableHeaderCell alignment="right">Upgrade</TableHeaderCell> : null}
</TableRow>
</TableHeader>
<TableBody>
<RateLimitRow
info={rateLimits.api}
isOnTopPlan={isOnTopPlan}
billingPath={billingPath}
showSelfServe={showSelfServe}
/>
<RateLimitRow
info={rateLimits.batch}
isOnTopPlan={isOnTopPlan}
billingPath={billingPath}
showSelfServe={showSelfServe}
/>
</TableBody>
</Table>
</div>
);
}
function RateLimitRow({
info,
isOnTopPlan,
billingPath,
showSelfServe,
}: {
info: RateLimitInfo;
isOnTopPlan: boolean;
billingPath: string;
showSelfServe: boolean;
}) {
const maxTokens = info.config.type === "tokenBucket" ? info.config.maxTokens : info.config.tokens;
const percentage =
info.currentTokens !== null && maxTokens > 0 ? info.currentTokens / maxTokens : null;
return (
<TableRow>
<TableCell>
<span className="flex items-center gap-1 text-sm">
{info.name}
<InfoIconTooltip content={info.description} disableHoverableContent />
</span>
</TableCell>
<TableCell alignment="right">
<div className="flex justify-end">
<RateLimitTypeBadge config={info.config} />
</div>
</TableCell>
<TableCell alignment="right">
<RateLimitConfigDisplay config={info.config} />
</TableCell>
<TableCell alignment="right">
{info.currentTokens !== null ? (
<div className="flex flex-col items-end gap-0.5">
<span
className={cn(
"font-medium tabular-nums",
getUsageColorClass(percentage, "remaining")
)}
>
<AnimatedNumber value={info.currentTokens} />
</span>
<span className="text-xs tabular-nums text-text-dimmed">
of {formatNumber(maxTokens)}
</span>
</div>
) : (
<span className="text-text-dimmed"></span>
)}
</TableCell>
{showSelfServe ? (
<TableCell alignment="right">
<div className="flex justify-end">
{info.name === "Batch rate limit" ? (
isOnTopPlan ? (
<Feedback
button={<Button variant="secondary/small">Contact us</Button>}
defaultValue="help"
/>
) : (
<LinkButton to={billingPath} variant="secondary/small">
View plans
</LinkButton>
)
) : (
<Feedback
button={<Button variant="secondary/small">Contact us</Button>}
defaultValue="help"
/>
)}
</div>
</TableCell>
) : null}
</TableRow>
);
}
function RateLimitTypeBadge({
config,
type,
}: {
config?: RateLimitInfo["config"];
type?: "tokenBucket" | "fixedWindow" | "slidingWindow";
}) {
const rateLimitType = type ?? config?.type;
switch (rateLimitType) {
case "tokenBucket":
return (
<Badge variant="small" className="w-fit bg-blue-500/20 text-blue-400">
Token bucket
</Badge>
);
case "fixedWindow":
return (
<Badge variant="small" className="w-fit bg-purple-500/20 text-purple-400">
Fixed window
</Badge>
);
case "slidingWindow":
return (
<Badge variant="small" className="w-fit bg-green-500/20 text-green-400">
Sliding window
</Badge>
);
default:
return null;
}
}
function RateLimitConfigDisplay({ config }: { config: RateLimitInfo["config"] }) {
if (config.type === "tokenBucket") {
return (
<div className="flex flex-col items-end gap-0.5 text-xs">
<span className="tabular-nums">
<span className="text-text-dimmed">Max tokens:</span>{" "}
<span className="font-medium text-text-bright">{formatNumber(config.maxTokens)}</span>
</span>
<span className="tabular-nums">
<span className="text-text-dimmed">Refill:</span>{" "}
<span className="font-medium text-text-bright">
{formatNumber(config.refillRate)}/{config.interval}
</span>
</span>
</div>
);
}
if (config.type === "fixedWindow" || config.type === "slidingWindow") {
return (
<div className="flex flex-col items-end gap-0.5 text-xs">
<span className="tabular-nums">
<span className="text-text-dimmed">Tokens:</span>{" "}
<span className="font-medium text-text-bright">{formatNumber(config.tokens)}</span>
</span>
<span className="tabular-nums">
<span className="text-text-dimmed">Window:</span>{" "}
<span className="font-medium text-text-bright">{config.window}</span>
</span>
</div>
);
}
return <span className="text-text-dimmed"></span>;
}
function QuotasSection({
quotas,
isOnTopPlan,
billingPath,
}: {
quotas: LimitsResult["quotas"];
isOnTopPlan: boolean;
billingPath: string;
}) {
// Collect all quotas that should be shown
const quotaRows: QuotaInfo[] = [];
// Always show projects
quotaRows.push(quotas.projects);
// Add plan-based quotas if they exist
if (quotas.teamMembers) quotaRows.push(quotas.teamMembers);
if (quotas.schedules) quotaRows.push(quotas.schedules);
if (quotas.alerts) quotaRows.push(quotas.alerts);
if (quotas.branches) quotaRows.push(quotas.branches);
if (quotas.realtimeConnections) quotaRows.push(quotas.realtimeConnections);
if (quotas.logRetentionDays) quotaRows.push(quotas.logRetentionDays);
// Include batch processing concurrency
quotaRows.push(quotas.batchProcessingConcurrency);
// Add queue size quota if set
if (quotas.queueSize.limit !== null) quotaRows.push(quotas.queueSize);
// Metric & query quotas
if (quotas.metricDashboards) quotaRows.push(quotas.metricDashboards);
if (quotas.metricWidgetsPerDashboard) quotaRows.push(quotas.metricWidgetsPerDashboard);
if (quotas.queryPeriodDays) quotaRows.push(quotas.queryPeriodDays);
const showSelfServe = useShowSelfServe();
return (
<div className="flex flex-col gap-3">
<Header2 className="flex items-center gap-1">
<IconTallymark4 className="size-6 text-blue-500" />
Quotas
<InfoIconTooltip
content="Quotas define the maximum resources available to your organization."
disableHoverableContent
/>
</Header2>
<Table variant="bright/no-hover">
<TableHeader>
<TableRow>
<TableHeaderCell>Quota</TableHeaderCell>
<TableHeaderCell alignment="right">Limit</TableHeaderCell>
<TableHeaderCell alignment="right">Current</TableHeaderCell>
<TableHeaderCell alignment="right">Source</TableHeaderCell>
{showSelfServe ? <TableHeaderCell alignment="right">Upgrade</TableHeaderCell> : null}
</TableRow>
</TableHeader>
<TableBody>
{quotaRows.map((quota) => (
<QuotaRow
key={quota.name}
quota={quota}
isOnTopPlan={isOnTopPlan}
billingPath={billingPath}
showSelfServe={showSelfServe}
/>
))}
</TableBody>
</Table>
</div>
);
}
function QuotaRow({
quota,
isOnTopPlan,
billingPath,
showSelfServe,
}: {
quota: QuotaInfo;
isOnTopPlan: boolean;
billingPath: string;
showSelfServe: boolean;
}) {
// For log retention and query period, we don't show current usage as it's a duration, not a count
// For widgets per dashboard, the usage varies per dashboard so we don't show a single number
const isDurationQuota = quota.name === "Log retention" || quota.name === "Query period";
const isPerItemQuota = quota.name === "Charts per dashboard";
const isRetentionQuota = isDurationQuota || isPerItemQuota;
const isQueueSizeQuota = quota.name === "Max queued runs";
const hideCurrentUsage = isRetentionQuota || isQueueSizeQuota;
const percentage =
!hideCurrentUsage && quota.limit && quota.limit > 0 ? quota.currentUsage / quota.limit : null;
// Special handling for duration-based quotas (Log retention, Query period)
if (isDurationQuota) {
const canUpgrade = !isOnTopPlan;
return (
<TableRow>
<TableCell className="flex w-full items-center gap-1">
<span className="text-sm">{quota.name}</span>
<InfoIconTooltip content={quota.description} disableHoverableContent />
</TableCell>
<TableCell alignment="right" className="font-medium tabular-nums">
{quota.limit !== null
? `${formatNumber(quota.limit)} ${quota.limit === 1 ? "day" : "days"}`
: "Unlimited"}
</TableCell>
<TableCell alignment="right" className="tabular-nums text-text-dimmed">
</TableCell>
<TableCell alignment="right">
<SourceBadge source={quota.source} />
</TableCell>
{showSelfServe ? (
<TableCell alignment="right">
<div className="flex justify-end">
{canUpgrade ? (
<LinkButton to={billingPath} variant="secondary/small">
View plans
</LinkButton>
) : (
<Feedback
button={<Button variant="secondary/small">Contact us</Button>}
defaultValue="help"
/>
)}
</div>
</TableCell>
) : null}
</TableRow>
);
}
const renderUpgrade = () => {
// Projects always show Contact us (regardless of upgrade flags)
if (quota.name === "Projects") {
return (
<div className="flex justify-end">
<Feedback
button={<Button variant="secondary/small">Contact us</Button>}
defaultValue="help"
/>
</div>
);
}
if (!quota.isUpgradable) {
return null;
}
// Not on top plan - show View plans
if (!isOnTopPlan) {
return (
<div className="flex justify-end">
<LinkButton to={billingPath} variant="secondary/small">
View plans
</LinkButton>
</div>
);
}
// On top plan - show Contact us if canExceed is true
if (quota.canExceed) {
return (
<div className="flex justify-end">
<Feedback
button={<Button variant="secondary/small">Contact us</Button>}
defaultValue="help"
/>
</div>
);
}
// On top plan but cannot exceed - no upgrade option
return null;
};
return (
<TableRow>
<TableCell className="flex w-full items-center gap-1">
<span className="text-sm">{quota.name}</span>
<InfoIconTooltip content={quota.description} disableHoverableContent />
</TableCell>
<TableCell alignment="right" className="font-medium tabular-nums">
{quota.limit !== null
? isDurationQuota
? `${formatNumber(quota.limit)} ${quota.limit === 1 ? "day" : "days"}`
: formatNumber(quota.limit)
: "Unlimited"}
</TableCell>
<TableCell
alignment="right"
className={cn(
"tabular-nums",
hideCurrentUsage ? "text-text-dimmed" : getUsageColorClass(percentage, "usage")
)}
>
{hideCurrentUsage ? "" : formatNumber(quota.currentUsage)}
</TableCell>
<TableCell alignment="right">
<SourceBadge source={quota.source} />
</TableCell>
{showSelfServe ? <TableCell alignment="right">{renderUpgrade()}</TableCell> : null}
</TableRow>
);
}
function FeaturesSection({
features,
isOnTopPlan,
billingPath,
}: {
features: LimitsResult["features"];
isOnTopPlan: boolean;
billingPath: string;
}) {
// For staging environment: show View plans if not enabled (i.e., on Free plan)
const stagingUpgradeType = features.hasStagingEnvironment.enabled ? "none" : "view-plans";
const showSelfServe = useShowSelfServe();
return (
<div className="flex flex-col gap-3">
<Header2 className="flex items-center gap-1.5">
<IconDiamondFilled className="size-5 text-green-600" />
Plan features
</Header2>
<Table variant="bright/no-hover">
<TableHeader>
<TableRow>
<TableHeaderCell>Feature</TableHeaderCell>
<TableHeaderCell alignment="right">Status</TableHeaderCell>
{showSelfServe ? <TableHeaderCell alignment="right">Upgrade</TableHeaderCell> : null}
</TableRow>
</TableHeader>
<TableBody>
<FeatureRow
feature={features.hasStagingEnvironment}
upgradeType={stagingUpgradeType}
billingPath={billingPath}
showSelfServe={showSelfServe}
/>
<FeatureRow
feature={features.support}
upgradeType={isOnTopPlan ? "contact-us" : "view-plans"}
billingPath={billingPath}
showSelfServe={showSelfServe}
/>
<FeatureRow
feature={features.includedUsage}
upgradeType={isOnTopPlan ? "contact-us" : "view-plans"}
billingPath={billingPath}
showSelfServe={showSelfServe}
/>
</TableBody>
</Table>
</div>
);
}
function FeatureRow({
feature,
upgradeType,
billingPath,
showSelfServe,
}: {
feature: FeatureInfo;
upgradeType: "view-plans" | "contact-us" | "none";
billingPath: string;
showSelfServe: boolean;
}) {
const displayValue = () => {
if (feature.name === "Included compute" && typeof feature.value === "number") {
if (!feature.enabled || feature.value === 0) {
return <span className="text-text-dimmed">None</span>;
}
return (
<span className="font-medium text-text-bright">${formatNumber(feature.value / 100)}</span>
);
}
if (feature.value !== undefined) {
return <span className="font-medium text-text-bright">{feature.value}</span>;
}
return feature.enabled ? (
<span className="inline-flex items-center gap-1 text-success">
<CheckIcon className="size-4" />
Enabled
</span>
) : (
<span className="inline-flex items-center gap-1 text-text-dimmed">Not available</span>
);
};
const renderUpgrade = () => {
switch (upgradeType) {
case "view-plans":
return (
<div className="flex justify-end">
<LinkButton to={billingPath} variant="secondary/small">
View plans
</LinkButton>
</div>
);
case "contact-us":
return (
<div className="flex justify-end">
<Feedback
button={<Button variant="secondary/small">Contact us</Button>}
defaultValue="help"
/>
</div>
);
case "none":
return null;
}
};
return (
<TableRow>
<TableCell className="flex w-full items-center gap-1">
<span className="text-sm">{feature.name}</span>
<InfoIconTooltip content={feature.description} disableHoverableContent />
</TableCell>
<TableCell alignment="right">{displayValue()}</TableCell>
{showSelfServe ? <TableCell alignment="right">{renderUpgrade()}</TableCell> : null}
</TableRow>
);
}
/**
* Returns the appropriate color class based on usage percentage.
* @param percentage - The usage percentage (0-1 scale)
* @param mode - "usage" means higher is worse (quotas), "remaining" means lower is worse (rate limits)
* @returns Tailwind color class
*/
function getUsageColorClass(
percentage: number | null,
mode: "usage" | "remaining" = "usage"
): string {
if (percentage === null) return "text-text-dimmed";
if (mode === "remaining") {
// For remaining tokens: 0 = bad (red), <=10% = warning (orange)
if (percentage <= 0) return "text-error";
if (percentage <= 0.1) return "text-warning";
return "text-text-bright";
} else {
// For usage: 100% = bad (red), >=90% = warning (orange)
if (percentage >= 1) return "text-error";
if (percentage >= 0.9) return "text-warning";
return "text-text-bright";
}
}
function SourceBadge({ source }: { source: "default" | "plan" | "override" }) {
const variants: Record<typeof source, { label: string; className: string }> = {
default: {
label: "Default",
className: "bg-indigo-500/20 text-indigo-400",
},
plan: {
label: "Plan",
className: "bg-purple-500/20 text-purple-400",
},
override: {
label: "Override",
className: "bg-amber-500/20 text-amber-400",
},
};
const variant = variants[source];
return (
<span
className={cn(
"inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium",
variant.className
)}
>
{variant.label}
</span>
);
}
@@ -0,0 +1,511 @@
import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
import { type MetaFunction, useFetcher, useNavigation, useLocation, Form } from "@remix-run/react";
import { XMarkIcon } from "@heroicons/react/20/solid";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import {
TypedAwait,
typeddefer,
type UseDataFunctionReturn,
useTypedLoaderData,
} from "remix-typedjson";
import { requireUser } from "~/services/session.server";
import { getCurrentPlan } from "~/services/platform.v3.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
import { LogsListPresenter } from "~/presenters/v3/LogsListPresenter.server";
import type { LogLevel } from "~/utils/logUtils";
import { $replica, prisma } from "~/db.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { Spinner } from "~/components/primitives/Spinner";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Callout } from "~/components/primitives/Callout";
import { LogsTable } from "~/components/logs/LogsTable";
import { LogDetailView } from "~/components/logs/LogDetailView";
import { SearchInput } from "~/components/primitives/SearchInput";
import { LogsLevelFilter } from "~/components/logs/LogsLevelFilter";
import { LogsTaskFilter } from "~/components/logs/LogsTaskFilter";
import { LogsRunIdFilter } from "~/components/logs/LogsRunIdFilter";
import { TimeFilter } from "~/components/runs/v3/SharedFilters";
import {
RESIZABLE_PANEL_ANIMATION,
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
collapsibleHandleClassName,
useFrozenValue,
} from "~/components/primitives/Resizable";
import { Button } from "~/components/primitives/Buttons";
import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags";
// Valid log levels for filtering
const validLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"];
function parseLevelsFromUrl(url: URL): LogLevel[] | undefined {
const levelParams = url.searchParams.getAll("levels").filter((v) => v.length > 0);
if (levelParams.length === 0) return undefined;
return levelParams.filter((l): l is LogLevel => validLevels.includes(l as LogLevel));
}
export const meta: MetaFunction = () => {
return [
{
title: `Logs | Trigger.dev`,
},
];
};
// TODO: Move this to a more appropriate shared location
async function hasLogsPageAccess(
userId: string,
isAdmin: boolean,
isImpersonating: boolean,
organizationSlug: string
): Promise<boolean> {
if (isAdmin || isImpersonating) {
return true;
}
// Check organization feature flags
const organization = await prisma.organization.findFirst({
where: {
slug: organizationSlug,
members: { some: { userId } },
},
select: {
featureFlags: true,
},
});
if (!organization?.featureFlags) {
return false;
}
const flags = organization.featureFlags as Record<string, unknown>;
const hasLogsPageAccessResult = validateFeatureFlagValue(
FEATURE_FLAG.hasLogsPageAccess,
flags.hasLogsPageAccess
);
return hasLogsPageAccessResult.success && hasLogsPageAccessResult.data === true;
}
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const userId = user.id;
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
const canAccess = await hasLogsPageAccess(
userId,
user.admin,
user.isImpersonating,
organizationSlug
);
if (!canAccess) {
throw redirect("/");
}
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Project not found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response("Environment not found", { status: 404 });
}
// Get filters from query params
const url = new URL(request.url);
const tasks = url.searchParams.getAll("tasks").filter((t) => t.length > 0);
const runId = url.searchParams.get("runId") ?? undefined;
const search = url.searchParams.get("search") ?? undefined;
const levels = parseLevelsFromUrl(url);
const period = url.searchParams.get("period") ?? undefined;
const fromStr = url.searchParams.get("from");
const toStr = url.searchParams.get("to");
const from = fromStr ? parseInt(fromStr, 10) : undefined;
const to = toStr ? parseInt(toStr, 10) : undefined;
// Get the user's plan to determine log retention limit
const plan = await getCurrentPlan(project.organizationId);
const retentionLimitDays = plan?.v3Subscription?.plan?.limits.logRetentionDays.number ?? 30;
const logsClickhouse = await clickhouseFactory.getClickhouseForOrganization(
project.organizationId,
"logs"
);
const presenter = new LogsListPresenter($replica, logsClickhouse);
const listPromise = presenter
.call(project.organizationId, environment.id, {
userId,
projectId: project.id,
tasks: tasks.length > 0 ? tasks : undefined,
runId,
search,
levels,
period,
from,
to,
defaultPeriod: "1h",
retentionLimitDays,
})
.catch((error) => {
if (error instanceof ServiceValidationError) {
return { error: error.message };
}
throw error;
});
return typeddefer({
data: listPromise,
defaultPeriod: "1h",
retentionLimitDays,
});
};
export default function Page() {
const { data, defaultPeriod, retentionLimitDays } = useTypedLoaderData<typeof loader>();
return (
<PageContainer>
<NavBar>
<PageTitle title="Logs" />
</NavBar>
<PageBody scrollable={false}>
<Suspense
fallback={
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto] overflow-hidden">
<div className="border-b border-grid-bright" />
<div className="my-2 flex items-center justify-center">
<div className="mx-auto flex items-center gap-2">
<Spinner />
<Paragraph variant="small">Loading logs</Paragraph>
</div>
</div>
</div>
}
>
<TypedAwait
resolve={data}
errorElement={
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto_1fr] overflow-hidden">
<FiltersBar defaultPeriod={defaultPeriod} retentionLimitDays={retentionLimitDays} />
<div className="flex items-center justify-center px-3 py-12">
<Callout variant="error" className="max-w-fit">
Unable to load your logs. Please refresh the page or try again in a moment.
</Callout>
</div>
</div>
}
>
{(result) => {
// Check if result contains an error
if ("error" in result) {
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto_1fr] overflow-hidden">
<FiltersBar
defaultPeriod={defaultPeriod}
retentionLimitDays={retentionLimitDays}
/>
<div className="flex items-center justify-center px-3 py-12">
<Callout variant="error" className="max-w-fit">
{result.error}
</Callout>
</div>
</div>
);
}
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden">
<FiltersBar
list={result}
defaultPeriod={defaultPeriod}
retentionLimitDays={retentionLimitDays}
/>
<LogsList list={result} defaultPeriod={defaultPeriod} />
</div>
);
}}
</TypedAwait>
</Suspense>
</PageBody>
</PageContainer>
);
}
function FiltersBar({
list,
defaultPeriod,
retentionLimitDays,
}: {
list?: Exclude<Awaited<UseDataFunctionReturn<typeof loader>["data"]>, { error: string }>;
defaultPeriod?: string;
retentionLimitDays: number;
}) {
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const hasFilters =
searchParams.has("tasks") ||
searchParams.has("runId") ||
searchParams.has("search") ||
searchParams.has("levels") ||
searchParams.has("period") ||
searchParams.has("from") ||
searchParams.has("to");
return (
<div className="flex items-start justify-between gap-x-2 border-b border-grid-bright p-2">
<div className="flex flex-row flex-wrap items-center gap-1.5">
{list ? (
<>
<SearchInput />
<LogsTaskFilter possibleTasks={list.possibleTasks} />
<LogsRunIdFilter />
<TimeFilter defaultPeriod={defaultPeriod} maxPeriodDays={retentionLimitDays} />
<LogsLevelFilter />
{hasFilters && (
<Form className="-ml-1 h-6">
<Button
variant="minimal/small"
LeadingIcon={XMarkIcon}
tooltip="Clear all filters"
className="group-hover/button:bg-transparent"
leadingIconClassName="group-hover/button:text-text-bright"
/>
</Form>
)}
</>
) : (
<>
<LogsTaskFilter possibleTasks={[]} />
<LogsRunIdFilter />
<TimeFilter defaultPeriod={defaultPeriod} maxPeriodDays={retentionLimitDays} />
<LogsLevelFilter />
<SearchInput />
{hasFilters && (
<Form className="-ml-1 h-6">
<Button
variant="minimal/small"
LeadingIcon={XMarkIcon}
tooltip="Clear all filters"
className="group-hover/button:bg-transparent"
leadingIconClassName="group-hover/button:text-text-bright"
/>
</Form>
)}
</>
)}
</div>
</div>
);
}
function LogsList({
list,
}: {
list: Exclude<Awaited<UseDataFunctionReturn<typeof loader>["data"]>, { error: string }>; //exclude error, it is handled
defaultPeriod?: string;
}) {
const navigation = useNavigation();
const location = useLocation();
const fetcher = useFetcher<{ logs: LogEntry[]; pagination: { next?: string } }>();
const [, startTransition] = useTransition();
const isLoading = navigation.state !== "idle";
// Accumulated logs state
const [accumulatedLogs, setAccumulatedLogs] = useState<LogEntry[]>(list.logs);
const [nextCursor, setNextCursor] = useState<string | undefined>(list.pagination.next);
// Selected log state - managed locally to avoid triggering navigation
const [selectedLogId, setSelectedLogId] = useState<string | undefined>(() => {
const params = new URLSearchParams(location.search);
return params.get("log") ?? undefined;
});
// Track which filter state (search params) the current fetcher request corresponds to
const fetcherFilterStateRef = useRef<string>(location.search);
// Track whether the current fetch is a "check for new" request vs "load more"
const isCheckingForNewRef = useRef<boolean>(false);
// Clear accumulated logs immediately when filters change (for instant visual feedback)
useEffect(() => {
setAccumulatedLogs([]);
setNextCursor(undefined);
// Preserve log selection from URL param, clear if not present
const params = new URLSearchParams(location.search);
setSelectedLogId(params.get("log") ?? undefined);
}, [location.search]);
// Populate accumulated logs when new data arrives
useEffect(() => {
setAccumulatedLogs(list.logs);
setNextCursor(list.pagination.next);
}, [list.logs, list.pagination.next]);
// Clear log parameter from URL when selectedLogId is cleared
useEffect(() => {
if (!selectedLogId) {
const url = new URL(window.location.href);
if (url.searchParams.has("log")) {
url.searchParams.delete("log");
window.history.replaceState(null, "", url.toString());
}
}
}, [selectedLogId]);
// Append/prepend new logs when fetcher completes (with deduplication)
useEffect(() => {
if (fetcher.data && fetcher.state === "idle") {
// Ignore fetcher data if it was loaded for a different filter state
if (fetcherFilterStateRef.current !== location.search) {
return;
}
if (isCheckingForNewRef.current) {
// "Check for new" - prepend new logs, don't update cursor
setAccumulatedLogs((prev) => {
const existingIds = new Set(prev.map((log) => log.id));
const newLogs = fetcher.data!.logs.filter((log) => !existingIds.has(log.id));
return newLogs.length > 0 ? [...newLogs, ...prev] : prev;
});
isCheckingForNewRef.current = false;
} else {
// "Load more" - append logs and update cursor
setAccumulatedLogs((prev) => {
const existingIds = new Set(prev.map((log) => log.id));
const newLogs = fetcher.data!.logs.filter((log) => !existingIds.has(log.id));
return newLogs.length > 0 ? [...prev, ...newLogs] : prev;
});
setNextCursor(fetcher.data.pagination.next);
}
}
}, [fetcher.data, fetcher.state, location.search]);
// Build resource URL for loading more
const loadMoreUrl = useMemo(() => {
if (!nextCursor) return null;
const resourcePath = `/resources${location.pathname}`;
const params = new URLSearchParams(location.search);
params.set("cursor", nextCursor);
params.delete("log");
return `${resourcePath}?${params.toString()}`;
}, [location.pathname, location.search, nextCursor]);
const handleLoadMore = useCallback(() => {
if (loadMoreUrl && fetcher.state === "idle") {
// Store the current filter state before loading
fetcherFilterStateRef.current = location.search;
fetcher.load(loadMoreUrl);
}
}, [loadMoreUrl, fetcher, location.search]);
const selectedLog = useMemo(() => {
if (!selectedLogId) return undefined;
return accumulatedLogs.find((log) => log.id === selectedLogId);
}, [selectedLogId, accumulatedLogs]);
const frozenLogId = useFrozenValue(selectedLogId);
const frozenLog = useFrozenValue(selectedLog);
const displayLogId = selectedLogId ?? frozenLogId;
const displayLog = selectedLog ?? frozenLog ?? undefined;
const updateUrlWithLog = useCallback((logId: string | undefined) => {
const url = new URL(window.location.href);
if (logId) {
url.searchParams.set("log", logId);
} else {
url.searchParams.delete("log");
}
window.history.replaceState(null, "", url.toString());
}, []);
const handleLogSelect = useCallback(
(logId: string) => {
startTransition(() => {
setSelectedLogId(logId);
});
updateUrlWithLog(logId);
},
[updateUrlWithLog, startTransition]
);
const handleClosePanel = useCallback(() => {
startTransition(() => {
setSelectedLogId(undefined);
});
updateUrlWithLog(undefined);
}, [updateUrlWithLog, startTransition]);
const handleCheckForMore = useCallback(() => {
if (fetcher.state !== "idle") return;
// Fetch without cursor to check for new logs
const resourcePath = `/resources${location.pathname}`;
const params = new URLSearchParams(location.search);
params.delete("cursor");
params.delete("log");
fetcherFilterStateRef.current = location.search;
isCheckingForNewRef.current = true;
fetcher.load(`${resourcePath}?${params.toString()}`);
}, [fetcher, location.pathname, location.search]);
return (
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
<ResizablePanel id="logs-main" min="200px">
<LogsTable
key={location.search}
logs={accumulatedLogs}
searchTerm={list.searchTerm}
isLoading={isLoading}
isLoadingMore={fetcher.state === "loading"}
hasMore={!!nextCursor}
onLoadMore={handleLoadMore}
onCheckForMore={handleCheckForMore}
selectedLogId={selectedLogId}
onLogSelect={handleLogSelect}
/>
</ResizablePanel>
<ResizableHandle id="logs-handle" className={collapsibleHandleClassName(!!selectedLogId)} />
<ResizablePanel
id="log-detail"
default="430px"
min="430px"
max="600px"
className="overflow-hidden"
collapsible
collapsed={!selectedLogId}
onCollapseChange={() => {}}
collapsedSize="0px"
collapseAnimation={RESIZABLE_PANEL_ANIMATION}
>
<div className="h-full" style={{ minWidth: 430 }}>
{displayLogId && (
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<Spinner />
</div>
}
>
<LogDetailView
logId={displayLogId}
initialLog={displayLog}
onClose={handleClosePanel}
searchTerm={list.searchTerm}
/>
</Suspense>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}
@@ -0,0 +1,20 @@
import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { EnvironmentParamSchema, v3BuiltInDashboardPath } from "~/utils/pathBuilder";
const ParamSchema = EnvironmentParamSchema.extend({
dashboardKey: z.string(),
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { organizationSlug, projectParam, envParam, dashboardKey } = ParamSchema.parse(params);
return redirect(
v3BuiltInDashboardPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam },
dashboardKey
),
301
);
};
@@ -0,0 +1,20 @@
import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { EnvironmentParamSchema, v3CustomDashboardPath } from "~/utils/pathBuilder";
const ParamSchema = EnvironmentParamSchema.extend({
dashboardId: z.string(),
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { organizationSlug, projectParam, envParam, dashboardId } = ParamSchema.parse(params);
return redirect(
v3CustomDashboardPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam },
{ friendlyId: dashboardId }
),
301
);
};
@@ -0,0 +1,593 @@
import { ArrowsRightLeftIcon } from "@heroicons/react/20/solid";
import { type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { InlineCode } from "~/components/code/InlineCode";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import type { QueryWidgetConfig } from "~/components/metrics/QueryWidget";
import { Badge } from "~/components/primitives/Badge";
import { LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { Header2 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { Label } from "~/components/primitives/Label";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import * as Property from "~/components/primitives/PropertyTable";
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { ModelRegistryPresenter } from "~/presenters/v3/ModelRegistryPresenter.server";
import { MetricWidget } from "~/routes/resources.metric";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { requireUserId } from "~/services/session.server";
import {
formatFeature,
formatModelCost,
formatModelPrice,
formatProviderName,
formatTokenCount,
} from "~/utils/modelFormatters";
import { EnvironmentParamSchema, v3ModelComparePath, v3ModelsPath } from "~/utils/pathBuilder";
const ParamSchema = EnvironmentParamSchema.extend({
modelId: z.string(),
});
export const meta: MetaFunction = () => {
return [{ title: "Model Detail | Trigger.dev" }];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam, modelId } = ParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Project not found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response("Environment not found", { status: 404 });
}
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
project.organizationId,
"standard"
);
const presenter = new ModelRegistryPresenter(clickhouse);
const model = await presenter.getModelDetail(modelId);
if (!model) {
throw new Response("Model not found", { status: 404 });
}
const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const userMetrics = await presenter.getUserMetrics(
model.modelName,
project.id,
environment.id,
sevenDaysAgo,
now
);
return typedjson({
model,
userMetrics,
organizationId: project.organizationId,
projectId: project.id,
environmentId: environment.id,
});
};
/** Escape a value for safe interpolation into a TSQL single-quoted string. */
function escapeTSQL(value: string): string {
return value.replace(/'/g, "''");
}
function bignumberConfig(
column: string,
opts?: { aggregation?: "sum" | "avg" | "first"; suffix?: string; abbreviate?: boolean }
): QueryWidgetConfig {
return {
type: "bignumber",
column,
aggregation: opts?.aggregation ?? "sum",
abbreviate: opts?.abbreviate ?? false,
suffix: opts?.suffix,
};
}
function chartConfig(opts: {
chartType: "bar" | "line";
xAxisColumn: string;
yAxisColumns: string[];
aggregation?: "sum" | "avg";
}): QueryWidgetConfig {
return {
type: "chart",
chartType: opts.chartType,
xAxisColumn: opts.xAxisColumn,
yAxisColumns: opts.yAxisColumns,
groupByColumn: null,
stacked: false,
sortByColumn: null,
sortDirection: "asc",
aggregation: opts.aggregation ?? "sum",
};
}
type Tab = "overview" | "global" | "usage";
const TAB_CONFIG: { id: Tab; label: string }[] = [
{ id: "overview", label: "Overview" },
{ id: "usage", label: "Metrics" },
{ id: "global", label: "Global metrics" },
];
export default function ModelDetailPage() {
const { model, userMetrics, organizationId, projectId, environmentId } =
useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const [activeTab, setActiveTab] = useState<Tab>("overview");
return (
<PageContainer>
<NavBar>
<PageTitle
title={model.displayId}
backButton={{
to: v3ModelsPath(organization, project, environment),
text: "Models",
}}
/>
<PageAccessories>
<LinkButton
variant="tertiary/small"
to={`${v3ModelComparePath(organization, project, environment)}?models=${model.modelName}`}
LeadingIcon={ArrowsRightLeftIcon}
>
Compare with...
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable>
<TabContainer>
{TAB_CONFIG.map((tab) => (
<TabButton
key={tab.id}
isActive={activeTab === tab.id}
layoutId="model-detail-tabs"
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</TabButton>
))}
</TabContainer>
<div className="p-4">
{activeTab === "overview" && <OverviewTab model={model} userMetrics={userMetrics} />}
{activeTab === "global" && (
<GlobalMetricsTab
modelName={model.modelName}
organizationId={organizationId}
projectId={projectId}
environmentId={environmentId}
/>
)}
{activeTab === "usage" && (
<YourUsageTab
modelName={model.modelName}
organizationId={organizationId}
projectId={projectId}
environmentId={environmentId}
/>
)}
</div>
</PageBody>
</PageContainer>
);
}
// --- Cost Estimator ---
function CostEstimator({
inputPrice,
outputPrice,
defaultInputTokens,
defaultOutputTokens,
}: {
inputPrice: number | null;
outputPrice: number | null;
defaultInputTokens?: number;
defaultOutputTokens?: number;
}) {
const [inputTokens, setInputTokens] = useState(defaultInputTokens ?? 1000);
const [outputTokens, setOutputTokens] = useState(defaultOutputTokens ?? 500);
const [numCalls, setNumCalls] = useState(1000);
if (inputPrice === null && outputPrice === null) return null;
const inputCost = inputTokens * (inputPrice ?? 0) * numCalls;
const outputCost = outputTokens * (outputPrice ?? 0) * numCalls;
const totalCost = inputCost + outputCost;
return (
<div className="rounded-md border border-grid-dimmed p-4">
<Header2 className="mb-3">Cost Estimator</Header2>
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label htmlFor="input-tokens">Input tokens/call</Label>
<Input
id="input-tokens"
variant="medium"
fullWidth
type="number"
value={inputTokens}
onChange={(e) => setInputTokens(parseInt(e.target.value) || 0)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="output-tokens">Output tokens/call</Label>
<Input
id="output-tokens"
variant="medium"
fullWidth
type="number"
value={outputTokens}
onChange={(e) => setOutputTokens(parseInt(e.target.value) || 0)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="num-calls">Number of calls</Label>
<Input
id="num-calls"
variant="medium"
fullWidth
type="number"
value={numCalls}
onChange={(e) => setNumCalls(parseInt(e.target.value) || 0)}
/>
</div>
</div>
<Callout variant="info">
<div className="text-lg font-semibold tabular-nums text-text-bright">
{formatModelCost(totalCost)}
</div>
<div className="mt-1 space-y-0.5 text-xs tabular-nums text-text-dimmed">
<div>
Input: {formatModelCost(inputCost)} ({formatTokenCount(inputTokens * numCalls)} tokens
x {formatModelPrice(inputPrice)}/1M)
</div>
<div>
Output: {formatModelCost(outputCost)} ({formatTokenCount(outputTokens * numCalls)}{" "}
tokens x {formatModelPrice(outputPrice)}/1M)
</div>
</div>
</Callout>
</div>
</div>
);
}
// --- Overview Tab ---
function OverviewTab({
model,
userMetrics,
}: {
model: ReturnType<typeof useTypedLoaderData<typeof loader>>["model"];
userMetrics: ReturnType<typeof useTypedLoaderData<typeof loader>>["userMetrics"];
}) {
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* Model Info */}
<div className="rounded-md border border-grid-dimmed p-4">
<Header2 className="mb-3">Model Info</Header2>
<Property.Table>
<Property.Item>
<Property.Label>Provider</Property.Label>
<Property.Value>{formatProviderName(model.provider)}</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Model Name</Property.Label>
<Property.Value>
<InlineCode variant="small">{model.modelName}</InlineCode>
</Property.Value>
</Property.Item>
{model.description && (
<Property.Item>
<Property.Label>Description</Property.Label>
<Property.Value>{model.description}</Property.Value>
</Property.Item>
)}
{model.contextWindow && (
<Property.Item>
<Property.Label>Context Window</Property.Label>
<Property.Value>{formatTokenCount(model.contextWindow)} tokens</Property.Value>
</Property.Item>
)}
{model.maxOutputTokens && (
<Property.Item>
<Property.Label>Max Output</Property.Label>
<Property.Value>{formatTokenCount(model.maxOutputTokens)} tokens</Property.Value>
</Property.Item>
)}
{model.features.length > 0 && (
<Property.Item>
<Property.Label>Features</Property.Label>
<Property.Value>
<div className="flex flex-wrap gap-1">
{model.features.map((f) => (
<Badge key={f} variant="outline-rounded">
{formatFeature(f)}
</Badge>
))}
</div>
</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Match Pattern</Property.Label>
<Property.Value>
<InlineCode variant="small">{model.matchPattern}</InlineCode>
</Property.Value>
</Property.Item>
</Property.Table>
</div>
{/* Pricing */}
<div className="rounded-md border border-grid-dimmed p-4">
<Header2 className="mb-3">Pricing</Header2>
<Property.Table>
<Property.Item>
<Property.Label>Input</Property.Label>
<Property.Value>{formatModelPrice(model.inputPrice)} / 1M tokens</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Output</Property.Label>
<Property.Value>{formatModelPrice(model.outputPrice)} / 1M tokens</Property.Value>
</Property.Item>
</Property.Table>
{model.pricingTiers.length > 1 && (
<div className="mt-4">
<p className="mb-2 text-xs font-medium text-text-dimmed">All pricing tiers</p>
{model.pricingTiers.map((tier) => (
<div key={tier.name} className="mb-2 rounded border border-grid-dimmed p-2 text-xs">
<span className="font-medium text-text-bright">{tier.name}</span>
{tier.isDefault && (
<Badge variant="outline-rounded" className="ml-2">
default
</Badge>
)}
<div className="mt-1 space-y-0.5 text-text-dimmed">
{Object.entries(tier.prices).map(([usage, price]) => (
<div key={usage}>
{usage}: ${(price * 1_000_000).toFixed(4)} / 1M
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Cost Estimator */}
<CostEstimator
inputPrice={model.inputPrice}
outputPrice={model.outputPrice}
defaultInputTokens={
userMetrics.totalCalls > 0
? Math.round(userMetrics.totalInputTokens / userMetrics.totalCalls)
: undefined
}
defaultOutputTokens={
userMetrics.totalCalls > 0
? Math.round(userMetrics.totalOutputTokens / userMetrics.totalCalls)
: undefined
}
/>
</div>
);
}
// --- Global Metrics Tab ---
function GlobalMetricsTab({
modelName,
organizationId,
projectId,
environmentId,
}: {
modelName: string;
organizationId: string;
projectId: string;
environmentId: string;
}) {
const widgetProps = {
organizationId,
projectId,
environmentId,
scope: "environment" as const,
period: "7d",
from: null,
to: null,
};
return (
<div className="space-y-4">
{/* Big numbers */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div className="h-24">
<MetricWidget
widgetKey={`${modelName}-ttfc-p50`}
title="p50 TTFC"
query={`SELECT round(quantilesMerge(0.5)(ttfc_quantiles)[1], 0) AS ttfc_p50 FROM llm_models WHERE response_model = '${escapeTSQL(modelName)}'`}
config={bignumberConfig("ttfc_p50", { aggregation: "avg", suffix: "ms" })}
{...widgetProps}
/>
</div>
<div className="h-24">
<MetricWidget
widgetKey={`${modelName}-ttfc-p90`}
title="p90 TTFC"
query={`SELECT round(quantilesMerge(0.9)(ttfc_quantiles)[1], 0) AS ttfc_p90 FROM llm_models WHERE response_model = '${escapeTSQL(modelName)}'`}
config={bignumberConfig("ttfc_p90", { aggregation: "avg", suffix: "ms" })}
{...widgetProps}
/>
</div>
<div className="h-24">
<MetricWidget
widgetKey={`${modelName}-tps`}
title="Tokens/sec (p50)"
query={`SELECT round(quantilesMerge(0.5)(tps_quantiles)[1], 0) AS tps_p50 FROM llm_models WHERE response_model = '${escapeTSQL(modelName)}'`}
config={bignumberConfig("tps_p50", { aggregation: "avg" })}
{...widgetProps}
/>
</div>
</div>
{/* Charts */}
<div className="h-[300px]">
<MetricWidget
widgetKey={`${modelName}-ttfc-time`}
title="TTFC over time"
query={`SELECT timeBucket(), round(quantilesMerge(0.5)(ttfc_quantiles)[1], 0) AS ttfc_p50, round(quantilesMerge(0.9)(ttfc_quantiles)[1], 0) AS ttfc_p90 FROM llm_models WHERE response_model = '${escapeTSQL(modelName)}' GROUP BY timeBucket ORDER BY timeBucket`}
config={chartConfig({
chartType: "line",
xAxisColumn: "timebucket",
yAxisColumns: ["ttfc_p50", "ttfc_p90"],
aggregation: "avg",
})}
{...widgetProps}
/>
</div>
<Callout variant="info">
Aggregated across all Trigger.dev users. No tenant-specific data is exposed.
</Callout>
</div>
);
}
// --- Your Usage Tab ---
function YourUsageTab({
modelName,
organizationId,
projectId,
environmentId,
}: {
modelName: string;
organizationId: string;
projectId: string;
environmentId: string;
}) {
const widgetProps = {
organizationId,
projectId,
environmentId,
scope: "environment" as const,
period: "7d",
from: null,
to: null,
};
return (
<div className="space-y-4">
{/* Big numbers */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="h-24">
<MetricWidget
widgetKey={`${modelName}-user-calls`}
title="Total calls (7d)"
query={`SELECT count() AS total_calls FROM llm_metrics WHERE response_model = '${escapeTSQL(modelName)}'`}
config={bignumberConfig("total_calls", { abbreviate: true })}
{...widgetProps}
/>
</div>
<div className="h-24">
<MetricWidget
widgetKey={`${modelName}-user-cost`}
title="Total cost (7d)"
query={`SELECT sum(total_cost) AS total_cost FROM llm_metrics WHERE response_model = '${escapeTSQL(modelName)}'`}
config={bignumberConfig("total_cost", { aggregation: "sum" })}
{...widgetProps}
/>
</div>
<div className="h-24">
<MetricWidget
widgetKey={`${modelName}-user-ttfc`}
title="Avg TTFC"
query={`SELECT round(avg(ms_to_first_chunk), 0) AS avg_ttfc FROM llm_metrics WHERE response_model = '${escapeTSQL(modelName)}' AND ms_to_first_chunk > 0`}
config={bignumberConfig("avg_ttfc", { aggregation: "avg", suffix: "ms" })}
{...widgetProps}
/>
</div>
<div className="h-24">
<MetricWidget
widgetKey={`${modelName}-user-tps`}
title="Avg Tokens/sec"
query={`SELECT round(avg(tokens_per_second), 0) AS avg_tps FROM llm_metrics WHERE response_model = '${escapeTSQL(modelName)}' AND tokens_per_second > 0`}
config={bignumberConfig("avg_tps", { aggregation: "avg" })}
{...widgetProps}
/>
</div>
</div>
{/* Charts */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<div className="h-[300px]">
<MetricWidget
widgetKey={`${modelName}-user-cost-time`}
title="Cost over time"
query={`SELECT timeBucket(), sum(total_cost) AS cost FROM llm_metrics WHERE response_model = '${escapeTSQL(modelName)}' GROUP BY timeBucket ORDER BY timeBucket`}
config={chartConfig({
chartType: "bar",
xAxisColumn: "timebucket",
yAxisColumns: ["cost"],
})}
{...widgetProps}
/>
</div>
<div className="h-[300px]">
<MetricWidget
widgetKey={`${modelName}-user-tokens-time`}
title="Tokens over time"
query={`SELECT timeBucket(), sum(input_tokens) AS input_tokens, sum(output_tokens) AS output_tokens FROM llm_metrics WHERE response_model = '${escapeTSQL(modelName)}' GROUP BY timeBucket ORDER BY timeBucket`}
config={chartConfig({
chartType: "bar",
xAxisColumn: "timebucket",
yAxisColumns: ["input_tokens", "output_tokens"],
})}
{...widgetProps}
/>
</div>
</div>
{/* Task breakdown */}
<div className="h-[300px]">
<MetricWidget
widgetKey={`${modelName}-user-tasks`}
title="Cost by task"
query={`SELECT task_identifier, count() AS calls, sum(total_cost) AS cost FROM llm_metrics WHERE response_model = '${escapeTSQL(modelName)}' GROUP BY task_identifier ORDER BY cost DESC LIMIT 20`}
config={{ type: "table", prettyFormatting: true, sorting: [] }}
{...widgetProps}
/>
</div>
</div>
);
}
@@ -0,0 +1,223 @@
import { ArrowsRightLeftIcon } from "@heroicons/react/20/solid";
import { type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { LinkButton } from "~/components/primitives/Buttons";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import {
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import {
type ModelComparisonItem,
ModelRegistryPresenter,
} from "~/presenters/v3/ModelRegistryPresenter.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { requireUserId } from "~/services/session.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useEnvironment } from "~/hooks/useEnvironment";
import { EnvironmentParamSchema, v3ModelsPath } from "~/utils/pathBuilder";
import { formatModelCost } from "~/utils/modelFormatters";
import { formatNumberCompact } from "~/utils/numberFormatter";
export const meta: MetaFunction = () => {
return [{ title: "Compare Models | Trigger.dev" }];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Project not found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response("Environment not found", { status: 404 });
}
const url = new URL(request.url);
const modelsParam = url.searchParams.get("models") ?? "";
const responseModels = modelsParam.split(",").filter(Boolean).slice(0, 4);
if (responseModels.length < 2) {
return typedjson({ comparison: [] as ModelComparisonItem[], models: responseModels });
}
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
project.organizationId,
"standard"
);
const presenter = new ModelRegistryPresenter(clickhouse);
const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const comparison = await presenter.getModelComparison(responseModels, sevenDaysAgo, now);
return typedjson({ comparison, models: responseModels });
};
type ComparisonRow = {
label: string;
values: string[];
bestIndex?: number;
};
function buildRows(models: string[], comparison: ModelComparisonItem[]): ComparisonRow[] {
const dataMap = new Map<string, ModelComparisonItem>();
for (const item of comparison) {
dataMap.set(item.responseModel, item);
}
const getValue = (model: string, key: keyof ModelComparisonItem) => {
const d = dataMap.get(model);
return d ? d[key] : 0;
};
const findBest = (values: number[], lowerIsBetter: boolean) => {
if (values.every((v) => v === 0)) return undefined;
const filtered = values.map((v, i) => ({ v, i })).filter(({ v }) => v > 0);
if (filtered.length === 0) return undefined;
const best = lowerIsBetter
? filtered.reduce((a, b) => (a.v < b.v ? a : b))
: filtered.reduce((a, b) => (a.v > b.v ? a : b));
return best.i;
};
const callValues = models.map((m) => Number(getValue(m, "callCount")));
const ttfcP50Values = models.map((m) => Number(getValue(m, "ttfcP50")));
const ttfcP90Values = models.map((m) => Number(getValue(m, "ttfcP90")));
const tpsP50Values = models.map((m) => Number(getValue(m, "tpsP50")));
const tpsP90Values = models.map((m) => Number(getValue(m, "tpsP90")));
const costValues = models.map((m) => Number(getValue(m, "totalCost")));
return [
{
label: "Provider",
values: models.map((m) => dataMap.get(m)?.genAiSystem ?? "—"),
},
{
label: "Total Calls (7d)",
values: callValues.map((v) => formatNumberCompact(v)),
bestIndex: findBest(callValues, false),
},
{
label: "p50 TTFC",
values: ttfcP50Values.map((v) => (v > 0 ? `${v.toFixed(0)}ms` : "—")),
bestIndex: findBest(ttfcP50Values, true),
},
{
label: "p90 TTFC",
values: ttfcP90Values.map((v) => (v > 0 ? `${v.toFixed(0)}ms` : "—")),
bestIndex: findBest(ttfcP90Values, true),
},
{
label: "Tokens/sec (p50)",
values: tpsP50Values.map((v) => (v > 0 ? v.toFixed(0) : "—")),
bestIndex: findBest(tpsP50Values, false),
},
{
label: "Tokens/sec (p90)",
values: tpsP90Values.map((v) => (v > 0 ? v.toFixed(0) : "—")),
bestIndex: findBest(tpsP90Values, false),
},
{
label: "Total Cost (7d)",
values: costValues.map((v) => (v > 0 ? formatModelCost(v) : "—")),
bestIndex: findBest(costValues, true),
},
];
}
export default function ModelComparePage() {
const { comparison, models } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const rows = buildRows(models, comparison);
return (
<PageContainer>
<NavBar>
<PageTitle
title="Compare Models"
backButton={{
to: v3ModelsPath(organization, project, environment),
text: "Models",
}}
/>
</NavBar>
<PageBody scrollable>
{models.length < 2 ? (
<MainCenteredContainer className="max-w-md">
<InfoPanel
title="Compare models side by side"
icon={ArrowsRightLeftIcon}
iconClassName="text-indigo-500"
panelClassName="max-w-md"
>
<p className="text-sm text-text-dimmed">
Select 2-4 models from the catalog to compare their pricing, capabilities, and
performance metrics side by side.
</p>
<LinkButton
to={v3ModelsPath(organization, project, environment)}
variant="primary/small"
className="mt-3"
>
Browse models
</LinkButton>
</InfoPanel>
</MainCenteredContainer>
) : (
<div className="p-4">
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Metric</TableHeaderCell>
{models.map((model) => (
<TableHeaderCell key={model} alignment="right">
{model}
</TableHeaderCell>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.label}>
<TableCell>
<span className="text-xs font-medium text-text-dimmed">{row.label}</span>
</TableCell>
{row.values.map((value, i) => (
<TableCell key={i} alignment="right">
<span
className={`tabular-nums ${
row.bestIndex === i ? "font-medium text-success" : "text-text-bright"
}`}
>
{value}
</span>
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</PageBody>
</PageContainer>
);
}
@@ -0,0 +1,234 @@
import { BookOpenIcon, ChevronUpDownIcon, CpuChipIcon } from "@heroicons/react/20/solid";
import { json, type MetaFunction } from "@remix-run/node";
import { Outlet, useLoaderData, useNavigate, useParams } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { CubeSparkleIcon } from "~/assets/icons/CubeSparkleIcon";
import { CodeBlock } from "~/components/code/CodeBlock";
import { InlineCode } from "~/components/code/InlineCode";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Badge } from "~/components/primitives/Badge";
import { LinkButton } from "~/components/primitives/Buttons";
import { Header2 } from "~/components/primitives/Headers";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Select, SelectItem } from "~/components/primitives/Select";
import { Table, TableBody, TableCell, TableRow } from "~/components/primitives/Table";
import { $replica } from "~/db.server";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { playgroundPresenter } from "~/presenters/v3/PlaygroundPresenter.server";
import { RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
import { requireUser } from "~/services/session.server";
import { docsPath, EnvironmentParamSchema, v3PlaygroundAgentPath } from "~/utils/pathBuilder";
export const meta: MetaFunction = () => {
return [{ title: "Playground | Trigger.dev" }];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
throw new Response(undefined, { status: 404, statusText: "Project not found" });
}
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
if (!environment) {
throw new Response(undefined, { status: 404, statusText: "Environment not found" });
}
const [agents, backgroundWorkers, regionsResult] = await Promise.all([
playgroundPresenter.listAgents({
environmentId: environment.id,
environmentType: environment.type,
}),
$replica.backgroundWorker.findMany({
where: { runtimeEnvironmentId: environment.id },
select: { version: true },
orderBy: { createdAt: "desc" },
take: 20,
}),
new RegionsPresenter().call({
userId: user.id,
projectSlug: projectParam,
isAdmin: user.admin || user.isImpersonating,
}),
]);
return json({
agents,
versions: backgroundWorkers.map((w) => w.version),
regions: regionsResult.regions,
isDev: environment.type === "DEVELOPMENT",
});
};
export default function PlaygroundPage() {
const { agents } = useLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const params = useParams();
const navigate = useNavigate();
const selectedAgent = params.agentParam ?? "";
const selectedAgentType = (() => {
if (!selectedAgent) return null;
const agent = agents.find((a) => a.slug === selectedAgent);
const config = (agent?.config ?? null) as { type?: string } | null;
return config?.type ?? null;
})();
if (agents.length === 0) {
return (
<PageContainer>
<NavBar>
<PageTitle title="Test" />
</NavBar>
<PageBody>
<MainCenteredContainer className="max-w-2xl">
<InfoPanel
title="Create your first agent"
icon={CpuChipIcon}
iconClassName="text-indigo-500"
panelClassName="max-w-2xl"
accessory={
<LinkButton
to={docsPath("ai-chat/overview")}
variant="docs/small"
LeadingIcon={BookOpenIcon}
>
Agent docs
</LinkButton>
}
>
<Paragraph spacing variant="small">
Test lets you exercise your AI agents with an interactive chat interface, realtime
streaming, and conversation history.
</Paragraph>
<Paragraph spacing variant="small">
Define a chat agent using <InlineCode variant="small">chat.agent()</InlineCode>:
</Paragraph>
<CodeBlock
code={`import { chat } from "@trigger.dev/sdk/ai";
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";
export const myAgent = chat.agent({
id: "my-agent",
run: async ({ messages, signal }) => {
return streamText({
model: openai("gpt-4o"),
messages,
abortSignal: signal,
});
},
});`}
showLineNumbers={false}
showOpenInModal={false}
/>
<Paragraph variant="small" className="mt-2">
Deploy your project and your agents will appear here ready to test.
</Paragraph>
</InfoPanel>
</MainCenteredContainer>
</PageBody>
</PageContainer>
);
}
return (
<PageContainer>
<NavBar>
{selectedAgent ? (
<PageTitle
title={
<div className="flex items-center gap-1">
<Select
value={selectedAgent}
setValue={(slug) => {
if (slug && typeof slug === "string" && slug !== selectedAgent) {
navigate(v3PlaygroundAgentPath(organization, project, environment, slug));
}
}}
icon={<CubeSparkleIcon className="mr-1 size-4 text-agents" />}
text={(val) => val || undefined}
variant="minimal/small"
items={agents}
filter={(item, search) => item.slug.toLowerCase().includes(search.toLowerCase())}
className="-ml-2"
dropdownIcon={
<ChevronUpDownIcon className="size-4 flex-none text-text-dimmed transition group-hover:text-text-bright group-focus:text-text-bright" />
}
>
{(matches) =>
matches.map((a) => (
<SelectItem key={a.slug} value={a.slug}>
<div className="flex items-center gap-2">
<CubeSparkleIcon className="size-4 text-agents" />
<span className="text-text-bright">{a.slug}</span>
</div>
</SelectItem>
))
}
</Select>
{selectedAgentType && (
<Badge variant="extra-small">{formatAgentType(selectedAgentType)}</Badge>
)}
</div>
}
/>
) : (
<PageTitle title="Test" />
)}
</NavBar>
<PageBody scrollable={!selectedAgent}>
{selectedAgent ? (
<Outlet />
) : (
<MainCenteredContainer className="max-w-xl">
<div className="flex flex-col gap-4 py-8">
<Header2>Choose an agent to start a conversation</Header2>
<Table containerClassName="overflow-hidden rounded-md border-l border-r border-b border-grid-dimmed [&_tbody_tr:last-child]:after:hidden">
<TableBody>
{agents.map((agent) => {
const path = v3PlaygroundAgentPath(
organization,
project,
environment,
agent.slug
);
return (
<TableRow key={agent.slug}>
<TableCell to={path} isTabbableCell>
<div className="flex items-center gap-2">
<CubeSparkleIcon className="size-5 text-agents" />
<span className="text-sm">{agent.slug}</span>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</MainCenteredContainer>
)}
</PageBody>
</PageContainer>
);
}
function formatAgentType(type: string): string {
switch (type) {
case "ai-sdk-chat":
return "AI SDK Chat";
default:
return type;
}
}
@@ -0,0 +1,287 @@
import { type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import {
Bar,
BarChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
YAxis,
type TooltipProps,
} from "recharts";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { PromptsNone } from "~/components/BlankStatePanels";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue";
import { DateTime, formatDateTime } from "~/components/primitives/DateTime";
import { Header3 } from "~/components/primitives/Headers";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import TooltipPortal from "~/components/primitives/TooltipPortal";
import { cn } from "~/utils/cn";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { PromptPresenter } from "~/presenters/v3/PromptPresenter.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { requireUserId } from "~/services/session.server";
import { docsPath, EnvironmentParamSchema, v3PromptsPath } from "~/utils/pathBuilder";
import { LinkButton } from "~/components/primitives/Buttons";
import { BookOpenIcon } from "@heroicons/react/24/solid";
export const meta: MetaFunction = () => {
return [{ title: "Prompts | Trigger.dev" }];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Project not found", { status: 404 });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response("Environment not found", { status: 404 });
}
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
project.organizationId,
"standard"
);
const presenter = new PromptPresenter(clickhouse);
const prompts = await presenter.listPrompts(project.id, environment.id);
const sparklines = await presenter.getUsageSparklines(
environment.id,
prompts.map((p) => p.slug)
);
return typedjson({ prompts, sparklines });
};
export default function PromptsPage() {
const { prompts, sparklines } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
if (prompts.length === 0) {
return (
<PageContainer>
<NavBar>
<PageTitle title="Prompts" />
</NavBar>
<PageBody>
<MainCenteredContainer className="max-w-lg">
<PromptsNone />
</MainCenteredContainer>
</PageBody>
</PageContainer>
);
}
return (
<PageContainer>
<NavBar>
<PageTitle title="Prompts" />
<PageAccessories>
<LinkButton variant="docs/small" LeadingIcon={BookOpenIcon} to={docsPath("ai/prompts")}>
Prompts docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<Table containerClassName="border-t-0">
<TableHeader>
<TableRow>
<TableHeaderCell>ID</TableHeaderCell>
<TableHeaderCell>Slug</TableHeaderCell>
<TableHeaderCell>Description</TableHeaderCell>
<TableHeaderCell>Model</TableHeaderCell>
<TableHeaderCell
tooltip={
<div className="flex max-w-64 flex-col gap-3 p-1 pb-2">
<div className="flex flex-col gap-2">
<div>
<div className="mb-0.5 flex items-center gap-2">
<span className="size-1.5 shrink-0 rounded-full bg-success" />
<Paragraph variant="small" className="text-wrap! text-text-bright">
Latest version
</Paragraph>
</div>
<Paragraph variant="small" className="text-wrap! pl-3.5 text-text-dimmed">
Running the most recently published version.
</Paragraph>
</div>
<div>
<div className="mb-0.5 flex items-center gap-2">
<span className="size-1.5 shrink-0 rounded-full bg-warning" />
<Paragraph variant="small" className="text-wrap! text-text-bright">
Version overridden
</Paragraph>
</div>
<Paragraph variant="small" className="text-wrap! pl-3.5 text-text-dimmed">
Pinned to an older version instead of the latest.
</Paragraph>
</div>
</div>
</div>
}
disableTooltipHoverableContent
>
Version
</TableHeaderCell>
<TableHeaderCell>Usage (24h)</TableHeaderCell>
<TableHeaderCell alignment="right">Last updated</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{prompts.length === 0 ? (
<TableBlankRow colSpan={7}>No prompts found</TableBlankRow>
) : (
prompts.map((prompt) => {
const path = `${v3PromptsPath(organization, project, environment)}/${prompt.slug}`;
const activeVersion = prompt.overrideVersion ?? prompt.currentVersion;
const isOverride = !!prompt.overrideVersion;
return (
<TableRow key={prompt.id}>
<TableCell to={path} isTabbableCell>
<TruncatedCopyableValue value={prompt.friendlyId} />
</TableCell>
<TableCell to={path}>{prompt.slug}</TableCell>
<TableCell to={path}>
{prompt.description ? (
<span
title={prompt.description.length > 80 ? prompt.description : undefined}
>
{prompt.description.length > 80
? prompt.description.slice(0, 80) + "…"
: prompt.description}
</span>
) : (
<span>-</span>
)}
</TableCell>
<TableCell to={path}>
<span>{prompt.defaultModel ?? <span>-</span>}</span>
</TableCell>
<TableCell to={path}>
{activeVersion ? (
<div className="flex items-center gap-1.5">
<div
className={`size-1.5 rounded-full ${
isOverride ? "bg-warning" : "bg-success"
}`}
/>
<span>v{activeVersion.version}</span>
</div>
) : (
<span>-</span>
)}
</TableCell>
<TableCell to={path}>
<UsageSparkline data={sparklines[prompt.slug]} />
</TableCell>
<TableCell to={path} alignment="right">
<DateTime date={prompt.updatedAt} />
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</PageBody>
</PageContainer>
);
}
type UsageDatum = { date: Date; count: number };
function UsageSparkline({ data }: { data?: number[] }) {
if (!data || data.every((v) => v === 0)) {
return <span className="text-text-dimmed"></span>;
}
const total = data.reduce((a, b) => a + b, 0);
const max = Math.max(...data);
// Map the 24-bucket array to dated points so the tooltip can show the
// hour each bar represents. Bucket i is `23 - i` hours before now.
const now = new Date();
const chartData: UsageDatum[] = data.map((count, i) => ({
date: new Date(now.getTime() - (data.length - 1 - i) * 3600_000),
count,
}));
return (
<div className="flex items-start gap-2">
<div className="h-6 w-28 rounded-sm">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
<YAxis domain={[0, max || 1]} hide />
<Tooltip
cursor={{ fill: "rgba(255, 255, 255, 0.06)" }}
content={<UsageSparklineTooltip />}
allowEscapeViewBox={{ x: true, y: true }}
wrapperStyle={{ zIndex: 1000 }}
animationDuration={0}
/>
<Bar
dataKey="count"
fill="var(--color-pending)"
strokeWidth={0}
isAnimationActive={false}
minPointSize={1}
/>
<ReferenceLine y={0} stroke="var(--color-border-bright)" strokeWidth={1} />
{max > 0 && (
<ReferenceLine
y={max}
stroke="var(--color-border-brighter)"
strokeDasharray="4 4"
strokeWidth={1}
/>
)}
</BarChart>
</ResponsiveContainer>
</div>
<span className={cn("-mt-1 text-xs tabular-nums text-blue-400")}>
{total.toLocaleString()}
</span>
</div>
);
}
function UsageSparklineTooltip({ active, payload }: TooltipProps<number, string>) {
if (!active || !payload || payload.length === 0) return null;
const entry = payload[0].payload as UsageDatum;
const date = entry.date instanceof Date ? entry.date : new Date(entry.date);
const formattedDate = formatDateTime(date, "UTC", [], false, true);
return (
<TooltipPortal active={active}>
<div className="rounded-sm border border-grid-bright bg-background-dimmed px-3 py-2">
<Header3 className="border-b border-b-border-bright pb-2">{formattedDate}</Header3>
<div className="mt-2 text-xs text-text-bright">
<span className="tabular-nums">{entry.count.toLocaleString()}</span>{" "}
<span className="text-text-dimmed">{entry.count === 1 ? "call" : "calls"}</span>
</div>
</div>
</TooltipPortal>
);
}
@@ -0,0 +1,75 @@
import { useState } from "react";
import { SparkleListIcon } from "~/assets/icons/SparkleListIcon";
import { AIQueryInput } from "~/components/code/AIQueryInput";
import { Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import type { AITimeFilter } from "./types";
export function AITabContent({
onQueryGenerated,
onTimeFilterChange,
getCurrentQuery,
aiFixRequest,
}: {
onQueryGenerated: (query: string) => void;
onTimeFilterChange?: (filter: AITimeFilter) => void;
getCurrentQuery: () => string;
aiFixRequest: { prompt: string; key: number } | null;
}) {
const [examplePromptRequest, setExamplePromptRequest] = useState<{
prompt: string;
key: number;
} | null>(null);
// Use aiFixRequest if present, otherwise use example prompt request
const activeRequest = aiFixRequest ?? examplePromptRequest;
const examplePrompts = [
"Show me failed runs by hour for the past 7 days",
"Count of runs by status by hour for the past 48h",
"Top 50 most expensive runs this week",
"Average execution duration by task this week",
"Run counts by tag in the past 7 days",
"CPU utilization over time by task",
"Peak memory usage per run",
];
return (
<div className="space-y-2">
<AIQueryInput
onQueryGenerated={onQueryGenerated}
onTimeFilterChange={onTimeFilterChange}
autoSubmitPrompt={activeRequest?.prompt}
autoSubmitKey={activeRequest?.key}
getCurrentQuery={getCurrentQuery}
/>
<div className="pt-4">
<Header3 className="mb-3 text-text-bright">Example prompts</Header3>
<div className="flex flex-wrap gap-2">
{examplePrompts.map((example) => (
<button
key={example}
type="button"
onClick={() => {
setExamplePromptRequest((prev) => ({
prompt: example,
key: (prev?.key ?? 0) + 1,
}));
}}
className="group flex w-fit items-center gap-2 rounded-full border border-dashed border-border-bright px-4 py-2 transition-colors hover:border-solid hover:border-indigo-500 focus-custom focus-visible:rounded-full!"
>
<SparkleListIcon className="size-4 shrink-0 text-text-dimmed transition group-hover:text-indigo-500" />
<Paragraph
variant="small"
className="text-left transition group-hover:text-text-bright"
>
{example}
</Paragraph>
</button>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,250 @@
import { useState } from "react";
import { Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import SegmentedControl from "~/components/primitives/SegmentedControl";
import type { QueryScope } from "~/services/queryService.server";
import { querySchemas } from "~/v3/querySchemas";
import { TryableCodeBlock } from "./TRQLGuideContent";
// Example queries for the Examples tab
export const exampleQueries: Array<{
title: string;
description: string;
query: string;
scope: QueryScope;
table: string;
}> = [
{
title: "Failed runs by task (past 7 days)",
description: "Count of failed runs grouped by task identifier over the last 7 days.",
query: `SELECT
task_identifier,
count() AS failed_count
FROM runs
WHERE status = 'Failed'
AND triggered_at > now() - INTERVAL 7 DAY
GROUP BY task_identifier
ORDER BY failed_count DESC
LIMIT 20`,
scope: "environment",
table: "runs",
},
{
title: "Execution duration p50 by task (past 7d)",
description: "Median (50th percentile) execution duration for each task.",
query: `SELECT
task_identifier,
quantile(0.5)(execution_duration) AS p50_duration_ms
FROM runs
WHERE triggered_at > now() - INTERVAL 7 DAY
AND execution_duration IS NOT NULL
GROUP BY task_identifier
ORDER BY p50_duration_ms DESC
LIMIT 20`,
scope: "environment",
table: "runs",
},
{
title: "Runs over time",
description:
"Count of runs bucketed over time. The bucket size adjusts automatically to the time range.",
query: `SELECT
timeBucket(),
count() AS run_count
FROM runs
GROUP BY timeBucket
ORDER BY timeBucket
LIMIT 1000`,
scope: "environment",
table: "runs",
},
{
title: "Most expensive 100 runs (past 7d)",
description: "Top 100 runs by cost over the last 7 days.",
query: `SELECT
run_id,
task_identifier,
status,
total_cost,
usage_duration,
machine,
triggered_at
FROM runs
WHERE triggered_at > now() - INTERVAL 7 DAY
ORDER BY total_cost DESC
LIMIT 100`,
scope: "environment",
table: "runs",
},
{
title: "CPU utilization over time",
description: "Track process CPU utilization bucketed over time.",
query: `SELECT
timeBucket(),
avg(metric_value) AS avg_cpu
FROM metrics
WHERE metric_name = 'process.cpu.utilization'
GROUP BY timeBucket
ORDER BY timeBucket
LIMIT 1000`,
scope: "environment",
table: "metrics",
},
{
title: "Memory usage by task (past 7d)",
description: "Average memory usage per task identifier over the last 7 days.",
query: `SELECT
task_identifier,
avg(metric_value) AS avg_memory
FROM metrics
WHERE metric_name = 'system.memory.usage'
AND bucket_start > now() - INTERVAL 7 DAY
GROUP BY task_identifier
ORDER BY avg_memory DESC
LIMIT 20`,
scope: "environment",
table: "metrics",
},
{
title: "Available metric names",
description: "List all distinct metric names collected in your environment.",
query: `SELECT
metric_name,
count() AS sample_count
FROM metrics
GROUP BY metric_name
ORDER BY sample_count DESC
LIMIT 100`,
scope: "environment",
table: "metrics",
},
{
title: "LLM cost by model (past 7d)",
description:
"Total cost, input tokens, and output tokens grouped by model over the last 7 days.",
query: `SELECT
response_model,
SUM(total_cost) AS total_cost,
SUM(input_tokens) AS input_tokens,
SUM(output_tokens) AS output_tokens
FROM llm_metrics
WHERE start_time > now() - INTERVAL 7 DAY
GROUP BY response_model
ORDER BY total_cost DESC`,
scope: "environment",
table: "llm_metrics",
},
{
title: "LLM cost over time",
description: "Total LLM cost bucketed over time. The bucket size adjusts automatically.",
query: `SELECT
timeBucket(),
SUM(total_cost) AS total_cost
FROM llm_metrics
GROUP BY timeBucket
ORDER BY timeBucket
LIMIT 1000`,
scope: "environment",
table: "llm_metrics",
},
{
title: "Most expensive runs by LLM cost (top 50)",
description: "Top 50 runs by total LLM cost with token breakdown.",
query: `SELECT
run_id,
task_identifier,
SUM(total_cost) AS llm_cost,
SUM(input_tokens) AS input_tokens,
SUM(output_tokens) AS output_tokens
FROM llm_metrics
GROUP BY run_id, task_identifier
ORDER BY llm_cost DESC
LIMIT 50`,
scope: "environment",
table: "llm_metrics",
},
{
title: "LLM calls by provider",
description: "Count and cost of LLM calls grouped by AI provider.",
query: `SELECT
gen_ai_system,
count() AS call_count,
SUM(total_cost) AS total_cost
FROM llm_metrics
GROUP BY gen_ai_system
ORDER BY total_cost DESC`,
scope: "environment",
table: "llm_metrics",
},
{
title: "LLM cost by user",
description:
"Total LLM cost per user from run tags or AI SDK telemetry metadata. Uses metadata.userId which comes from experimental_telemetry metadata or run tags like user:123.",
query: `SELECT
metadata.userId AS user_id,
SUM(total_cost) AS total_cost,
SUM(total_tokens) AS total_tokens,
count() AS call_count
FROM llm_metrics
WHERE metadata.userId != ''
GROUP BY metadata.userId
ORDER BY total_cost DESC
LIMIT 50`,
scope: "environment",
table: "llm_metrics",
},
{
title: "LLM cost by metadata key",
description:
"Browse all metadata keys and their LLM cost. Metadata comes from run tags (key:value) and AI SDK telemetry metadata.",
query: `SELECT
metadata,
response_model,
total_cost,
total_tokens,
run_id
FROM llm_metrics
ORDER BY start_time DESC
LIMIT 20`,
scope: "environment",
table: "llm_metrics",
},
];
const tableOptions = querySchemas.map((s) => ({ label: s.name, value: s.name }));
export function ExamplesContent({
onTryExample,
}: {
onTryExample: (query: string, scope: QueryScope) => void;
}) {
const [selectedTable, setSelectedTable] = useState(querySchemas[0].name);
const filtered = exampleQueries.filter((e) => e.table === selectedTable);
return (
<div className="space-y-6">
<div className="sticky top-0 z-10 bg-background-bright pb-3">
<SegmentedControl
name="examples-table-selector"
value={selectedTable}
options={tableOptions}
variant="secondary/small"
fullWidth
onChange={setSelectedTable}
/>
</div>
{filtered.map((example) => (
<div key={example.title}>
<Header3 className="mb-1 text-text-bright">{example.title}</Header3>
<Paragraph variant="small" className="mb-2 text-text-dimmed">
{example.description}
</Paragraph>
<TryableCodeBlock
code={example.query}
onTry={() => onTryExample(example.query, example.scope)}
/>
</div>
))}
</div>
);
}
@@ -0,0 +1,117 @@
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
import {
ClientTabs,
ClientTabsContent,
ClientTabsList,
ClientTabsTrigger,
} from "~/components/primitives/ClientTabs";
import type { QueryScope } from "~/services/queryService.server";
import { AITabContent } from "./AITabContent";
import { ExamplesContent } from "./ExamplesContent";
import { TableSchemaContent } from "./TableSchemaContent";
import { TRQLGuideContent } from "./TRQLGuideContent";
import type { AITimeFilter } from "./types";
export function QueryHelpSidebar({
onTryExample,
onQueryGenerated,
onTimeFilterChange,
getCurrentQuery,
activeTab,
onTabChange,
aiFixRequest,
}: {
onTryExample: (query: string, scope: QueryScope) => void;
onQueryGenerated: (query: string) => void;
onTimeFilterChange?: (filter: AITimeFilter) => void;
getCurrentQuery: () => string;
activeTab: string;
onTabChange: (tab: string) => void;
aiFixRequest: { prompt: string; key: number } | null;
}) {
return (
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden bg-background-bright">
<ClientTabs
value={activeTab}
onValueChange={onTabChange}
className="flex min-h-0 flex-col overflow-hidden pt-1"
>
<div className="h-fit overflow-x-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<ClientTabsList variant="underline" className="mx-3 shrink-0">
<ClientTabsTrigger
value="ai"
variant="underline"
layoutId="query-help-tabs"
className="shrink-0"
>
<div className="flex items-center gap-0.5">
<AISparkleIcon className="size-4" /> AI
</div>
</ClientTabsTrigger>
<ClientTabsTrigger
value="guide"
variant="underline"
layoutId="query-help-tabs"
className="shrink-0"
>
Writing TRQL
</ClientTabsTrigger>
<ClientTabsTrigger
value="schema"
variant="underline"
layoutId="query-help-tabs"
className="shrink-0"
>
Table schema
</ClientTabsTrigger>
<ClientTabsTrigger
value="examples"
variant="underline"
layoutId="query-help-tabs"
className="shrink-0"
>
Examples
</ClientTabsTrigger>
</ClientTabsList>
</div>
<ClientTabsContent
value="ai"
className="min-h-0 flex-1 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
>
<div className="min-w-64 p-3">
<AITabContent
onQueryGenerated={onQueryGenerated}
onTimeFilterChange={onTimeFilterChange}
getCurrentQuery={getCurrentQuery}
aiFixRequest={aiFixRequest}
/>
</div>
</ClientTabsContent>
<ClientTabsContent
value="guide"
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
>
<div className="min-w-64 p-3">
<TRQLGuideContent onTryExample={onTryExample} />
</div>
</ClientTabsContent>
<ClientTabsContent
value="schema"
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
>
<div className="min-w-64 p-3">
<TableSchemaContent />
</div>
</ClientTabsContent>
<ClientTabsContent
value="examples"
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
>
<div className="min-w-64 p-3">
<ExamplesContent onTryExample={onTryExample} />
</div>
</ClientTabsContent>
</ClientTabs>
</div>
);
}
@@ -0,0 +1,163 @@
import { useState } from "react";
import { ClockRotateLeftIcon } from "~/assets/icons/ClockRotateLeftIcon";
import { Button } from "~/components/primitives/Buttons";
import { Popover, PopoverTrigger } from "~/components/primitives/Popover";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import type { QueryHistoryItem } from "~/presenters/v3/QueryPresenter.server";
import { timeFilterRenderValues } from "~/components/runs/v3/SharedFilters";
import { cn } from "~/utils/cn";
const SQL_KEYWORDS = [
"SELECT",
"FROM",
"WHERE",
"ORDER BY",
"LIMIT",
"GROUP BY",
"HAVING",
"JOIN",
"LEFT JOIN",
"RIGHT JOIN",
"INNER JOIN",
"OUTER JOIN",
"AND",
"OR",
"AS",
"ON",
"IN",
"NOT",
"NULL",
"DESC",
"ASC",
"DISTINCT",
"COUNT",
"SUM",
"AVG",
"MIN",
"MAX",
];
function highlightSQL(query: string): React.ReactNode[] {
// Normalize: collapse multiple spaces/tabs to single space, but preserve newlines
// Then trim each line and limit total length
const normalized = query
.split("\n")
.map((line) => line.replace(/[ \t]+/g, " ").trim())
.filter((line) => line.length > 0)
.join("\n")
.slice(0, 500);
// Create a regex pattern that matches keywords as whole words (case insensitive)
const keywordPattern = new RegExp(
`\\b(${SQL_KEYWORDS.map((k) => k.replace(/\s+/g, "\\s+")).join("|")})\\b`,
"gi"
);
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match;
while ((match = keywordPattern.exec(normalized)) !== null) {
// Add text before the match
if (match.index > lastIndex) {
parts.push(normalized.slice(lastIndex, match.index));
}
// Add the highlighted keyword
parts.push(
<span key={match.index} className="text-[#c678dd]">
{match[0]}
</span>
);
lastIndex = keywordPattern.lastIndex;
}
// Add remaining text
if (lastIndex < normalized.length) {
parts.push(normalized.slice(lastIndex));
}
return parts;
}
export function QueryHistoryPopover({
history,
onQuerySelected,
}: {
history: QueryHistoryItem[];
onQuerySelected: (item: QueryHistoryItem) => void;
}) {
const [isOpen, setIsOpen] = useState(false);
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="secondary/small"
LeadingIcon={ClockRotateLeftIcon}
leadingIconClassName="-mr-1.5"
disabled={history.length === 0}
tooltip="Query history"
shortcut={{ key: "h" }}
>
History
</Button>
</PopoverTrigger>
<PopoverPrimitive.Content
className={cn(
"z-50 w-[400px] min-w-0 overflow-hidden rounded border border-grid-bright bg-background-bright p-0 shadow-md outline-hidden animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
)}
align="start"
sideOffset={6}
style={{ maxHeight: "var(--radix-popover-content-available-height)" }}
>
<div className="max-h-160 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<div className="p-1">
{history.map((item) => {
// Format time filter display
const { valueLabel } = timeFilterRenderValues({
period: item.filterPeriod ?? undefined,
from: item.filterFrom ?? undefined,
to: item.filterTo ?? undefined,
});
return (
<button
key={item.id}
type="button"
onClick={() => {
onQuerySelected(item);
setIsOpen(false);
}}
className="flex w-full flex-col gap-1 rounded-sm px-2 py-2 outline-hidden transition-colors focus-custom hover:bg-background-hover"
>
<div className="flex w-full flex-col items-start">
{item.title ? (
<p className="mb-1 truncate text-left text-sm font-medium text-text-bright">
{item.title}
</p>
) : (
<p className="mb-1 truncate text-left font-mono text-xs text-text-bright">
{item.query.split("\n")[0].slice(0, 60)}
</p>
)}
<div className="flex items-center gap-1.5 text-xs text-text-dimmed">
<span className="capitalize">{item.scope}</span>
{valueLabel && <span>· {valueLabel}</span>}
{item.userName && <span>· {item.userName}</span>}
</div>
</div>
<div className="w-full border-l-2 border-border-bright pl-2.5">
<p className="line-clamp-4 w-full whitespace-pre-wrap text-left font-mono text-xs text-text-dimmed">
{highlightSQL(item.query)}
</p>
</div>
</button>
);
})}
</div>
</div>
</PopoverPrimitive.Content>
</Popover>
);
}
@@ -0,0 +1,78 @@
import type { ColumnSchema } from "@internal/tsql";
import { useState } from "react";
import { Badge } from "~/components/primitives/Badge";
import { CopyableText } from "~/components/primitives/CopyableText";
import { Paragraph } from "~/components/primitives/Paragraph";
import SegmentedControl from "~/components/primitives/SegmentedControl";
import { querySchemas } from "~/v3/querySchemas";
function ColumnHelpItem({ col }: { col: ColumnSchema }) {
return (
<div className="pt-1">
<div className="flex items-center gap-2">
<CopyableText value={col.name} className="text-sm text-indigo-400" />
<Badge className="font-mono text-xxs">{col.type}</Badge>
</div>
{col.description && (
<Paragraph variant="extra-small" className="mt-1 text-text-dimmed">
{col.description}
</Paragraph>
)}
{col.example && (
<div className="mt-1 flex items-baseline gap-0.5">
<span className="text-xs text-text-dimmed">Example:</span>
<CopyableText
value={col.example}
className="rounded-sm bg-background-hover px-1.5 py-0.5 font-mono text-xxs"
/>
</div>
)}
{col.allowedValues && col.allowedValues.length > 0 && (
<div className="mt-0.5 flex flex-wrap gap-1">
<span className="text-xs text-text-dimmed">Available options:</span>
{col.allowedValues.map((value) => (
<CopyableText
key={value}
value={col.valueMap?.[value] ?? value}
className="rounded-sm bg-background-hover px-1.5 py-0.5 font-mono text-xxs"
/>
))}
</div>
)}
</div>
);
}
const tableOptions = querySchemas.map((s) => ({ label: s.name, value: s.name }));
export function TableSchemaContent() {
const [selectedTable, setSelectedTable] = useState(querySchemas[0].name);
const table = querySchemas.find((s) => s.name === selectedTable) ?? querySchemas[0];
return (
<div>
<div className="sticky top-0 z-10 bg-background-bright pb-3">
<SegmentedControl
name="table-schema-selector"
value={selectedTable}
options={tableOptions}
variant="secondary/small"
fullWidth
onChange={setSelectedTable}
/>
</div>
<div className="mb-2">
{table.description && (
<Paragraph variant="small" className="text-text-dimmed">
{table.description}
</Paragraph>
)}
</div>
<div className="flex flex-col gap-2 divide-y divide-grid-dimmed">
{Object.values(table.columns).map((col) => (
<ColumnHelpItem key={col.name} col={col} />
))}
</div>
</div>
);
}
@@ -0,0 +1,295 @@
import {
redirect,
type ActionFunctionArgs,
type LoaderFunctionArgs,
} from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { QueryEditor } from "~/components/query/QueryEditor";
import { env } from "~/env.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { QueryPresenter } from "~/presenters/v3/QueryPresenter.server";
import { executeQuery, getDefaultPeriod } from "~/services/queryService.server";
import { requireUser } from "~/services/session.server";
import { EnvironmentParamSchema, queryPath } from "~/utils/pathBuilder";
import { canAccessQuery } from "~/v3/canAccessQuery.server";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useEnvironment } from "~/hooks/useEnvironment";
/** Convert a Date or ISO string to ISO string format */
function toISOString(value: Date | string): string {
if (typeof value === "string") {
return value;
}
return value.toISOString();
}
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
const canAccess = await canAccessQuery({
userId: user.id,
isAdmin: user.admin,
isImpersonating: user.isImpersonating,
organizationSlug,
});
if (!canAccess) {
throw redirect("/");
}
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
throw new Response(undefined, {
status: 404,
statusText: "Project not found",
});
}
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
if (!environment) {
throw new Response(undefined, {
status: 404,
statusText: "Environment not found",
});
}
const presenter = new QueryPresenter();
const { defaultQuery, history } = await presenter.call({
organizationId: project.organizationId,
});
// Admins and impersonating users can use EXPLAIN
const isAdmin = user.admin || user.isImpersonating;
return typedjson({
defaultQuery,
defaultPeriod: await getDefaultPeriod(project.organizationId),
history,
isAdmin,
maxRows: env.QUERY_CLICKHOUSE_MAX_RETURNED_ROWS,
});
};
const ActionSchema = z.object({
query: z.string().min(1, "Query is required"),
scope: z.enum(["environment", "project", "organization"]),
explain: z.enum(["true", "false"]).nullable().optional(),
period: z.string().nullable().optional(),
from: z.string().nullable().optional(),
to: z.string().nullable().optional(),
});
export const action = async ({ request, params }: ActionFunctionArgs) => {
const user = await requireUser(request);
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
const canAccess = await canAccessQuery({
userId: user.id,
isAdmin: user.admin,
isImpersonating: user.isImpersonating,
organizationSlug,
});
if (!canAccess) {
return typedjson(
{
error: "Unauthorized",
rows: null,
columns: null,
stats: null,
hiddenColumns: null,
reachedMaxRows: null,
explainOutput: null,
generatedSql: null,
periodClipped: null,
},
{ status: 403 }
);
}
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
return typedjson(
{
error: "Project not found",
rows: null,
columns: null,
stats: null,
hiddenColumns: null,
reachedMaxRows: null,
explainOutput: null,
generatedSql: null,
periodClipped: null,
},
{ status: 404 }
);
}
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
if (!environment) {
return typedjson(
{
error: "Environment not found",
rows: null,
columns: null,
stats: null,
hiddenColumns: null,
reachedMaxRows: null,
explainOutput: null,
generatedSql: null,
periodClipped: null,
},
{ status: 404 }
);
}
const formData = await request.formData();
const parsed = ActionSchema.safeParse({
query: formData.get("query"),
scope: formData.get("scope"),
explain: formData.get("explain"),
period: formData.get("period"),
from: formData.get("from"),
to: formData.get("to"),
});
if (!parsed.success) {
return typedjson(
{
error: parsed.error.errors.map((e) => e.message).join(", "),
rows: null,
columns: null,
stats: null,
hiddenColumns: null,
reachedMaxRows: null,
explainOutput: null,
generatedSql: null,
periodClipped: null,
},
{ status: 400 }
);
}
const { query, scope, explain: explainParam, period, from, to } = parsed.data;
// Only allow explain for admins/impersonating users
const isAdmin = user.admin || user.isImpersonating;
const explain = explainParam === "true" && isAdmin;
try {
const queryResult = await executeQuery({
name: "query-page",
query,
scope,
organizationId: project.organizationId,
projectId: project.id,
environmentId: environment.id,
explain,
period,
from,
to,
history: {
source: "DASHBOARD",
userId: user.id,
skip: user.isImpersonating,
},
});
if (!queryResult.success) {
return typedjson(
{
error: queryResult.error.message,
rows: null,
columns: null,
stats: null,
hiddenColumns: null,
reachedMaxRows: null,
explainOutput: null,
generatedSql: null,
queryId: null,
periodClipped: null,
},
{ status: 400 }
);
}
return typedjson({
error: null,
rows: queryResult.result.rows,
columns: queryResult.result.columns,
stats: queryResult.result.stats,
hiddenColumns: queryResult.result.hiddenColumns ?? null,
reachedMaxRows: queryResult.result.reachedMaxRows,
explainOutput: queryResult.result.explainOutput ?? null,
generatedSql: queryResult.result.generatedSql ?? null,
queryId: queryResult.queryId,
periodClipped: queryResult.periodClipped,
maxQueryPeriod: queryResult.maxQueryPeriod,
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Unknown error executing query";
return typedjson(
{
error: errorMessage,
rows: null,
columns: null,
stats: null,
hiddenColumns: null,
reachedMaxRows: null,
explainOutput: null,
generatedSql: null,
queryId: null,
periodClipped: null,
},
{ status: 500 }
);
}
};
export default function Page() {
const { defaultPeriod, defaultQuery, history, isAdmin, maxRows } =
useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const plan = useCurrentPlan();
const maxPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number;
// Use most recent history item if available, otherwise fall back to defaults
const initialQuery = history.length > 0 ? history[0].query : defaultQuery;
const initialScope = history.length > 0 ? history[0].scope : "environment";
const initialTimeFilter =
history.length > 0
? {
period: history[0].filterPeriod ?? undefined,
from: history[0].filterFrom ? toISOString(history[0].filterFrom) : undefined,
to: history[0].filterTo ? toISOString(history[0].filterTo) : undefined,
}
: undefined;
// Build the query action URL for this page
const queryActionUrl = queryPath(
{ slug: organization.slug },
{ slug: project.slug },
{ slug: environment.slug }
);
return (
<QueryEditor
defaultQuery={initialQuery}
defaultScope={initialScope}
defaultPeriod={defaultPeriod}
defaultTimeFilter={initialTimeFilter}
history={history}
isAdmin={isAdmin}
maxRows={maxRows}
queryActionUrl={queryActionUrl}
mode={{ type: "standalone" }}
maxPeriodDays={maxPeriodDays}
/>
);
}
@@ -0,0 +1,9 @@
/**
* Time filter configuration that can be set by the AI.
* Used across both server and client code for AI query generation.
*/
export type AITimeFilter = {
period?: string;
from?: string;
to?: string;
};
@@ -0,0 +1,502 @@
import {
ArrowRightIcon,
ArrowUpCircleIcon,
BookOpenIcon,
ChatBubbleLeftEllipsisIcon,
InformationCircleIcon,
MapPinIcon,
} from "@heroicons/react/20/solid";
import { Form } from "@remix-run/react";
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core";
import { useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { CloudProviderIcon } from "~/assets/icons/CloudProviderIcon";
import { FlagIcon } from "~/assets/icons/RegionIcons";
import { cloudProviderTitle } from "~/components/CloudProvider";
import { Feedback } from "~/components/Feedback";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Badge } from "~/components/primitives/Badge";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { CopyableText } from "~/components/primitives/CopyableText";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/primitives/Dialog";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { TextLink } from "~/components/primitives/TextLink";
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
import { useFeatures } from "~/hooks/useFeatures";
import { useOrganization } from "~/hooks/useOrganizations";
import { useHasAdminAccess } from "~/hooks/useUser";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { type Region, RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
import { requireUser } from "~/services/session.server";
import {
docsPath,
EnvironmentParamSchema,
ProjectParamSchema,
regionsPath,
v3BillingPath,
} from "~/utils/pathBuilder";
import { SetDefaultRegionService } from "~/v3/services/setDefaultRegion.server";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { projectParam } = ProjectParamSchema.parse(params);
const presenter = new RegionsPresenter();
const [error, result] = await tryCatch(
presenter.call({
userId: user.id,
projectSlug: projectParam,
isAdmin: user.admin || user.isImpersonating,
})
);
if (error) {
throw new Response(undefined, {
status: 400,
statusText: error.message,
});
}
return typedjson(result);
};
const FormSchema = z.object({
regionId: z.string(),
});
export const action = async ({ request, params }: ActionFunctionArgs) => {
const user = await requireUser(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
const redirectPath = regionsPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam }
);
if (!project) {
throw await redirectWithErrorMessage(redirectPath, request, "Project not found");
}
const formData = await request.formData();
const parsedFormData = FormSchema.safeParse(Object.fromEntries(formData));
if (!parsedFormData.success) {
throw await redirectWithErrorMessage(redirectPath, request, "No region specified");
}
const service = new SetDefaultRegionService();
const [error, result] = await tryCatch(
service.call({
projectId: project.id,
regionId: parsedFormData.data.regionId,
isAdmin: user.admin || user.isImpersonating,
})
);
if (error) {
return redirectWithErrorMessage(redirectPath, request, error.message);
}
return redirectWithSuccessMessage(redirectPath, request, `Set ${result.name} as default`);
};
export default function Page() {
const { regions, isPaying: _isPaying } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const isAdmin = useHasAdminAccess();
const { isManagedCloud } = useFeatures();
return (
<PageContainer>
<NavBar>
<PageTitle title="Regions" />
<PageAccessories>
<AdminDebugTooltip>
<Property.Table>
{regions.map((region) => (
<Property.Item key={region.id}>
<Property.Label>{region.name}</Property.Label>
<Property.Value>{region.id}</Property.Value>
</Property.Item>
))}
</Property.Table>
</AdminDebugTooltip>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<div className="grid max-h-full min-h-full grid-rows-[1fr]">
{regions.length === 0 ? (
<MainCenteredContainer className="max-w-md">
<div className="text-center">
<Paragraph>No regions found for this project.</Paragraph>
</div>
</MainCenteredContainer>
) : (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Region</TableHeaderCell>
<TableHeaderCell>Cloud Provider</TableHeaderCell>
<TableHeaderCell>
<span className="flex items-center gap-1">
Location
<InfoIconTooltip
content="Region location is where your runs execute, not where your data is stored."
contentClassName="normal-case tracking-normal"
/>
</span>
</TableHeaderCell>
<TableHeaderCell>Static IPs</TableHeaderCell>
{isAdmin && <TableHeaderCell>Admin</TableHeaderCell>}
<TableHeaderCell
alignment="right"
tooltip={
<div className="max-w-48">
<Paragraph variant="small">
When you trigger a run it will execute in your default region, unless
you override the region when triggering.
</Paragraph>
<LinkButton
variant="docs/small"
LeadingIcon={BookOpenIcon}
to={docsPath("triggering#region")}
className="mb-1 mt-3"
>
Read docs
</LinkButton>
</div>
}
>
Default region
</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{regions.length === 0 ? (
<TableBlankRow colSpan={5}>
<Paragraph>There are no regions for this project</Paragraph>
</TableBlankRow>
) : (
regions.map((region) => {
return (
<TableRow key={region.id}>
<TableCell isTabbableCell>
<span className="flex items-center gap-2">
<CopyableText value={region.name} />
{region.workloadType === "MICROVM" && (
<Badge variant="small">MicroVM</Badge>
)}
</span>
</TableCell>
<TableCell>
{region.cloudProvider ? (
<span className="flex items-center gap-2">
<CloudProviderIcon
provider={region.cloudProvider}
className="size-6"
/>
{cloudProviderTitle(region.cloudProvider)}
</span>
) : (
""
)}
</TableCell>
<TableCell>
<span className="flex items-center gap-2">
{region.location ? (
<FlagIcon region={region.location} className="size-5" />
) : null}
{region.description ?? ""}
</span>
</TableCell>
<TableCell>
{region.staticIPs === null ? (
<LinkButton
variant="secondary/small"
to={v3BillingPath(
organization,
"Upgrade your plan to unlock static IPs"
)}
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="text-indigo-500"
>
Unlock static IPs
</LinkButton>
) : region.staticIPs !== undefined ? (
<ClipboardField
value={region.staticIPs}
variant={"secondary/small"}
/>
) : (
"Not available"
)}
</TableCell>
{isAdmin && (
<TableCell>{region.isHidden ? "Hidden" : "Visible"}</TableCell>
)}
{region.isDefault ? (
<TableCell alignment="right">
<Badge variant="small" className="inline-grid">
Default
</Badge>
</TableCell>
) : (
<TableCellMenu
className="pl-32"
isSticky
hiddenButtons={
<SetDefaultDialog regions={regions} newDefaultRegion={region} />
}
/>
)}
</TableRow>
);
})
)}
<TableRow className="h-12.5">
<TableCell colSpan={isAdmin ? 5 : 4}>
<Paragraph variant="extra-small">Suggest a new region</Paragraph>
</TableCell>
<TableCellMenu
alignment="right"
isSticky
visibleButtons={
<Feedback
button={
<Button
variant="secondary/small"
LeadingIcon={ChatBubbleLeftEllipsisIcon}
leadingIconClassName="text-indigo-500"
>
Suggest a region
</Button>
}
defaultValue="region"
/>
}
/>
</TableRow>
</TableBody>
</Table>
{isManagedCloud && (
<InfoPanel
icon={InformationCircleIcon}
iconClassName="size-4"
variant="minimal"
panelClassName="max-w-full gap-1"
>
<Paragraph variant="extra-small">
Trigger.dev is fully{" "}
<TextLink to="https://security.trigger.dev/gdpr?tab=securityControls&frameworks=gdpr_v1">
GDPR compliant
</TextLink>
. Learn more in our{" "}
<TextLink to="https://security.trigger.dev">security portal</TextLink> or{" "}
<Feedback
button={
<span className="cursor-pointer text-xs text-indigo-500 transition hover:text-indigo-400">
get in touch
</span>
}
defaultValue="feedback"
/>
.
</Paragraph>
</InfoPanel>
)}
</div>
</>
)}
</div>
</PageBody>
</PageContainer>
);
}
function SetDefaultDialog({
regions,
newDefaultRegion,
}: {
regions: Region[];
newDefaultRegion: Region;
}) {
const [isOpen, setIsOpen] = useState(false);
const { isManagedCloud } = useFeatures();
const currentDefaultRegion = regions.find((r) => r.isDefault);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="minimal/small"
LeadingIcon={MapPinIcon}
leadingIconClassName="text-blue-500"
iconSpacing="gap-2"
className="pl-2"
>
<span className="text-text-bright">Set as default</span>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Set as default region</DialogTitle>
</DialogHeader>
<DialogDescription asChild>
<div>
<Paragraph>
Are you sure you want to set {newDefaultRegion.name} as your new default region?
</Paragraph>
<div className="my-4 flex">
<div className="flex flex-1 flex-col rounded-md border border-grid-dimmed">
<div className="border-b border-grid-dimmed bg-background-bright p-3 font-medium">
<Paragraph variant="small/bright">Current default</Paragraph>
</div>
<div className="border-b border-grid-dimmed p-3">
<Paragraph variant="small">{currentDefaultRegion?.name ?? ""}</Paragraph>
</div>
<div className="border-b border-grid-dimmed p-3">
<Paragraph variant="small" className="flex items-center gap-2">
{currentDefaultRegion?.cloudProvider ? (
<>
<CloudProviderIcon
provider={currentDefaultRegion.cloudProvider}
className="size-6"
/>
{cloudProviderTitle(currentDefaultRegion.cloudProvider)}
</>
) : (
""
)}
</Paragraph>
</div>
<div className="p-3">
<Paragraph variant="small" className="flex items-center gap-2">
{currentDefaultRegion?.location ? (
<FlagIcon region={currentDefaultRegion.location} className="size-5" />
) : null}
{currentDefaultRegion?.description ?? ""}
</Paragraph>
</div>
</div>
{/* Middle column with arrow */}
<div className="flex items-center justify-center px-3">
<div className="flex size-10 items-center justify-center rounded-full border border-grid-dimmed bg-background-bright p-2">
<ArrowRightIcon className="size-4 text-text-dimmed" />
</div>
</div>
{/* Right column */}
<div className="flex flex-1 flex-col rounded-md border border-grid-dimmed">
<div className="border-b border-grid-dimmed bg-background-bright p-3 font-medium">
<Paragraph variant="small/bright">New default</Paragraph>
</div>
<div className="border-b border-grid-dimmed p-3">
<Paragraph variant="small">{newDefaultRegion.name}</Paragraph>
</div>
<div className="border-b border-grid-dimmed p-3">
<Paragraph variant="small" className="flex items-center gap-2">
{newDefaultRegion.cloudProvider ? (
<>
<CloudProviderIcon
provider={newDefaultRegion.cloudProvider}
className="size-6"
/>
{cloudProviderTitle(newDefaultRegion.cloudProvider)}
</>
) : (
""
)}
</Paragraph>
</div>
<div className="p-3">
<Paragraph variant="small" className="flex items-center gap-2">
{newDefaultRegion.location ? (
<FlagIcon region={newDefaultRegion.location} className="size-5" />
) : null}
{newDefaultRegion.description ?? ""}
</Paragraph>
</div>
</div>
</div>
<Paragraph>
Runs triggered from now on will execute in "{newDefaultRegion.name}", unless you{" "}
<TextLink to={docsPath("triggering#region")}>override when triggering</TextLink>.
</Paragraph>
<InfoPanel
icon={InformationCircleIcon}
iconClassName="size-4"
variant="minimal"
panelClassName="mt-4 max-w-full gap-1 border-t border-grid-dimmed pt-4 pb-0 pl-0"
>
<Paragraph variant="extra-small">
Region is where your runs execute, not where your data is stored.
{isManagedCloud ? (
<>
{" "}
Trigger.dev is fully{" "}
<TextLink to="https://security.trigger.dev/gdpr?tab=securityControls&frameworks=gdpr_v1">
GDPR compliant
</TextLink>
.
</>
) : null}
</Paragraph>
</InfoPanel>
</div>
</DialogDescription>
<DialogFooter>
<Button variant="secondary/medium" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Form method="post">
<Button
variant="primary/medium"
type="submit"
name="regionId"
shortcut={{ modifiers: ["mod"], key: "enter" }}
value={newDefaultRegion.id}
>
Set as default
</Button>
</Form>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,11 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { runStreamLoader } from "~/presenters/v3/RunStreamPresenter.server";
import { requireUserId } from "~/services/session.server";
export async function loader(args: LoaderFunctionArgs) {
// Authenticate the user before starting the stream
await requireUserId(args.request);
// Delegate to the SSE loader
return runStreamLoader(args);
}
@@ -0,0 +1,566 @@
import { BeakerIcon, BookOpenIcon } from "@heroicons/react/24/solid";
import { type MetaFunction, useLocation, useNavigation, useRevalidator } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Suspense, useState } from "react";
import {
TypedAwait,
typeddefer,
type UseDataFunctionReturn,
useTypedLoaderData,
} from "remix-typedjson";
import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon";
import { QuestionMarkIcon } from "~/assets/icons/QuestionMarkIcon";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { DevDisconnectedBanner, useDevPresence } from "~/components/DevPresence";
import { InlineCode } from "~/components/code/InlineCode";
import { StepContentContainer } from "~/components/StepContentContainer";
import { MainCenteredContainer, PageBody } from "~/components/layout/AppLayout";
import { Badge } from "~/components/primitives/Badge";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Header1 } from "~/components/primitives/Headers";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { PulsingDot } from "~/components/primitives/PulsingDot";
import {
RESIZABLE_PANEL_ANIMATION,
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
collapsibleHandleClassName,
} from "~/components/primitives/Resizable";
import { SelectedItemsProvider } from "~/components/primitives/SelectedItemsProvider";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import { Spinner } from "~/components/primitives/Spinner";
import { StepNumber } from "~/components/primitives/StepNumber";
import { TextLink } from "~/components/primitives/TextLink";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { RunsFilters, type TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
import { BULK_ACTION_RUN_LIMIT } from "~/consts";
import { $replica } from "~/db.server";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { redirectWithErrorMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server";
import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import {
setRootOnlyFilterPreference,
uiPreferencesStorage,
} from "~/services/preferences/uiPreferences.server";
import { requireUserId } from "~/services/session.server";
import { rbac } from "~/services/rbac.server";
import { checkPermissions } from "~/services/routeBuilders/permissions.server";
import { cn } from "~/utils/cn";
import {
docsPath,
EnvironmentParamSchema,
v3ProjectPath,
v3TestPath,
v3TestTaskPath,
} from "~/utils/pathBuilder";
import { throwNotFound } from "~/utils/httpErrors";
import { ListPagination } from "../../components/ListPagination";
import { CreateBulkActionInspector } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction";
import { Callout } from "~/components/primitives/Callout";
import {
isRunsListLoading,
RUNS_BULK_INSPECTOR_OPEN_VALUE,
shouldRevalidateRunsList,
} from "./shouldRevalidateRunsList";
import { useRunsLiveReload } from "./useRunsLiveReload";
export { shouldRevalidateRunsList as shouldRevalidate };
export const meta: MetaFunction = () => {
return [
{
title: `Runs metrics | Trigger.dev`,
},
];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
return redirectWithErrorMessage("/", request, "Project not found");
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throwNotFound("Environment not found");
}
const filters = await getRunFiltersFromRequest(request);
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
project.organizationId,
"standard"
);
const presenter = new NextRunListPresenter($replica, clickhouse);
const list = presenter.call(project.organizationId, environment.id, {
userId,
projectId: project.id,
...filters,
includeHasAnyRuns: true,
});
// Only persist rootOnly when no tasks are filtered. While a task filter is active,
// the toggle's URL value can be a temporary auto-flip (or a user override scoped to
// the current task filter), and we don't want either bleeding into the saved
// session preference. Clearing the task filter restores the saved preference.
const shouldPersistRootOnly = !filters.tasks || filters.tasks.length === 0;
const headers = shouldPersistRootOnly
? {
"Set-Cookie": await uiPreferencesStorage.commitSession(
await setRootOnlyFilterPreference(filters.rootOnly, request)
),
}
: undefined;
// Display flags for the row-menu and bulk-action controls — the cancel/
// replay action routes enforce write:runs independently. Permissive in OSS.
const runAuth = await rbac.authenticateSession(request, {
userId,
organizationId: project.organizationId,
});
const runPermissions = runAuth.ok
? checkPermissions(runAuth.ability, {
canCancelRuns: { action: "write", resource: { type: "runs" } },
canReplayRuns: { action: "write", resource: { type: "runs" } },
})
: { canCancelRuns: true, canReplayRuns: true };
return typeddefer(
{
data: list,
rootOnlyDefault: filters.rootOnly,
filters,
...runPermissions,
},
headers ? { headers } : undefined
);
};
export default function Page() {
const { data, rootOnlyDefault, filters, canCancelRuns, canReplayRuns } =
useTypedLoaderData<typeof loader>();
const { isConnected } = useDevPresence();
const project = useProject();
const environment = useEnvironment();
return (
<>
<NavBar>
<PageTitle title="Runs" accessory={<RunsHelpTooltip />} />
{environment.type === "DEVELOPMENT" && project.engine === "V2" && (
<DevDisconnectedBanner isConnected={isConnected} />
)}
<PageAccessories>
<LinkButton
variant={"docs/small"}
LeadingIcon={BookOpenIcon}
to={docsPath("/runs-and-attempts")}
>
Runs docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<SelectedItemsProvider
initialSelectedItems={[]}
maxSelectedItemCount={BULK_ACTION_RUN_LIMIT}
>
{({ selectedItems }) => (
<Suspense
fallback={
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto] overflow-hidden">
<div className="border-b border-grid-bright" />
<div className="my-2 flex items-center justify-center">
<div className="mx-auto flex items-center gap-2">
<Spinner />
<Paragraph variant="small">Loading runs</Paragraph>
</div>
</div>
</div>
}
>
<TypedAwait
resolve={data}
errorElement={
<div className="flex items-center justify-center px-3 py-12">
<Callout variant="error" className="max-w-fit">
Unable to load your task runs. Please refresh the page or try again in a
moment.
</Callout>
</div>
}
>
{(list) => {
return (
<RunsList
list={list}
selectedItems={selectedItems}
rootOnlyDefault={rootOnlyDefault}
filters={filters}
canCancelRuns={canCancelRuns}
canReplayRuns={canReplayRuns}
/>
);
}}
</TypedAwait>
</Suspense>
)}
</SelectedItemsProvider>
</PageBody>
</>
);
}
function RunsList({
list,
selectedItems,
rootOnlyDefault,
filters,
canCancelRuns,
canReplayRuns,
}: {
list: Awaited<UseDataFunctionReturn<typeof loader>["data"]>;
selectedItems: Set<string>;
rootOnlyDefault: boolean;
filters: TaskRunListSearchFilters;
canCancelRuns: boolean;
canReplayRuns: boolean;
}) {
const revalidator = useRevalidator();
const location = useLocation();
const navigation = useNavigation();
const isLoading = isRunsListLoading(navigation, location.search);
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { has, replace } = useSearchParams();
const { visibleRuns, showNewRunsBanner, newRunsCount, dismissNewRuns, childrenStatusesBasePath } =
useRunsLiveReload({
runs: list.runs,
hasAnyRuns: list.hasAnyRuns,
isLoading,
organizationSlug: organization.slug,
projectSlug: project.slug,
environmentSlug: environment.slug,
});
const onClickShowNewRuns = () => {
const isPaginated = has("cursor") || has("direction");
dismissNewRuns();
if (isPaginated) {
replace({
cursor: undefined,
direction: undefined,
});
return;
}
revalidator.revalidate();
};
// Shortcut keys for bulk actions — disabled when the role can't perform them.
useShortcutKeys({
shortcut: { key: "r" },
disabled: !canReplayRuns,
action: (e) => {
replace({
bulkInspector: RUNS_BULK_INSPECTOR_OPEN_VALUE,
action: "replay",
mode: selectedItems.size > 0 ? "selected" : undefined,
});
},
});
useShortcutKeys({
shortcut: { key: "c" },
disabled: !canCancelRuns,
action: (e) => {
replace({
bulkInspector: RUNS_BULK_INSPECTOR_OPEN_VALUE,
action: "cancel",
mode: selectedItems.size > 0 ? "selected" : undefined,
});
},
});
const isShowingBulkActionInspector = has("bulkInspector") && list.hasAnyRuns;
const [isBulkInspectorPanelCollapsed, setIsBulkInspectorPanelCollapsed] = useState(
!isShowingBulkActionInspector
);
// Keep content mounted until onCollapseChange reports the panel is fully collapsed.
const showBulkInspectorContent = isShowingBulkActionInspector || !isBulkInspectorPanelCollapsed;
return (
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
<ResizablePanel id="runs-main" min={"100px"}>
<div
className={cn(
"grid h-full max-h-full overflow-hidden",
selectedItems.size === 0 ? "grid-rows-1" : "grid-rows-[1fr_auto]"
)}
>
<>
{list.runs.length === 0 && !list.hasAnyRuns ? (
list.possibleTasks.length === 0 ? (
<CreateFirstTaskInstructions />
) : (
<RunTaskInstructions
task={
list.filters.tasks.length === 1
? list.possibleTasks.find((t) => t.slug === list.filters.tasks[0])
: undefined
}
/>
)
) : (
<div className={cn("grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden")}>
<div className="flex items-start justify-between gap-x-2 p-2">
<RunsFilters
possibleTasks={list.possibleTasks}
bulkActions={list.bulkActions}
hasFilters={list.hasFilters}
rootOnlyDefault={rootOnlyDefault}
/>
<div className="flex items-center justify-end gap-x-2">
{showNewRunsBanner && (
<span className="flex duration-150 animate-in fade-in-0">
<Button
variant="secondary/small"
className="text-text-bright"
onClick={onClickShowNewRuns}
LeadingIcon={<PulsingDot className="h-2 w-2" />}
tooltip="Refresh to see new runs"
aria-label="New runs created. Refresh to see new runs."
>
{newRunsCount >= 100
? "99+ new runs"
: `${newRunsCount} new ${newRunsCount === 1 ? "run" : "runs"}`}
</Button>
</span>
)}
{/* Stay mounted while the inspector is open to avoid toolbar layout shift. */}
<Button
variant="secondary/small"
disabled={isShowingBulkActionInspector || (!canCancelRuns && !canReplayRuns)}
onClick={() =>
replace({
bulkInspector: RUNS_BULK_INSPECTOR_OPEN_VALUE,
mode: selectedItems.size > 0 ? "selected" : undefined,
})
}
LeadingIcon={ListCheckedIcon}
className={cn(
selectedItems.size > 0 ? "pr-1" : undefined,
isShowingBulkActionInspector && "pointer-events-none invisible"
)}
tooltip={
!canCancelRuns && !canReplayRuns ? (
"You don't have permission to cancel or replay runs"
) : (
<div className="-mr-1 flex items-center gap-3 text-xs text-text-dimmed">
<div className="flex items-center gap-0.5">
<span>Replay</span>
<ShortcutKey shortcut={{ key: "r" }} variant={"small"} />
</div>
<div className="flex items-center gap-0.5">
<span>Cancel</span>
<ShortcutKey shortcut={{ key: "c" }} variant={"small"} />
</div>
</div>
)
}
>
<span className="flex items-center gap-x-1 whitespace-nowrap text-text-bright">
<span>Bulk action</span>
{selectedItems.size > 0 && (
<Badge variant="rounded">{selectedItems.size}</Badge>
)}
</span>
</Button>
<ListPagination list={list} />
</div>
</div>
<TaskRunsTable
total={visibleRuns.length}
hasFilters={list.hasFilters}
filters={list.filters}
runs={visibleRuns}
childrenStatusesBasePath={childrenStatusesBasePath}
isLoading={isLoading}
allowSelection
rootOnlyDefault={rootOnlyDefault}
canCancelRuns={canCancelRuns}
canReplayRuns={canReplayRuns}
/>
</div>
)}
</>
</div>
</ResizablePanel>
<ResizableHandle
id="runs-handle"
className={collapsibleHandleClassName(isShowingBulkActionInspector)}
/>
<ResizablePanel
id="bulk-action-inspector"
default="400px"
min="400px"
max="600px"
className="overflow-hidden"
collapsible
collapsed={!isShowingBulkActionInspector}
onCollapseChange={setIsBulkInspectorPanelCollapsed}
collapsedSize="0px"
collapseAnimation={RESIZABLE_PANEL_ANIMATION}
>
<div className="h-full" style={{ minWidth: 400 }}>
{showBulkInspectorContent && (
<CreateBulkActionInspector
filters={filters}
selectedItems={selectedItems}
hasBulkActions={list.bulkActions.length > 0}
/>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}
function CreateFirstTaskInstructions() {
const organization = useOrganization();
const project = useProject();
return (
<MainCenteredContainer className="max-w-md">
<InfoPanel
icon={TaskIcon}
iconClassName="text-blue-500"
panelClassName="max-full"
title="Create your first task"
accessory={
<LinkButton to={v3ProjectPath(organization, project)} variant="primary/small">
Create a task
</LinkButton>
}
>
<Paragraph variant="small">
Before running a task, you must first create one. Follow the instructions on the{" "}
<TextLink to={v3ProjectPath(organization, project)}>Tasks</TextLink> page to create a
task, then return here to run it.
</Paragraph>
</InfoPanel>
</MainCenteredContainer>
);
}
function RunTaskInstructions({ task }: { task?: { slug: string } }) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
return (
<MainCenteredContainer className="max-w-prose">
<Header1 className="mb-6 border-b py-2">How to run your tasks</Header1>
<StepNumber stepNumber="A" title="Trigger a test run" />
<StepContentContainer>
<Paragraph spacing>
Perform a test run with a payload directly from the dashboard.
</Paragraph>
<LinkButton
to={
task
? v3TestTaskPath(organization, project, environment, { taskIdentifier: task.slug })
: v3TestPath(organization, project, environment)
}
variant="secondary/medium"
LeadingIcon={BeakerIcon}
leadingIconClassName="text-tests"
className="inline-flex"
>
Test
</LinkButton>
<div className="mt-6 flex items-center gap-2">
<hr className="w-full" />
<Paragraph variant="extra-extra-small/dimmed/caps">OR</Paragraph>
<hr className="w-full" />
</div>
</StepContentContainer>
<StepNumber stepNumber="B" title="Trigger your task for real" />
<StepContentContainer>
<Paragraph spacing>
Performing a real run depends on the type of trigger your task is using.
</Paragraph>
<LinkButton
to={docsPath("/triggering")}
variant="docs/medium"
LeadingIcon={BookOpenIcon}
className="inline-flex"
>
How to trigger a task
</LinkButton>
</StepContentContainer>
</MainCenteredContainer>
);
}
function RunsHelpTooltip() {
return (
<SimpleTooltip
button={
<QuestionMarkIcon className="size-4 text-text-dimmed transition hover:text-text-bright" />
}
side="bottom"
className="max-w-sm p-3"
disableHoverableContent
content={
<div className="flex flex-col gap-3">
<div>
<Paragraph variant="small/bright">What is a run?</Paragraph>
<Paragraph variant="small" className="mt-1">
A run is a single instance of a task being executed. It's created when you trigger a
task, for example{" "}
<InlineCode variant="extra-extra-small">
yourTask.trigger({`{ foo: "bar" }`})
</InlineCode>
. Runs are durable, so they survive crashes, deploys, and restarts, and will
automatically retry on failure.
</Paragraph>
</div>
<div className="flex flex-col gap-2.5 border-t border-grid-dimmed pt-3">
<div>
<Paragraph variant="small/bright">
<InlineCode>task.trigger()</InlineCode>
</Paragraph>
<Paragraph variant="small" className="mt-1">
Triggered from your backend code, an API call, or another task. Each call creates a
single run with the payload you pass in.
</Paragraph>
</div>
<div>
<Paragraph variant="small/bright">Scheduled triggers</Paragraph>
<Paragraph variant="small" className="mt-1">
Runs created automatically from a cron schedule attached to a scheduled task. Use
them for recurring jobs like nightly syncs or hourly cleanups.
</Paragraph>
</div>
</div>
</div>
}
/>
);
}
@@ -0,0 +1,73 @@
import type { Navigation, ShouldRevalidateFunction } from "@remix-run/react";
/** Search params that only control the bulk-action inspector UI, not list data. */
export const RUNS_BULK_INSPECTOR_UI_SEARCH_PARAMS = ["bulkInspector", "action", "mode"] as const;
/** URL value set on `bulkInspector` when the inspector panel is open (presence flag). */
export const RUNS_BULK_INSPECTOR_OPEN_VALUE = "show";
/** Returns a copy with bulk-inspector UI params removed. */
export function stripBulkInspectorUiParams(params: URLSearchParams): URLSearchParams {
const stripped = new URLSearchParams(params);
for (const key of RUNS_BULK_INSPECTOR_UI_SEARCH_PARAMS) {
stripped.delete(key);
}
return stripped;
}
/** Canonical string for list-data params (UI keys stripped, entries sorted by key). */
export function canonicalRunsListDataSearchParams(params: URLSearchParams): string {
const stripped = stripBulkInspectorUiParams(params);
stripped.sort();
return stripped.toString();
}
export function searchParamsEqualIgnoringBulkInspectorUiState(
current: URLSearchParams,
next: URLSearchParams
) {
return canonicalRunsListDataSearchParams(current) === canonicalRunsListDataSearchParams(next);
}
/** True when navigation should show the runs table loading state (excludes bulk-inspector UI toggles). */
export function isRunsListLoading(navigation: Navigation, currentSearch: string): boolean {
if (navigation.state === "idle" || !navigation.location) {
return false;
}
const currentParams = new URLSearchParams(currentSearch);
const nextParams = new URLSearchParams(navigation.location.search);
if (searchParamsEqualIgnoringBulkInspectorUiState(currentParams, nextParams)) {
return false;
}
return true;
}
/**
* Skip runs list loader revalidation when only bulk-inspector UI params change.
* Explicit revalidate() (unchanged URL) and filter/pagination changes still revalidate.
*/
export const shouldRevalidateRunsList: ShouldRevalidateFunction = ({
currentUrl,
nextUrl,
defaultShouldRevalidate,
}) => {
if (currentUrl.pathname !== nextUrl.pathname) {
return defaultShouldRevalidate;
}
const currentParams = new URLSearchParams(currentUrl.search);
const nextParams = new URLSearchParams(nextUrl.search);
if (currentParams.toString() === nextParams.toString()) {
return defaultShouldRevalidate;
}
if (searchParamsEqualIgnoringBulkInspectorUiState(currentParams, nextParams)) {
return false;
}
return defaultShouldRevalidate;
};
@@ -0,0 +1,267 @@
import { useLocation } from "@remix-run/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTypedFetcher } from "remix-typedjson";
import { useInterval } from "~/hooks/useInterval";
import type { NextRunListItem } from "~/presenters/v3/NextRunListPresenter.server";
import type { loader as liveRunsLoader } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.live";
import { stripBulkInspectorUiParams } from "./shouldRevalidateRunsList";
const RUNS_PAGINATION_SEARCH_PARAMS = ["cursor", "direction"] as const;
const RUNS_POLL_INTERVAL_MS = 3000;
/** Check for new runs every N poll ticks (~6s at 3s interval). */
const NEW_RUNS_EVERY_N_POLL_TICKS = 2;
type ListedRun = NextRunListItem;
type LiveRunFields = Awaited<ReturnType<typeof liveRunsLoader>>["runs"][number];
type LivePollFetcherData = Awaited<ReturnType<typeof liveRunsLoader>> | undefined;
function hasNewRunsCountFields(
data: LivePollFetcherData
): data is NonNullable<LivePollFetcherData> & { count: number; since: number } {
return data !== undefined && "count" in data && "since" in data;
}
function maxCreatedAtMs(runs: ListedRun[]): number | undefined {
if (runs.length === 0) return undefined;
return runs.reduce<number>((maxTimestamp, run) => {
const runTimestamp = new Date(run.createdAt).getTime();
return Math.max(maxTimestamp, runTimestamp);
}, 0);
}
function filterParamsWithoutPagination(search: string) {
const params = stripBulkInspectorUiParams(new URLSearchParams(search));
for (const key of RUNS_PAGINATION_SEARCH_PARAMS) {
params.delete(key);
}
return params;
}
function getRunsSearchKeyWithoutPagination(search: string) {
return filterParamsWithoutPagination(search).toString();
}
function isNewRunsCheckTick(tick: number) {
return tick === 1 || tick % NEW_RUNS_EVERY_N_POLL_TICKS === 0;
}
function appendNewRunsSearchParams(
searchParams: URLSearchParams,
{ locationSearch, since }: { locationSearch: string; since: number }
) {
const filterParams = filterParamsWithoutPagination(locationSearch);
for (const [key, value] of filterParams) {
searchParams.append(key, value);
}
searchParams.set("includeNewRuns", "true");
searchParams.set("since", String(since));
}
function patchVisibleRunsWithLiveUpdates(currentRuns: ListedRun[], liveRuns: LiveRunFields[]) {
const updatesById = new Map(liveRuns.map((run) => [run.friendlyId, run]));
return currentRuns.map((run) => {
const update = updatesById.get(run.friendlyId);
if (!update) return run;
return {
...run,
status: update.status,
updatedAt: update.updatedAt,
startedAt: update.startedAt,
finishedAt: update.finishedAt,
hasFinished: update.hasFinished,
isCancellable: update.isCancellable,
isPending: update.isPending,
usageDurationMs: update.usageDurationMs,
costInCents: update.costInCents,
baseCostInCents: update.baseCostInCents,
};
});
}
function useNewRunsDetection({
runs,
hasAnyRuns,
isLoading,
}: {
runs: ListedRun[];
hasAnyRuns: boolean;
isLoading: boolean;
}) {
const pollTickRef = useRef(0);
const [knownNewestRunMs, setKnownNewestRunMs] = useState(
() => maxCreatedAtMs(runs) ?? Date.now()
);
const [newRunsCount, setNewRunsCount] = useState(0);
const shouldPollForNewRuns = hasAnyRuns && !isLoading && newRunsCount < 100;
// Re-baseline the cutoff and clear banner/throttle state. The parent calls
// this from its single "list context changed" reset path.
const resetNewRunsTracking = useCallback(() => {
setKnownNewestRunMs(maxCreatedAtMs(runs) ?? Date.now());
setNewRunsCount(0);
pollTickRef.current = 0;
}, [runs]);
const dismissNewRuns = useCallback(() => {
setNewRunsCount(0);
setKnownNewestRunMs(Date.now());
pollTickRef.current = 0;
}, []);
const checkNewRunsOnTick = useCallback(() => {
pollTickRef.current += 1;
return shouldPollForNewRuns && isNewRunsCheckTick(pollTickRef.current);
}, [shouldPollForNewRuns]);
const showNewRunsBanner = newRunsCount > 0;
return {
knownNewestRunMs,
newRunsCount,
setNewRunsCount,
shouldPollForNewRuns,
showNewRunsBanner,
dismissNewRuns,
checkNewRunsOnTick,
resetNewRunsTracking,
};
}
export function useRunsLiveReload({
runs,
hasAnyRuns,
isLoading,
organizationSlug,
projectSlug,
environmentSlug,
}: {
runs: ListedRun[];
hasAnyRuns: boolean;
isLoading: boolean;
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
}) {
const location = useLocation();
const runsPollFetcher = useTypedFetcher<typeof liveRunsLoader>();
const runsPollFetcherStateRef = useRef(runsPollFetcher.state);
runsPollFetcherStateRef.current = runsPollFetcher.state;
const [visibleRuns, setVisibleRuns] = useState(runs);
const searchKeyWithoutPagination = useMemo(
() => getRunsSearchKeyWithoutPagination(location.search),
[location.search]
);
const {
knownNewestRunMs,
newRunsCount,
setNewRunsCount,
shouldPollForNewRuns,
showNewRunsBanner,
dismissNewRuns,
checkNewRunsOnTick,
resetNewRunsTracking,
} = useNewRunsDetection({
runs,
hasAnyRuns,
isLoading,
});
// Single reset path: new loader data or changed filters re-baseline both the
// visible rows and new-run tracking.
useEffect(() => {
setVisibleRuns(runs);
resetNewRunsTracking();
}, [runs, searchKeyWithoutPagination, resetNewRunsTracking]);
// Patch visible rows from the live response. Keyed to the response alone so a
// loader refresh never re-applies a stale poll payload over fresh rows.
useEffect(() => {
const data = runsPollFetcher.data;
if (!data?.runs.length) return;
setVisibleRuns((currentRuns) => patchVisibleRunsWithLiveUpdates(currentRuns, data.runs));
}, [runsPollFetcher.data]);
// Update new-runs count from the poll response. Re-evaluates when the cutoff
// changes, even if the response object itself is unchanged.
useEffect(() => {
const data = runsPollFetcher.data;
if (!hasNewRunsCountFields(data)) return;
if (data.since === knownNewestRunMs) {
setNewRunsCount(data.count);
}
}, [runsPollFetcher.data, knownNewestRunMs, setNewRunsCount]);
const activeRunIdsParam = useMemo(
() =>
visibleRuns
.filter((run) => !run.hasFinished)
.map((run) => run.friendlyId)
.join(","),
[visibleRuns]
);
const hasActiveRuns = activeRunIdsParam.length > 0;
const runsResourcesBasePath = useMemo(
() => `/resources/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/runs`,
[organizationSlug, projectSlug, environmentSlug]
);
const loadRunsPoll = useCallback(
(checkForNewRuns: boolean) => {
if (runsPollFetcherStateRef.current !== "idle") return;
if (!hasActiveRuns && !checkForNewRuns) return;
const searchParams = new URLSearchParams();
if (hasActiveRuns) {
searchParams.set("runIds", activeRunIdsParam);
}
if (checkForNewRuns) {
appendNewRunsSearchParams(searchParams, {
locationSearch: location.search,
since: knownNewestRunMs,
});
}
runsPollFetcher.load(`${runsResourcesBasePath}/live?${searchParams.toString()}`);
},
[
activeRunIdsParam,
hasActiveRuns,
location.search,
knownNewestRunMs,
runsPollFetcher,
runsResourcesBasePath,
]
);
const shouldPoll = !isLoading && (hasActiveRuns || shouldPollForNewRuns);
useInterval({
interval: RUNS_POLL_INTERVAL_MS,
onLoad: true,
pauseWhenHidden: true,
disabled: !shouldPoll,
callback: () => {
loadRunsPoll(checkNewRunsOnTick());
},
});
return {
visibleRuns,
showNewRunsBanner,
newRunsCount,
dismissNewRuns,
childrenStatusesBasePath: runsResourcesBasePath,
};
}
@@ -0,0 +1,10 @@
import { Outlet } from "@remix-run/react";
import { PageContainer } from "~/components/layout/AppLayout";
export default function Page() {
return (
<PageContainer>
<Outlet />
</PageContainer>
);
}
@@ -0,0 +1,220 @@
import { parseWithZod } from "@conform-to/zod";
import { useLocation } from "@remix-run/react";
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { LinkButton } from "~/components/primitives/Buttons";
import { ScheduleInspector } from "~/components/schedules/ScheduleInspector";
import { prisma } from "~/db.server";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server";
import { requireUserId } from "~/services/session.server";
import { v3EnvironmentPath, v3ScheduleParams, v3SchedulePath } from "~/utils/pathBuilder";
import { DeleteTaskScheduleService } from "~/v3/services/deleteTaskSchedule.server";
import { SetActiveOnTaskScheduleService } from "~/v3/services/setActiveOnTaskSchedule.server";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, envParam, scheduleParam } =
v3ScheduleParams.parse(params);
// Find the project scoped to the organization
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
return redirectWithErrorMessage("/", request, "Project not found");
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
return redirectWithErrorMessage("/", request, "Environment not found");
}
const presenter = new ViewSchedulePresenter();
const result = await presenter.call({
userId,
projectId: project.id,
friendlyId: scheduleParam,
environmentId: environment.id,
});
// Return null (not a 404 throw) so fetcher-driven hosts (e.g. the sheet
// running this loader after a delete-in-flight) don't surface a
// page-level error boundary. The standalone Page below renders a
// not-found message when `schedule` is null.
return typedjson({ schedule: result?.schedule ?? null });
};
const schema = z.discriminatedUnion("action", [
z.object({
action: z.literal("delete"),
}),
z.object({
action: z.literal("enable"),
}),
z.object({
action: z.literal("disable"),
}),
]);
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam, scheduleParam } =
v3ScheduleParams.parse(params);
const formData = await request.formData();
const submission = parseWithZod(formData, { schema });
if (submission.status !== "success") {
return json(submission.reply());
}
// `_format=json` → return JSON instead of redirecting; caller stays put.
const wantsJson = formData.get("_format") === "json";
const project = await prisma.project.findFirst({
where: {
slug: projectParam,
},
});
if (!project) {
const message = `No project found with slug ${projectParam}`;
if (wantsJson) {
return json({ ok: false as const, message }, { status: 404 });
}
return redirectWithErrorMessage(
v3SchedulePath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam },
{ friendlyId: scheduleParam }
),
request,
message
);
}
switch (submission.value.action) {
case "delete": {
const deleteService = new DeleteTaskScheduleService();
try {
await deleteService.call({
projectId: project.id,
userId,
friendlyId: scheduleParam,
});
if (wantsJson) {
return json({ ok: true as const, message: `${scheduleParam} deleted` });
}
return redirectWithSuccessMessage(
v3EnvironmentPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }),
request,
`${scheduleParam} deleted`
);
} catch (e) {
const message = `${scheduleParam} could not be deleted: ${
e instanceof Error ? e.message : JSON.stringify(e)
}`;
if (wantsJson) {
return json({ ok: false as const, message }, { status: 500 });
}
return redirectWithErrorMessage(
v3SchedulePath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam },
{ friendlyId: scheduleParam }
),
request,
message
);
}
}
case "enable":
case "disable": {
const service = new SetActiveOnTaskScheduleService();
const active = submission.value.action === "enable";
try {
await service.call({
projectId: project.id,
userId,
friendlyId: scheduleParam,
active,
});
if (wantsJson) {
return json({ ok: true as const, active });
}
return redirectWithSuccessMessage(
v3SchedulePath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam },
{ friendlyId: scheduleParam }
),
request,
`${scheduleParam} ${active ? "enabled" : "disabled"}`
);
} catch (e) {
const message = e instanceof Error ? e.message : JSON.stringify(e);
if (wantsJson) {
return json({ ok: false as const, message }, { status: 500 });
}
return redirectWithErrorMessage(
v3SchedulePath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam },
{ friendlyId: scheduleParam }
),
request,
`${scheduleParam} could not be ${active ? "enabled" : "disabled"}: ${message}`
);
}
}
}
};
export default function Page() {
const { schedule } = useTypedLoaderData<typeof loader>();
const location = useLocation();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
if (!schedule) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 bg-background-bright p-6">
<p className="text-sm text-text-bright">Schedule not found.</p>
<LinkButton
to={`${v3EnvironmentPath(organization, project, environment)}${location.search}`}
variant="secondary/small"
>
Back to tasks
</LinkButton>
</div>
);
}
return (
<ScheduleInspector
schedule={schedule}
headerActions={
<LinkButton
to={`${v3EnvironmentPath(organization, project, environment)}${location.search}`}
variant="minimal/small"
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
className="pl-1"
/>
}
/>
);
}
@@ -0,0 +1,22 @@
import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
import { EnvironmentParamSchema, v3EnvironmentPath } from "~/utils/pathBuilder";
/**
* The standalone `/schedules` listing page was removed when the unified
* Tasks page subsumed the Agents / Standard / Schedules listings. This
* thin redirect catches bookmarks and shared links pointing at the old
* URL and sends users to the Tasks page pre-filtered to Scheduled tasks.
*
* Individual schedule routes (`/schedules/:scheduleParam`,
* `/schedules/edit/:scheduleParam`, `/schedules/new`) live in sibling
* route files and are unaffected.
*/
export async function loader({ params }: LoaderFunctionArgs) {
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const tasksPath = v3EnvironmentPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam }
);
return redirect(`${tasksPath}?types=SCHEDULED`);
}
@@ -0,0 +1,44 @@
import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { EditSchedulePresenter } from "~/presenters/v3/EditSchedulePresenter.server";
import { requireUserId } from "~/services/session.server";
import { v3EnvironmentPath, v3ScheduleParams } from "~/utils/pathBuilder";
import { humanToCronSupported } from "~/v3/humanToCron.server";
import { UpsertScheduleForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, envParam, scheduleParam } =
v3ScheduleParams.parse(params);
const presenter = new EditSchedulePresenter();
const result = await presenter.call({
userId,
projectSlug: projectParam,
environmentSlug: envParam,
friendlyId: scheduleParam,
});
if (result.schedule?.type === "DECLARATIVE") {
throw redirect(
v3EnvironmentPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam })
);
}
return typedjson({ ...result, showGenerateField: humanToCronSupported });
};
export default function Page() {
const { schedule, possibleTasks, possibleEnvironments, possibleTimezones, showGenerateField } =
useTypedLoaderData<typeof loader>();
return (
<UpsertScheduleForm
schedule={schedule}
possibleTasks={possibleTasks}
possibleEnvironments={possibleEnvironments}
possibleTimezones={possibleTimezones}
showGenerateField={showGenerateField}
/>
);
}
@@ -0,0 +1,40 @@
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { EditSchedulePresenter } from "~/presenters/v3/EditSchedulePresenter.server";
import { requireUserId } from "~/services/session.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { humanToCronSupported } from "~/v3/humanToCron.server";
import { UpsertScheduleForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const {
projectParam,
envParam,
organizationSlug: _organizationSlug,
} = EnvironmentParamSchema.parse(params);
const presenter = new EditSchedulePresenter();
const result = await presenter.call({
userId,
projectSlug: projectParam,
environmentSlug: envParam,
});
return typedjson({ ...result, showGenerateField: humanToCronSupported });
};
export default function Page() {
const { schedule, possibleTasks, possibleEnvironments, possibleTimezones, showGenerateField } =
useTypedLoaderData<typeof loader>();
return (
<UpsertScheduleForm
schedule={schedule}
possibleTasks={possibleTasks}
possibleEnvironments={possibleEnvironments}
showGenerateField={showGenerateField}
possibleTimezones={possibleTimezones}
/>
);
}
@@ -0,0 +1,165 @@
import { BookOpenIcon } from "@heroicons/react/24/solid";
import { type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { QuestionMarkIcon } from "~/assets/icons/QuestionMarkIcon";
import { InlineCode } from "~/components/code/InlineCode";
import { ListPagination } from "~/components/ListPagination";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { MainCenteredContainer, PageBody } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { SessionFilters } from "~/components/sessions/v1/SessionFilters";
import { SessionsTable } from "~/components/sessions/v1/SessionsTable";
import { SessionsNone } from "~/components/BlankStatePanels";
import { $replica } from "~/db.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { getSessionFiltersFromRequest } from "~/presenters/SessionFilters.server";
import { SessionListPresenter } from "~/presenters/v3/SessionListPresenter.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { requireUserId } from "~/services/session.server";
import { docsPath, EnvironmentParamSchema } from "~/utils/pathBuilder";
import { throwNotFound } from "~/utils/httpErrors";
export const meta: MetaFunction = () => {
return [
{
title: `Sessions | Trigger.dev`,
},
];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
return redirectWithErrorMessage("/", request, "Project not found");
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throwNotFound("Environment not found");
}
const filters = getSessionFiltersFromRequest(request);
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
project.organizationId,
"standard"
);
const presenter = new SessionListPresenter($replica, clickhouse);
const list = await presenter.call(project.organizationId, environment.id, {
userId,
projectId: project.id,
statuses: filters.statuses,
types: filters.types,
taskIdentifiers: filters.taskIdentifiers,
externalId: filters.externalId,
tags: filters.tags,
period: filters.period,
from: filters.from,
to: filters.to,
cursor: filters.cursor,
direction: filters.direction,
});
return typedjson(list);
};
export default function Page() {
const list = useTypedLoaderData<typeof loader>();
return (
<>
<NavBar>
<PageTitle title="Sessions" accessory={<SessionsHelpTooltip />} />
<PageAccessories>
<AdminDebugTooltip />
<LinkButton
variant={"docs/small"}
LeadingIcon={BookOpenIcon}
to={docsPath("ai-chat/sessions")}
>
Sessions docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
{!list.hasAnySessions ? (
<MainCenteredContainer className="max-w-md">
<SessionsNone />
</MainCenteredContainer>
) : (
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
<div className="flex items-start justify-between gap-x-2 p-2">
<SessionFilters hasFilters={list.hasFilters} possibleTasks={list.possibleTasks} />
<div className="flex items-center justify-end gap-x-2">
<ListPagination list={{ pagination: list.pagination }} />
</div>
</div>
<SessionsTable
sessions={list.sessions}
filters={list.filters}
hasFilters={list.hasFilters}
/>
</div>
)}
</PageBody>
</>
);
}
function SessionsHelpTooltip() {
return (
<SimpleTooltip
button={
<QuestionMarkIcon className="size-4 text-text-dimmed transition hover:text-text-bright" />
}
side="bottom"
className="max-w-sm p-3"
disableHoverableContent
content={
<div className="flex flex-col gap-3">
<div>
<Paragraph variant="small/bright">What is a session?</Paragraph>
<Paragraph variant="small" className="mt-1">
A session is a stateful execution of an agent, with two-way streaming and durable
compute. A single session can have multiple runs associated with it, so one
conversation can span many task triggers. The input stream carries incoming user
messages, and the output stream carries everything the agent produces, including AI
generation parts (text, reasoning, tool calls, etc.) and any custom data parts your
task emits.
</Paragraph>
</div>
<div className="flex flex-col gap-2.5 border-t border-grid-dimmed pt-3">
<div>
<Paragraph variant="small/bright">
<InlineCode>chat.agent</InlineCode>
</Paragraph>
<Paragraph variant="small" className="mt-1">
The high-level chat building block. Built on sessions and handles the chat turn loop
for you. Use it for chat apps and conversational AI experiences.
</Paragraph>
</div>
<div>
<Paragraph variant="small/bright">
<InlineCode>sessions.start()</InlineCode>
</Paragraph>
<Paragraph variant="small" className="mt-1">
The raw sessions API. Use it for non-chat patterns like agent inboxes, approval
flows, or server-to-server streaming where you need a durable bi-directional
channel.
</Paragraph>
</div>
</div>
</div>
}
/>
);
}
@@ -0,0 +1,10 @@
import { Outlet } from "@remix-run/react";
import { PageContainer } from "~/components/layout/AppLayout";
export default function Page() {
return (
<PageContainer>
<Outlet />
</PageContainer>
);
}
@@ -0,0 +1,293 @@
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
import { conformZodMessage, parseWithZod } from "@conform-to/zod";
import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { type ActionFunction, json } from "@remix-run/server-runtime";
import { z } from "zod";
import { InlineCode } from "~/components/code/InlineCode";
import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout";
import { Button } from "~/components/primitives/Buttons";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header2 } from "~/components/primitives/Headers";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import { useProject } from "~/hooks/useProject";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { ProjectSettingsService } from "~/services/projectSettings.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { organizationPath, v3ProjectPath } from "~/utils/pathBuilder";
import { useState } from "react";
function createSchema(
constraints: {
getSlugMatch?: (slug: string) => { isMatch: boolean; projectSlug: string };
} = {}
) {
return z.discriminatedUnion("action", [
z.object({
action: z.literal("rename"),
projectName: z.string().min(3, "Project name must have at least 3 characters").max(50),
}),
z.object({
action: z.literal("delete"),
projectSlug: z.string().superRefine((slug, ctx) => {
if (constraints.getSlugMatch === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: conformZodMessage.VALIDATION_UNDEFINED,
});
} else {
const { isMatch, projectSlug } = constraints.getSlugMatch(slug);
if (isMatch) {
return;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `The slug must match ${projectSlug}`,
});
}
}),
}),
]);
}
export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = params;
if (!organizationSlug || !projectParam) {
return json(
{ errors: { body: "organizationSlug and projectParam are required" } },
{ status: 400 }
);
}
const formData = await request.formData();
const schema = createSchema({
getSlugMatch: (slug) => {
return { isMatch: slug === projectParam, projectSlug: projectParam };
},
});
const submission = parseWithZod(formData, { schema });
if (submission.status !== "success") {
return json(submission.reply());
}
const projectSettingsService = new ProjectSettingsService();
const membershipResultOrFail = await projectSettingsService.verifyProjectMembership(
organizationSlug,
projectParam,
userId
);
if (membershipResultOrFail.isErr()) {
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
}
const { projectId } = membershipResultOrFail.value;
switch (submission.value.action) {
case "rename": {
const resultOrFail = await projectSettingsService.renameProject(
projectId,
submission.value.projectName
);
if (resultOrFail.isErr()) {
switch (resultOrFail.error.type) {
case "other":
default: {
resultOrFail.error.type satisfies "other";
logger.error("Failed to rename project", {
error: resultOrFail.error,
});
return json({ errors: { body: "Failed to rename project" } }, { status: 400 });
}
}
}
return redirectWithSuccessMessage(
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
request,
`Project renamed to ${submission.value.projectName}`
);
}
case "delete": {
const resultOrFail = await projectSettingsService.deleteProject(projectId, userId);
if (resultOrFail.isErr()) {
switch (resultOrFail.error.type) {
case "other":
default: {
resultOrFail.error.type satisfies "other";
logger.error("Failed to delete project", {
error: resultOrFail.error,
});
return redirectWithErrorMessage(
v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }),
request,
`Project ${projectParam} could not be deleted`
);
}
}
}
return redirectWithSuccessMessage(
organizationPath({ slug: organizationSlug }),
request,
"Project deleted"
);
}
}
};
export default function GeneralSettingsPage() {
const project = useProject();
const lastSubmission = useActionData();
const navigation = useNavigation();
const [hasRenameFormChanges, setHasRenameFormChanges] = useState(false);
const [renameForm, { projectName }] = useForm({
id: "rename-project",
// TODO: type this
lastResult: lastSubmission as any,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parseWithZod(formData, {
schema: createSchema(),
});
},
});
const isRenameLoading =
navigation.formData?.get("action") === "rename" &&
(navigation.state === "submitting" || navigation.state === "loading");
const [deleteForm, { projectSlug }] = useForm({
id: "delete-project",
// TODO: type this
lastResult: lastSubmission as any,
shouldValidate: "onInput",
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parseWithZod(formData, {
schema: createSchema({
getSlugMatch: (slug) => ({ isMatch: slug === project.slug, projectSlug: project.slug }),
}),
});
},
});
const isDeleteLoading =
navigation.formData?.get("action") === "delete" &&
(navigation.state === "submitting" || navigation.state === "loading");
const [deleteInputValue, setDeleteInputValue] = useState("");
return (
<MainHorizontallyCenteredContainer className="md:mt-6">
<div className="flex flex-col gap-6">
<div>
<Header2 spacing>General</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
<Fieldset className="mb-5">
<InputGroup fullWidth>
<Label>Project ref</Label>
<ClipboardField value={project.externalRef} variant={"secondary/medium"} />
<Hint>
This goes in your{" "}
<InlineCode variant="extra-extra-small">trigger.config</InlineCode> file.
</Hint>
</InputGroup>
</Fieldset>
<Form method="post" {...getFormProps(renameForm)}>
<Fieldset>
<InputGroup fullWidth>
<Label htmlFor={projectName.id}>Project name</Label>
<Input
{...getInputProps(projectName, { type: "text" })}
defaultValue={project.name}
placeholder="Project name"
icon={FolderIcon}
autoFocus
onChange={(e) => {
setHasRenameFormChanges(e.target.value !== project.name);
}}
/>
<FormError id={projectName.errorId}>{projectName.errors}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="rename"
variant={"secondary/small"}
disabled={isRenameLoading || !hasRenameFormChanges}
LeadingIcon={isRenameLoading ? SpinnerWhite : undefined}
>
Save
</Button>
}
/>
</Fieldset>
</Form>
</div>
</div>
<div>
<Header2 spacing>Danger zone</Header2>
<div className="w-full rounded-sm border border-rose-500/40 p-4">
<Form method="post" {...getFormProps(deleteForm)}>
<Fieldset>
<InputGroup fullWidth>
<Label htmlFor={projectSlug.id}>Delete project</Label>
<Input
{...getInputProps(projectSlug, { type: "text" })}
placeholder="Your project slug"
icon={ExclamationTriangleIcon}
onChange={(e) => setDeleteInputValue(e.target.value)}
/>
<FormError id={projectSlug.errorId}>{projectSlug.errors}</FormError>
<FormError>{deleteForm.errors}</FormError>
<Hint>
This change is irreversible, so please be certain. Type in the Project slug
<InlineCode variant="extra-small">{project.slug}</InlineCode> and then press
Delete.
</Hint>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="delete"
variant={"danger/small"}
LeadingIcon={isDeleteLoading ? SpinnerWhite : TrashIcon}
leadingIconClassName="text-white"
disabled={isDeleteLoading || deleteInputValue !== project.slug}
>
Delete
</Button>
}
/>
</Fieldset>
</Form>
</div>
</div>
</div>
</MainHorizontallyCenteredContainer>
);
}
@@ -0,0 +1,567 @@
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { Form, useActionData, useNavigation, useSearchParams } from "@remix-run/react";
import { json } from "@remix-run/server-runtime";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { typedjson, useTypedFetcher, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout";
import { Button } from "~/components/primitives/Buttons";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header2 } from "~/components/primitives/Headers";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import {
redirectBackWithErrorMessage,
redirectBackWithSuccessMessage,
} from "~/models/message.server";
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
import { logger } from "~/services/logger.server";
import { ProjectSettingsService } from "~/services/projectSettings.server";
import { ProjectSettingsPresenter } from "~/services/projectSettingsPresenter.server";
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { EnvironmentParamSchema, v3BillingPath, vercelResourcePath } from "~/utils/pathBuilder";
import { throwPermissionDenied } from "~/utils/permissionDenied";
import { type BuildSettings } from "~/v3/buildSettings";
import { GitHubSettingsPanel } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
import type { loader as vercelLoader } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
import {
VercelOnboardingModal,
VercelSettingsPanel,
} from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
export const loader = dashboardLoader(
{
params: EnvironmentParamSchema,
context: async (params) => {
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
return organizationId ? { organizationId } : {};
},
// No hard authorization: the page renders a PermissionDenied panel for
// roles that can't manage any integration (see canManageIntegrations).
},
async ({ params, user, ability }) => {
const { projectParam, organizationSlug } = params;
const canManageIntegrations =
ability.can("write", { type: "github" }) || ability.can("write", { type: "vercel" });
if (!canManageIntegrations) {
throwPermissionDenied("With your current role, you can't manage integrations.");
}
const projectSettingsPresenter = new ProjectSettingsPresenter();
const resultOrFail = await projectSettingsPresenter.getProjectSettings(
organizationSlug,
projectParam,
user.id
);
if (resultOrFail.isErr()) {
switch (resultOrFail.error.type) {
case "project_not_found": {
throw new Response(undefined, {
status: 404,
statusText: "Project not found",
});
}
case "other":
default: {
resultOrFail.error.type satisfies "other";
logger.error("Failed loading project settings", {
error: resultOrFail.error,
});
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, please try again!",
});
}
}
}
const { gitHubApp, buildSettings } = resultOrFail.value;
return typedjson({
githubAppEnabled: gitHubApp.enabled,
buildSettings,
vercelIntegrationEnabled: OrgIntegrationRepository.isVercelSupported,
});
}
);
const UpdateBuildSettingsFormSchema = z.object({
action: z.literal("update-build-settings"),
triggerConfigFilePath: z
.string()
.trim()
.optional()
.transform((val) => (val ? val.replace(/^\/+/, "") : val))
.refine((val) => !val || val.length <= 255, {
message: "Config file path must not exceed 255 characters",
}),
installCommand: z
.string()
.trim()
.optional()
.refine((val) => !val || !val.includes("\n"), {
message: "Install command must be a single line",
})
.refine((val) => !val || val.length <= 500, {
message: "Install command must not exceed 500 characters",
}),
preBuildCommand: z
.string()
.trim()
.optional()
.refine((val) => !val || !val.includes("\n"), {
message: "Pre-build command must be a single line",
})
.refine((val) => !val || val.length <= 500, {
message: "Pre-build command must not exceed 500 characters",
}),
useNativeBuildServer: z
.string()
.optional()
.transform((val) => val === "on"),
});
export const action = dashboardAction(
{
params: EnvironmentParamSchema,
context: async (params) => {
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
return organizationId ? { organizationId } : {};
},
// Build settings configure the Git-based deploy, so gate on write:github
// (a restricted role can view neither this page nor mutate via a POST).
authorization: { action: "write", resource: { type: "github" } },
},
async ({ request, params, user }) => {
const { organizationSlug, projectParam } = params;
const formData = await request.formData();
const submission = parseWithZod(formData, { schema: UpdateBuildSettingsFormSchema });
if (submission.status !== "success") {
return json(submission.reply());
}
const projectSettingsService = new ProjectSettingsService();
const membershipResultOrFail = await projectSettingsService.verifyProjectMembership(
organizationSlug,
projectParam,
user.id
);
if (membershipResultOrFail.isErr()) {
return json({ errors: { body: membershipResultOrFail.error.type } }, { status: 404 });
}
const { projectId } = membershipResultOrFail.value;
const { installCommand, preBuildCommand, triggerConfigFilePath, useNativeBuildServer } =
submission.value;
const resultOrFail = await projectSettingsService.updateBuildSettings(projectId, {
installCommand: installCommand || undefined,
preBuildCommand: preBuildCommand || undefined,
triggerConfigFilePath: triggerConfigFilePath || undefined,
useNativeBuildServer: useNativeBuildServer,
});
if (resultOrFail.isErr()) {
switch (resultOrFail.error.type) {
case "other":
default: {
resultOrFail.error.type satisfies "other";
logger.error("Failed to update build settings", {
error: resultOrFail.error,
});
return redirectBackWithErrorMessage(request, "Failed to update build settings");
}
}
}
return redirectBackWithSuccessMessage(request, "Build settings updated successfully");
}
);
export default function IntegrationsSettingsPage() {
const { githubAppEnabled, buildSettings, vercelIntegrationEnabled } =
useTypedLoaderData<typeof loader>();
const project = useProject();
const organization = useOrganization();
const environment = useEnvironment();
const [searchParams, setSearchParams] = useSearchParams();
// Vercel onboarding modal state
const hasQueryParam = searchParams.get("vercelOnboarding") === "true";
const nextUrl = searchParams.get("next");
const [isModalOpen, setIsModalOpen] = useState(false);
const vercelFetcher = useTypedFetcher<typeof vercelLoader>();
// Helper to open modal and ensure query param is present
const openVercelOnboarding = useCallback(() => {
setIsModalOpen(true);
// Ensure query param is present to maintain state during form submissions
if (!hasQueryParam) {
setSearchParams((prev) => {
prev.set("vercelOnboarding", "true");
return prev;
});
}
}, [hasQueryParam, setSearchParams]);
const closeVercelOnboarding = useCallback(() => {
// Remove query param if present
if (hasQueryParam) {
setSearchParams((prev) => {
prev.delete("vercelOnboarding");
return prev;
});
}
// Close modal
setIsModalOpen(false);
}, [hasQueryParam, setSearchParams]);
// When query param is present, handle modal opening
// Note: We don't close the modal based on data state during onboarding - only when explicitly closed
useEffect(() => {
if (hasQueryParam && vercelIntegrationEnabled) {
// Ensure query param is present and modal is open
if (vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
// Data is loaded, ensure modal is open (query param takes precedence)
if (!isModalOpen) {
openVercelOnboarding();
}
} else if (vercelFetcher.state === "idle" && vercelFetcher.data === undefined) {
// Load onboarding data
vercelFetcher.load(
`${vercelResourcePath(
organization.slug,
project.slug,
environment.slug
)}?vercelOnboarding=true`
);
}
} else if (!hasQueryParam && isModalOpen) {
// Query param removed but modal is open, close modal
setIsModalOpen(false);
}
}, [
hasQueryParam,
vercelIntegrationEnabled,
organization.slug,
project.slug,
environment.slug,
vercelFetcher.data,
vercelFetcher.state,
isModalOpen,
openVercelOnboarding,
]);
// Ensure modal stays open when query param is present (even after data reloads)
// This is a safeguard to prevent the modal from closing during form submissions
useEffect(() => {
if (hasQueryParam && !isModalOpen) {
// Query param is present but modal is closed, open it
// This ensures the modal stays open during the onboarding flow
openVercelOnboarding();
}
}, [hasQueryParam, isModalOpen, openVercelOnboarding]);
// When data finishes loading (from query param), ensure modal is open
useEffect(() => {
if (hasQueryParam && vercelFetcher.data?.onboardingData && vercelFetcher.state === "idle") {
// Data loaded and query param is present, ensure modal is open
if (!isModalOpen) {
openVercelOnboarding();
}
}
}, [hasQueryParam, vercelFetcher.data, vercelFetcher.state, isModalOpen, openVercelOnboarding]);
// Track if we're waiting for data from button click (not query param)
const waitingForButtonClickRef = useRef(false);
// Handle opening modal from button click (without query param)
const handleOpenVercelModal = useCallback(() => {
// Add query param to maintain state during form submissions
if (!hasQueryParam) {
setSearchParams((prev) => {
prev.set("vercelOnboarding", "true");
return prev;
});
}
if (vercelFetcher.data && vercelFetcher.data.onboardingData) {
// Data already loaded, open modal immediately
openVercelOnboarding();
} else {
// Need to load data first, mark that we're waiting for button click
waitingForButtonClickRef.current = true;
vercelFetcher.load(
`${vercelResourcePath(
organization.slug,
project.slug,
environment.slug
)}?vercelOnboarding=true`
);
}
}, [
organization.slug,
project.slug,
environment.slug,
vercelFetcher,
setSearchParams,
hasQueryParam,
openVercelOnboarding,
]);
// When data loads from button click, open modal
useEffect(() => {
if (
waitingForButtonClickRef.current &&
vercelFetcher.data?.onboardingData &&
vercelFetcher.state === "idle"
) {
// Data loaded from button click, open modal and ensure query param is present
waitingForButtonClickRef.current = false;
openVercelOnboarding();
}
}, [vercelFetcher.data, vercelFetcher.state, openVercelOnboarding]);
return (
<>
<MainHorizontallyCenteredContainer className="md:mt-6">
<div className="flex flex-col gap-6">
{githubAppEnabled && (
<React.Fragment>
<div>
<Header2 spacing>Git settings</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
<GitHubSettingsPanel
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
billingPath={v3BillingPath({ slug: organization.slug })}
/>
</div>
</div>
{vercelIntegrationEnabled && (
<div>
<Header2 spacing>Vercel integration</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
<VercelSettingsPanel
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
onOpenVercelModal={handleOpenVercelModal}
isLoadingVercelData={
vercelFetcher.state === "loading" || vercelFetcher.state === "submitting"
}
/>
</div>
</div>
)}
<div>
<Header2 spacing>Build settings</Header2>
<div className="w-full rounded-sm border border-grid-dimmed p-4">
<BuildSettingsForm buildSettings={buildSettings ?? {}} />
</div>
</div>
</React.Fragment>
)}
</div>
</MainHorizontallyCenteredContainer>
{/* Vercel Onboarding Modal */}
{vercelIntegrationEnabled && (
<VercelOnboardingModal
isOpen={isModalOpen}
onClose={closeVercelOnboarding}
onboardingData={vercelFetcher.data?.onboardingData ?? null}
organizationSlug={organization.slug}
projectSlug={project.slug}
environmentSlug={environment.slug}
hasStagingEnvironment={vercelFetcher.data?.hasStagingEnvironment ?? false}
hasPreviewEnvironment={vercelFetcher.data?.hasPreviewEnvironment ?? false}
hasOrgIntegration={vercelFetcher.data?.hasOrgIntegration ?? false}
nextUrl={nextUrl ?? undefined}
vercelManageAccessUrl={vercelFetcher.data?.vercelManageAccessUrl}
onDataReload={(vercelEnvironmentId) => {
vercelFetcher.load(
`${vercelResourcePath(
organization.slug,
project.slug,
environment.slug
)}?vercelOnboarding=true${
vercelEnvironmentId
? `&vercelEnvironmentId=${encodeURIComponent(vercelEnvironmentId)}`
: ""
}`
);
}}
/>
)}
</>
);
}
function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings }) {
const lastSubmission = useActionData() as any;
const navigation = useNavigation();
const [hasBuildSettingsChanges, setHasBuildSettingsChanges] = useState(false);
const [buildSettingsValues, setBuildSettingsValues] = useState({
preBuildCommand: buildSettings?.preBuildCommand || "",
installCommand: buildSettings?.installCommand || "",
triggerConfigFilePath: buildSettings?.triggerConfigFilePath || "",
useNativeBuildServer: buildSettings?.useNativeBuildServer || false,
});
useEffect(() => {
const hasChanges =
buildSettingsValues.preBuildCommand !== (buildSettings?.preBuildCommand || "") ||
buildSettingsValues.installCommand !== (buildSettings?.installCommand || "") ||
buildSettingsValues.triggerConfigFilePath !== (buildSettings?.triggerConfigFilePath || "") ||
buildSettingsValues.useNativeBuildServer !== (buildSettings?.useNativeBuildServer || false);
setHasBuildSettingsChanges(hasChanges);
}, [buildSettingsValues, buildSettings]);
const [buildSettingsForm, fields] = useForm({
id: "update-build-settings",
lastResult: lastSubmission,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parseWithZod(formData, {
schema: UpdateBuildSettingsFormSchema,
});
},
});
const isBuildSettingsLoading =
navigation.formData?.get("action") === "update-build-settings" &&
(navigation.state === "submitting" || navigation.state === "loading");
return (
<Form method="post" {...getFormProps(buildSettingsForm)}>
<Fieldset>
<InputGroup fullWidth>
<Label htmlFor={fields.triggerConfigFilePath.id}>Trigger config file</Label>
<Input
{...getInputProps(fields.triggerConfigFilePath, { type: "text" })}
defaultValue={buildSettings?.triggerConfigFilePath || ""}
placeholder="trigger.config.ts"
onChange={(e) => {
setBuildSettingsValues((prev) => ({
...prev,
triggerConfigFilePath: e.target.value,
}));
}}
/>
<Hint>
Path to your Trigger configuration file, relative to the root directory of your repo.
</Hint>
<FormError id={fields.triggerConfigFilePath.errorId}>
{fields.triggerConfigFilePath.errors}
</FormError>
</InputGroup>
<InputGroup fullWidth>
<Label htmlFor={fields.installCommand.id}>Install command</Label>
<Input
{...getInputProps(fields.installCommand, { type: "text" })}
defaultValue={buildSettings?.installCommand || ""}
placeholder="e.g., `npm install`, `pnpm install`, or `bun install`"
onChange={(e) => {
setBuildSettingsValues((prev) => ({
...prev,
installCommand: e.target.value,
}));
}}
/>
<Hint>
Command to install your project dependencies. This will be run from the root directory
of your repo. Auto-detected by default.
</Hint>
<FormError id={fields.installCommand.errorId}>
{fields.installCommand.errors?.join(", ")}
</FormError>
</InputGroup>
<InputGroup fullWidth>
<Label htmlFor={fields.preBuildCommand.id}>Pre-build command</Label>
<Input
{...getInputProps(fields.preBuildCommand, { type: "text" })}
defaultValue={buildSettings?.preBuildCommand || ""}
placeholder="e.g., `npm run prisma:generate`"
onChange={(e) => {
setBuildSettingsValues((prev) => ({
...prev,
preBuildCommand: e.target.value,
}));
}}
/>
<Hint>
Any command that needs to run before we build and deploy your project. This will be run
from the root directory of your repo.
</Hint>
<FormError id={fields.preBuildCommand.errorId}>
{fields.preBuildCommand.errors?.join(", ")}
</FormError>
</InputGroup>
<div className="border-t border-grid-dimmed pt-4">
<InputGroup>
<CheckboxWithLabel
{...getInputProps(fields.useNativeBuildServer, { type: "checkbox" })}
label="Use native build server"
variant="simple/small"
defaultChecked={buildSettings?.useNativeBuildServer || false}
onChange={(isChecked) => {
setBuildSettingsValues((prev) => ({
...prev,
useNativeBuildServer: isChecked,
}));
}}
/>
<Hint>
Native build server builds do not rely on external build providers and will become the
default in the future. Version 4.2.0 or newer is required.
</Hint>
<FormError id={fields.useNativeBuildServer.errorId}>
{fields.useNativeBuildServer.errors}
</FormError>
</InputGroup>
</div>
<FormError>{buildSettingsForm.errors}</FormError>
<FormButtons
confirmButton={
<Button
type="submit"
name="action"
value="update-build-settings"
variant="secondary/small"
disabled={isBuildSettingsLoading || !hasBuildSettingsChanges}
LeadingIcon={isBuildSettingsLoading ? SpinnerWhite : undefined}
>
Save
</Button>
}
/>
</Fieldset>
</Form>
);
}
@@ -0,0 +1,77 @@
import { Outlet, type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { useProject } from "~/hooks/useProject";
import { requireUserId } from "~/services/session.server";
import {
EnvironmentParamSchema,
v3ProjectSettingsGeneralPath,
v3ProjectSettingsIntegrationsPath,
} from "~/utils/pathBuilder";
export const meta: MetaFunction = () => {
return [
{
title: `Project settings | Trigger.dev`,
},
];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
// Redirect /settings to /settings/general (or /settings/integrations for Vercel onboarding)
const url = new URL(request.url);
if (url.pathname.endsWith("/settings") || url.pathname.endsWith("/settings/")) {
const org = { slug: organizationSlug };
const project = { slug: projectParam };
const env = { slug: envParam };
const basePath = url.searchParams.has("vercelOnboarding")
? v3ProjectSettingsIntegrationsPath(org, project, env)
: v3ProjectSettingsGeneralPath(org, project, env);
return redirect(`${basePath}${url.search}`);
}
return null;
};
export default function SettingsLayout() {
const project = useProject();
return (
<PageContainer>
<NavBar>
<PageTitle title="Project settings" />
<PageAccessories>
<AdminDebugTooltip>
<Property.Table>
<Property.Item>
<Property.Label>ID</Property.Label>
<Property.Value>{project.id}</Property.Value>
<div className="flex items-center gap-2">
<Paragraph variant="extra-small/bright/mono">{project.id}</Paragraph>
</div>
</Property.Item>
<Property.Item>
<Property.Label>Org ID</Property.Label>
<Property.Value>{project.organizationId}</Property.Value>
</Property.Item>
</Property.Table>
</AdminDebugTooltip>
</PageAccessories>
</NavBar>
<PageBody>
<Outlet />
</PageBody>
</PageContainer>
);
}
@@ -0,0 +1,342 @@
import { BookOpenIcon } from "@heroicons/react/20/solid";
import { type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Suspense, useMemo } from "react";
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import { Card } from "~/components/primitives/charts/Card";
import type { ChartConfig } from "~/components/primitives/charts/Chart";
import { Chart } from "~/components/primitives/charts/ChartCompound";
import { Header1, Header3 } from "~/components/primitives/Headers";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Spinner } from "~/components/primitives/Spinner";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import {
tasksDashboardPresenter,
type DailyRunPoint,
} from "~/presenters/v3/TasksDashboardPresenter.server";
import { requireUserId } from "~/services/session.server";
import {
docsPath,
EnvironmentParamSchema,
v3EnvironmentPath,
v3RunsPath,
} from "~/utils/pathBuilder";
export const meta: MetaFunction = () => {
return [{ title: "Tasks | Trigger.dev" }];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response(undefined, { status: 404, statusText: "Project not found" });
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response(undefined, { status: 404, statusText: "Environment not found" });
}
const result = await tasksDashboardPresenter.call({
organizationId: project.organizationId,
projectId: project.id,
environmentId: environment.id,
environmentType: environment.type,
});
return typeddefer(result);
};
const isoDateFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
timeZone: "utc",
});
function formatDay(value: string) {
// value is a YYYY-MM-DD date string
const d = new Date(value + "T00:00:00Z");
return isoDateFormatter.format(d);
}
const STANDARD_EXAMPLE = `import { task } from "@trigger.dev/sdk";
export const helloWorld = task({
id: "hello-world",
run: async (payload: { name: string }) => {
return { greeting: \`Hello, \${payload.name}!\` };
},
});
`;
const SCHEDULED_EXAMPLE = `import { schedules } from "@trigger.dev/sdk";
export const dailyReport = schedules.task({
id: "daily-report",
cron: "0 9 * * *",
run: async (payload) => {
// Runs every day at 9am UTC
return { ranAt: payload.timestamp };
},
});
`;
const AGENT_EXAMPLE = `import { agent } from "@trigger.dev/sdk/ai";
export const supportAgent = agent({
id: "support-agent",
model: "anthropic/claude-sonnet-4-6",
instructions: "You are a helpful customer support agent.",
});
`;
export default function TasksDashboardPage() {
const { counts, series } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
return (
<PageContainer>
<NavBar>
<PageTitle title="Tasks" />
</NavBar>
<PageBody scrollable={true}>
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4 p-6">
<Header1 className="text-text-bright">Tasks overview</Header1>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Suspense fallback={<PanelSkeleton title="Agent tasks" />}>
<TypedAwait resolve={series}>
{(s) => (
<TaskTypePanel
title="Agent tasks"
count={counts.agents}
description="AI agents are tasks that can call LLMs, use tools, and run multi-step conversations. Use them to power chat experiences, intelligent automations, or autonomous workflows."
example="A support agent that drafts replies using your docs as context."
data={s.agents}
seriesColor="hsl(280 80% 65%)"
listingPath={v3EnvironmentPath(organization, project, environment)}
docsHref={docsPath("v3/agents")}
emptyTitle="You don't have any agents yet"
emptyDescription="Create an AI agent to call LLMs, use tools, and run multi-step conversations from a Trigger.dev task."
emptyCta="Create your first agent"
exampleCode={AGENT_EXAMPLE}
/>
)}
</TypedAwait>
</Suspense>
<Suspense fallback={<PanelSkeleton title="Standard tasks" />}>
<TypedAwait resolve={series}>
{(s) => (
<TaskTypePanel
title="Standard tasks"
count={counts.standard}
description="Standard tasks are durable background functions you trigger from your code. Use them for any async work that needs retries, observability, and reliable execution."
example="Process an uploaded video, send a transactional email, or sync data from a third-party API."
data={s.standard}
seriesColor="hsl(200 80% 60%)"
listingPath={v3RunsPath(organization, project, environment)}
docsHref={docsPath("v3/tasks-overview")}
emptyTitle="You don't have any standard tasks yet"
emptyDescription="Standard tasks are the building block of Trigger.dev. Define one in your codebase and trigger it from anywhere."
emptyCta="Create your first task"
exampleCode={STANDARD_EXAMPLE}
/>
)}
</TypedAwait>
</Suspense>
<Suspense fallback={<PanelSkeleton title="Scheduled tasks" />}>
<TypedAwait resolve={series}>
{(s) => (
<TaskTypePanel
title="Scheduled tasks"
count={counts.scheduled}
description="Scheduled tasks run automatically on a cron schedule. Attach as many schedules to a task as you need (e.g. one per customer)."
example="Generate a nightly report, refresh a cache every 5 minutes, or send a weekly digest email."
data={s.scheduled}
seriesColor="hsl(30 90% 60%)"
listingPath={v3EnvironmentPath(organization, project, environment)}
docsHref={docsPath("v3/tasks-scheduled")}
emptyTitle="You don't have any scheduled tasks yet"
emptyDescription="Schedule a task to run on a cron expression. Once you've defined a `schedules.task` you can attach one or many schedules to it."
emptyCta="Create your first scheduled task"
exampleCode={SCHEDULED_EXAMPLE}
/>
)}
</TypedAwait>
</Suspense>
</div>
</div>
</PageBody>
</PageContainer>
);
}
function PanelSkeleton({ title }: { title: string }) {
return (
<Card>
<Card.Header>{title}</Card.Header>
<Card.Content className="flex h-72 items-center justify-center">
<Spinner className="size-6" />
</Card.Content>
</Card>
);
}
type TaskTypePanelProps = {
title: string;
count: number;
description: string;
example: string;
data: DailyRunPoint[];
seriesColor: string;
listingPath: string;
docsHref: string;
emptyTitle: string;
emptyDescription: string;
emptyCta: string;
exampleCode: string;
};
function TaskTypePanel(props: TaskTypePanelProps) {
const {
title,
count,
description,
example,
data,
seriesColor,
listingPath,
docsHref,
emptyTitle,
emptyDescription,
emptyCta,
exampleCode,
} = props;
const hasData = count > 0;
const chartConfig = useMemo<ChartConfig>(
() => ({
count: {
label: "Runs",
color: seriesColor,
},
}),
[seriesColor]
);
return (
<Card>
<Card.Header>
<span className="truncate">{title}</span>
<Card.Accessory>
<span className="shrink-0 whitespace-nowrap text-base font-medium tabular-nums leading-none text-text-bright">
{count.toLocaleString()}
</span>
</Card.Accessory>
</Card.Header>
<Card.Content className="flex flex-col gap-3">
<div className="h-40">
<Chart.Root
config={chartConfig}
data={data}
dataKey="day"
labelFormatter={(value) => formatDay(value as string)}
fillContainer
>
<Chart.Bar
tooltipLabelFormatter={(label) => formatDay(label)}
xAxisProps={{ tickFormatter: (value) => formatDay(value) }}
/>
</Chart.Root>
</div>
{hasData ? (
<>
<Paragraph variant="small" className="px-2 text-text-dimmed">
{description}
</Paragraph>
<div className="flex flex-wrap items-center gap-2 px-2 pb-2 pt-1">
<LinkButton to={listingPath} variant="secondary/small">
View all
</LinkButton>
<LinkButton to={docsHref} variant="docs/small" LeadingIcon={BookOpenIcon}>
Read docs
</LinkButton>
</div>
</>
) : (
<EmptyContent
title={emptyTitle}
description={emptyDescription}
example={example}
listingPath={listingPath}
docsHref={docsHref}
cta={emptyCta}
exampleCode={exampleCode}
/>
)}
</Card.Content>
</Card>
);
}
function EmptyContent({
title,
description,
example,
listingPath,
docsHref,
cta,
exampleCode,
}: {
title: string;
description: string;
example: string;
listingPath: string;
docsHref: string;
cta: string;
exampleCode: string;
}) {
const copyExample = () => {
if (typeof navigator !== "undefined" && navigator.clipboard) {
navigator.clipboard.writeText(exampleCode).catch(() => {
/* swallow */
});
}
};
return (
<>
<Header3 className="px-2 text-text-bright">{title}</Header3>
<Paragraph variant="small" className="px-2 text-text-dimmed">
{description}
</Paragraph>
<div className="flex flex-wrap items-center gap-2 px-2 pb-2 pt-1">
<LinkButton to={listingPath} variant="primary/small">
{cta}
</LinkButton>
<button
type="button"
onClick={copyExample}
className="inline-flex items-center rounded border border-grid-bright bg-background-bright px-2 py-1 text-xs text-text-dimmed transition-colors hover:text-text-bright"
>
Copy example code
</button>
<LinkButton to={docsHref} variant="docs/small" LeadingIcon={BookOpenIcon}>
Read docs
</LinkButton>
</div>
</>
);
}
@@ -0,0 +1,432 @@
import { type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
import { Suspense, useMemo } from "react";
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { BeakerIcon } from "~/assets/icons/BeakerIcon";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { MachineLabelCombo } from "~/components/MachineLabelCombo";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { DirectionSchema, ListPagination } from "~/components/ListPagination";
import { LinkButton } from "~/components/primitives/Buttons";
import { ChartCard } from "~/components/primitives/charts/ChartCard";
import { ChartSyncProvider } from "~/components/primitives/charts/ChartSyncContext";
import { useZoomToTimeFilter } from "~/hooks/useZoomToTimeFilter";
import { Chart, type ChartConfig } from "~/components/primitives/charts/ChartCompound";
import { buildActivityTimeAxis } from "~/components/primitives/charts/activityTimeAxis";
import { statusColor } from "~/components/primitives/charts/statusColors";
import { CopyableText } from "~/components/primitives/CopyableText";
import { DateTime } from "~/components/primitives/DateTime";
import { Header2 } from "~/components/primitives/Headers";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import { Spinner } from "~/components/primitives/Spinner";
import { TextLink } from "~/components/primitives/TextLink";
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
import { $replica } from "~/db.server";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server";
import {
TaskDetailPresenter,
type TaskActivity,
type TaskDetail,
} from "~/presenters/v3/TaskDetailPresenter.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { requireUser } from "~/services/session.server";
import {
EnvironmentParamSchema,
v3EnvironmentPath,
v3QueuesPath,
v3TestTaskPath,
} from "~/utils/pathBuilder";
import { parseFiniteInt } from "~/utils/searchParams";
export const meta: MetaFunction<typeof loader> = ({ data }) => {
const slug = (data as { task?: TaskDetail | null } | undefined)?.task?.slug;
return [{ title: slug ? `${slug} | Tasks | Trigger.dev` : "Task | Trigger.dev" }];
};
const ParamsSchema = EnvironmentParamSchema.extend({
taskParam: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const userId = user.id;
const { organizationSlug, projectParam, envParam, taskParam } = ParamsSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) throw new Response("Project not found", { status: 404 });
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) throw new Response("Environment not found", { status: 404 });
const url = new URL(request.url);
const period = url.searchParams.get("period") ?? undefined;
const from = parseFiniteInt(url.searchParams.get("from"));
const to = parseFiniteInt(url.searchParams.get("to"));
const cursor = url.searchParams.get("cursor") ?? undefined;
const directionRaw = url.searchParams.get("direction") ?? undefined;
const direction = directionRaw ? DirectionSchema.parse(directionRaw) : undefined;
const versions = url.searchParams.getAll("versions").filter((v) => v.length > 0);
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
project.organizationId,
"standard"
);
const presenter = new TaskDetailPresenter($replica, clickhouse);
const task = await presenter.findTask({
environmentId: environment.id,
environmentType: environment.type,
taskSlug: taskParam,
expectedTriggerSource: "STANDARD",
});
if (!task) throw new Response("Task not found", { status: 404 });
const time = timeFilterFromTo({ period, from, to, defaultPeriod: "7d" });
const activity = presenter
.getActivity({
organizationId: project.organizationId,
projectId: project.id,
environmentId: environment.id,
taskSlug: task.slug,
from: time.from,
to: time.to,
})
.catch(() => ({ data: [], statuses: [] }) satisfies TaskActivity);
const runList = new NextRunListPresenter($replica, clickhouse)
.call(project.organizationId, environment.id, {
userId,
projectId: project.id,
tasks: [task.slug],
versions: versions.length > 0 ? versions : undefined,
period,
from,
to,
cursor,
direction,
})
.catch(() => null);
return typeddefer({
task,
activity,
runList,
});
};
export default function Page() {
const { task, activity, runList } = useTypedLoaderData<typeof loader>();
const zoomToTimeFilter = useZoomToTimeFilter();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const tasksListingPath = v3EnvironmentPath(organization, project, environment);
const testPath = v3TestTaskPath(organization, project, environment, {
taskIdentifier: task.slug,
});
const queuesPath = v3QueuesPath(organization, project, environment);
return (
<PageContainer>
<NavBar>
<PageTitle
backButton={{ to: tasksListingPath, text: "Tasks" }}
title={
<span className="flex items-center gap-1">
<TaskIcon className="size-4.5 text-tasks" />
<span>{task.slug}</span>
</span>
}
/>
</NavBar>
<PageBody scrollable={false}>
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
<ResizablePanel id="task-main" min="300px">
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden">
{/* Top bar — title on the left; TimeFilter + pagination on the right.
h-10 matches the right-hand sidebar header height. */}
<div className="flex h-10 items-center border-b border-grid-dimmed bg-background-bright pl-3 pr-2">
<Header2>Runs</Header2>
<div className="ml-auto flex items-center gap-1.5">
<TimeFilter defaultPeriod="7d" labelName="Runs" />
<Suspense fallback={null}>
<TypedAwait resolve={runList} errorElement={null}>
{(list) => (list ? <ListPagination list={list} /> : null)}
</TypedAwait>
</Suspense>
</div>
</div>
<ResizablePanelGroup orientation="vertical" className="max-h-full">
{/* Activity chart */}
<ResizablePanel id="task-activity" min="220px" default="320px">
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background p-2">
<ChartSyncProvider onZoom={zoomToTimeFilter}>
<ChartCard title="Runs by status">
<Suspense fallback={<ActivityChartSkeleton />}>
<TypedAwait resolve={activity} errorElement={<ActivityChartSkeleton />}>
{(result) => <ActivityChart activity={result} />}
</TypedAwait>
</Suspense>
</ChartCard>
</ChartSyncProvider>
</div>
</ResizablePanel>
<ResizableHandle id="task-activity-handle" />
{/* Runs table */}
<ResizablePanel id="task-content" min="160px">
<div className="h-full overflow-hidden">
<Suspense fallback={<TableLoading />}>
<TypedAwait resolve={runList} errorElement={<TableLoading />}>
{(list) =>
list ? (
<div className="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<TaskRunsTable
total={list.runs.length}
hasFilters={list.hasFilters}
filters={list.filters}
runs={list.runs}
variant="dimmed"
showTopBorder={false}
stickyHeader
/>
</div>
) : (
<TableLoading />
)
}
</TypedAwait>
</Suspense>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</ResizablePanel>
<ResizableHandle id="task-detail-handle" />
<ResizablePanel id="task-detail" min="280px" default="380px" max="500px" isStaticAtRest>
<TaskDetailSidebar task={task} testPath={testPath} queuesPath={queuesPath} />
</ResizablePanel>
</ResizablePanelGroup>
</PageBody>
</PageContainer>
);
}
function TaskDetailSidebar({
task,
testPath,
queuesPath,
}: {
task: TaskDetail;
testPath: string;
queuesPath: string;
}) {
const showExportName = task.exportName && task.exportName !== task.slug;
const retrySummary = formatRetrySummary(task.retry);
return (
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden bg-background-bright">
<div className="flex min-w-0 items-center gap-2 border-b border-grid-dimmed py-2 pl-3 pr-2">
<Header2 className="flex min-w-0 flex-1 items-center gap-1.5">
<TaskIcon className="size-4.5 shrink-0 text-tasks" />
<span className="truncate">{task.slug}</span>
</Header2>
<LinkButton
variant="primary/small"
to={testPath}
LeadingIcon={BeakerIcon}
iconSpacing="gap-x-2"
leadingIconClassName="-mx-2"
className="shrink-0"
>
Test task
</LinkButton>
</div>
<div className="overflow-y-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<Property.Table>
<Property.Item>
<Property.Label>Identifier</Property.Label>
<Property.Value>
<CopyableText value={task.slug} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>File path</Property.Label>
<Property.Value>
<CopyableText value={task.filePath} />
</Property.Value>
</Property.Item>
{showExportName ? (
<Property.Item>
<Property.Label>Export name</Property.Label>
<Property.Value>
<CopyableText value={task.exportName ?? ""} />
</Property.Value>
</Property.Item>
) : null}
{task.description ? (
<Property.Item>
<Property.Label>Description</Property.Label>
<Property.Value>
<Paragraph variant="small">{task.description}</Paragraph>
</Property.Value>
</Property.Item>
) : null}
<Property.Item>
<Property.Label>Type</Property.Label>
<Property.Value>
<Paragraph variant="small">Standard task</Paragraph>
</Property.Value>
</Property.Item>
{task.workerVersion ? (
<Property.Item>
<Property.Label>Version</Property.Label>
<Property.Value>
<Paragraph variant="small" className="font-mono">
{task.workerVersion}
</Paragraph>
</Property.Value>
</Property.Item>
) : null}
{task.queue ? (
<Property.Item>
<Property.Label>Queue</Property.Label>
<Property.Value>
<div className="flex flex-col gap-0.5">
<TextLink to={queuesPath}>{task.queue.name}</TextLink>
<Paragraph variant="extra-small" className="text-text-dimmed">
Concurrency: {task.queue.concurrencyLimit ?? "Unlimited"}
{task.queue.paused ? " · Paused" : ""}
</Paragraph>
</div>
</Property.Value>
</Property.Item>
) : null}
<Property.Item>
<Property.Label>Machine</Property.Label>
<Property.Value className="-ml-0.5">
<MachineLabelCombo preset={task.machinePreset} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Max duration</Property.Label>
<Property.Value>
<Paragraph variant="small">
{task.maxDurationInSeconds
? `${task.maxDurationInSeconds}s (${formatDurationMilliseconds(
task.maxDurationInSeconds * 1000,
{ style: "short" }
)})`
: ""}
</Paragraph>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>TTL</Property.Label>
<Property.Value>
<Paragraph variant="small">{task.ttl ?? ""}</Paragraph>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Retry</Property.Label>
<Property.Value>
<Paragraph variant="small">{retrySummary}</Paragraph>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Payload schema</Property.Label>
<Property.Value>
<Paragraph variant="small">{task.hasPayloadSchema ? "Yes" : ""}</Paragraph>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Created</Property.Label>
<Property.Value>
<DateTime date={task.createdAt} />
</Property.Value>
</Property.Item>
</Property.Table>
</div>
</div>
);
}
function formatRetrySummary(retry: TaskDetail["retry"]): string {
if (!retry || retry.maxAttempts === undefined) return "";
if (retry.maxAttempts <= 1) return "Disabled";
return `${retry.maxAttempts} attempts`;
}
function ActivityChart({ activity }: { activity: TaskActivity }) {
const chartConfig: ChartConfig = useMemo(() => {
const cfg: ChartConfig = {};
for (const status of activity.statuses) {
cfg[status] = {
label: status.charAt(0) + status.slice(1).toLowerCase(),
color: statusColor(status),
};
}
return cfg;
}, [activity.statuses]);
const { tickFormatter, tooltipLabelFormatter } = useMemo(
() => buildActivityTimeAxis(activity.data),
[activity.data]
);
return (
<Chart.Root
config={chartConfig}
data={activity.data}
dataKey="bucket"
series={activity.statuses}
fillContainer
>
<Chart.Bar
stackId="status"
barRadius={0}
xAxisProps={{ tickFormatter }}
tooltipLabelFormatter={tooltipLabelFormatter}
/>
</Chart.Root>
);
}
function ActivityChartSkeleton() {
return (
<div className="flex min-h-0 flex-1 items-end gap-px rounded-sm">
{Array.from({ length: 42 }).map((_, i) => (
<div key={i} className="h-full flex-1 bg-background-dimmed" />
))}
</div>
);
}
function TableLoading() {
return (
<div className="flex h-full items-center justify-center">
<Spinner className="size-6" />
</div>
);
}
@@ -0,0 +1,19 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { TasksStreamPresenter } from "~/presenters/v3/TasksStreamPresenter.server";
import { requireUserId } from "~/services/session.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const presenter = new TasksStreamPresenter();
return presenter.call({
request,
projectSlug: projectParam,
environmentSlug: envParam,
organizationSlug,
userId,
});
}
@@ -0,0 +1,425 @@
import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { ClipboardIcon } from "@heroicons/react/24/outline";
import { AnimatePresence, motion } from "framer-motion";
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { SparkleListIcon } from "~/assets/icons/SparkleListIcon";
import { Button } from "~/components/primitives/Buttons";
import { StreamdownRenderer } from "~/components/code/StreamdownRenderer";
import { Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Spinner } from "~/components/primitives/Spinner";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { cn } from "~/utils/cn";
type StreamEventType =
| { type: "thinking"; content: string }
| { type: "result"; success: true; payload: string }
| { type: "result"; success: false; error: string };
export function AIPayloadTabContent({
onPayloadGenerated,
payloadSchema,
taskIdentifier,
getCurrentPayload,
generateButtonLabel = "Generate payload",
placeholder,
examplePromptsOverride,
isAgent = false,
showExamplePromptsHeader = true,
}: {
onPayloadGenerated: (payload: string) => void;
payloadSchema?: unknown;
taskIdentifier: string;
getCurrentPayload?: () => string;
generateButtonLabel?: string;
placeholder?: string;
examplePromptsOverride?: string[];
isAgent?: boolean;
showExamplePromptsHeader?: boolean;
}) {
const [prompt, setPrompt] = useState("");
const [isLoading, setIsLoading] = useState(false);
const isLoadingRef = useRef(false);
const [thinking, setThinking] = useState("");
const [error, setError] = useState<string | null>(null);
const [showThinking, setShowThinking] = useState(false);
const [lastResult, setLastResult] = useState<"success" | "error" | null>(null);
const [lastPayload, setLastPayload] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const resourcePath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/test/ai-generate-payload`;
const submitGeneration = useCallback(
async (queryPrompt: string) => {
if (!queryPrompt.trim() || isLoadingRef.current) return;
isLoadingRef.current = true;
setIsLoading(true);
setThinking("");
setError(null);
setShowThinking(true);
setLastResult(null);
setLastPayload(null);
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
try {
const formData = new FormData();
formData.append("prompt", queryPrompt);
formData.append("taskIdentifier", taskIdentifier);
formData.append("isAgent", isAgent ? "true" : "false");
if (payloadSchema) {
formData.append("payloadSchema", JSON.stringify(payloadSchema));
}
const currentPayload = getCurrentPayload?.();
if (currentPayload) {
formData.append("currentPayload", currentPayload);
}
const response = await fetch(resourcePath, {
method: "POST",
body: formData,
signal: abortControllerRef.current.signal,
});
if (!response.ok) {
const errorData = (await response.json()) as { error?: string };
setError(errorData.error || "Failed to generate payload");
setIsLoading(false);
setLastResult("error");
return;
}
const reader = response.body?.getReader();
if (!reader) {
setError("No response stream");
setIsLoading(false);
setLastResult("error");
return;
}
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
try {
const event = JSON.parse(line.slice(6)) as StreamEventType;
processStreamEvent(event);
} catch {
// Ignore parse errors
}
}
}
}
if (buffer.startsWith("data: ")) {
try {
const event = JSON.parse(buffer.slice(6)) as StreamEventType;
processStreamEvent(event);
} catch {
// Ignore parse errors
}
}
} catch (err) {
if (err instanceof Error && err.name === "AbortError") return;
setError(err instanceof Error ? err.message : "An error occurred");
setLastResult("error");
} finally {
isLoadingRef.current = false;
setIsLoading(false);
}
},
[resourcePath, taskIdentifier, payloadSchema, getCurrentPayload, isAgent]
);
const processStreamEvent = useCallback(
(event: StreamEventType) => {
switch (event.type) {
case "thinking":
setThinking((prev) => prev + event.content);
break;
case "result":
if (event.success) {
onPayloadGenerated(event.payload);
setLastPayload(event.payload);
setPrompt("");
setLastResult("success");
} else {
setError(event.error);
setLastResult("error");
}
break;
}
},
[onPayloadGenerated]
);
const handleSubmit = useCallback(
(e?: React.FormEvent) => {
e?.preventDefault();
submitGeneration(prompt);
},
[prompt, submitGeneration]
);
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
useEffect(() => {
if (error) {
const timer = setTimeout(() => setError(null), 15000);
return () => clearTimeout(timer);
}
}, [error]);
const examplePrompts =
examplePromptsOverride ??
(payloadSchema
? [
"Generate a valid payload",
"Generate a payload with edge cases",
"Generate a minimal payload with only required fields",
]
: [
"Generate a simple JSON payload",
"Generate a payload with nested objects",
"Generate a payload with an array of items",
]);
return (
<div className="space-y-0">
<div
className="overflow-hidden rounded-md p-px"
style={{ background: "linear-gradient(to bottom right, #E543FF, #286399)" }}
>
<div className="overflow-hidden rounded-md bg-background-bright">
<div>
<textarea
ref={textareaRef}
name="prompt"
placeholder={
placeholder ??
(payloadSchema
? "e.g. generate a payload for a new user signup"
: "e.g. generate a JSON payload with name, email, and age fields")
}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
disabled={isLoading}
rows={5}
className="focus-custom m-0 min-h-10 w-full resize-none border-0 bg-background-bright px-3 py-2.5 text-sm text-text-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control placeholder:text-text-dimmed focus:border-0 focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && prompt.trim() && !isLoading) {
e.preventDefault();
handleSubmit();
}
}}
/>
<div className="flex justify-end gap-2 px-2 pb-2">
{isLoading ? (
<Button
type="button"
variant="tertiary/small"
disabled={true}
LeadingIcon={Spinner}
className="pl-2"
iconSpacing="gap-1.5"
>
Generating
</Button>
) : (
<Button
type="button"
variant="tertiary/small"
disabled={!prompt.trim()}
className={cn(!prompt.trim() && "opacity-50")}
onClick={() => handleSubmit()}
>
{generateButtonLabel}
</Button>
)}
</div>
</div>
</div>
</div>
{/* Error message */}
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-sm text-error">
{error}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Thinking panel */}
<AnimatePresence>
{showThinking && thinking && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden rounded-b-md border-x border-b border-grid-bright bg-background-bright pb-3"
>
<div className="space-y-2 px-2 pt-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
{isLoading ? (
<Spinner className="size-4" />
) : lastResult === "success" ? (
<CheckIcon className="size-4 text-success" />
) : lastResult === "error" ? (
<XMarkIcon className="size-4 text-error" />
) : null}
<span className="text-xs font-medium text-text-dimmed">
{isLoading
? "AI is thinking…"
: lastResult === "success"
? "Payload generated"
: lastResult === "error"
? "Generation failed"
: "AI response"}
</span>
</div>
<div className="flex items-center gap-0.5">
{lastResult === "success" && lastPayload && (
<SimpleTooltip
asChild
side="top"
content="Copy"
disableHoverableContent
button={
<button
type="button"
aria-label="Copy"
onClick={() => {
if (lastPayload) {
void navigator.clipboard.writeText(lastPayload);
}
}}
className="rounded p-1 text-text-dimmed transition-colors hover:bg-background-raised hover:text-text-bright"
>
<ClipboardIcon className="size-4" />
</button>
}
/>
)}
<SimpleTooltip
asChild
side="top"
content="Dismiss"
disableHoverableContent
button={
<button
type="button"
aria-label="Dismiss"
onClick={() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
setIsLoading(false);
setShowThinking(false);
setThinking("");
}}
className="rounded p-1 text-text-dimmed transition-colors hover:bg-background-raised hover:text-text-bright"
>
<XMarkIcon className="size-4" />
</button>
}
/>
</div>
</div>
<div
className={cn(
"streamdown-container max-h-96 overflow-y-auto text-xs text-text-dimmed",
"scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control",
// Strip Streamdown's code-block chrome
"**:data-[streamdown=code-block-header]:hidden",
"**:data-[streamdown=code-block]:border-0!",
"**:data-[streamdown=code-block]:bg-transparent!",
"**:data-[streamdown=code-block]:p-0!",
"**:data-[streamdown=code-block]:my-0!",
"**:data-[streamdown=code-block]:gap-0!",
// Strip the inner code-block-body border (it draws a faint inset frame)
"**:data-[streamdown=code-block-body]:border-0!",
// Style Streamdown's inner code-block (where the horizontal scrollbar lives)
"**:data-[streamdown=code-block-body]:scrollbar-thin",
"**:data-[streamdown=code-block-body]:scrollbar-track-transparent",
"**:data-[streamdown=code-block-body]:scrollbar-thumb-surface-control"
)}
>
<Suspense fallback={<p className="whitespace-pre-wrap">{thinking}</p>}>
<StreamdownRenderer isAnimating={isLoading}>{thinking}</StreamdownRenderer>
</Suspense>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Example prompts */}
<div className="pt-4">
{showExamplePromptsHeader && (
<Header3 className="mb-3 text-text-bright">Example prompts</Header3>
)}
<div className="flex flex-wrap gap-2">
{examplePrompts.map((example) => (
<button
key={example}
type="button"
disabled={isLoading}
onClick={() => {
setPrompt(example);
submitGeneration(example);
}}
className="group flex w-fit items-center gap-2 rounded-full border border-dashed border-border-bright px-4 py-2 transition-colors focus-custom hover:border-solid hover:border-indigo-500 focus-visible:rounded-full! disabled:cursor-not-allowed disabled:opacity-50"
>
<SparkleListIcon className="size-4 shrink-0 text-text-dimmed transition group-hover:text-indigo-500" />
<Paragraph
variant="small"
className="text-left transition group-hover:text-text-bright"
>
{example}
</Paragraph>
</button>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,111 @@
import { BookOpenIcon } from "@heroicons/react/20/solid";
import { CodeBlock } from "~/components/code/CodeBlock";
import { LinkButton } from "~/components/primitives/Buttons";
import { Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { TextLink } from "~/components/primitives/TextLink";
import { docsPath } from "~/utils/pathBuilder";
export function SchemaTabContent({
schema,
inferredSchema,
title = "Payload schema",
description,
showDocsLink = true,
}: {
schema?: unknown;
inferredSchema?: unknown;
title?: string;
description?: string;
showDocsLink?: boolean;
}) {
if (schema) {
return (
<div className="space-y-2">
<div className="space-y-0.5">
<Header3 className="text-text-bright">{title}</Header3>
{showDocsLink ? (
<Paragraph variant="extra-small" className="text-text-dimmed">
{description ?? (
<>
JSON Schema defined by this task via{" "}
<TextLink to={docsPath("tasks/schemaTask")}>schemaTask</TextLink>.
</>
)}
</Paragraph>
) : description ? (
<Paragraph variant="extra-small" className="text-text-dimmed">
{description}
</Paragraph>
) : null}
</div>
<CodeBlock
code={JSON.stringify(schema, null, 2)}
language="json"
showLineNumbers={false}
showOpenInModal={false}
/>
</div>
);
}
if (inferredSchema) {
return (
<div className="space-y-3">
<Header3 className="text-text-bright">Inferred schema</Header3>
<Paragraph variant="extra-small" className="text-text-dimmed">
Schema inferred from recent run payloads. For an exact schema, use schemaTask.
</Paragraph>
<LinkButton
variant="docs/small"
LeadingIcon={BookOpenIcon}
to={docsPath("tasks/schemaTask")}
>
schemaTask docs
</LinkButton>
<CodeBlock
code={JSON.stringify(inferredSchema, null, 2)}
language="json"
showLineNumbers={false}
showOpenInModal={false}
/>
</div>
);
}
return (
<div className="space-y-3">
<Header3 className="text-text-bright">No schema defined</Header3>
<Paragraph variant="small" className="text-text-dimmed">
Use <code className="text-text-bright">schemaTask</code> to define a payload schema for this
task. The schema will appear here and can be used by AI to generate example payloads.
</Paragraph>
<LinkButton variant="docs/small" LeadingIcon={BookOpenIcon} to={docsPath("tasks/schemaTask")}>
schemaTask docs
</LinkButton>
<CodeBlock
code={exampleCode}
language="typescript"
showLineNumbers={false}
showCopyButton={false}
showOpenInModal={false}
/>
</div>
);
}
const exampleCode = `import { schemaTask } from "@trigger.dev/sdk";
import { z } from "zod";
export const myTask = schemaTask({
id: "my-task",
schema: z.object({
name: z.string(),
email: z.string().email(),
count: z.number().int().positive(),
}),
run: async (payload) => {
// payload is fully typed
console.log(payload.name);
},
});`;
@@ -0,0 +1,78 @@
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
import {
ClientTabs,
ClientTabsContent,
ClientTabsList,
ClientTabsTrigger,
} from "~/components/primitives/ClientTabs";
export function TestSidebarTabs({
activeTab,
onTabChange,
optionsContent,
aiContent,
schemaContent,
}: {
activeTab: string;
onTabChange: (tab: string) => void;
optionsContent: React.ReactNode;
aiContent: React.ReactNode;
schemaContent: React.ReactNode;
}) {
return (
<ClientTabs
value={activeTab}
onValueChange={onTabChange}
className="flex h-full min-h-0 flex-col overflow-hidden pt-1"
>
<div className="h-fit overflow-x-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<ClientTabsList variant="underline" className="mx-3 shrink-0">
<ClientTabsTrigger
value="options"
variant="underline"
layoutId="test-sidebar-tabs"
className="shrink-0"
>
Options
</ClientTabsTrigger>
<ClientTabsTrigger
value="ai"
variant="underline"
layoutId="test-sidebar-tabs"
className="shrink-0"
>
<span className="flex items-center gap-0.5">
<AISparkleIcon className="size-4" /> AI
</span>
</ClientTabsTrigger>
<ClientTabsTrigger
value="schema"
variant="underline"
layoutId="test-sidebar-tabs"
className="shrink-0"
>
Schema
</ClientTabsTrigger>
</ClientTabsList>
</div>
<ClientTabsContent
value="options"
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
>
{optionsContent}
</ClientTabsContent>
<ClientTabsContent
value="ai"
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
>
<div className="min-w-64 p-3">{aiContent}</div>
</ClientTabsContent>
<ClientTabsContent
value="schema"
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
>
<div className="min-w-64 p-3">{schemaContent}</div>
</ClientTabsContent>
</ClientTabs>
);
}
@@ -0,0 +1,224 @@
import { BookOpenIcon, MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import { type MetaFunction, Outlet, useParams } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { TestHasNoTasks } from "~/components/BlankStatePanels";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { Input } from "~/components/primitives/Input";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { RadioButtonCircle } from "~/components/primitives/RadioButton";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { TaskTriggerSourceIcon } from "~/components/runs/v3/TaskTriggerSource";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useFuzzyFilter } from "~/hooks/useFuzzyFilter";
import { useLinkStatus } from "~/hooks/useLinkStatus";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { type TaskListItem, TestPresenter } from "~/presenters/v3/TestPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { docsPath, EnvironmentParamSchema, v3TestTaskPath } from "~/utils/pathBuilder";
export const meta: MetaFunction = () => {
return [
{
title: `Test | Trigger.dev`,
},
];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response(undefined, {
status: 404,
statusText: "Project not found",
});
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response(undefined, {
status: 404,
statusText: "Environment not found",
});
}
const presenter = new TestPresenter();
const result = await presenter.call({
userId,
projectId: project.id,
environmentId: environment.id,
environmentType: environment.type,
});
return typedjson(result);
};
export default function Page() {
const { tasks } = useTypedLoaderData<typeof loader>();
const { taskParam } = useParams();
return (
<PageContainer>
<NavBar>
<PageTitle title="Test" />
<PageAccessories>
<LinkButton variant={"docs/small"} LeadingIcon={BookOpenIcon} to={docsPath("/run-tests")}>
Test docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
{tasks.length === 0 ? (
<MainCenteredContainer className="max-w-md">
<TestHasNoTasks />
</MainCenteredContainer>
) : taskParam ? (
// Task selected via URL → skip the picker; the child route owns the page.
<Outlet key={taskParam} />
) : (
// No task in URL: show the picker as a fallback so users landing on
// /test (e.g. from the runs blank state with multiple task filters)
// can still choose one.
<div className={cn("grid h-full max-h-full grid-cols-1")}>
<ResizablePanelGroup orientation="horizontal" className="h-full max-h-full">
<ResizablePanel id="test-selector" min="200px" default="20%">
<TaskSelector tasks={tasks} activeTaskIdentifier={taskParam} />
</ResizablePanel>
<ResizableHandle id="test-handle" />
<ResizablePanel id="test-main" min="225px">
<Outlet key={taskParam} />
</ResizablePanel>
</ResizablePanelGroup>
</div>
)}
</PageBody>
</PageContainer>
);
}
function TaskSelector({
tasks,
activeTaskIdentifier,
}: {
tasks: TaskListItem[];
activeTaskIdentifier?: string;
}) {
const { filterText, setFilterText, filteredItems } = useFuzzyFilter<TaskListItem>({
items: tasks,
keys: ["taskIdentifier", "friendlyId", "id", "filePath", "triggerSource"],
});
const hasTaskInEnvironment = activeTaskIdentifier
? tasks.some((t) => t.taskIdentifier === activeTaskIdentifier)
: undefined;
return (
<div
className={cn(
"grid h-full max-h-full overflow-hidden",
hasTaskInEnvironment === false ? "grid-rows-[auto_auto_1fr]" : "grid-rows-[auto_1fr]"
)}
>
<div className="p-2">
<Input
placeholder="Search tasks"
variant="secondary-small"
icon={MagnifyingGlassIcon}
iconClassName="text-text-bright"
fullWidth={true}
value={filterText}
autoFocus
onChange={(e) => setFilterText(e.target.value)}
/>
</div>
{hasTaskInEnvironment === false && (
<div className="px-2 pb-2">
<Callout variant="warning" className="text-sm text-yellow-300">
There is no "{activeTaskIdentifier}" task in the selected environment.
</Callout>
</div>
)}
<Table>
<TableHeader className="hidden">
<TableRow>
<TableHeaderCell className="w-[1%]" />
<TableHeaderCell className="pl-0">Task</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{filteredItems.length > 0 ? (
filteredItems.map((t) => <TaskRow key={t.friendlyId} task={t} />)
) : (
<TableBlankRow colSpan={2}>
<Paragraph spacing variant="small">
No tasks match "{filterText}"
</Paragraph>
</TableBlankRow>
)}
</TableBody>
</Table>
</div>
);
}
function TaskRow({ task }: { task: TaskListItem }) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const path = v3TestTaskPath(organization, project, environment, task);
const { isActive, isPending } = useLinkStatus(path);
return (
<TableRow
key={task.taskIdentifier}
className={cn((isActive || isPending) && "bg-indigo-500/10")}
>
<TableCell
to={path}
actionClassName="pl-2.5 pr-2 py-1"
className={cn("w-[1%]", (isActive || isPending) && "group-hover/table-row:bg-indigo-500/5")}
>
<RadioButtonCircle checked={isActive || isPending} />
</TableCell>
<TableCell
to={path}
isTabbableCell
actionClassName="pl-0 pr-2"
className={cn((isActive || isPending) && "group-hover/table-row:bg-indigo-500/5")}
>
<div className="flex items-center gap-1.5">
<TaskTriggerSourceIcon source={task.triggerSource} />
<Paragraph
variant="extra-small"
className="text-text-dimmed group-hover/table-row:text-text-bright"
>
{task.taskIdentifier}
</Paragraph>
</div>
</TableCell>
</TableRow>
);
}
@@ -0,0 +1,150 @@
import { useLocation } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { LinkButton } from "~/components/primitives/Buttons";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { WaitpointPresenter } from "~/presenters/v3/WaitpointPresenter.server";
import {
runOpsNewReplicaClient,
runOpsLegacyReplica,
runOpsSplitReadEnabled,
type PrismaClientOrTransaction,
} from "~/db.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { EnvironmentParamSchema, v3WaitpointTokensPath } from "~/utils/pathBuilder";
import { CompleteWaitpointForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route";
import { WaitpointDetailTable } from "~/components/runs/v3/WaitpointDetails";
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
import { logger } from "~/services/logger.server";
const Params = EnvironmentParamSchema.extend({
waitpointParam: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam, waitpointParam } = Params.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response(undefined, {
status: 404,
statusText: "Project not found",
});
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response(undefined, {
status: 404,
statusText: "Environment not found",
});
}
try {
const presenter = new WaitpointPresenter(undefined, undefined, {
newClient: runOpsNewReplicaClient as unknown as PrismaClientOrTransaction,
legacyReplica: runOpsLegacyReplica as unknown as PrismaClientOrTransaction,
splitEnabled: runOpsSplitReadEnabled,
});
const result = await presenter.call({
friendlyId: waitpointParam,
environmentId: environment.id,
projectId: project.id,
});
if (!result) {
throw new Response(undefined, {
status: 404,
statusText: "Waitpoint not found",
});
}
return typedjson({ waitpoint: result });
} catch (error) {
logger.error("Error loading waitpoint for inspector", {
error,
organizationSlug,
projectParam,
envParam,
waitpointParam,
});
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, if this problem persists please contact support.",
});
}
};
export default function Page() {
const { waitpoint } = useTypedLoaderData<typeof loader>();
const location = useLocation();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
return (
<div
className={cn(
cn(
"grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden bg-background-bright",
waitpoint.status === "WAITING" && "grid-rows-[2.5rem_1fr_auto]"
)
)}
>
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
<Header2 className={cn("whitespace-nowrap")}>{waitpoint.id}</Header2>
<LinkButton
to={`${v3WaitpointTokensPath(organization, project, environment)}${location.search}`}
variant="minimal/small"
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
className="pl-1"
/>
</div>
<div className="overflow-y-auto pt-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<div className="px-3">
<WaitpointDetailTable waitpoint={waitpoint} />
</div>
<div className="flex flex-col gap-1 pt-6">
<div className="mb-1 flex items-center gap-1 pl-3">
<Header3>Related runs</Header3>
<InfoIconTooltip content="These runs have been blocked by this waitpoint." />
</div>
<TaskRunsTable
total={waitpoint.connectedRuns.length}
hasFilters={false}
filters={{
tasks: [],
versions: [],
statuses: [],
from: undefined,
to: undefined,
}}
runs={waitpoint.connectedRuns}
isLoading={false}
variant="bright"
disableAdjacentRows
/>
</div>
</div>
{waitpoint.status === "WAITING" && (
<div>
<CompleteWaitpointForm waitpoint={waitpoint} />
</div>
)}
</div>
);
}
@@ -0,0 +1,279 @@
import { BookOpenIcon } from "@heroicons/react/20/solid";
import { Outlet, useParams, type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { NoWaitpointTokens } from "~/components/BlankStatePanels";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { ListPagination } from "~/components/ListPagination";
import { LinkButton } from "~/components/primitives/Buttons";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { CopyableText } from "~/components/primitives/CopyableText";
import { DateTime } from "~/components/primitives/DateTime";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
RESIZABLE_PANEL_ANIMATION,
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
collapsibleHandleClassName,
} from "~/components/primitives/Resizable";
import {
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { RunTag } from "~/components/runs/v3/RunTag";
import { WaitpointStatusCombo } from "~/components/runs/v3/WaitpointStatus";
import {
WaitpointSearchParamsSchema,
WaitpointTokenFilters,
} from "~/components/runs/v3/WaitpointTokenFilters";
import { V4Title } from "~/components/V4Badge";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { WaitpointListPresenter } from "~/presenters/v3/WaitpointListPresenter.server";
import { requireUserId } from "~/services/session.server";
import {
runOpsNewReplicaClient,
runOpsLegacyReplica,
runOpsSplitReadEnabled,
type PrismaClientOrTransaction,
} from "~/db.server";
import { docsPath, EnvironmentParamSchema, v3WaitpointTokenPath } from "~/utils/pathBuilder";
export const meta: MetaFunction = () => {
return [
{
title: `Waitpoint tokens | Trigger.dev`,
},
];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const url = new URL(request.url);
const s = {
id: url.searchParams.get("id") ?? undefined,
statuses: url.searchParams.getAll("statuses"),
idempotencyKey: url.searchParams.get("idempotencyKey") ?? undefined,
tags: url.searchParams.getAll("tags"),
period: url.searchParams.get("period") ?? undefined,
from: url.searchParams.get("from") ?? undefined,
to: url.searchParams.get("to") ?? undefined,
cursor: url.searchParams.get("cursor") ?? undefined,
direction: url.searchParams.get("direction") ?? undefined,
};
const searchParams = WaitpointSearchParamsSchema.parse(s);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response(undefined, {
status: 404,
statusText: "Project not found",
});
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
throw new Response(undefined, {
status: 404,
statusText: "Environment not found",
});
}
try {
const presenter = new WaitpointListPresenter(undefined, undefined, {
runOpsNew: runOpsNewReplicaClient as unknown as PrismaClientOrTransaction,
runOpsLegacyReplica: runOpsLegacyReplica as unknown as PrismaClientOrTransaction,
splitEnabled: runOpsSplitReadEnabled,
});
const result = await presenter.call({
environment,
...searchParams,
});
return typedjson(result);
} catch (error) {
console.error(error);
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, if this problem persists please contact support.",
});
}
};
export default function Page() {
const {
success: _success,
tokens,
pagination,
hasFilters,
hasAnyTokens,
filters,
} = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { waitpointParam } = useParams();
const isShowingWaitpoint = !!waitpointParam;
return (
<PageContainer>
<NavBar>
<PageTitle title={<V4Title>Waitpoint Tokens</V4Title>} />
<PageAccessories>
<AdminDebugTooltip />
<LinkButton variant={"docs/small"} LeadingIcon={BookOpenIcon} to={docsPath("/wait")}>
Waitpoints docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
{!hasAnyTokens ? (
<MainCenteredContainer className="max-w-md">
<NoWaitpointTokens />
</MainCenteredContainer>
) : (
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
<ResizablePanel id="waitpoint-tokens-main" min={"100px"}>
<div className="grid max-h-full grid-rows-[auto_1fr] overflow-hidden">
<div className="flex items-start justify-between gap-x-2 p-2">
<WaitpointTokenFilters hasFilters={hasFilters} />
<div className="flex items-center justify-end gap-x-2">
<ListPagination list={{ pagination }} />
</div>
</div>
<div className="grid h-fit max-h-full min-h-full grid-rows-[1fr] overflow-x-auto">
<Table containerClassName="border-t">
<TableHeader>
<TableRow>
<TableHeaderCell className="w-[1%]">Created</TableHeaderCell>
<TableHeaderCell className="w-[20%]">ID</TableHeaderCell>
<TableHeaderCell className="w-[20%]">Callback URL</TableHeaderCell>
<TableHeaderCell className="w-[20%]">Status</TableHeaderCell>
<TableHeaderCell className="w-[20%]">Completed</TableHeaderCell>
<TableHeaderCell className="w-[20%]">Idempotency Key</TableHeaderCell>
<TableHeaderCell className="w-[20%]">Tags</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{tokens.length > 0 ? (
tokens.map((token) => {
const ttlExpired =
token.idempotencyKeyExpiresAt &&
token.idempotencyKeyExpiresAt < new Date();
const path = v3WaitpointTokenPath(
organization,
project,
environment,
token,
filters
);
const rowIsSelected = waitpointParam === token.id;
return (
<TableRow
key={token.id}
className={rowIsSelected ? "bg-grid-dimmed" : undefined}
>
<TableCell to={path}>
<span className="opacity-60">
<DateTime date={token.createdAt} />
</span>
</TableCell>
<TableCell to={path}>
<CopyableText value={token.id} className="font-mono" />
</TableCell>
<TableCell to={path}>
<ClipboardField value={token.url} variant={"secondary/small"} />
</TableCell>
<TableCell to={path}>
<WaitpointStatusCombo status={token.status} className="text-xs" />
</TableCell>
<TableCell to={path}>
{token.completedAt ? <DateTime date={token.completedAt} /> : ""}
</TableCell>
<TableCell to={path}>
{token.idempotencyKey ? (
token.idempotencyKeyExpiresAt ? (
<SimpleTooltip
content={
<>
<DateTime date={token.idempotencyKeyExpiresAt} />
{ttlExpired ? (
<span className="text-xs opacity-50"> (expired)</span>
) : null}
</>
}
buttonClassName={ttlExpired ? "opacity-50" : undefined}
button={token.idempotencyKey}
/>
) : (
token.idempotencyKey
)
) : (
""
)}
</TableCell>
<TableCell to={path} actionClassName="py-1">
<div className="flex gap-1">
{token.tags.map((tag) => <RunTag key={tag} tag={tag} />) || ""}
</div>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={6}>
<div className="grid place-items-center py-6 text-text-dimmed">
<Paragraph>No waitpoint tokens found</Paragraph>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</ResizablePanel>
<ResizableHandle
id="waitpoint-tokens-handle"
className={collapsibleHandleClassName(isShowingWaitpoint)}
/>
<ResizablePanel
id="waitpoint-tokens-inspector"
min="450px"
default="500px"
className="overflow-hidden"
collapsible
collapsed={!isShowingWaitpoint}
onCollapseChange={() => {}}
collapsedSize="0px"
collapseAnimation={RESIZABLE_PANEL_ANIMATION}
>
<div className="h-full" style={{ minWidth: 450 }}>
<Outlet />
</div>
</ResizablePanel>
</ResizablePanelGroup>
)}
</PageBody>
</PageContainer>
);
}
@@ -0,0 +1,123 @@
import { Outlet, useLoaderData } from "@remix-run/react";
import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { RouteErrorDisplay } from "~/components/ErrorDisplay";
import { DashboardAgent } from "~/components/dashboard-agent/DashboardAgent";
import { prisma } from "~/db.server";
import { updateCurrentProjectEnvironmentId } from "~/services/dashboardPreferences.server";
import { logger } from "~/services/logger.server";
import { requireUser } from "~/services/session.server";
import { tenantContext } from "~/services/tenantContext.server";
import { EnvironmentParamSchema, v3ProjectPath } from "~/utils/pathBuilder";
import { canAccessDashboardAgent } from "~/v3/canAccessDashboardAgent.server";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const project = await prisma.project.findFirst({
where: {
slug: projectParam,
organization: {
slug: organizationSlug,
members: {
some: {
userId: user.id,
},
},
},
deletedAt: null,
},
select: {
id: true,
externalRef: true,
organization: { select: { id: true, featureFlags: true } },
environments: {
select: {
id: true,
type: true,
slug: true,
orgMember: {
select: {
userId: true,
},
},
},
},
},
});
if (!project) {
logger.error("Project not found", { params, user });
throw new Response("Project not Found", { status: 404, statusText: "Project not found" });
}
const environments = project.environments.filter((env) => env.slug === envParam);
if (environments.length === 0) {
return redirect(v3ProjectPath({ slug: organizationSlug }, { slug: projectParam }));
}
let environmentId: string | undefined = undefined;
let environmentType: "DEVELOPMENT" | "PREVIEW" | "STAGING" | "PRODUCTION" | undefined;
if (environments.length > 1) {
const bestEnvironment = environments.find((env) => env.orgMember?.userId === user.id);
if (!bestEnvironment) {
throw new Response("Environment not Found", {
status: 404,
statusText: "Environment not found",
});
}
environmentId = bestEnvironment.id;
environmentType = bestEnvironment.type;
} else {
environmentId = environments[0].id;
environmentType = environments[0].type;
}
// userId is enriched higher up in `_app/route.tsx`; only stamp tenant fields here.
tenantContext.enrich({
orgId: project.organization.id,
projectId: project.id,
projectRef: project.externalRef,
envId: environmentId,
envType: environmentType,
});
await updateCurrentProjectEnvironmentId({ user: user, projectId: project.id, environmentId });
// Resolve dashboard-agent access here (single source of truth: global env,
// admins/impersonators, then the global/per-org feature flag, default off) so
// the launcher button is hidden when it's not enabled. The org's featureFlags
// came from the membership-checked project query above, so we pass them in to
// avoid a second org lookup.
const hasDashboardAgentAccess = await canAccessDashboardAgent({
userId: user.id,
isAdmin: user.admin,
isImpersonating: user.isImpersonating,
organizationSlug,
orgFeatureFlags: (project.organization.featureFlags as Record<string, unknown>) ?? {},
});
return {
...project,
hasDashboardAgentAccess,
};
};
export default function Page() {
const { hasDashboardAgentAccess } = useLoaderData<typeof loader>();
return (
<DashboardAgent hasAccess={hasDashboardAgentAccess}>
<Outlet />
</DashboardAgent>
);
}
// Caught here (inside the project SideMenu's Outlet) rather than at the project
// layout, so a permission denial or error on any env-scoped page renders in the
// content pane with the SideMenu intact. RouteErrorDisplay renders the
// permission panel for a 403 and the generic error otherwise.
export function ErrorBoundary() {
return <RouteErrorDisplay />;
}
@@ -0,0 +1,44 @@
import { Outlet } from "@remix-run/react";
import { DevPresenceProvider } from "~/components/DevPresence";
import { RouteErrorDisplay } from "~/components/ErrorDisplay";
import { MainBody } from "~/components/layout/AppLayout";
import { SideMenu } from "~/components/navigation/SideMenu";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useIsImpersonating, useOrganization, useOrganizations } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useUser } from "~/hooks/useUser";
import { v3ProjectPath } from "~/utils/pathBuilder";
export default function Project() {
const organizations = useOrganizations();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const user = useUser();
const isImpersonating = useIsImpersonating();
return (
<>
<div className="grid grid-cols-[auto_1fr] overflow-hidden">
<DevPresenceProvider enabled={environment.type === "DEVELOPMENT"}>
<SideMenu
user={{ ...user, isImpersonating }}
project={project}
environment={environment}
organization={organization}
organizations={organizations}
/>
<MainBody>
<Outlet />
</MainBody>
</DevPresenceProvider>
</div>
</>
);
}
export function ErrorBoundary() {
const org = useOrganization();
const project = useProject();
return <RouteErrorDisplay button={{ title: project.name, to: v3ProjectPath(org, project) }} />;
}
@@ -0,0 +1,685 @@
import colorWheelIcon from "../../assets/images/color-wheel.png";
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
import { conformZodMessage, parseWithZod } from "@conform-to/zod";
import {
CheckIcon,
ExclamationTriangleIcon,
FolderIcon,
GlobeAltIcon,
TrashIcon,
} from "@heroicons/react/20/solid";
import { Form, type MetaFunction, useActionData, useNavigation, useSubmit } from "@remix-run/react";
import { type ActionFunction, json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { useEffect, useRef, useState } from "react";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { InlineCode } from "~/components/code/InlineCode";
import {
MainHorizontallyCenteredContainer,
PageBody,
PageContainer,
} from "~/components/layout/AppLayout";
import {
Avatar,
AvatarData,
avatarIcons,
AvatarType,
defaultAvatar,
parseAvatar,
defaultAvatarHex,
defaultAvatarColors,
type Avatar as AvatarT,
} from "~/components/primitives/Avatar";
import { Button } from "~/components/primitives/Buttons";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header2 } from "~/components/primitives/Headers";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import { prisma } from "~/db.server";
import { useFaviconUrl } from "~/hooks/useFaviconUrl";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { clearCurrentProject } from "~/services/dashboardPreferences.server";
import { DeleteOrganizationService } from "~/services/deleteOrganization.server";
import { logger } from "~/services/logger.server";
import { requireUser, requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { extractDomain, faviconUrl as buildFaviconUrl } from "~/utils/favicon";
import { OrganizationParamsSchema, organizationSettingsPath, rootPath } from "~/utils/pathBuilder";
export const meta: MetaFunction = () => {
return [
{
title: `Organization settings | Trigger.dev`,
},
];
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const organization = await prisma.organization.findFirst({
where: { slug: organizationSlug, members: { some: { userId } }, deletedAt: null },
orderBy: { createdAt: "desc" },
select: {
id: true,
slug: true,
title: true,
avatar: true,
onboardingData: true,
},
});
if (!organization) {
throw new Response("Not found", { status: 404 });
}
const onboardingData = toRecord(organization.onboardingData);
const parsedAvatar = parseAvatar(organization.avatar, defaultAvatar);
const lastIconHex =
parsedAvatar.type === "image" && parsedAvatar.lastIconHex
? parsedAvatar.lastIconHex
: defaultAvatarHex;
return typedjson({
organization: {
...organization,
avatar: parsedAvatar,
companyUrl: typeof onboardingData.companyUrl === "string" ? onboardingData.companyUrl : "",
lastIconHex,
},
});
};
export function createSchema(
constraints: {
getSlugMatch?: (slug: string) => { isMatch: boolean; organizationSlug: string };
} = {}
) {
return z.discriminatedUnion("action", [
z.object({
action: z.literal("avatar"),
type: AvatarType,
name: z.string().optional(),
hex: z.string().optional(),
url: z.string().optional(),
}),
z.object({
action: z.literal("rename"),
organizationName: z
.string()
.min(3, "Organization name must have at least 3 characters")
.max(50),
}),
z.object({
action: z.literal("delete"),
organizationSlug: z.string().superRefine((slug, ctx) => {
if (constraints.getSlugMatch === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: conformZodMessage.VALIDATION_UNDEFINED,
});
} else {
const { isMatch, organizationSlug } = constraints.getSlugMatch(slug);
if (isMatch) {
return;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `The slug must match ${organizationSlug}`,
});
}
}),
}),
]);
}
export const action: ActionFunction = async ({ request, params }) => {
const user = await requireUser(request);
const { organizationSlug } = params;
if (!organizationSlug) {
return json({ errors: { body: "organizationSlug is required" } }, { status: 400 });
}
const formData = await request.formData();
const schema = createSchema({
getSlugMatch: (slug) => {
return { isMatch: slug === organizationSlug, organizationSlug };
},
});
const submission = parseWithZod(formData, { schema });
if (submission.status !== "success") {
return json(submission.reply());
}
try {
switch (submission.value.action) {
case "rename": {
await prisma.organization.update({
where: {
slug: organizationSlug,
members: {
some: {
userId: user.id,
},
},
},
data: {
title: submission.value.organizationName,
},
});
return redirectWithSuccessMessage(
organizationSettingsPath({ slug: organizationSlug }),
request,
`Organization renamed to ${submission.value.organizationName}`
);
}
case "delete": {
const deleteOrganizationService = new DeleteOrganizationService();
try {
await deleteOrganizationService.call({ organizationSlug, userId: user.id, request });
//we need to clear the project from the session
await clearCurrentProject({
user,
});
return redirect(rootPath());
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
logger.error("Organization could not be deleted", {
error: errorMessage,
});
return redirectWithErrorMessage(
organizationSettingsPath({ slug: organizationSlug }),
request,
errorMessage
);
}
}
case "avatar": {
const orgWhere = {
slug: organizationSlug,
members: { some: { userId: user.id } },
};
if (submission.value.type === "image") {
const url = submission.value.url ?? "";
const domain = url ? extractDomain(url) : null;
const existing = await prisma.organization.findFirst({
where: orgWhere,
select: { avatar: true, onboardingData: true },
});
const existingData = toRecord(existing?.onboardingData);
const existingAvatar = parseAvatar(existing?.avatar ?? null, defaultAvatar);
const lastIconHex = extractLastIconHex(existingAvatar);
await prisma.organization.update({
where: orgWhere,
data: {
avatar: {
type: "image",
url: domain ? buildFaviconUrl(domain) : "",
...(lastIconHex ? { lastIconHex } : {}),
},
onboardingData: { ...existingData, companyUrl: url },
},
});
return redirectWithSuccessMessage(
organizationSettingsPath({ slug: organizationSlug }),
request,
`Updated logo`
);
}
const avatar = AvatarData.safeParse(submission.value);
if (!avatar.success) {
return redirectWithErrorMessage(
organizationSettingsPath({ slug: organizationSlug }),
request,
avatar.error.message
);
}
await prisma.organization.update({
where: orgWhere,
data: {
avatar: avatar.data,
},
});
return redirectWithSuccessMessage(
organizationSettingsPath({ slug: organizationSlug }),
request,
`Updated logo`
);
}
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "An unexpected error occurred";
return json({ errors: { body: message } }, { status: 400 });
}
};
export default function Page() {
const { organization } = useTypedLoaderData<typeof loader>();
const lastSubmission = useActionData();
const navigation = useNavigation();
const [renameForm, { organizationName }] = useForm({
id: "rename-organization",
// TODO: type this
lastResult: lastSubmission as any,
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parseWithZod(formData, {
schema: createSchema(),
});
},
});
const [deleteForm, { organizationSlug }] = useForm({
id: "delete-organization",
// TODO: type this
lastResult: lastSubmission as any,
shouldValidate: "onInput",
shouldRevalidate: "onSubmit",
onValidate({ formData }) {
return parseWithZod(formData, {
schema: createSchema({
getSlugMatch: (slug) => ({
isMatch: slug === organization.slug,
organizationSlug: organization.slug,
}),
}),
});
},
});
const isRenameLoading =
navigation.formData?.get("action") === "rename" &&
(navigation.state === "submitting" || navigation.state === "loading");
const isDeleteLoading =
navigation.formData?.get("action") === "delete" &&
(navigation.state === "submitting" || navigation.state === "loading");
return (
<PageContainer>
<NavBar>
<PageTitle title={`${organization.title} organization settings`} />
</NavBar>
<PageBody>
<MainHorizontallyCenteredContainer>
<div className="mb-3 border-b border-grid-dimmed pb-3">
<Header2>Settings</Header2>
</div>
<div className="flex flex-col gap-6">
<div>
<LogoForm organization={organization} />
</div>
<div>
<Form method="post" {...getFormProps(renameForm)}>
<input type="hidden" name="action" value="rename" />
<Fieldset className="gap-y-0">
<InputGroup fullWidth>
<Label htmlFor={organizationName.id}>Organization name</Label>
<Input
{...getInputProps(organizationName, { type: "text" })}
defaultValue={organization.title}
placeholder="Your organization name"
icon={FolderIcon}
autoFocus
/>
<FormError id={organizationName.errorId}>{organizationName.errors}</FormError>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
variant={"secondary/small"}
disabled={isRenameLoading}
LeadingIcon={isRenameLoading ? SpinnerWhite : undefined}
>
Rename organization
</Button>
}
className="border-t-0"
/>
</Fieldset>
</Form>
</div>
<div>
<Header2 spacing>Danger zone</Header2>
<Form
method="post"
{...getFormProps(deleteForm)}
className="w-full rounded-sm border border-rose-500/40"
>
<input type="hidden" name="action" value="delete" />
<Fieldset className="p-4">
<InputGroup>
<Label htmlFor={organizationSlug.id}>Delete organization</Label>
<Input
{...getInputProps(organizationSlug, { type: "text" })}
placeholder="Your organization slug"
icon={ExclamationTriangleIcon}
fullWidth
/>
<FormError id={organizationSlug.errorId}>{organizationSlug.errors}</FormError>
<FormError>{deleteForm.errors}</FormError>
<Hint>
This change is irreversible, so please be certain. Type in the Organization
slug <InlineCode variant="extra-small">{organization.slug}</InlineCode> and
then press Delete.
</Hint>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
variant={"danger/small"}
LeadingIcon={isDeleteLoading ? SpinnerWhite : TrashIcon}
leadingIconClassName="text-white"
disabled={isDeleteLoading}
>
Delete organization
</Button>
}
/>
</Fieldset>
</Form>
</div>
</div>
</MainHorizontallyCenteredContainer>
</PageBody>
</PageContainer>
);
}
function LogoForm({
organization,
}: {
organization: { avatar: AvatarT; title: string; companyUrl: string; lastIconHex: string };
}) {
const navigation = useNavigation();
const avatar = navigation.formData
? (avatarFromFormData(navigation.formData) ?? organization.avatar)
: organization.avatar;
const hex =
"hex" in avatar
? avatar.hex
: avatar.type === "image" && avatar.lastIconHex
? avatar.lastIconHex
: organization.lastIconHex;
const mode: "logo" | "icon" = avatar.type === "image" ? "logo" : "icon";
const [companyUrl, setCompanyUrl] = useState(organization.companyUrl);
const faviconPreview = useFaviconUrl(companyUrl);
const [faviconError, setFaviconError] = useState(false);
const logoFormRef = useRef<HTMLFormElement>(null);
const submit = useSubmit();
const prevFaviconRef = useRef(faviconPreview);
useEffect(() => {
if (faviconPreview === prevFaviconRef.current) return;
prevFaviconRef.current = faviconPreview;
if (mode === "logo" && logoFormRef.current) {
submit(logoFormRef.current);
}
}, [faviconPreview, mode, submit]);
const showFavicon = faviconPreview && !faviconError;
return (
<Fieldset>
<InputGroup fullWidth>
<Label>Logo</Label>
<div className="flex flex-col gap-3">
{/* Row 1: Logo from URL */}
<Form ref={logoFormRef} method="post" className="flex items-center gap-3">
<input type="hidden" name="action" value="avatar" />
<input type="hidden" name="type" value="image" />
<input type="hidden" name="url" value={companyUrl} />
<button type="submit" className="flex shrink-0 items-center gap-3">
<RadioDot active={mode === "logo"} />
</button>
<div className="flex flex-1 items-center gap-1.5">
<button
type="submit"
className={cn(
iconTileClass,
mode === "logo"
? "border-indigo-500"
: "border-grid-dimmed hover:border-border-bright"
)}
>
{showFavicon ? (
<img
src={faviconPreview}
alt=""
width={28}
height={28}
className="rounded-sm"
onError={() => setFaviconError(true)}
onLoad={() => setFaviconError(false)}
/>
) : (
<GlobeAltIcon className="size-6 text-text-dimmed" />
)}
</button>
<Input
type="text"
value={companyUrl}
onChange={(e) => {
setCompanyUrl(e.target.value);
setFaviconError(false);
}}
onFocus={() => {
if (mode !== "logo" && logoFormRef.current) {
submit(logoFormRef.current);
}
}}
placeholder="Enter your company URL to generate a logo"
variant="medium"
containerClassName="flex-1"
/>
</div>
</Form>
{/* Row 2: Icon picker */}
<div className="flex items-center gap-3">
<Form method="post" className="shrink-0">
<input type="hidden" name="action" value="avatar" />
<input type="hidden" name="type" value="letters" />
<input type="hidden" name="hex" value={hex} />
<button type="submit">
<RadioDot active={mode === "icon"} />
</button>
</Form>
<div className="flex flex-wrap items-center gap-1.5">
{/* Letters */}
<Form method="post">
<input type="hidden" name="action" value="avatar" />
<input type="hidden" name="type" value="letters" />
<input type="hidden" name="hex" value={hex} />
<button
type="submit"
className={cn(
iconTileClass,
avatar.type !== "letters" && "border-grid-dimmed hover:border-border-bright"
)}
style={{
borderColor: avatar.type === "letters" ? hex : undefined,
}}
>
<Avatar
avatar={{ type: "letters", hex }}
size={2.5}
includePadding
orgName={organization.title}
/>
</button>
</Form>
{/* Icons */}
{Object.entries(avatarIcons).map(([name]) => (
<Form key={name} method="post">
<input type="hidden" name="action" value="avatar" />
<input type="hidden" name="type" value="icon" />
<input type="hidden" name="name" value={name} />
<input type="hidden" name="hex" value={hex} />
<button
type="submit"
className={cn(
iconTileClass,
!(avatar.type === "icon" && avatar.name === name) &&
"border-grid-dimmed hover:border-border-bright"
)}
style={{
borderColor: avatar.type === "icon" && avatar.name === name ? hex : undefined,
}}
>
<Avatar
avatar={{ type: "icon", name, hex }}
size={2.5}
includePadding
orgName={organization.title}
/>
</button>
</Form>
))}
{/* Color picker */}
<HexPopover avatar={avatar} hex={hex} />
</div>
</div>
</div>
</InputGroup>
</Fieldset>
);
}
function HexPopover({ avatar, hex }: { avatar: Avatar; hex: string }) {
return (
<Popover>
<PopoverTrigger
className={cn(iconTileClass, "border-grid-dimmed hover:border-border-bright")}
>
<img src={colorWheelIcon} className="m-0 block size-[30px] p-0" />
</PopoverTrigger>
<PopoverContent
className="overflow-y-auto p-1 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
align="start"
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
>
<Form method="post" className="flex w-fit min-w-40 flex-col gap-1">
<input type="hidden" name="action" value="avatar" />
<input
type="hidden"
name="type"
value={avatar.type === "image" ? "letters" : avatar.type}
/>
{avatar.type === "icon" && <input type="hidden" name="name" value={avatar.name} />}
{defaultAvatarColors.map((color) => (
<Button
key={color.hex}
name="hex"
value={color.hex}
type="submit"
variant="small-menu-item"
LeadingIcon={
<div
className="size-4 rounded-full"
style={{
backgroundColor: color.hex,
}}
/>
}
TrailingIcon={hex === color.hex && <CheckIcon className="size-4 text-text-dimmed" />}
trailingIconClassName="ml-4"
fullWidth
textAlignLeft
className={cn(
"group-hover:bg-background-raised",
hex === color.hex
? "bg-background-hover group-hover:bg-surface-control/50"
: undefined
)}
>
{color.name}
</Button>
))}
</Form>
</PopoverContent>
</Popover>
);
}
function RadioDot({ active }: { active: boolean }) {
return (
<div
className={cn(
"flex shrink-0 items-center justify-center rounded-full border-2 p-0.5 transition",
active ? "border-indigo-500" : "border-grid-bright hover:border-border-bright"
)}
>
<div
className="size-2 rounded-full"
style={{ backgroundColor: active ? "#6366f1" : "transparent" }}
/>
</div>
);
}
const iconTileClass =
"box-content grid size-10 shrink-0 place-items-center rounded-sm border-2 bg-charcoal-775";
function toRecord(json: unknown): Record<string, unknown> {
return json && typeof json === "object" ? (json as Record<string, unknown>) : {};
}
function extractLastIconHex(avatar: AvatarT): string | undefined {
if ("hex" in avatar) return avatar.hex;
if (avatar.type === "image") return avatar.lastIconHex;
return undefined;
}
function avatarFromFormData(formData: FormData): AvatarT | undefined {
const action = formData.get("action");
if (action !== "avatar") return undefined;
const type = formData.get("type");
const hex = formData.get("hex") as string;
switch (type) {
case "letters":
return { type: "letters", hex };
case "icon":
return { type: "icon", name: formData.get("name") as string, hex };
case "image": {
const url = formData.get("url") as string;
const domain = url ? extractDomain(url) : null;
return { type: "image", url: domain ? buildFaviconUrl(domain) : "" };
}
default:
return undefined;
}
}
@@ -0,0 +1,7 @@
import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { OrganizationParamsSchema, v3BillingLimitsPath } from "~/utils/pathBuilder";
export async function loader({ params }: LoaderFunctionArgs) {
const { organizationSlug } = OrganizationParamsSchema.parse(params);
return redirect(v3BillingLimitsPath({ slug: organizationSlug }), 302);
}
@@ -0,0 +1,14 @@
/** Revalidate org layout (billingLimit banner) after billing limits settings forms submit. */
export function isBillingLimitSettingsFormSubmission(
formMethod: string | undefined,
formData: FormData | undefined
): boolean {
if (!formMethod || !formData || formMethod.toLowerCase() !== "post") {
return false;
}
const intent = formData.get("intent");
return (
intent === "billing-limit" || intent === "billing-alerts" || intent === "billing-limit-resolve"
);
}
@@ -0,0 +1,41 @@
import type {
BillingLimitResult,
ResolveBillingLimitRequest,
} from "~/services/billingLimit.schemas";
export function isEnforcementActive(billingLimit: BillingLimitResult): boolean {
return (
billingLimit.isConfigured &&
(billingLimit.limitState.status === "grace" || billingLimit.limitState.status === "rejected")
);
}
export function getAlertsResetRequested(request: Request): boolean {
return new URL(request.url).searchParams.get("alertsReset") === "1";
}
export function getEffectiveLimitCentsAfterLimitSave(
mode: "plan" | "custom" | "none",
planLimitCents: number,
customAmountDollars?: number
): number {
if (mode === "custom") {
return Math.round((customAmountDollars ?? 0) * 100);
}
return planLimitCents;
}
export function getResolveSubmitted(request: Request): boolean {
return new URL(request.url).searchParams.get("resolved") === "1";
}
export function getSubmittedResumeMode(
request: Request
): ResolveBillingLimitRequest["resumeMode"] | null {
const value = new URL(request.url).searchParams.get("resumeMode");
if (value === "queue" || value === "new_only") {
return value;
}
return null;
}
@@ -0,0 +1,567 @@
import { parseWithZod } from "@conform-to/zod";
import type { MetaFunction } from "@remix-run/react";
import { json, redirect } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import {
BillingAlertsSection,
billingAlertsSchema,
type BillingAlertsFormData,
} from "~/components/billing/BillingAlertsSection";
import {
getBillingLimitMode,
getEffectiveLimitCents,
isPercentageAlertMode,
MAX_ABSOLUTE_ALERTS,
MAX_PERCENTAGE_ALERTS,
MAX_PERCENTAGE_THRESHOLD,
normalizeBillingAlertsFromApi,
resetAlertsPayloadForLimitMode,
shouldResetAlertsOnLimitChange,
thresholdsToAlertPayload,
hadSavedAlertsToClearOnLimitChange,
thresholdValuesAreUnique,
} from "~/components/billing/billingAlertsFormat";
import { getSuggestedRecoveryLimitDollars } from "~/components/billing/billingLimitFormat";
import {
BillingLimitConfigSection,
billingLimitFormSchema,
} from "~/components/billing/BillingLimitConfigSection";
import {
BillingLimitRecoveryPanel,
billingLimitRecoveryFormSchema,
} from "~/components/billing/BillingLimitRecoveryPanel";
import { BillingLimitResolveProgress } from "~/components/billing/BillingLimitResolveProgress";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { prisma } from "~/db.server";
import { featuresForRequest } from "~/features.server";
import { useScrollContainerToTop } from "~/hooks/useScrollContainerToTop";
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import {
commitSession,
getSession,
redirectWithErrorMessage,
redirectWithSuccessMessage,
setSuccessMessage,
} from "~/models/message.server";
import {
getBillingAlerts,
getBillingLimit,
getCachedUsage,
getCurrentPlan,
resolveBillingLimit,
setBillingAlert,
setBillingLimit,
} from "~/services/platform.v3.server";
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import type { BillingLimitResult } from "~/services/billingLimit.schemas";
import {
getAlertsResetRequested,
getEffectiveLimitCentsAfterLimitSave,
getResolveSubmitted,
getSubmittedResumeMode,
isEnforcementActive,
} from "~/routes/_app.orgs.$organizationSlug.settings.billing-limits/billingLimitsRoute.server";
import {
countBillingLimitPausedEnvironments,
getBillingLimitQueuedRunCount,
} from "~/v3/services/billingLimit/getBillingLimitQueuedRunCount.server";
import {
OrganizationParamsSchema,
organizationPath,
v3BillingLimitsPath,
v3BillingPath,
} from "~/utils/pathBuilder";
const billingLimitsAuthorization = {
action: "manage" as const,
resource: { type: "billing-limits" as const },
};
export const meta: MetaFunction = () => {
return [{ title: `Billing limits | Trigger.dev` }];
};
export const loader = dashboardLoader(
{
params: OrganizationParamsSchema,
context: async (params) => {
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
return organizationId ? { organizationId } : {};
},
authorization: {
...billingLimitsAuthorization,
message: "With your current role, you can't manage billing limits.",
},
},
async ({ params, request, user }) => {
const userId = user.id;
const { organizationSlug } = params;
const { isManagedCloud } = featuresForRequest(request);
if (!isManagedCloud) {
return redirect(organizationPath({ slug: organizationSlug }));
}
const organization = await prisma.organization.findFirst({
where: { slug: organizationSlug, members: { some: { userId } } },
});
if (!organization) {
throw new Response(null, { status: 404, statusText: "Organization not found" });
}
const currentPlan = await getCurrentPlan(organization.id);
if (currentPlan?.v3Subscription?.showSelfServe === false) {
return redirect(v3BillingPath({ slug: organizationSlug }));
}
const [billingLimitError, billingLimit] = await tryCatch(getBillingLimit(organization.id));
if (billingLimitError || !billingLimit) {
throw new Response(null, {
status: 404,
statusText: `Billing limit error: ${billingLimitError ?? "not found"}`,
});
}
const [alertsError, alerts] = await tryCatch(getBillingAlerts(organization.id));
if (alertsError || !alerts) {
throw new Response(null, {
status: 404,
statusText: `Billing alerts error: ${alertsError ?? "not found"}`,
});
}
const planLimitCents = currentPlan?.v3Subscription?.plan?.limits.includedUsage ?? 500;
const alertsResetRequested = getAlertsResetRequested(request);
const resolveSubmitted = getResolveSubmitted(request);
const submittedResumeMode = getSubmittedResumeMode(request);
const firstDayOfMonth = new Date();
firstDayOfMonth.setUTCDate(1);
firstDayOfMonth.setUTCHours(0, 0, 0, 0);
const firstDayOfNextMonth = new Date();
firstDayOfNextMonth.setUTCDate(1);
firstDayOfNextMonth.setUTCHours(0, 0, 0, 0);
firstDayOfNextMonth.setUTCMonth(firstDayOfNextMonth.getUTCMonth() + 1);
const [usage, queuedRunCount, billingLimitPauseEnvCount] = await Promise.all([
getCachedUsage(organization.id, { from: firstDayOfMonth, to: firstDayOfNextMonth }),
isEnforcementActive(billingLimit)
? getBillingLimitQueuedRunCount(organization.id)
: Promise.resolve(0),
countBillingLimitPausedEnvironments(organization.id),
]);
const currentSpendCents = usage?.cents ?? 0;
const suggestedNewLimitDollars = isEnforcementActive(billingLimit)
? getSuggestedRecoveryLimitDollars(
billingLimit.isConfigured ? billingLimit.effectiveAmountCents : null,
currentSpendCents
)
: 0;
const alertsFormData = normalizeBillingAlertsFromApi(alerts, {
planLimitCents,
effectiveLimitCents: getEffectiveLimitCents(billingLimit, planLimitCents),
});
return typedjson({
billingLimit,
alerts: alertsFormData,
planLimitCents,
isRecoveryMode: isEnforcementActive(billingLimit),
alertsResetRequested,
currentSpendCents,
queuedRunCount,
billingLimitPauseEnvCount,
resolveSubmitted,
submittedResumeMode,
suggestedNewLimitDollars,
});
}
);
type LoaderData = {
billingLimit: BillingLimitResult;
alerts: BillingAlertsFormData;
planLimitCents: number;
isRecoveryMode: boolean;
alertsResetRequested: boolean;
currentSpendCents: number;
queuedRunCount: number;
billingLimitPauseEnvCount: number;
resolveSubmitted: boolean;
submittedResumeMode: "queue" | "new_only" | null;
suggestedNewLimitDollars: number;
};
export const action = dashboardAction(
{
params: OrganizationParamsSchema,
context: async (params) => {
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
return organizationId ? { organizationId } : {};
},
authorization: billingLimitsAuthorization,
},
async ({ request, params, user }) => {
const userId = user.id;
const { organizationSlug } = params;
const organization = await prisma.organization.findFirst({
where: { slug: organizationSlug, members: { some: { userId } } },
});
if (!organization) {
return redirectWithErrorMessage(
v3BillingPath({ slug: organizationSlug }),
request,
"You are not authorized to update billing settings"
);
}
const currentPlan = await getCurrentPlan(organization.id);
if (currentPlan?.v3Subscription?.showSelfServe === false) {
return redirect(v3BillingPath({ slug: organizationSlug }));
}
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "billing-alerts") {
const submission = parseWithZod(formData, { schema: billingAlertsSchema });
if (submission.status !== "success") {
return json({ formIntent: "billing-alerts", submission: submission.reply() });
}
const [billingLimitError, billingLimit] = await tryCatch(getBillingLimit(organization.id));
if (billingLimitError || !billingLimit) {
return redirectWithErrorMessage(
v3BillingLimitsPath({ slug: organizationSlug }),
request,
"Failed to load billing limit for alerts"
);
}
const planLimitCents = currentPlan?.v3Subscription?.plan?.limits.includedUsage ?? 500;
const billingLimitMode = getBillingLimitMode(billingLimit);
const maxAlerts = isPercentageAlertMode(billingLimitMode)
? MAX_PERCENTAGE_ALERTS
: MAX_ABSOLUTE_ALERTS;
if (submission.value.alertLevels.length > maxAlerts) {
return redirectWithErrorMessage(
v3BillingLimitsPath({ slug: organizationSlug }),
request,
`You can add at most ${maxAlerts} alerts`
);
}
if (
submission.value.alertLevels.some(
(threshold) => !Number.isFinite(threshold) || threshold <= 0
)
) {
return redirectWithErrorMessage(
v3BillingLimitsPath({ slug: organizationSlug }),
request,
"Each alert must be greater than 0"
);
}
if (!thresholdValuesAreUnique(submission.value.alertLevels)) {
return redirectWithErrorMessage(
v3BillingLimitsPath({ slug: organizationSlug }),
request,
"Each alert must be unique"
);
}
if (
isPercentageAlertMode(billingLimitMode) &&
submission.value.alertLevels.some((threshold) => threshold > MAX_PERCENTAGE_THRESHOLD)
) {
return redirectWithErrorMessage(
v3BillingLimitsPath({ slug: organizationSlug }),
request,
"Alerts cannot exceed 100% of your billing limit"
);
}
const effectiveLimitCents = getEffectiveLimitCents(billingLimit, planLimitCents);
const alertPayload = thresholdsToAlertPayload(
submission.value.alertLevels,
billingLimitMode,
effectiveLimitCents
);
const [error] = await tryCatch(
setBillingAlert(organization.id, {
emails: submission.value.emails,
...alertPayload,
})
);
if (error) {
return redirectWithErrorMessage(
v3BillingLimitsPath({ slug: organizationSlug }),
request,
"Failed to update billing alerts"
);
}
return redirectWithSuccessMessage(
v3BillingLimitsPath({ slug: organizationSlug }),
request,
"Billing alerts updated"
);
}
if (intent === "billing-limit") {
const submission = parseWithZod(formData, { schema: billingLimitFormSchema });
if (submission.status !== "success") {
return json({ formIntent: "billing-limit", submission: submission.reply() });
}
const [billingLimitError, billingLimit] = await tryCatch(getBillingLimit(organization.id));
if (billingLimitError || !billingLimit) {
return redirectWithErrorMessage(
v3BillingLimitsPath({ slug: organizationSlug }),
request,
"Failed to load billing limit"
);
}
if (isEnforcementActive(billingLimit)) {
return redirectWithErrorMessage(
v3BillingLimitsPath({ slug: organizationSlug }),
request,
"Resolve the active billing limit before changing settings"
);
}
const cancelInProgressRuns =
submission.value.mode === "none" ? false : (submission.value.cancelInProgressRuns ?? false);
const previousMode = getBillingLimitMode(billingLimit);
const resettingAlerts = shouldResetAlertsOnLimitChange(previousMode, submission.value.mode);
const planLimitCents = currentPlan?.v3Subscription?.plan?.limits.includedUsage ?? 500;
try {
if (submission.value.mode === "custom") {
await setBillingLimit(organization.id, {
mode: "custom",
amountCents: Math.round(submission.value.amount * 100),
cancelInProgressRuns,
});
} else if (submission.value.mode === "plan") {
await setBillingLimit(organization.id, {
mode: "plan",
cancelInProgressRuns,
});
} else {
await setBillingLimit(organization.id, {
mode: "none",
cancelInProgressRuns: false,
});
}
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to update billing limit";
return redirectWithErrorMessage(
v3BillingLimitsPath({ slug: organizationSlug }),
request,
message
);
}
if (resettingAlerts) {
const [alertsError, existingAlerts] = await tryCatch(getBillingAlerts(organization.id));
if (alertsError || !existingAlerts) {
return redirectWithErrorMessage(
v3BillingLimitsPath({ slug: organizationSlug }),
request,
"Billing limit updated, but failed to clear billing alerts"
);
}
const existingAlertsFormData = normalizeBillingAlertsFromApi(existingAlerts, {
planLimitCents,
effectiveLimitCents: getEffectiveLimitCents(billingLimit, planLimitCents),
});
const shouldClearSavedAlerts = hadSavedAlertsToClearOnLimitChange(
existingAlertsFormData,
billingLimit,
planLimitCents
);
if (shouldClearSavedAlerts) {
const effectiveLimitCents = getEffectiveLimitCentsAfterLimitSave(
submission.value.mode,
planLimitCents,
submission.value.mode === "custom" ? submission.value.amount : undefined
);
const [clearAlertsError] = await tryCatch(
setBillingAlert(
organization.id,
resetAlertsPayloadForLimitMode(
submission.value.mode,
effectiveLimitCents,
existingAlerts.emails ?? []
)
)
);
if (clearAlertsError) {
return redirectWithErrorMessage(
v3BillingLimitsPath({ slug: organizationSlug }),
request,
"Billing limit updated, but failed to clear billing alerts"
);
}
const session = await getSession(request.headers.get("cookie"));
setSuccessMessage(session, "Billing limit updated");
return redirect(`${v3BillingLimitsPath({ slug: organizationSlug })}?alertsReset=1`, {
headers: {
"Set-Cookie": await commitSession(session),
},
});
}
}
const session = await getSession(request.headers.get("cookie"));
setSuccessMessage(session, "Billing limit updated");
return redirect(v3BillingLimitsPath({ slug: organizationSlug }), {
headers: {
"Set-Cookie": await commitSession(session),
},
});
}
if (intent === "billing-limit-resolve") {
const submission = parseWithZod(formData, { schema: billingLimitRecoveryFormSchema });
if (submission.status !== "success") {
return json({ formIntent: "billing-limit-resolve", submission: submission.reply() });
}
const [billingLimitError, billingLimit] = await tryCatch(getBillingLimit(organization.id));
if (billingLimitError || !billingLimit) {
return redirectWithErrorMessage(
v3BillingLimitsPath({ slug: organizationSlug }),
request,
"Failed to load billing limit"
);
}
if (!isEnforcementActive(billingLimit)) {
return redirectWithErrorMessage(
v3BillingLimitsPath({ slug: organizationSlug }),
request,
"Billing limit is not in an enforced state"
);
}
const resolvePayload =
submission.value.action === "increase"
? {
action: "increase" as const,
newAmountCents: Math.round((submission.value.newAmount ?? 0) * 100),
resumeMode: submission.value.resumeMode,
}
: {
action: "remove" as const,
resumeMode: submission.value.resumeMode,
};
const [error] = await tryCatch(resolveBillingLimit(organization.id, resolvePayload));
if (error) {
const message = error instanceof Error ? error.message : "Failed to resolve billing limit";
return redirectWithErrorMessage(
v3BillingLimitsPath({ slug: organizationSlug }),
request,
message
);
}
const session = await getSession(request.headers.get("cookie"));
setSuccessMessage(session, "Billing limit resolved");
const resumeModeParam = submission.value.resumeMode;
return redirect(
`${v3BillingLimitsPath({ slug: organizationSlug })}?resolved=1&resumeMode=${resumeModeParam}`,
{
headers: {
"Set-Cookie": await commitSession(session),
},
}
);
}
return json({ error: "Unknown form intent" }, { status: 400 });
}
);
export default function Page() {
const {
billingLimit,
alerts,
planLimitCents,
isRecoveryMode,
alertsResetRequested,
currentSpendCents,
queuedRunCount,
billingLimitPauseEnvCount,
resolveSubmitted,
submittedResumeMode,
suggestedNewLimitDollars,
} = useTypedLoaderData<LoaderData>();
const showResolveProgress = resolveSubmitted && billingLimitPauseEnvCount > 0;
const pageBodyRef = useScrollContainerToTop<HTMLDivElement>();
return (
<PageContainer>
<NavBar>
<PageTitle title="Billing limits" />
<PageAccessories>
<AdminDebugTooltip />
</PageAccessories>
</NavBar>
<PageBody scrollable ref={pageBodyRef}>
<div className="mx-auto flex max-w-3xl flex-col gap-10 px-4 pb-4 pt-10">
<BillingLimitResolveProgress
show={showResolveProgress}
cancellingQueuedRuns={submittedResumeMode === "new_only"}
/>
{isRecoveryMode && billingLimit.isConfigured ? (
<BillingLimitRecoveryPanel
billingLimit={billingLimit}
currentSpendCents={currentSpendCents}
queuedRunCount={queuedRunCount}
suggestedNewLimitDollars={suggestedNewLimitDollars}
/>
) : (
<BillingLimitConfigSection
billingLimit={billingLimit}
planLimitCents={planLimitCents}
/>
)}
<BillingAlertsSection
alerts={alerts}
billingLimit={billingLimit}
planLimitCents={planLimitCents}
alertsResetRequested={alertsResetRequested}
/>
</div>
</PageBody>
</PageContainer>
);
}
@@ -0,0 +1,239 @@
import { CalendarDaysIcon, CreditCardIcon, StarIcon } from "@heroicons/react/20/solid";
import { type MetaFunction } from "@remix-run/react";
import { type PlanDefinition } from "@trigger.dev/platform";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { Feedback } from "~/components/Feedback";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { DateTime } from "~/components/primitives/DateTime";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { prisma } from "~/db.server";
import { featuresForRequest } from "~/features.server";
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import { getCurrentPlan, getPlans } from "~/services/platform.v3.server";
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import {
OrganizationParamsSchema,
organizationPath,
v3StripePortalPath,
} from "~/utils/pathBuilder";
import { PricingPlans } from "../resources.orgs.$organizationSlug.select-plan";
export const meta: MetaFunction = () => {
return [
{
title: `Billing | Trigger.dev`,
},
];
};
export const loader = dashboardLoader(
{
params: OrganizationParamsSchema,
context: async (params) => {
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
return organizationId ? { organizationId } : {};
},
authorization: {
action: "manage",
resource: { type: "billing" },
message: "With your current role, you can't manage billing.",
},
},
async ({ params, request, user }) => {
const userId = user.id;
const { organizationSlug } = params;
const { isManagedCloud } = featuresForRequest(request);
if (!isManagedCloud) {
return redirect(organizationPath({ slug: organizationSlug }));
}
const organization = await prisma.organization.findFirst({
where: { slug: organizationSlug, members: { some: { userId } } },
});
if (!organization) {
throw new Response(null, { status: 404, statusText: "Organization not found" });
}
const currentPlan = await getCurrentPlan(organization.id);
const showSelfServe = currentPlan?.v3Subscription?.showSelfServe !== false;
//periods
const periodStart = new Date();
periodStart.setUTCHours(0, 0, 0, 0);
periodStart.setUTCDate(1);
const periodEnd = new Date();
periodEnd.setUTCMonth(periodEnd.getMonth() + 1);
periodEnd.setUTCDate(0);
periodEnd.setUTCHours(0, 0, 0, 0);
const daysRemaining = Math.ceil(
(periodEnd.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)
);
// Extract 'message' from search params
const url = new URL(request.url);
const message = url.searchParams.get("message");
if (!showSelfServe) {
return typedjson({
showSelfServe: false as const,
...currentPlan,
organizationSlug,
periodStart,
periodEnd,
daysRemaining,
message,
});
}
const plans = await getPlans();
if (!plans) {
throw new Response(null, { status: 404, statusText: "Plans not found" });
}
return typedjson({
showSelfServe: true as const,
...plans,
...currentPlan,
organizationSlug,
periodStart,
periodEnd,
daysRemaining,
message,
});
}
);
export default function ChoosePlanPage() {
const loaderData = useTypedLoaderData<typeof loader>();
const {
showSelfServe,
v3Subscription,
organizationSlug,
periodStart,
periodEnd,
daysRemaining,
message,
} = loaderData;
return (
<PageContainer>
<NavBar>
<PageTitle title="Billing" />
<PageAccessories>
{v3Subscription?.isPaying && showSelfServe && (
<>
<LinkButton
to={v3StripePortalPath({ slug: organizationSlug })}
variant="tertiary/small"
>
Invoices
</LinkButton>
<LinkButton
to={v3StripePortalPath({ slug: organizationSlug })}
variant="tertiary/small"
>
Manage card details
</LinkButton>
</>
)}
</PageAccessories>
</NavBar>
<PageBody scrollable={showSelfServe}>
{showSelfServe ? (
<div className="flex flex-col gap-3">
{message && (
<Callout variant="idea" className="mb-2">
{message}
</Callout>
)}
<div className="flex flex-col gap-y-3 divide-grid-bright rounded-sm border border-grid-bright bg-background-bright py-2 pr-1 text-text-bright lg:w-fit lg:flex-row lg:items-center lg:divide-x">
<div className="flex gap-2 px-3 lg:items-center">
<StarIcon className="size-5 min-w-5 lg:-mt-0.5" />
{planLabel(
v3Subscription?.plan,
v3Subscription?.canceledAt !== undefined,
periodEnd
)}
</div>
{v3Subscription?.isPaying ? (
<div className="flex gap-2 px-3 lg:items-center">
<CalendarDaysIcon className="size-5 min-w-5 lg:-mt-0.5" />
Billing period: <DateTime
date={periodStart}
includeTime={false}
timeZone="UTC"
/>{" "}
to <DateTime date={periodEnd} includeTime={false} timeZone="UTC" /> (
{daysRemaining} days remaining)
</div>
) : null}
</div>
<div>
<PricingPlans
plans={loaderData.plans}
concurrencyAddOnPricing={loaderData.addOnPricing.concurrency}
subscription={v3Subscription}
organizationSlug={organizationSlug}
hasPromotedPlan={false}
periodEnd={periodEnd}
/>
</div>
</div>
) : (
<MainCenteredContainer className="max-w-md">
<InfoPanel
title="Billing"
icon={CreditCardIcon}
iconClassName="text-emerald-500"
panelClassName="max-w-full"
accessory={
<Feedback
defaultValue="enterprise"
button={<Button variant="secondary/small">Contact us</Button>}
/>
}
>
<Paragraph spacing variant="small">
Your billing is managed by our team.
</Paragraph>
<Paragraph spacing variant="small">
Get in touch for invoices, plan changes, or other billing questions.
</Paragraph>
</InfoPanel>
</MainCenteredContainer>
)}
</PageBody>
</PageContainer>
);
}
function planLabel(plan: PlanDefinition | undefined, canceled: boolean, periodEnd: Date) {
if (!plan || plan.type === "free") {
return "You're on the Free plan";
}
if (plan.type === "enterprise") {
return "You're on the Enterprise plan";
}
const text = `You're on the $${plan.tierPrice}/mo ${plan.title} plan`;
if (canceled) {
return (
<>
{text}. From <DateTime includeTime={false} date={periodEnd} /> you're on the Free plan.
</>
);
}
return text;
}
@@ -0,0 +1,343 @@
import { type ActionFunctionArgs, type LoaderFunctionArgs, json, redirect } from "@remix-run/node";
import { fromPromise } from "neverthrow";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { DialogClose } from "@radix-ui/react-dialog";
import { TrashIcon } from "@heroicons/react/20/solid";
import { BugIcon } from "~/assets/icons/BugIcon";
import { SlackMonoIcon } from "~/assets/icons/SlackMonoIcon";
import { Button } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/primitives/Dialog";
import { FormButtons } from "~/components/primitives/FormButtons";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { Hint } from "~/components/primitives/Hint";
import {
MainHorizontallyCenteredContainer,
PageBody,
PageContainer,
} from "~/components/layout/AppLayout";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
import { $transaction, prisma } from "~/db.server";
import { requireOrganization } from "~/services/org.server";
import { OrganizationParamsSchema, organizationSlackIntegrationPath } from "~/utils/pathBuilder";
import { logger } from "~/services/logger.server";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const { organization } = await requireOrganization(request, organizationSlug);
const slackIntegration = await prisma.organizationIntegration.findFirst({
where: {
organizationId: organization.id,
service: "SLACK",
deletedAt: null,
},
});
if (!slackIntegration) {
return typedjson({
organization,
slackIntegration: null,
alertChannels: [],
teamName: null,
});
}
const integrationData = slackIntegration.integrationData as any;
const teamName = integrationData?.team?.name ?? null;
const alertChannels = await prisma.projectAlertChannel.findMany({
where: {
type: "SLACK",
project: { organizationId: organization.id },
OR: [
{ integrationId: slackIntegration.id },
{
properties: {
path: ["integrationId"],
equals: slackIntegration.id,
},
},
],
},
include: {
project: {
select: {
id: true,
slug: true,
name: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
return typedjson({
organization,
slackIntegration,
alertChannels,
teamName,
});
};
const ActionSchema = z.object({
intent: z.literal("uninstall"),
});
export const action = async ({ request, params }: ActionFunctionArgs) => {
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const { organization, userId } = await requireOrganization(request, organizationSlug);
const formData = await request.formData();
const result = ActionSchema.safeParse({ intent: formData.get("intent") });
if (!result.success) {
return json({ error: "Invalid action" }, { status: 400 });
}
const slackIntegration = await prisma.organizationIntegration.findFirst({
where: {
organizationId: organization.id,
service: "SLACK",
deletedAt: null,
},
});
if (!slackIntegration) {
return json({ error: "Slack integration not found" }, { status: 404 });
}
const txResult = await fromPromise(
$transaction(prisma, async (tx) => {
await tx.projectAlertChannel.updateMany({
where: {
type: "SLACK",
OR: [
{ integrationId: slackIntegration.id },
{
properties: {
path: ["integrationId"],
equals: slackIntegration.id,
},
},
],
},
data: {
enabled: false,
integrationId: null,
},
});
await tx.organizationIntegration.update({
where: { id: slackIntegration.id },
data: { deletedAt: new Date() },
});
}),
(error) => error
);
if (txResult.isErr()) {
logger.error("Failed to remove Slack integration", {
organizationId: organization.id,
organizationSlug,
userId,
integrationId: slackIntegration.id,
error: txResult.error instanceof Error ? txResult.error.message : String(txResult.error),
});
return json(
{ error: "Failed to remove Slack integration. Please try again." },
{ status: 500 }
);
}
logger.info("Slack integration removed successfully", {
organizationId: organization.id,
organizationSlug,
userId,
integrationId: slackIntegration.id,
});
return redirect(organizationSlackIntegrationPath({ slug: organizationSlug }));
};
export default function SlackIntegrationPage() {
const { slackIntegration, alertChannels, teamName } = useTypedLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isUninstalling =
navigation.state === "submitting" && navigation.formData?.get("intent") === "uninstall";
if (!slackIntegration) {
return (
<PageContainer>
<NavBar>
<PageTitle title="Slack integration" />
</NavBar>
<PageBody>
<div className="flex h-full flex-col items-center justify-center gap-3">
<SlackMonoIcon className="mb-2 size-16 text-secondary" />
<Header2>No Slack integration found</Header2>
<Paragraph className="max-w-md text-center text-text-dimmed">
Your organization doesn't have a Slack integration configured. You can connect Slack
when setting up alerts from the{" "}
<BugIcon className="-ml-0.5 mb-0.5 inline size-5 text-errors" />
<span className="text-text-bright">Errors</span> page.
</Paragraph>
</div>
</PageBody>
</PageContainer>
);
}
return (
<PageContainer>
<NavBar>
<PageTitle title="Slack integration" />
</NavBar>
<PageBody>
<MainHorizontallyCenteredContainer>
<div className="flex flex-col gap-6">
<div>
<div className="mb-3 border-b border-grid-dimmed pb-3">
<Header2>Integration details</Header2>
</div>
<div className="flex flex-col gap-1">
{teamName && (
<Paragraph variant="small">
<span className="text-text-dimmed">Workspace:</span>{" "}
<span className="text-text-bright">{teamName}</span>
</Paragraph>
)}
<Paragraph variant="small">
<span className="text-text-dimmed">Installed:</span>{" "}
<span className="text-text-bright">
<DateTime date={slackIntegration.createdAt} />
</span>
</Paragraph>
</div>
</div>
<div>
<Header3 spacing>
Connected alert channels
<span className="ml-1 text-text-dimmed">({alertChannels.length})</span>
</Header3>
{alertChannels.length === 0 ? (
<Paragraph variant="small" className="text-text-dimmed">
No alert channels are currently connected to this Slack integration.
</Paragraph>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Channel</TableHeaderCell>
<TableHeaderCell>Project</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Created</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{alertChannels.map((channel) => (
<TableRow key={channel.id}>
<TableCell>{channel.name}</TableCell>
<TableCell>{channel.project.name}</TableCell>
<TableCell>
<EnabledStatus enabled={channel.enabled} />
</TableCell>
<TableCell>
<DateTime date={channel.createdAt} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<div>
<Header2 spacing>Danger zone</Header2>
<div className="w-full rounded-sm border border-rose-500/40 p-4">
<Header3 spacing>Remove integration</Header3>
<Hint>
This will remove the Slack integration and disable all connected alert channels.
This action cannot be undone.
</Hint>
{actionData?.error && (
<Paragraph variant="small" className="mt-2 text-error">
{actionData.error}
</Paragraph>
)}
<FormButtons
className="mt-2"
confirmButton={
<Dialog>
<DialogTrigger asChild>
<Button
variant="danger/small"
LeadingIcon={TrashIcon}
disabled={isUninstalling}
>
Remove integration
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove Slack integration</DialogTitle>
</DialogHeader>
<DialogDescription className="mb-2">
This will remove the Slack integration and disable all connected alert
channels. This action cannot be undone.
</DialogDescription>
<FormButtons
confirmButton={
<Form method="post">
<input type="hidden" name="intent" value="uninstall" />
<Button
variant="danger/medium"
LeadingIcon={TrashIcon}
type="submit"
disabled={isUninstalling}
>
{isUninstalling ? "Removing…" : "Remove integration"}
</Button>
</Form>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</DialogContent>
</Dialog>
}
/>
</div>
</div>
</div>
</MainHorizontallyCenteredContainer>
</PageBody>
</PageContainer>
);
}
@@ -0,0 +1,412 @@
import { TrashIcon } from "@heroicons/react/20/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { fromPromise } from "neverthrow";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/primitives/Dialog";
import { FormButtons } from "~/components/primitives/FormButtons";
import { Header1 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { $transaction, prisma } from "~/db.server";
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import { VercelIntegrationRepository } from "~/models/vercelIntegration.server";
import { logger } from "~/services/logger.server";
import { requireOrganization } from "~/services/org.server";
import { rbac } from "~/services/rbac.server";
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
import { OrganizationParamsSchema, v3ProjectSettingsIntegrationsPath } from "~/utils/pathBuilder";
function formatDate(date: Date): string {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true,
}).format(date);
}
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const url = new URL(request.url);
const configurationId = url.searchParams.get("configurationId") ?? undefined;
const { organization, userId } = await requireOrganization(request, organizationSlug);
// Display flag for the Remove Integration control — the action enforces
// write:vercel independently. Permissive in OSS.
const sessionAuth = await rbac.authenticateSession(request, {
userId,
organizationId: organization.id,
});
const canManageVercel = sessionAuth.ok
? sessionAuth.ability.can("write", { type: "vercel" })
: true;
// Find Vercel integration for this organization
let vercelIntegration = await prisma.organizationIntegration.findFirst({
where: {
organizationId: organization.id,
service: "VERCEL",
deletedAt: null,
// If configurationId is provided, filter by it in integrationData
...(configurationId && {
integrationData: {
path: ["installationId"],
equals: configurationId,
},
}),
},
include: {
tokenReference: true,
},
});
if (!vercelIntegration) {
return typedjson({
organization,
vercelIntegration: null,
connectedProjects: [],
teamId: null,
installationId: null,
canManageVercel,
});
}
// Get team ID from integrationData
const integrationData = vercelIntegration.integrationData as any;
const teamId = integrationData?.teamId ?? null;
const installationId = integrationData?.installationId ?? null;
// Get all connected projects for this integration
const connectedProjects = await prisma.organizationProjectIntegration.findMany({
where: {
organizationIntegrationId: vercelIntegration.id,
deletedAt: null,
},
include: {
project: {
select: {
id: true,
slug: true,
name: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
return typedjson({
organization,
vercelIntegration,
connectedProjects,
teamId,
installationId,
canManageVercel,
});
};
const ActionSchema = z.object({
intent: z.literal("uninstall"),
});
export const action = dashboardAction(
{
params: OrganizationParamsSchema,
context: async (params) => {
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
return organizationId ? { organizationId } : {};
},
authorization: { action: "write", resource: { type: "vercel" } },
},
async ({ request, params }) => {
const { organizationSlug } = params;
const { organization, userId } = await requireOrganization(request, organizationSlug);
const formData = await request.formData();
const result = ActionSchema.safeParse({ intent: formData.get("intent") });
if (!result.success) {
return json({ error: "Invalid action" }, { status: 400 });
}
// Find Vercel integration
const vercelIntegration = await prisma.organizationIntegration.findFirst({
where: {
organizationId: organization.id,
service: "VERCEL",
deletedAt: null,
},
include: {
tokenReference: true,
},
});
if (!vercelIntegration) {
return json({ error: "Vercel integration not found" }, { status: 404 });
}
// Uninstall from Vercel side
const uninstallResult =
await VercelIntegrationRepository.uninstallVercelIntegration(vercelIntegration);
if (uninstallResult.isErr()) {
logger.error("Failed to uninstall Vercel integration", {
organizationId: organization.id,
organizationSlug,
userId,
integrationId: vercelIntegration.id,
error: uninstallResult.error.message,
});
return json(
{ error: "Failed to uninstall Vercel integration. Please try again." },
{ status: 500 }
);
}
// Soft-delete the integration and all connected projects in a transaction
const txResult = await fromPromise(
$transaction(prisma, async (tx) => {
await tx.organizationProjectIntegration.updateMany({
where: {
organizationIntegrationId: vercelIntegration.id,
deletedAt: null,
},
data: { deletedAt: new Date() },
});
await tx.organizationIntegration.update({
where: { id: vercelIntegration.id },
data: { deletedAt: new Date() },
});
}),
(error) => error
);
if (txResult.isErr()) {
logger.error("Failed to soft-delete Vercel integration records", {
organizationId: organization.id,
organizationSlug,
userId,
integrationId: vercelIntegration.id,
error: txResult.error instanceof Error ? txResult.error.message : String(txResult.error),
});
return json(
{ error: "Failed to uninstall Vercel integration. Please try again." },
{ status: 500 }
);
}
if (uninstallResult.value.authInvalid) {
logger.warn("Vercel integration uninstalled with auth error - token invalid", {
organizationId: organization.id,
organizationSlug,
userId,
integrationId: vercelIntegration.id,
});
} else {
logger.info("Vercel integration uninstalled successfully", {
organizationId: organization.id,
organizationSlug,
userId,
integrationId: vercelIntegration.id,
});
}
// Redirect back to organization settings
return redirect(`/orgs/${organizationSlug}/settings`);
}
);
export default function VercelIntegrationPage() {
const {
organization,
vercelIntegration,
connectedProjects,
teamId,
installationId,
canManageVercel,
} = useTypedLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isUninstalling =
navigation.state === "submitting" && navigation.formData?.get("intent") === "uninstall";
if (!vercelIntegration) {
return (
<PageContainer>
<PageBody>
<div className="flex flex-col items-center justify-center py-8">
<Header1>No Vercel Integration Found</Header1>
<Paragraph className="mt-2 text-center text-text-dimmed">
This organization doesn't have a Vercel integration configured.
</Paragraph>
</div>
</PageBody>
</PageContainer>
);
}
return (
<PageContainer>
<PageBody>
<div className="mb-8">
<Header1>Vercel Integration</Header1>
<Paragraph className="mt-2 text-text-dimmed">
Manage your organization's Vercel integration and connected projects.
</Paragraph>
</div>
{/* Integration Info Section */}
<div className="mb-8 rounded-lg border border-grid-bright bg-background-bright p-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-medium text-text-bright">Integration Details</h2>
<div className="mt-2 space-y-1 text-sm text-text-dimmed">
{teamId && (
<div>
<span className="font-medium">Vercel Team ID:</span> {teamId}
</div>
)}
{installationId && (
<div>
<span className="font-medium">Installation ID:</span> {installationId}
</div>
)}
<div>
<span className="font-medium">Installed:</span>{" "}
{formatDate(new Date(vercelIntegration.createdAt))}
</div>
</div>
</div>
<div className="flex flex-col items-end gap-2">
<Dialog>
<DialogTrigger asChild>
<Button
variant="danger/medium"
LeadingIcon={TrashIcon}
disabled={isUninstalling || !canManageVercel}
tooltip={
canManageVercel
? undefined
: "You don't have permission to manage the Vercel integration"
}
>
Remove Integration
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove Vercel Integration</DialogTitle>
</DialogHeader>
<DialogDescription>
This will permanently remove the Vercel integration and disconnect all projects.
This action cannot be undone.
</DialogDescription>
<FormButtons
confirmButton={
<Form method="post">
<input type="hidden" name="intent" value="uninstall" />
<Button
variant="danger/medium"
LeadingIcon={TrashIcon}
type="submit"
disabled={isUninstalling}
>
{isUninstalling ? "Removing..." : "Remove Integration"}
</Button>
</Form>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</DialogContent>
</Dialog>
{actionData?.error && (
<Paragraph variant="small" className="text-error">
{actionData.error}
</Paragraph>
)}
</div>
</div>
</div>
{/* Connected Projects Section */}
<div>
<h2 className="mb-4 text-lg font-medium text-text-bright">
Connected Projects ({connectedProjects.length})
</h2>
{connectedProjects.length === 0 ? (
<div className="rounded-lg border border-grid-bright bg-background-bright p-6 text-center">
<Paragraph className="text-text-dimmed">
No projects are currently connected to this Vercel integration.
</Paragraph>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Project Name</TableHeaderCell>
<TableHeaderCell>Vercel Project ID</TableHeaderCell>
<TableHeaderCell>Connected</TableHeaderCell>
<TableHeaderCell hiddenLabel>Actions</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{connectedProjects.map((projectIntegration) => (
<TableRow key={projectIntegration.id}>
<TableCell>{projectIntegration.project.name}</TableCell>
<TableCell className="font-mono text-xs">
{projectIntegration.externalEntityId}
</TableCell>
<TableCell>{formatDate(new Date(projectIntegration.createdAt))}</TableCell>
<TableCell>
<LinkButton
variant="minimal/small"
to={v3ProjectSettingsIntegrationsPath(
organization,
projectIntegration.project,
{ slug: "prod" } // Default to production environment
)}
>
Configure
</LinkButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</PageBody>
</PageContainer>
);
}
@@ -0,0 +1,308 @@
import {
BookOpenIcon,
ClipboardDocumentIcon,
PlusIcon,
TrashIcon,
} from "@heroicons/react/20/solid";
import { Form, useRevalidator, type MetaFunction } from "@remix-run/react";
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core/utils";
import type { PrivateLinkConnectionStatus } from "@trigger.dev/platform";
import { useMemo, useState } from "react";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import {
MainHorizontallyCenteredContainer,
PageBody,
PageContainer,
} from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import { Header2 } from "~/components/primitives/Headers";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { prisma } from "~/db.server";
import { useInterval } from "~/hooks/useInterval";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { logger } from "~/services/logger.server";
import { deletePrivateLink, getPrivateLinks } from "~/services/platform.v3.server";
import { requireUserId } from "~/services/session.server";
import {
docsPath,
OrganizationParamsSchema,
organizationPath,
v3PrivateConnectionsPath,
} from "~/utils/pathBuilder";
import { canAccessPrivateConnections } from "~/v3/canAccessPrivateConnections.server";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
export const meta: MetaFunction = () => {
return [{ title: `Private Connections | Trigger.dev` }];
};
export async function loader({ params, request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const canAccess = await canAccessPrivateConnections({ organizationSlug, userId });
if (!canAccess) {
return redirect(organizationPath({ slug: organizationSlug }));
}
const organization = await prisma.organization.findFirst({
where: { slug: organizationSlug, members: { some: { userId } } },
});
if (!organization) {
throw new Response(null, { status: 404, statusText: "Organization not found" });
}
const [error, connections] = await tryCatch(getPrivateLinks(organization.id));
if (error) {
logger.error("Error loading private link connections", {
error,
organizationId: organization.id,
});
}
return typedjson({
connections: connections?.connections ?? [],
organizationId: organization.id,
});
}
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
if (request.method !== "DELETE" && request.method !== "POST") {
return json({ error: "Method not allowed" }, { status: 405 });
}
const formData = await request.formData();
const connectionId = formData.get("connectionId");
const intent = formData.get("intent");
if (intent !== "delete" || typeof connectionId !== "string") {
return json({ error: "Invalid request" }, { status: 400 });
}
const organization = await prisma.organization.findFirst({
where: { slug: organizationSlug, members: { some: { userId } } },
});
if (!organization) {
return redirectWithErrorMessage(
v3PrivateConnectionsPath({ slug: organizationSlug }),
request,
"Organization not found"
);
}
const [error] = await tryCatch(deletePrivateLink(organization.id, connectionId));
if (error) {
return redirectWithErrorMessage(
v3PrivateConnectionsPath({ slug: organizationSlug }),
request,
`Failed to delete connection: ${error.message}`
);
}
return redirectWithSuccessMessage(
v3PrivateConnectionsPath({ slug: organizationSlug }),
request,
"Connection deletion initiated"
);
};
const STATUS_COLORS: Record<PrivateLinkConnectionStatus, string> = {
PENDING: "bg-amber-500/20 text-amber-400",
PROVISIONING: "bg-blue-500/20 text-blue-400",
ACTIVE: "bg-emerald-500/20 text-emerald-400",
ERROR: "bg-rose-500/20 text-rose-400",
DELETING: "bg-surface-control-active/20 text-text-dimmed",
};
function StatusBadge({ status }: { status: PrivateLinkConnectionStatus }) {
return (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[status]}`}
>
{status}
</span>
);
}
function CopyButton({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
return (
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
className="ml-1 inline-flex items-center text-text-dimmed transition hover:text-text-bright"
title="Copy to clipboard"
>
<ClipboardDocumentIcon className="h-3.5 w-3.5" />
{copied && <span className="ml-1 text-xs text-emerald-400">Copied</span>}
</button>
);
}
const TERMINAL_STATUSES: PrivateLinkConnectionStatus[] = ["ACTIVE", "ERROR"];
export default function Page() {
const { connections } = useTypedLoaderData<typeof loader>();
const plan = useCurrentPlan();
const revalidator = useRevalidator();
const hasInProgressConnections = useMemo(
() => connections.some((c) => !TERMINAL_STATUSES.includes(c.status)),
[connections]
);
useInterval({
interval: 3_000,
onLoad: false,
callback: () => {
if (revalidator.state === "idle") {
revalidator.revalidate();
}
},
disabled: !hasInProgressConnections,
});
const hasPrivateNetworking = plan?.v3Subscription?.plan?.limits?.hasPrivateNetworking ?? false;
const limit = plan?.v3Subscription?.plan?.limits?.privateLinkConnectionLimit ?? 2;
const canAdd = connections.filter((c) => c.status !== "DELETING").length < limit;
return (
<PageContainer>
<NavBar>
<PageTitle title="Private Connections" />
<PageAccessories>
<LinkButton
variant="docs/small"
LeadingIcon={BookOpenIcon}
to={docsPath("private-networking/overview")}
>
Private connection docs
</LinkButton>
{hasPrivateNetworking && canAdd && (
<LinkButton variant="primary/small" LeadingIcon={PlusIcon} to="new">
Add Connection
</LinkButton>
)}
</PageAccessories>
</NavBar>
<PageBody scrollable={true}>
<MainHorizontallyCenteredContainer className="max-w-3xl">
<div>
<div className="mb-4 border-b border-grid-dimmed pb-3">
<Header2 spacing>Private Connections</Header2>
<Paragraph variant="small">
Connect your AWS resources (databases, caches, APIs) to your Trigger.dev tasks via
AWS PrivateLink. Connections are organization-wide and work across all projects and
environments.
</Paragraph>
</div>
{!hasPrivateNetworking ? (
<div className="rounded-lg border border-grid-dimmed p-6 text-center">
<Paragraph variant="small" className="text-text-dimmed">
Private Connections require upgrading to Pro or an Enterprise plan.
</Paragraph>
</div>
) : connections.length === 0 ? (
<div className="rounded-lg border border-grid-dimmed p-6 text-center">
<Paragraph variant="small" className="mb-4 text-text-dimmed">
No private connections yet. Add your first connection to securely reach your AWS
resources from task pods.
</Paragraph>
<LinkButton variant="primary/small" LeadingIcon={PlusIcon} to="new">
Add Connection
</LinkButton>
</div>
) : (
<div className="flex flex-col gap-3">
{connections.map((connection) => (
<div key={connection.id} className="rounded-lg border border-grid-dimmed p-4">
<div>
<div className="mb-1 flex items-center gap-2">
<span className="font-medium text-text-bright">{connection.name}</span>
<StatusBadge status={connection.status} />
{connection.status !== "DELETING" && (
<Form method="POST" className="ml-auto">
<input type="hidden" name="connectionId" value={connection.id} />
<input type="hidden" name="intent" value="delete" />
<button
type="submit"
className="text-text-dimmed transition hover:text-rose-400"
title="Delete connection"
onClick={(e) => {
if (
!confirm(
`Delete connection "${connection.name}"? This will remove the VPC Endpoint and network access.`
)
) {
e.preventDefault();
}
}}
>
<TrashIcon className="h-4 w-4" />
</button>
</Form>
)}
</div>
<div className="space-y-1">
<div className="flex items-center text-xs text-text-dimmed">
<span className="w-24 shrink-0">Service:</span>
<code className="truncate font-mono text-text-dimmed">
{connection.endpointServiceName}
</code>
<CopyButton value={connection.endpointServiceName} />
</div>
<div className="flex items-center text-xs text-text-dimmed">
<span className="w-24 shrink-0">Region:</span>
<span>{connection.targetRegion}</span>
</div>
{connection.endpointIps && connection.endpointIps.length > 0 && (
<div className="flex items-center text-xs text-text-dimmed">
<span className="w-24 shrink-0">IPs:</span>
<code className="truncate font-mono text-emerald-400">
{connection.endpointIps.join(", ")}
</code>
<CopyButton value={connection.endpointIps.join(", ")} />
</div>
)}
{connection.statusMessage && (
<div className="flex items-center text-xs text-rose-400">
<span className="w-24 shrink-0">Error:</span>
<span>{connection.statusMessage}</span>
</div>
)}
<div className="flex items-center text-xs text-text-dimmed">
<span className="w-24 shrink-0">Created:</span>
<span>{new Date(connection.createdAt).toLocaleDateString()}</span>
</div>
</div>
</div>
</div>
))}
{!canAdd && (
<Paragraph variant="extra-small" className="text-text-dimmed">
Connection limit reached ({limit}). Delete an existing connection to add a new
one.
</Paragraph>
)}
</div>
)}
</div>
</MainHorizontallyCenteredContainer>
</PageBody>
</PageContainer>
);
}
@@ -0,0 +1,795 @@
import { getFormProps, getInputProps, getSelectProps, useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { Form, useActionData, useParams, type MetaFunction } from "@remix-run/react";
import { json, type ActionFunction, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core/utils";
import { useState } from "react";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import {
MainHorizontallyCenteredContainer,
PageBody,
PageContainer,
} from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { ClipboardField } from "~/components/primitives/ClipboardField";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Select, SelectItem } from "~/components/primitives/Select";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { canAccessPrivateConnections } from "~/v3/canAccessPrivateConnections.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import type { CreatePrivateLinkConnectionBody } from "@trigger.dev/platform";
import { createPrivateLink, getPrivateLinkRegions } from "~/services/platform.v3.server";
import { requireUserId } from "~/services/session.server";
import {
docsPath,
OrganizationParamsSchema,
organizationPath,
v3PrivateConnectionsPath,
} from "~/utils/pathBuilder";
import {
ArrowTopRightOnSquareIcon,
BookOpenIcon,
CommandLineIcon,
DocumentTextIcon,
PencilSquareIcon,
SparklesIcon,
TrashIcon,
} from "@heroicons/react/20/solid";
export const meta: MetaFunction = () => {
return [{ title: `Add Private Connection | Trigger.dev` }];
};
export async function loader({ params, request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const canAccess = await canAccessPrivateConnections({ organizationSlug, userId });
if (!canAccess) {
return redirect(organizationPath({ slug: organizationSlug }));
}
const organization = await prisma.organization.findFirst({
where: { slug: organizationSlug, members: { some: { userId } } },
});
if (!organization) {
throw new Response(null, { status: 404, statusText: "Organization not found" });
}
const [_error, regions] = await tryCatch(getPrivateLinkRegions(organization.id));
const awsAccountIds = env.PRIVATE_CONNECTIONS_AWS_ACCOUNT_IDS?.split(",").filter(Boolean) ?? [];
return typedjson({
availableRegions: regions?.availableRegions ?? ["us-east-1", "eu-central-1"],
activeRegions: regions?.activeRegions ?? [],
awsAccountIds,
});
}
const schema = z.object({
name: z.string().min(1, "Name is required").max(100, "Name must be 100 characters or less"),
endpointServiceName: z
.string()
.min(1, "VPC Endpoint Service name is required")
.regex(
/^com\.amazonaws\.vpce\..+\.vpce-svc-.+$/,
"Must be a valid VPC Endpoint Service name (com.amazonaws.vpce.<region>.vpce-svc-*)"
),
targetRegion: z.string().min(1, "Region is required"),
});
export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const formData = await request.formData();
const submission = parseWithZod(formData, { schema });
if (submission.status !== "success") {
return json(submission.reply());
}
const organization = await prisma.organization.findFirst({
where: { slug: organizationSlug, members: { some: { userId } } },
});
if (!organization) {
return redirectWithErrorMessage(
v3PrivateConnectionsPath({ slug: organizationSlug }),
request,
"Organization not found"
);
}
// Fetch available regions dynamically (same call the loader makes)
const [, fetchedRegions] = await tryCatch(getPrivateLinkRegions(organization.id));
const availableRegions = fetchedRegions?.availableRegions ?? ["us-east-1", "eu-central-1"];
const { targetRegion: selectedRegion, ...rest } = submission.value;
if (!availableRegions.includes(selectedRegion)) {
return redirectWithErrorMessage(
v3PrivateConnectionsPath({ slug: organizationSlug }),
request,
`Invalid region: ${selectedRegion}`
);
}
const [error] = await tryCatch(
createPrivateLink(organization.id, {
...rest,
targetRegion: selectedRegion as CreatePrivateLinkConnectionBody["targetRegion"],
})
);
if (error) {
return redirectWithErrorMessage(
v3PrivateConnectionsPath({ slug: organizationSlug }),
request,
error.message
);
}
const message = "Connection created! Provisioning will begin shortly.";
return redirectWithSuccessMessage(
v3PrivateConnectionsPath({ slug: organizationSlug }),
request,
message
);
};
type SetupMethod = "manual" | "ai" | "terraform" | "docs";
type PortEntry = { port: string; protocol: "TCP" | "UDP" };
const AWS_REGIONS = [
{ value: "us-east-1", label: "US East (N. Virginia)" },
{ value: "us-east-2", label: "US East (Ohio)" },
{ value: "us-west-1", label: "US West (N. California)" },
{ value: "us-west-2", label: "US West (Oregon)" },
{ value: "af-south-1", label: "Africa (Cape Town)" },
{ value: "ap-east-1", label: "Asia Pacific (Hong Kong)" },
{ value: "ap-south-1", label: "Asia Pacific (Mumbai)" },
{ value: "ap-south-2", label: "Asia Pacific (Hyderabad)" },
{ value: "ap-southeast-1", label: "Asia Pacific (Singapore)" },
{ value: "ap-southeast-2", label: "Asia Pacific (Sydney)" },
{ value: "ap-southeast-3", label: "Asia Pacific (Jakarta)" },
{ value: "ap-southeast-4", label: "Asia Pacific (Melbourne)" },
{ value: "ap-northeast-1", label: "Asia Pacific (Tokyo)" },
{ value: "ap-northeast-2", label: "Asia Pacific (Seoul)" },
{ value: "ap-northeast-3", label: "Asia Pacific (Osaka)" },
{ value: "ca-central-1", label: "Canada (Central)" },
{ value: "ca-west-1", label: "Canada West (Calgary)" },
{ value: "eu-central-1", label: "Europe (Frankfurt)" },
{ value: "eu-central-2", label: "Europe (Zurich)" },
{ value: "eu-west-1", label: "Europe (Ireland)" },
{ value: "eu-west-2", label: "Europe (London)" },
{ value: "eu-west-3", label: "Europe (Paris)" },
{ value: "eu-south-1", label: "Europe (Milan)" },
{ value: "eu-south-2", label: "Europe (Spain)" },
{ value: "eu-north-1", label: "Europe (Stockholm)" },
{ value: "il-central-1", label: "Israel (Tel Aviv)" },
{ value: "me-south-1", label: "Middle East (Bahrain)" },
{ value: "me-central-1", label: "Middle East (UAE)" },
{ value: "sa-east-1", label: "South America (São Paulo)" },
];
function TerraformWizard({ awsAccountIds }: { awsAccountIds: string[] }) {
const [hostname, setHostname] = useState("");
const [ports, setPorts] = useState<PortEntry[]>([{ port: "5432", protocol: "TCP" }]);
const [region, setRegion] = useState("us-east-1");
const addPort = () => setPorts([...ports, { port: "", protocol: "TCP" }]);
const removePort = (index: number) => setPorts(ports.filter((_, i) => i !== index));
const updatePort = (index: number, field: keyof PortEntry, value: string) =>
setPorts(ports.map((p, i) => (i === index ? { ...p, [field]: value } : p)));
const validPorts = ports.filter((p) => p.port !== "");
const terraformScript = `# Trigger.dev Private Networking - Terraform Configuration
# Creates an NLB and VPC Endpoint Service for your resource
variable "vpc_id" {
description = "Your VPC ID"
type = string
}
variable "subnet_ids" {
description = "Private subnet IDs in your VPC"
type = list(string)
}
variable "target_ip" {
description = "IP address of the target resource"
type = string${hostname ? `\n default = "${hostname}"` : ""}
}
# Network Load Balancer
resource "aws_lb" "trigger_privatelink" {
name = "trigger-privatelink"
internal = true
load_balancer_type = "network"
subnets = var.subnet_ids
}
${validPorts
.map(
(p, i) => `
resource "aws_lb_target_group" "port_${p.port}" {
name = "trigger-pl-${p.port}"
port = ${p.port}
protocol = "${p.protocol}"
vpc_id = var.vpc_id
target_type = "ip"
health_check {
protocol = "TCP"
port = ${p.port}
}
}
resource "aws_lb_target_group_attachment" "port_${p.port}" {
target_group_arn = aws_lb_target_group.port_${p.port}.arn
target_id = var.target_ip
port = ${p.port}
}
resource "aws_lb_listener" "port_${p.port}" {
load_balancer_arn = aws_lb.trigger_privatelink.arn
port = ${p.port}
protocol = "${p.protocol}"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.port_${p.port}.arn
}
}`
)
.join("\n")}
# VPC Endpoint Service
resource "aws_vpc_endpoint_service" "trigger_privatelink" {
acceptance_required = false
network_load_balancer_arns = [aws_lb.trigger_privatelink.arn]
# Trigger.dev runs in us-east-1 and eu-central-1. Listing both makes this
# service consumable from either region so any of your tasks can connect.
supported_regions = ["us-east-1", "eu-central-1"]
allowed_principals = [
${awsAccountIds.map((id) => ` "arn:aws:iam::${id}:root",`).join("\n")}
]
}
output "endpoint_service_name" {
description = "Paste this into the Trigger.dev dashboard"
value = aws_vpc_endpoint_service.trigger_privatelink.service_name
}
`;
return (
<div className="space-y-4">
<InputGroup>
<Label>Resource hostname or IP</Label>
<Input
value={hostname}
onChange={(e) => setHostname(e.target.value)}
placeholder="my-database.abc123.us-east-1.rds.amazonaws.com"
fullWidth
/>
</InputGroup>
<div>
<Label>Ports</Label>
<div className="mt-1 space-y-2">
{ports.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<Input
type="number"
min={1}
max={65535}
value={entry.port}
onChange={(e) => updatePort(index, "port", e.target.value)}
placeholder="Port"
className="w-24"
/>
<select
value={entry.protocol}
onChange={(e) => updatePort(index, "protocol", e.target.value)}
className="rounded-md border border-grid-bright bg-background-bright px-3 py-2 text-sm text-text-bright"
>
<option value="TCP">TCP</option>
<option value="UDP">UDP</option>
</select>
{ports.length > 1 && (
<button
type="button"
onClick={() => removePort(index)}
className="text-text-dimmed transition hover:text-rose-400"
title="Remove port"
>
<TrashIcon className="h-4 w-4" />
</button>
)}
</div>
))}
<button
type="button"
onClick={addPort}
className="text-xs text-indigo-400 transition hover:text-indigo-300"
>
+ Add port
</button>
</div>
</div>
<InputGroup>
<Label>Resource AWS Region</Label>
<Select
variant="tertiary/medium"
value={region}
setValue={(v) => setRegion(v)}
items={AWS_REGIONS}
filter={(item, search) => {
const s = search.toLowerCase();
return item.value.toLowerCase().includes(s) || item.label.toLowerCase().includes(s);
}}
text={(value) => {
const r = AWS_REGIONS.find((r) => r.value === value);
return r ? `${r.value}${r.label}` : value;
}}
placeholder="Select a region"
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
<span className="text-text-bright">{item.value}</span>
<span className="ml-2 text-text-dimmed">{item.label}</span>
</SelectItem>
))
}
</Select>
</InputGroup>
<div className="rounded-md border border-grid-bright bg-background-deep">
<div className="flex items-center justify-between border-b border-grid-bright px-3 py-2">
<span className="text-xs font-medium text-text-dimmed">main.tf</span>
<button
type="button"
onClick={() => navigator.clipboard.writeText(terraformScript)}
className="text-xs text-text-dimmed transition hover:text-text-bright"
>
Copy
</button>
</div>
<pre className="max-h-64 overflow-auto p-3 text-xs text-text-dimmed">
<code>{terraformScript}</code>
</pre>
</div>
</div>
);
}
function AIPromptWizard({ awsAccountIds }: { awsAccountIds: string[] }) {
const [hostname, setHostname] = useState("");
const [ports, setPorts] = useState<PortEntry[]>([{ port: "5432", protocol: "TCP" }]);
const [region, setRegion] = useState("us-east-1");
const addPort = () => setPorts([...ports, { port: "", protocol: "TCP" }]);
const removePort = (index: number) => setPorts(ports.filter((_, i) => i !== index));
const updatePort = (index: number, field: keyof PortEntry, value: string) =>
setPorts(ports.map((p, i) => (i === index ? { ...p, [field]: value } : p)));
const validPorts = ports.filter((p) => p.port !== "");
const regionLabel = AWS_REGIONS.find((r) => r.value === region)?.label ?? region;
const _portsDescription =
validPorts.length > 0
? validPorts.map((p) => `${p.port} (${p.protocol})`).join(", ")
: "5432 (TCP)";
const prompt = `I need to set up AWS PrivateLink so that Trigger.dev can connect to my resource. Please create the following in my AWS account in the ${region} (${regionLabel}) region:
1. A Network Load Balancer (NLB):
- Name: trigger-privatelink
- Internal: yes
- Type: network
- Place it in my private subnets
2. For each of the following ports, create a target group, target group attachment, and listener:
${validPorts.length > 0 ? validPorts.map((p) => ` - Port ${p.port} (${p.protocol})`).join("\n") : " - Port 5432 (TCP)"}
Each target group should:
- Target type: ip
- Target IP: ${hostname || "<my-resource-ip>"}
- Have a TCP health check on the same port
3. A VPC Endpoint Service:
- Acceptance required: no
- Attach the NLB created above
- Supported regions: us-east-1, eu-central-1 (these are the AWS regions Trigger.dev runs in, so the service must be consumable from both)
- Allowed principals:
${awsAccountIds.map((id) => ` - arn:aws:iam::${id}:root`).join("\n") || " - <Trigger.dev AWS account ARN>"}
After creating everything, give me the VPC Endpoint Service name (it looks like com.amazonaws.vpce.<region>.vpce-svc-*) so I can paste it into the Trigger.dev dashboard.`;
return (
<div className="space-y-4">
<InputGroup>
<Label>Resource hostname or IP</Label>
<Input
value={hostname}
onChange={(e) => setHostname(e.target.value)}
placeholder="my-database.abc123.us-east-1.rds.amazonaws.com"
fullWidth
/>
</InputGroup>
<div>
<Label>Ports</Label>
<div className="mt-1 space-y-2">
{ports.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<Input
type="number"
min={1}
max={65535}
value={entry.port}
onChange={(e) => updatePort(index, "port", e.target.value)}
placeholder="Port"
className="w-24"
/>
<select
value={entry.protocol}
onChange={(e) => updatePort(index, "protocol", e.target.value)}
className="rounded-md border border-grid-bright bg-background-bright px-3 py-2 text-sm text-text-bright"
>
<option value="TCP">TCP</option>
<option value="UDP">UDP</option>
</select>
{ports.length > 1 && (
<button
type="button"
onClick={() => removePort(index)}
className="text-text-dimmed transition hover:text-rose-400"
title="Remove port"
>
<TrashIcon className="h-4 w-4" />
</button>
)}
</div>
))}
<button
type="button"
onClick={addPort}
className="text-xs text-indigo-400 transition hover:text-indigo-300"
>
+ Add port
</button>
</div>
</div>
<InputGroup>
<Label>Resource AWS Region</Label>
<Select
variant="tertiary/medium"
value={region}
setValue={(v) => setRegion(v)}
items={AWS_REGIONS}
filter={(item, search) => {
const s = search.toLowerCase();
return item.value.toLowerCase().includes(s) || item.label.toLowerCase().includes(s);
}}
text={(value) => {
const r = AWS_REGIONS.find((r) => r.value === value);
return r ? `${r.value}${r.label}` : value;
}}
placeholder="Select a region"
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
<span className="text-text-bright">{item.value}</span>
<span className="ml-2 text-text-dimmed">{item.label}</span>
</SelectItem>
))
}
</Select>
</InputGroup>
<div className="rounded-md border border-grid-bright bg-background-deep">
<div className="flex items-center justify-between border-b border-grid-bright px-3 py-2">
<span className="text-xs font-medium text-text-dimmed">AI Prompt</span>
<button
type="button"
onClick={() => navigator.clipboard.writeText(prompt)}
className="text-xs text-text-dimmed transition hover:text-text-bright"
>
Copy
</button>
</div>
<pre className="max-h-80 overflow-auto whitespace-pre-wrap p-3 text-xs text-text-dimmed">
{prompt}
</pre>
</div>
</div>
);
}
export default function Page() {
const { availableRegions, activeRegions, awsAccountIds } = useTypedLoaderData<typeof loader>();
const { organizationSlug } = useParams();
const lastSubmission = useActionData();
const [setupMethod, setSetupMethod] = useState<SetupMethod | null>("manual");
const defaultRegion = "us-east-1";
const [form, { name, endpointServiceName, targetRegion }] = useForm({
id: "create-private-connection",
lastResult: lastSubmission as any,
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
});
return (
<PageContainer>
<NavBar>
<PageTitle
title="Add Private Connection"
backButton={{
to: v3PrivateConnectionsPath({ slug: organizationSlug! }),
text: "Private Connections",
}}
/>
<PageAccessories>
<LinkButton
variant="docs/small"
LeadingIcon={BookOpenIcon}
to={docsPath("private-networking/overview")}
>
Private connection docs
</LinkButton>
</PageAccessories>
</NavBar>
<PageBody scrollable={true}>
<MainHorizontallyCenteredContainer className="max-w-3xl">
<div>
<div className="mb-4 border-b border-grid-dimmed pb-3">
<Header2 spacing>Add Private Connection</Header2>
<Paragraph variant="small">
Connect your AWS resources to Trigger.dev task pods via AWS PrivateLink. You'll need
to create a VPC Endpoint Service on your AWS account first.
</Paragraph>
</div>
{/* Setup method cards */}
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<button
type="button"
onClick={() => setSetupMethod("manual")}
className={`rounded-lg border p-4 text-left transition ${
setupMethod === "manual"
? "border-indigo-500 bg-indigo-500/10"
: "border-grid-dimmed hover:border-border-bright"
}`}
>
<PencilSquareIcon className="mb-2 h-5 w-5 text-indigo-400" />
<div className="text-sm font-medium text-text-bright">I have my details</div>
<div className="mt-1 text-xs text-text-dimmed">
Already have your VPC Endpoint Service name
</div>
</button>
<button
type="button"
onClick={() => setSetupMethod("ai")}
className={`rounded-lg border p-4 text-left transition ${
setupMethod === "ai"
? "border-indigo-500 bg-indigo-500/10"
: "border-grid-dimmed hover:border-border-bright"
}`}
>
<SparklesIcon className="mb-2 h-5 w-5 text-purple-400" />
<div className="text-sm font-medium text-text-bright">Set up with AI</div>
<div className="mt-1 text-xs text-text-dimmed">
Generate a prompt for your AI assistant
</div>
</button>
<button
type="button"
onClick={() => setSetupMethod("terraform")}
className={`rounded-lg border p-4 text-left transition ${
setupMethod === "terraform"
? "border-indigo-500 bg-indigo-500/10"
: "border-grid-dimmed hover:border-border-bright"
}`}
>
<CommandLineIcon className="mb-2 h-5 w-5 text-emerald-400" />
<div className="text-sm font-medium text-text-bright">Set up with Terraform</div>
<div className="mt-1 text-xs text-text-dimmed">
Generate a customized Terraform script
</div>
</button>
<button
type="button"
onClick={() => setSetupMethod("docs")}
className={`rounded-lg border p-4 text-left transition ${
setupMethod === "docs"
? "border-indigo-500 bg-indigo-500/10"
: "border-grid-dimmed hover:border-border-bright"
}`}
>
<DocumentTextIcon className="mb-2 h-5 w-5 text-amber-400" />
<div className="text-sm font-medium text-text-bright">Step-by-step guide</div>
<div className="mt-1 text-xs text-text-dimmed">
Visual instructions for the AWS Console
</div>
</button>
</div>
{/* AI prompt wizard */}
{setupMethod === "ai" && (
<div className="mb-6 rounded-lg border border-grid-dimmed p-4">
<Header3 spacing>AI-Assisted Setup</Header3>
<Paragraph variant="small" className="mb-4">
Fill in your resource details below and we'll generate a prompt you can paste into
Claude, ChatGPT, or any AI assistant with AWS access. After it creates the
resources, paste the VPC Endpoint Service name below.
</Paragraph>
<AIPromptWizard awsAccountIds={awsAccountIds} />
</div>
)}
{/* Terraform wizard (expandable) */}
{setupMethod === "terraform" && (
<div className="mb-6 rounded-lg border border-grid-dimmed p-4">
<Header3 spacing>Terraform Configuration</Header3>
<Paragraph variant="small" className="mb-4">
Fill in your resource details below and we'll generate a Terraform script. Run{" "}
<code className="rounded bg-background-bright px-1 py-0.5 text-xs">
terraform apply
</code>{" "}
to create the VPC Endpoint Service, then paste the output service name below.
</Paragraph>
<TerraformWizard awsAccountIds={awsAccountIds} />
</div>
)}
{/* Docs link */}
{setupMethod === "docs" && (
<div className="mb-6 rounded-lg border border-grid-dimmed p-4">
<Header3 spacing>Setup Guide</Header3>
{awsAccountIds.length > 0 && (
<>
<Paragraph variant="small" className="mb-3">
When adding allowed principals to your VPC Endpoint Service, use the following
AWS account ARN(s):
</Paragraph>
<div className="mb-4 space-y-2">
{awsAccountIds.map((id) => (
<ClipboardField
key={id}
value={`arn:aws:iam::${id}:root`}
variant="primary/medium"
/>
))}
</div>
</>
)}
<Paragraph variant="small" className="mb-3">
Follow the step-by-step guide in our docs to create a VPC Endpoint Service in the
AWS Console, then come back here to add the connection.
</Paragraph>
<LinkButton
to={docsPath("private-networking/aws-console-setup")}
variant="primary/medium"
TrailingIcon={ArrowTopRightOnSquareIcon}
>
Open setup guide
</LinkButton>
</div>
)}
{/* AWS account ARNs reference */}
{setupMethod === "manual" && awsAccountIds.length > 0 && (
<div className="mb-6 rounded-lg border border-grid-dimmed p-4">
<Paragraph variant="small" className="mb-3">
Add the following AWS account ARN(s) to your VPC Endpoint Service's allowed
principals:
</Paragraph>
<div className="space-y-2">
{awsAccountIds.map((id) => (
<ClipboardField
key={id}
value={`arn:aws:iam::${id}:root`}
variant="primary/medium"
/>
))}
</div>
</div>
)}
{/* Connection form (always visible) */}
<div className="rounded-lg border border-grid-dimmed p-4">
<Header3 spacing>Connection Details</Header3>
<Form method="post" {...getFormProps(form)}>
<Fieldset>
<InputGroup fullWidth>
<Label htmlFor={name.id} required>
Friendly name
</Label>
<Input
{...getInputProps(name, { type: "text" })}
placeholder="e.g., Production Database, Redis Cache"
fullWidth
/>
<FormError id={name.errorId}>{name.errors}</FormError>
</InputGroup>
<InputGroup fullWidth>
<Label htmlFor={endpointServiceName.id} required>
VPC Endpoint Service name
</Label>
<Input
{...getInputProps(endpointServiceName, { type: "text" })}
placeholder="com.amazonaws.vpce.us-east-1.vpce-svc-0123456789abcdef0"
fullWidth
/>
<FormError id={endpointServiceName.errorId}>
{endpointServiceName.errors}
</FormError>
</InputGroup>
<InputGroup fullWidth>
<Label htmlFor={targetRegion.id} required>
Target region
</Label>
<select
{...getSelectProps(targetRegion)}
defaultValue={defaultRegion}
className="w-full rounded-md border border-grid-bright bg-background-bright px-3 py-2 text-sm text-text-bright"
>
{availableRegions.map((region: string) => (
<option key={region} value={region}>
{region}
</option>
))}
</select>
<FormError id={targetRegion.errorId}>{targetRegion.errors}</FormError>
{activeRegions.length > 0 && (
<Paragraph variant="extra-small" className="text-text-dimmed">
Your tasks have recently run in: {activeRegions.join(", ")}
</Paragraph>
)}
</InputGroup>
<FormButtons
cancelButton={
<LinkButton variant="tertiary/small" to="..">
Cancel
</LinkButton>
}
confirmButton={
<Button type="submit" variant="primary/small">
Create Connection
</Button>
}
/>
</Fieldset>
</Form>
</div>
</div>
</MainHorizontallyCenteredContainer>
</PageBody>
</PageContainer>
);
}
@@ -0,0 +1,392 @@
import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { type MetaFunction } from "@remix-run/react";
import { useState } from "react";
import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
import { Feedback } from "~/components/Feedback";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Badge } from "~/components/primitives/Badge";
import { Button } from "~/components/primitives/Buttons";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { Header3 } from "~/components/primitives/Headers";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { TextLink } from "~/components/primitives/TextLink";
import { useOrganization } from "~/hooks/useOrganizations";
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import { rbac } from "~/services/rbac.server";
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
export const meta: MetaFunction = () => {
return [
{
title: `Roles | Trigger.dev`,
},
];
};
const Params = z.object({
organizationSlug: z.string(),
});
export const loader = dashboardLoader(
{
params: Params,
context: async (params) => {
const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
return orgId ? { organizationId: orgId } : {};
},
authorization: { action: "read", resource: { type: "members" } },
},
async ({ context, user }) => {
const orgId = context.organizationId;
if (!orgId) {
throw new Response("Not Found", { status: 404 });
}
const [roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin, currentRole] =
await Promise.all([
rbac.allRoles(orgId),
rbac.getAssignableRoleIds(orgId),
rbac.allPermissions(orgId),
rbac.systemRoles(orgId),
// OSS self-host has no RBAC plugin.
rbac.isUsingPlugin(),
rbac.getUserRole({ userId: user.id, organizationId: orgId }),
]);
return typedjson({
roles,
assignableRoleIds,
allPermissions,
systemRoles,
isUsingPlugin,
currentRoleName: currentRole?.name ?? null,
});
}
);
type LoaderData = UseDataFunctionReturn<typeof loader>;
type LoaderRole = LoaderData["roles"][number];
type LoaderPermission = LoaderData["allPermissions"][number];
type RolePermission = LoaderRole["permissions"][number];
// Ungrouped permissions fall into "Other".
const FALLBACK_GROUP = "Other";
export default function Page() {
const { roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin, currentRoleName } =
useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const showSelfServe = useShowSelfServe();
const rolesById = new Map<string, LoaderRole>(roles.map((r) => [r.id, r]));
const assignable = new Set(assignableRoleIds);
// System roles first (plugin order), then custom roles.
const systemRoleOrder = systemRoles ?? [];
const systemRoleIdSet = new Set(systemRoleOrder.map((r) => r.id));
const systemColumns = systemRoleOrder.flatMap((meta) => {
const role = rolesById.get(meta.id);
return role ? [{ role, fallbackName: meta.name }] : [];
});
const customColumns = roles
.filter((r) => !systemRoleIdSet.has(r.id))
.map((role) => ({ role, fallbackName: role.name }));
const columns = [...systemColumns, ...customColumns];
const grouped = groupPermissions(allPermissions);
return (
<PageContainer>
<NavBar>
<PageTitle title="Roles" />
{/* Hide on OSS self-host and managed customers (!showSelfServe). */}
{isUsingPlugin && showSelfServe ? <RequestCustomRoles /> : null}
</NavBar>
<PageBody scrollable={false}>
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
<div className="border-b border-grid-bright px-4 py-6">
<Paragraph variant="small">
Roles control what each team member can do in <strong>{organization.title}</strong>.
Compare what each role grants below; assign a role to a team member from the{" "}
<TextLink to={`/orgs/${organization.slug}/settings/team`}>Team page</TextLink>.
</Paragraph>
{currentRoleName ? (
<Paragraph variant="small" className="mt-2">
Your role is <strong className="text-text-bright">{currentRoleName}</strong>.
</Paragraph>
) : null}
</div>
<div className="min-h-0 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
{columns.length === 0 ? (
<EmptyState isUsingPlugin={isUsingPlugin} showSelfServe={showSelfServe} />
) : (
<Table stickyHeader containerClassName="border-t-0">
<TableHeader>
<TableRow>
<TableHeaderCell>Permission</TableHeaderCell>
{columns.map(({ role }) => (
<TableHeaderCell key={role.id}>
<div className="flex items-center gap-1">
<span>{role.name}</span>
<PlanBadge
roleId={role.id}
assignable={assignable}
systemRoleIdSet={systemRoleIdSet}
/>
</div>
</TableHeaderCell>
))}
<TableHeaderCell>Description</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{grouped.length === 0 ? (
<TableBlankRow colSpan={columns.length + 2}>
<Paragraph variant="small" className="text-text-dimmed">
No permissions to display.
</Paragraph>
</TableBlankRow>
) : (
grouped.flatMap(({ group, permissions }) => [
<TableRow key={`${group}-header`}>
<TableCell colSpan={columns.length + 2} className="bg-background-bright">
<Header3 className="text-xs uppercase tracking-wide text-text-dimmed">
{group}
</Header3>
</TableCell>
</TableRow>,
...permissions.map((permission) => (
<TableRow key={permission.name}>
<TableCell>
<code className="text-xs">{permission.name}</code>
</TableCell>
{columns.map(({ role }) => (
<TableCell key={role.id}>
<RoleCell
permissionName={permission.name}
rolePermissions={role.permissions}
/>
</TableCell>
))}
<TableCell>
<Paragraph variant="small">
{permission.description || (
<span className="text-text-dimmed"></span>
)}
</Paragraph>
</TableCell>
</TableRow>
)),
])
)}
</TableBody>
</Table>
)}
</div>
</div>
</PageBody>
</PageContainer>
);
}
function EmptyState({
isUsingPlugin,
showSelfServe,
}: {
isUsingPlugin: boolean;
showSelfServe: boolean;
}) {
// OSS self-host vs plan-gated empty state.
if (!isUsingPlugin) {
return (
<div className="flex flex-col items-center gap-2 p-8 text-center">
<Header3>Roles aren't available in this self-hosted deployment.</Header3>
<Paragraph variant="small" className="text-text-dimmed">
All members have full access. Role-Based Access Controls are available in Trigger.dev
Cloud or with an enterprise self-hosted license.
</Paragraph>
</div>
);
}
return (
<div className="flex flex-col items-center gap-2 p-8 text-center">
<Header3>No roles available on this plan.</Header3>
<Paragraph variant="small" className="text-text-dimmed">
{showSelfServe
? "Upgrade to Pro to unlock RBAC."
: "Contact us to discuss RBAC for your organization."}
</Paragraph>
{!showSelfServe ? (
<Feedback
defaultValue="enterprise"
button={<Button variant="secondary/small">Contact us</Button>}
/>
) : null}
</div>
);
}
function PlanBadge({
roleId,
assignable,
systemRoleIdSet,
}: {
roleId: string;
assignable: ReadonlySet<string>;
systemRoleIdSet: ReadonlySet<string>;
}) {
if (assignable.has(roleId)) return null;
// Unassignable system roles → Pro; custom roles → Enterprise.
if (systemRoleIdSet.has(roleId)) {
return <Badge variant="extra-small">Pro</Badge>;
}
return <Badge variant="extra-small">Enterprise</Badge>;
}
function RoleCell({
permissionName,
rolePermissions,
}: {
permissionName: string;
rolePermissions: RolePermission[];
}) {
const matching = rolePermissions.filter((p) => p.name === permissionName);
if (matching.length === 0) {
return (
<span className="text-text-dimmed" aria-label="Not granted">
<XMarkIcon className="size-4" />
</span>
);
}
const allowed = matching.filter((p) => !p.inverted);
const denied = matching.filter((p) => p.inverted);
if (allowed.length === 0) {
return (
<span className="text-error" aria-label="Denied">
<XMarkIcon className="size-4" />
</span>
);
}
const conditionalDeny = denied.find((p) => p.conditions);
if (conditionalDeny?.conditions) {
const allowedEnvTypes = allowedEnvTypesFromDeny(conditionalDeny.conditions);
if (allowedEnvTypes) {
// Conditional grant: show the environments the permission is allowed in.
return (
<div className="flex flex-col items-start gap-1">
{allowedEnvTypes.map((type) => (
<EnvironmentCombo key={type} environment={{ type }} className="text-xs" />
))}
</div>
);
}
// Conditions we can't map to environments fall back to a text label.
return (
<span className="text-xs text-text-dimmed">{conditionLabel(conditionalDeny.conditions)}</span>
);
}
return (
<span className="text-success" aria-label="Allowed">
<CheckIcon className="size-4" />
</span>
);
}
const ENV_TYPES = ["DEVELOPMENT", "STAGING", "PREVIEW", "PRODUCTION"] as const;
type EnvType = (typeof ENV_TYPES)[number];
// A conditional `cannot` rule denies the permission where the resource matches
// its condition, so the permission stays allowed everywhere else. Translate the
// envType condition into the set of environments where it's still allowed, or
// null when we can't interpret it (caller falls back to a text label).
function allowedEnvTypesFromDeny(conditions: Record<string, unknown>): EnvType[] | null {
const envType = conditions.envType;
// Equality, e.g. { envType: "PRODUCTION" } → denied in prod, allowed elsewhere.
if (typeof envType === "string") {
return ENV_TYPES.includes(envType as EnvType) ? ENV_TYPES.filter((t) => t !== envType) : null;
}
// Negation, e.g. { envType: { $ne: "DEVELOPMENT" } } → denied everywhere except
// DEVELOPMENT, so allowed only in DEVELOPMENT.
if (envType && typeof envType === "object" && "$ne" in envType) {
const ne = (envType as { $ne: unknown }).$ne;
return typeof ne === "string" && ENV_TYPES.includes(ne as EnvType) ? [ne as EnvType] : null;
}
return null;
}
// Only `envType` is supported today.
function conditionLabel(conditions: Record<string, unknown>): string {
if (typeof conditions.envType === "string") {
if (conditions.envType === "PRODUCTION") return "Non-prod only";
return `Non-${conditions.envType.toLowerCase()} only`;
}
return JSON.stringify(conditions);
}
function groupPermissions(
permissions: LoaderPermission[]
): { group: string; permissions: LoaderPermission[] }[] {
const buckets = new Map<string, LoaderPermission[]>();
for (const permission of permissions) {
const group = permission.group ?? FALLBACK_GROUP;
const list = buckets.get(group) ?? [];
list.push(permission);
buckets.set(group, list);
}
return Array.from(buckets, ([group, permissions]) => ({ group, permissions }));
}
function RequestCustomRoles() {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="primary/small">Create role</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>Custom roles are an Enterprise feature</DialogHeader>
<div className="flex flex-col gap-3 pt-2">
<Paragraph>
Define your own roles with bespoke permission sets perfect for "Member, but no
production deploys" or a vendor/contractor role. Available on the Enterprise plan.
</Paragraph>
<Paragraph variant="small" className="text-text-dimmed">
Get in touch and we'll walk you through the Enterprise plan and how custom roles fit
your team.
</Paragraph>
</div>
<div className="mt-6 flex justify-end gap-2">
<Button variant="secondary/medium" onClick={() => setOpen(false)}>
Maybe later
</Button>
<Button
variant="primary/medium"
onClick={() => {
window.open("https://trigger.dev/contact", "_blank");
setOpen(false);
}}
>
Contact us
</Button>
</div>
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More