Skip to content

Commit 99d0faf

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

15 files changed

Lines changed: 480 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: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { Link } from "@remix-run/react";
2+
import { typedjson, useTypedLoaderData } from "remix-typedjson";
3+
import { NODE_RUNTIME_UPDATE_MAJOR } from "@trigger.dev/core/v3";
4+
import { RuntimeIcon } from "~/components/RuntimeIcon";
5+
import {
6+
MainHorizontallyCenteredContainer,
7+
PageBody,
8+
PageContainer,
9+
} from "~/components/layout/AppLayout";
10+
import { Header2 } from "~/components/primitives/Headers";
11+
import { Paragraph } from "~/components/primitives/Paragraph";
12+
import {
13+
Table,
14+
TableBody,
15+
TableCell,
16+
TableHeader,
17+
TableHeaderCell,
18+
TableRow,
19+
} from "~/components/primitives/Table";
20+
import { resolveOrgIdFromSlug } from "~/models/organization.server";
21+
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
22+
import { listCurrentProductionProjectRuntimes } from "~/services/projectRuntimeUpdates.server";
23+
import { OrganizationParamsSchema, v3DeploymentsPath } from "~/utils/pathBuilder";
24+
import { pageMeta } from "~/utils/pageTitle";
25+
26+
export const meta = pageMeta("Runtime updates");
27+
28+
export const loader = dashboardLoader(
29+
{
30+
params: OrganizationParamsSchema,
31+
context: async (params) => {
32+
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
33+
return organizationId ? { organizationId } : {};
34+
},
35+
authorization: {
36+
action: "read",
37+
resource: { type: "deployments" },
38+
message: "With your current role, you can't view runtime updates.",
39+
},
40+
},
41+
async ({ context, params }) => {
42+
const runtimes = await listCurrentProductionProjectRuntimes({
43+
organizationId: context.organizationId,
44+
});
45+
46+
return typedjson({
47+
organizationSlug: params.organizationSlug,
48+
runtimes: runtimes.filter(
49+
(runtime) => runtime.deployment?.nodeMajor === NODE_RUNTIME_UPDATE_MAJOR
50+
),
51+
});
52+
}
53+
);
54+
55+
export default function Page() {
56+
const { organizationSlug, runtimes } = useTypedLoaderData<typeof loader>();
57+
58+
return (
59+
<PageContainer>
60+
<PageBody>
61+
<MainHorizontallyCenteredContainer>
62+
<div className="mb-6">
63+
<Header2>Runtime updates</Header2>
64+
<Paragraph className="mt-2 text-text-dimmed">
65+
Projects with a current Production deployment using Node.js{" "}
66+
{NODE_RUNTIME_UPDATE_MAJOR}. Update your <code>trigger.config</code> to use{" "}
67+
<code>runtime: "node-24"</code>, then deploy again.
68+
</Paragraph>
69+
</div>
70+
71+
{runtimes.length === 0 ? (
72+
<Paragraph className="text-text-dimmed">
73+
No Production projects are currently using Node.js {NODE_RUNTIME_UPDATE_MAJOR}.
74+
</Paragraph>
75+
) : (
76+
<Table variant="bright" fullWidth>
77+
<TableHeader>
78+
<TableRow>
79+
<TableHeaderCell>Project</TableHeaderCell>
80+
<TableHeaderCell>Runtime</TableHeaderCell>
81+
<TableHeaderCell>Deployed</TableHeaderCell>
82+
<TableHeaderCell />
83+
</TableRow>
84+
</TableHeader>
85+
<TableBody>
86+
{runtimes.map(({ project, environment, deployment }) => {
87+
if (!deployment) return null;
88+
89+
return (
90+
<TableRow key={project.externalRef}>
91+
<TableCell>
92+
<span className="font-medium text-text-bright">{project.name}</span>
93+
</TableCell>
94+
<TableCell>
95+
<RuntimeIcon
96+
runtime={deployment.runtime}
97+
runtimeVersion={deployment.runtimeVersion}
98+
withLabel
99+
/>
100+
</TableCell>
101+
<TableCell>{deployment.deployedAt?.toLocaleString() ?? "-"}</TableCell>
102+
<TableCell alignment="right">
103+
<Link
104+
to={v3DeploymentsPath(
105+
{ slug: organizationSlug },
106+
{ slug: project.slug },
107+
{ slug: environment.slug }
108+
)}
109+
className="text-sm text-indigo-500 hover:text-indigo-400"
110+
>
111+
View deployment
112+
</Link>
113+
</TableCell>
114+
</TableRow>
115+
);
116+
})}
117+
</TableBody>
118+
</Table>
119+
)}
120+
</MainHorizontallyCenteredContainer>
121+
</PageBody>
122+
</PageContainer>
123+
);
124+
}
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+
}

apps/webapp/app/utils/pathBuilder.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,10 @@ export function organizationSettingsPath(organization: OrgForPath) {
170170
return `${organizationPath(organization)}/settings`;
171171
}
172172

173+
export function organizationRuntimeUpdatesPath(organization: OrgForPath) {
174+
return `${organizationSettingsPath(organization)}/runtime-updates`;
175+
}
176+
173177
function organizationIntegrationsPath(organization: OrgForPath) {
174178
return `${organizationPath(organization)}/settings/integrations`;
175179
}

0 commit comments

Comments
 (0)