Skip to content

Commit 0d0522e

Browse files
committed
feat(webapp,cli,core): list production project runtime updates
1 parent 53ca44d commit 0d0522e

15 files changed

Lines changed: 486 additions & 9 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"trigger.dev": patch
3+
"@trigger.dev/core": patch
4+
---
5+
6+
List the current Production runtime for every accessible project with `trigger projects list`. Add `--needs-update` to identify projects currently running Node.js 21.

apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
1+
import { ArrowLeftIcon, ArrowPathIcon } from "@heroicons/react/24/solid";
22
import { BellIcon } from "~/assets/icons/BellIcon";
33
import { ChainLinkIcon } from "~/assets/icons/ChainLinkIcon";
44
import { CreditCardIcon } from "~/assets/icons/CreditCardIcon";
@@ -16,6 +16,7 @@ import { cn } from "~/utils/cn";
1616
import {
1717
organizationPath,
1818
organizationRolesPath,
19+
organizationRuntimeUpdatesPath,
1920
organizationSettingsPath,
2021
organizationSlackIntegrationPath,
2122
organizationSsoPath,
@@ -127,6 +128,14 @@ export function OrganizationSettingsSideMenu({
127128
) : null}
128129
</>
129130
)}
131+
<SideMenuItem
132+
name="Runtime updates"
133+
icon={ArrowPathIcon}
134+
activeIconColor="text-text-bright"
135+
inactiveIconColor="text-text-dimmed"
136+
to={organizationRuntimeUpdatesPath(organization)}
137+
data-action="runtime-updates"
138+
/>
130139
<SideMenuItem
131140
name="Team"
132141
icon={UserGroupIcon}

apps/webapp/app/routes/[_].$.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,16 @@ import { prisma } from "~/db.server";
33
import { getUsersInvites } from "~/models/member.server";
44
import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server";
55
import { requireUser } from "~/services/session.server";
6-
import { deeplinkSuffix, resolveDeeplinkPage } from "~/utils/deeplinkPages";
6+
import {
7+
deeplinkSuffix,
8+
resolveDeeplinkPage,
9+
resolveOrganizationDeeplinkPage,
10+
} from "~/utils/deeplinkPages";
711
import {
812
invitesPath,
913
newOrganizationPath,
1014
newProjectPath,
15+
organizationRuntimeUpdatesPath,
1116
v3EnvironmentPath,
1217
} from "~/utils/pathBuilder";
1318

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

1823
const { pathname, search } = new URL(request.url);
19-
const page = resolveDeeplinkPage(deeplinkSuffix(pathname));
24+
const suffix = deeplinkSuffix(pathname);
25+
const page = resolveDeeplinkPage(suffix);
26+
const organizationPage = resolveOrganizationDeeplinkPage(suffix);
2027

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

31-
const suffix = page ? `/${page}` : "";
40+
const environmentPath = v3EnvironmentPath(organization, project, environment);
41+
const pageSuffix = page ? `/${page}` : "";
3242

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

