Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/list-project-runtime-updates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
---

List the current Production runtime for every accessible project with `trigger projects list`. Add `--needs-update` to identify projects currently running Node.js 21.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
import { ArrowLeftIcon, ArrowPathIcon } from "@heroicons/react/24/solid";
import { BellIcon } from "~/assets/icons/BellIcon";
import { ChainLinkIcon } from "~/assets/icons/ChainLinkIcon";
import { CreditCardIcon } from "~/assets/icons/CreditCardIcon";
Expand All @@ -16,6 +16,7 @@ import { cn } from "~/utils/cn";
import {
organizationPath,
organizationRolesPath,
organizationRuntimeUpdatesPath,
organizationSettingsPath,
organizationSlackIntegrationPath,
organizationSsoPath,
Expand Down Expand Up @@ -127,6 +128,14 @@ export function OrganizationSettingsSideMenu({
) : null}
</>
)}
<SideMenuItem
name="Runtime updates"
icon={ArrowPathIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={organizationRuntimeUpdatesPath(organization)}
data-action="runtime-updates"
/>
<SideMenuItem
name="Team"
icon={UserGroupIcon}
Expand Down
24 changes: 19 additions & 5 deletions apps/webapp/app/routes/[_].$.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@ import { prisma } from "~/db.server";
import { getUsersInvites } from "~/models/member.server";
import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server";
import { requireUser } from "~/services/session.server";
import { deeplinkSuffix, resolveDeeplinkPage } from "~/utils/deeplinkPages";
import {
deeplinkSuffix,
resolveDeeplinkPage,
resolveOrganizationDeeplinkPage,
} from "~/utils/deeplinkPages";
import {
invitesPath,
newOrganizationPath,
newProjectPath,
organizationRuntimeUpdatesPath,
v3EnvironmentPath,
} from "~/utils/pathBuilder";

Expand All @@ -16,7 +21,9 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireUser(request);

const { pathname, search } = new URL(request.url);
const page = resolveDeeplinkPage(deeplinkSuffix(pathname));
const suffix = deeplinkSuffix(pathname);
const page = resolveDeeplinkPage(suffix);
const organizationPage = resolveOrganizationDeeplinkPage(suffix);

const invites = await getUsersInvites({ email: user.email });
if (invites.length > 0) {
Expand All @@ -26,11 +33,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const presenter = new SelectBestEnvironmentPresenter();
try {
const { project, organization, environment } = await presenter.call({ user });
const environmentPath = v3EnvironmentPath(organization, project, environment);
if (organizationPage === "runtime-updates") {
return redirect(`${organizationRuntimeUpdatesPath(organization)}${search}`);
}

const suffix = page ? `/${page}` : "";
const environmentPath = v3EnvironmentPath(organization, project, environment);
const pageSuffix = page ? `/${page}` : "";

return redirect(`${environmentPath}${suffix}${search}`);
return redirect(`${environmentPath}${pageSuffix}${search}`);
} catch (_e) {
const organization = await prisma.organization.findFirst({
where: {
Expand All @@ -47,6 +57,10 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
});

if (organization) {
if (organizationPage === "runtime-updates") {
return redirect(`${organizationRuntimeUpdatesPath(organization)}${search}`);
}

return redirect(newProjectPath(organization));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { ArrowUpRightIcon } from "@heroicons/react/20/solid";
import { NODE_RUNTIME_UPDATE_MAJOR } from "@trigger.dev/core/v3";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { RuntimeIcon } from "~/components/RuntimeIcon";
import {
MainHorizontallyCenteredContainer,
PageBody,
PageContainer,
} from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import { Header2 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { listCurrentProductionProjectRuntimes } from "~/services/projectRuntimeUpdates.server";
import { OrganizationParamsSchema, v3DeploymentsPath } from "~/utils/pathBuilder";
import { pageMeta } from "~/utils/pageTitle";

export const meta = pageMeta("Runtime updates");

export const loader = dashboardLoader(
{
params: OrganizationParamsSchema,
context: async (params) => {
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
return organizationId ? { organizationId } : {};
},
authorization: {
action: "read",
resource: { type: "deployments" },
message: "With your current role, you can't view runtime updates.",
},
},
async ({ context, params }) => {
const runtimes = await listCurrentProductionProjectRuntimes({
organizationId: context.organizationId,
});

return typedjson({
organizationSlug: params.organizationSlug,
runtimes: runtimes.filter(
(runtime) => runtime.deployment?.nodeMajor === NODE_RUNTIME_UPDATE_MAJOR
),
});
}
);

export default function Page() {
const { organizationSlug, runtimes } = useTypedLoaderData<typeof loader>();

return (
<PageContainer>
<PageBody>
<MainHorizontallyCenteredContainer>
<header className="mb-8 max-w-2xl">
<p className="mb-2 text-xs font-medium uppercase tracking-[0.16em] text-warning">
Runtime update available
</p>
<Header2>Move Production projects to Node.js 24</Header2>
<Paragraph className="mt-2 text-text-dimmed">
{runtimes.length} {runtimes.length === 1 ? "project is" : "projects are"} currently
running Node.js {NODE_RUNTIME_UPDATE_MAJOR} in Production. Update every project listed
below.
</Paragraph>
</header>

{runtimes.length === 0 ? (
<div className="border border-grid-bright bg-background-bright px-5 py-6">
<Header2 className="text-base">Everything is up to date</Header2>
<Paragraph className="mt-1 text-text-dimmed">
No Production projects are currently using Node.js {NODE_RUNTIME_UPDATE_MAJOR}.
</Paragraph>
</div>
) : (
<div className="space-y-4">
<div className="border border-warning/30 bg-warning/5 px-5 py-4">
<p className="text-sm font-medium text-text-bright">Update every listed project</p>
<Paragraph className="mt-1 text-sm text-text-dimmed">
Update each project listed below in its <code>trigger.config.ts</code>, then
deploy a new Production version.
</Paragraph>
<pre className="mt-3 overflow-x-auto border border-grid-bright bg-background-dimmed px-3 py-2.5 font-mono text-sm leading-6 text-text-bright">
<code>{`export default defineConfig({\n project: "<your-project-ref>",\n runtime: "node-24",\n});`}</code>
</pre>
<Paragraph className="mt-3 text-sm text-text-dimmed">
Prefer the command line? Run{" "}
<code>npx trigger.dev@latest projects list --needs-update</code> to find projects
that need an update.
</Paragraph>
</div>

<div className="overflow-hidden border border-grid-bright bg-background-bright">
{runtimes.map(({ project, environment, deployment }) => {
if (!deployment) return null;

return (
<div
key={project.externalRef}
className="grid gap-5 border-b border-grid-bright p-5 last:border-b-0 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
>
<div className="min-w-0">
<p className="truncate font-medium text-text-bright">{project.name}</p>
<p className="mt-1 truncate font-mono text-xs text-text-dimmed">
{project.externalRef}
</p>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-text-dimmed">
<RuntimeIcon
runtime={deployment.runtime}
runtimeVersion={deployment.runtimeVersion}
withLabel
/>
<span>
Production deployed {deployment.deployedAt?.toLocaleString() ?? "-"}
</span>
</div>
</div>
<LinkButton
variant="secondary/small"
LeadingIcon={ArrowUpRightIcon}
to={v3DeploymentsPath(
{ slug: organizationSlug },
{ slug: project.slug },
{ slug: environment.slug }
)}
>
View deployment
</LinkButton>
</div>
);
})}
</div>
</div>
)}
</MainHorizontallyCenteredContainer>
</PageBody>
</PageContainer>
);
}
17 changes: 17 additions & 0 deletions apps/webapp/app/routes/api.v1.projects.runtimes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { json } from "@remix-run/server-runtime";
import type { GetProjectRuntimesResponseBody } from "@trigger.dev/core/v3";
import { createLoaderPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { listCurrentProductionProjectRuntimes } from "~/services/projectRuntimeUpdates.server";

// Identity-only: like /api/v1/projects, this returns resources across every organization the PAT
// owner belongs to. Runtime details are limited to each project's current Production deployment.
export const loader = createLoaderPATApiRoute(
{ identityOnly: true },
async ({ authentication }) => {
const runtimes: GetProjectRuntimesResponseBody = await listCurrentProductionProjectRuntimes({
userId: authentication.userId,
});

return json(runtimes);
}
);
84 changes: 84 additions & 0 deletions apps/webapp/app/services/projectRuntimeUpdates.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { nodeMajor } from "@trigger.dev/core/v3";
import { CURRENT_DEPLOYMENT_LABEL } from "@trigger.dev/core/v3/isomorphic";
import { prisma } from "~/db.server";

type Options = {
organizationId?: string;
userId?: string;
};

export async function listCurrentProductionProjectRuntimes({ organizationId, userId }: Options) {
const projects = await prisma.project.findMany({
where: {
...(organizationId ? { organizationId } : {}),
...(userId
? {
organization: {
deletedAt: null,
members: { some: { userId } },
},
}
: {}),
version: "V3",
deletedAt: null,
},
select: {
name: true,
slug: true,
externalRef: true,
organization: {
select: {
title: true,
slug: true,
},
},
environments: {
where: { type: "PRODUCTION" },
select: {
slug: true,
workerDeploymentPromotions: {
where: { label: CURRENT_DEPLOYMENT_LABEL },
select: {
deployment: {
select: {
runtime: true,
runtimeVersion: true,
deployedAt: true,
shortCode: true,
},
},
},
},
},
},
},
orderBy: [{ organization: { title: "asc" } }, { name: "asc" }],
});

return projects.flatMap((project) =>
project.environments.map((environment) => {
const deployment = environment.workerDeploymentPromotions[0]?.deployment;

return {
organization: project.organization,
project: {
name: project.name,
slug: project.slug,
externalRef: project.externalRef,
},
environment: {
slug: environment.slug,
},
deployment: deployment
? {
runtime: deployment.runtime,
runtimeVersion: deployment.runtimeVersion,
nodeMajor: nodeMajor(deployment.runtime, deployment.runtimeVersion) ?? null,
deployedAt: deployment.deployedAt,
shortCode: deployment.shortCode,
}
: null,
};
})
);
}
11 changes: 11 additions & 0 deletions apps/webapp/app/utils/deeplinkPages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import {
DEEPLINK_PATH_PREFIX,
deeplinkSuffix,
ENV_PAGE_TARGETS,
ORG_PAGE_TARGETS,
resolveDeeplinkPage,
resolveOrganizationDeeplinkPage,
} from "./deeplinkPages";

const APP_DIR = join(__dirname, "..");
Expand Down Expand Up @@ -161,6 +163,15 @@ describe("resolveDeeplinkPage", () => {
expect(resolveDeeplinkPage("tasks")).toBe("");
});

it("resolves organization-level pages separately from environment pages", () => {
expect(ORG_PAGE_TARGETS.get("runtime-updates")).toEqual({
landing: "runtime-updates",
prefix: "runtime-updates",
});
expect(resolveOrganizationDeeplinkPage("runtime-updates")).toBe("runtime-updates");
expect(resolveDeeplinkPage("runtime-updates")).toBeUndefined();
});

it("grafts deeper segments onto the prefix", () => {
expect(resolveDeeplinkPage("runs/run_123")).toBe("runs/run_123");
expect(resolveDeeplinkPage("tasks/standard/my-task")).toBe("tasks/standard/my-task");
Expand Down
Loading
Loading