4959
if (organization) {
60+
if (organizationPage === "runtime-updates") {
61+
return redirect(`${organizationRuntimeUpdatesPath(organization)}${search}`);
62+
}
63+
5064
return redirect(newProjectPath(organization));
5165
}
5266

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { ArrowUpRightIcon } from "@heroicons/react/20/solid";
2+
import { NODE_RUNTIME_UPDATE_MAJOR } from "@trigger.dev/core/v3";
3+
import { typedjson, useTypedLoaderData } from "remix-typedjson";
4+
import { RuntimeIcon } from "~/components/RuntimeIcon";
5+
import {
6+
MainHorizontallyCenteredContainer,
7+
PageBody,
8+
PageContainer,
9+
} from "~/components/layout/AppLayout";
10+
import { LinkButton } from "~/components/primitives/Buttons";
11+
import { Header2 } from "~/components/primitives/Headers";
12+
import { Paragraph } from "~/components/primitives/Paragraph";
13+
import { resolveOrgIdFromSlug } from "~/models/organization.server";
14+
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
15+
import { listCurrentProductionProjectRuntimes } from "~/services/projectRuntimeUpdates.server";
16+
import { OrganizationParamsSchema, v3DeploymentsPath } from "~/utils/pathBuilder";
17+
import { pageMeta } from "~/utils/pageTitle";
18+
19+
export const meta = pageMeta("Runtime updates");
20+
21+
export const loader = dashboardLoader(
22+
{
23+
params: OrganizationParamsSchema,
24+
context: async (params) => {
25+
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
26+
return organizationId ? { organizationId } : {};
27+
},
28+
authorization: {
29+
action: "read",
30+
resource: { type: "deployments" },
31+
message: "With your current role, you can't view runtime updates.",
32+
},
33+
},
34+
async ({ context, params }) => {
35+
const runtimes = await listCurrentProductionProjectRuntimes({
36+
organizationId: context.organizationId,
37+
});
38+
39+
return typedjson({
40+
organizationSlug: params.organizationSlug,
41+
runtimes: runtimes.filter(
42+
(runtime) => runtime.deployment?.nodeMajor === NODE_RUNTIME_UPDATE_MAJOR
43+
),
44+
});
45+
}
46+
);
47+
48+
export default function Page() {
49+
const { organizationSlug, runtimes } = useTypedLoaderData<typeof loader>();
50+
51+
return (
52+
<PageContainer>
53+
<PageBody>
54+
<MainHorizontallyCenteredContainer>
55+
<header className="mb-8 max-w-2xl">
56+
<p className="mb-2 text-xs font-medium uppercase tracking-[0.16em] text-warning">
57+
Runtime update available
58+
</p>
59+
<Header2>Move Production projects to Node.js 24</Header2>
60+
<Paragraph className="mt-2 text-text-dimmed">
61+
{runtimes.length} {runtimes.length === 1 ? "project is" : "projects are"} currently
62+
running Node.js {NODE_RUNTIME_UPDATE_MAJOR} in Production. Update every project listed
63+
below.
64+
</Paragraph>
65+
</header>
66+
67+
{runtimes.length === 0 ? (
68+
<div className="border border-grid-bright bg-background-bright px-5 py-6">
69+
<Header2 className="text-base">Everything is up to date</Header2>
70+
<Paragraph className="mt-1 text-text-dimmed">
71+
No Production projects are currently using Node.js {NODE_RUNTIME_UPDATE_MAJOR}.
72+
</Paragraph>
73+
</div>
74+
) : (
75+
<div className="space-y-4">
76+
<div className="border border-warning/30 bg-warning/5 px-5 py-4">
77+
<p className="text-sm font-medium text-text-bright">Update every listed project</p>
78+
<Paragraph className="mt-1 text-sm text-text-dimmed">
79+
In each project&apos;s <code>trigger.config.ts</code>, set the runtime below and
80+
deploy a new Production version.
81+
</Paragraph>
82+
<pre className="mt-3 overflow-x-auto border border-grid-bright bg-background-dimmed px-3 py-2.5 font-mono text-sm text-text-bright">
83+
<code>runtime: "node-24",</code>
84+
</pre>
85+
</div>
86+
87+
<div className="overflow-hidden border border-grid-bright bg-background-bright">
88+
{runtimes.map(({ project, environment, deployment }) => {
89+
if (!deployment) return null;
90+
91+
return (
92+
<div
93+
key={project.externalRef}
94+
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"
95+
>
96+
<div className="min-w-0">
97+
<p className="truncate font-medium text-text-bright">{project.name}</p>
98+
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-text-dimmed">
99+
<RuntimeIcon
100+
runtime={deployment.runtime}
101+
runtimeVersion={deployment.runtimeVersion}
102+
withLabel
103+
/>
104+
<span>
105+
Production deployed {deployment.deployedAt?.toLocaleString() ?? "-"}
106+
</span>
107+
</div>
108+
</div>
109+
<LinkButton
110+
variant="secondary/small"
111+
LeadingIcon={ArrowUpRightIcon}
112+
to={v3DeploymentsPath(
113+
{ slug: organizationSlug },
114+
{ slug: project.slug },
115+
{ slug: environment.slug }
116+
)}
117+
>
118+
View deployment
119+
</LinkButton>
120+
</div>
121+
);
122+
})}
123+
</div>
124+
</div>
125+
)}
126+
</MainHorizontallyCenteredContainer>
127+
</PageBody>
128+
</PageContainer>
129+
);
130+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { json } from "@remix-run/server-runtime";
2+
import type { GetProjectRuntimesResponseBody } from "@trigger.dev/core/v3";
3+
import { createLoaderPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
4+
import { listCurrentProductionProjectRuntimes } from "~/services/projectRuntimeUpdates.server";
5+
6+
// Identity-only: like /api/v1/projects, this returns resources across every organization the PAT
7+
// owner belongs to. Runtime details are limited to each project's current Production deployment.
8+
export const loader = createLoaderPATApiRoute(
9+
{ identityOnly: true },
10+
async ({ authentication }) => {
11+
const runtimes: GetProjectRuntimesResponseBody = await listCurrentProductionProjectRuntimes({
12+
userId: authentication.userId,
13+
});
14+
15+
return json(runtimes);
16+
}
17+
);
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { nodeMajor } from "@trigger.dev/core/v3";
2+
import { CURRENT_DEPLOYMENT_LABEL } from "@trigger.dev/core/v3/isomorphic";
3+
import { prisma } from "~/db.server";
4+
5+
type Options = {
6+
organizationId?: string;
7+
userId?: string;
8+
};
9+
10+
export async function listCurrentProductionProjectRuntimes({ organizationId, userId }: Options) {
11+
const projects = await prisma.project.findMany({
12+
where: {
13+
...(organizationId ? { organizationId } : {}),
14+
...(userId
15+
? {
16+
organization: {
17+
deletedAt: null,
18+
members: { some: { userId } },
19+
},
20+
}
21+
: {}),
22+
version: "V3",
23+
deletedAt: null,
24+
},
25+
select: {
26+
name: true,
27+
slug: true,
28+
externalRef: true,
29+
organization: {
30+
select: {
31+
title: true,
32+
slug: true,
33+
},
34+
},
35+
environments: {
36+
where: { type: "PRODUCTION" },
37+
select: {
38+
slug: true,
39+
workerDeploymentPromotions: {
40+
where: { label: CURRENT_DEPLOYMENT_LABEL },
41+
select: {
42+
deployment: {
43+
select: {
44+
runtime: true,
45+
runtimeVersion: true,
46+
deployedAt: true,
47+
shortCode: true,
48+
},
49+
},
50+
},
51+
},
52+
},
53+
},
54+
},
55+
orderBy: [{ organization: { title: "asc" } }, { name: "asc" }],
56+
});
57+
58+
return projects.flatMap((project) =>
59+
project.environments.map((environment) => {
60+
const deployment = environment.workerDeploymentPromotions[0]?.deployment;
61+
62+
return {
63+
organization: project.organization,
64+
project: {
65+
name: project.name,
66+
slug: project.slug,
67+
externalRef: project.externalRef,
68+
},
69+
environment: {
70+
slug: environment.slug,
71+
},
72+
deployment: deployment
73+
? {
74+
runtime: deployment.runtime,
75+
runtimeVersion: deployment.runtimeVersion,
76+
nodeMajor: nodeMajor(deployment.runtime, deployment.runtimeVersion) ?? null,
77+
deployedAt: deployment.deployedAt,
78+
shortCode: deployment.shortCode,
79+
}
80+
: null,
81+
};
82+
})
83+
);
84+
}

apps/webapp/app/utils/deeplinkPages.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import {
88
DEEPLINK_PATH_PREFIX,
99
deeplinkSuffix,
1010
ENV_PAGE_TARGETS,
11+
ORG_PAGE_TARGETS,
1112
resolveDeeplinkPage,
13+
resolveOrganizationDeeplinkPage,
1214
} from "./deeplinkPages";
1315

1416
const APP_DIR = join(__dirname, "..");
@@ -161,6 +163,15 @@ describe("resolveDeeplinkPage", () => {
161163
expect(resolveDeeplinkPage("tasks")).toBe("");
162164
});
163165

166+
it("resolves organization-level pages separately from environment pages", () => {
167+
expect(ORG_PAGE_TARGETS.get("runtime-updates")).toEqual({
168+
landing: "runtime-updates",
169+
prefix: "runtime-updates",
170+
});
171+
expect(resolveOrganizationDeeplinkPage("runtime-updates")).toBe("runtime-updates");
172+
expect(resolveDeeplinkPage("runtime-updates")).toBeUndefined();
173+
});
174+
164175
it("grafts deeper segments onto the prefix", () => {
165176
expect(resolveDeeplinkPage("runs/run_123")).toBe("runs/run_123");
166177
expect(resolveDeeplinkPage("tasks/standard/my-task")).toBe("tasks/standard/my-task");

apps/webapp/app/utils/deeplinkPages.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ export const ENV_PAGE_TARGETS: ReadonlyMap<string, DeeplinkTarget> = new Map([
3838
["webhooks", page("webhooks")],
3939
]);
4040

41+
export const ORG_PAGE_TARGETS: ReadonlyMap<string, DeeplinkTarget> = new Map([
42+
["runtime-updates", page("runtime-updates")],
43+
]);
44+
4145
export const DEEPLINK_PATH_PREFIX = "/_";
4246

4347
export function deeplinkSuffix(pathname: string): string {
@@ -61,11 +65,14 @@ function isSafeSegment(segment: string): boolean {
6165
return decoded !== "." && decoded !== "..";
6266
}
6367

64-
export function resolveDeeplinkPage(suffix: string): string | undefined {
68+
function resolveDeeplinkTarget(
69+
targets: ReadonlyMap<string, DeeplinkTarget>,
70+
suffix: string
71+
): string | undefined {
6572
const segments = suffix.split("/").filter(isSafeSegment);
6673
const [first = "", ...rest] = segments;
6774

68-
const target = ENV_PAGE_TARGETS.get(first.toLowerCase());
75+
const target = targets.get(first.toLowerCase());
6976
if (target === undefined) return undefined;
7077

7178
if (rest.length === 0) return target.landing;
@@ -76,3 +83,11 @@ export function resolveDeeplinkPage(suffix: string): string | undefined {
7683

7784
return [target.prefix, ...beyondPrefix].join("/");
7885
}
86+
87+
export function resolveDeeplinkPage(suffix: string): string | undefined {
88+
return resolveDeeplinkTarget(ENV_PAGE_TARGETS, suffix);
89+
}
90+
91+
export function resolveOrganizationDeeplinkPage(suffix: string): string | undefined {
92+
return resolveDeeplinkTarget(ORG_PAGE_TARGETS, suffix);
93+
}

0 commit comments

Comments
 (0)