Skip to content

Commit 2107b53

Browse files
committed
feat(webapp): warn about Node.js 21 production deployments
1 parent 56823c3 commit 2107b53

9 files changed

Lines changed: 92 additions & 62 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Warn when Production projects still use Node.js 21 and link directly to update instructions

‎apps/webapp/app/components/billing/OrgBanner.tsx‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useLocation } from "@remix-run/react";
2+
import { NODE_RUNTIME_UPDATE_MAJOR } from "@trigger.dev/core/v3";
23
import { DateTime } from "~/components/primitives/DateTime";
34
import { environmentFullTitle } from "~/components/environments/EnvironmentLabel";
45
import { AnimatedOrgBannerBar } from "~/components/billing/AnimatedOrgBannerBar";
@@ -10,11 +11,17 @@ import {
1011
useOrganization,
1112
useBillingLimit,
1213
useCanManageBillingLimits,
14+
useHasProjectRuntimeUpdate,
1315
} from "~/hooks/useOrganizations";
1416
import { useOptionalProject, useProject } from "~/hooks/useProject";
1517
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
1618
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
17-
import { v3BillingLimitsPath, v3BillingPath, v3QueuesPath } from "~/utils/pathBuilder";
19+
import {
20+
organizationProjectsPath,
21+
v3BillingLimitsPath,
22+
v3BillingPath,
23+
v3QueuesPath,
24+
} from "~/utils/pathBuilder";
1825
import { ENVIRONMENT_PAUSE_SOURCE_BILLING_LIMIT } from "~/utils/environmentPauseSource";
1926

2027
function getUpgradeResetDate(): Date {
@@ -30,6 +37,7 @@ export function OrgBanner() {
3037
const project = useOptionalProject();
3138
const environment = useOptionalEnvironment();
3239
const billingLimit = useBillingLimit();
40+
const hasProjectRuntimeUpdate = useHasProjectRuntimeUpdate();
3341
const currentPlan = useCurrentPlan();
3442
const showSelfServe = useShowSelfServe();
3543
const location = useLocation();
@@ -48,6 +56,7 @@ export function OrgBanner() {
4856
const isArchived = !!(organization && project && environment && environment.archivedAt);
4957

5058
const bannerKind = selectOrgBanner({
59+
hasProjectRuntimeUpdate,
5160
billingLimit,
5261
hasExceededFreeTier: currentPlan?.v3Usage.hasExceededFreeTier === true,
5362
showEnvironmentWarning: isPaused || isArchived,
@@ -58,6 +67,8 @@ export function OrgBanner() {
5867
const hideBillingLimitBanner = location.pathname.endsWith("/settings/billing-limits");
5968

6069
switch (bannerKind) {
70+
case OrgBannerKind.RuntimeUpdate:
71+
return <RuntimeUpdateBanner />;
6172
case OrgBannerKind.LimitRejected:
6273
return hideBillingLimitBanner ? null : <LimitRejectedBanner />;
6374
case OrgBannerKind.LimitGrace:
@@ -77,6 +88,25 @@ export function OrgBanner() {
7788
}
7889
}
7990

91+
function RuntimeUpdateBanner() {
92+
const organization = useOrganization();
93+
94+
return (
95+
<AnimatedOrgBannerBar
96+
show
97+
variant="warning"
98+
action={
99+
<LinkButton variant="tertiary/small" to={organizationProjectsPath(organization)}>
100+
Review projects
101+
</LinkButton>
102+
}
103+
>
104+
Some Production projects are still running Node.js {NODE_RUNTIME_UPDATE_MAJOR}. Update them
105+
and deploy a new version.
106+
</AnimatedOrgBannerBar>
107+
);
108+
}
109+
80110
function LimitRejectedBanner() {
81111
const organization = useOrganization();
82112
const showSelfServe = useShowSelfServe();

‎apps/webapp/app/components/billing/selectOrgBanner.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { BillingLimitResult } from "~/services/billingLimit.schemas";
22

33
export enum OrgBannerKind {
4+
RuntimeUpdate = "runtime-update",
45
LimitRejected = "limit-rejected",
56
LimitGrace = "limit-grace",
67
NoLimitConfigured = "no-limit",
@@ -10,13 +11,24 @@ export enum OrgBannerKind {
1011
}
1112

1213
export function selectOrgBanner(input: {
14+
hasProjectRuntimeUpdate?: boolean;
1315
billingLimit?: BillingLimitResult;
1416
hasExceededFreeTier?: boolean;
1517
showEnvironmentWarning?: boolean;
1618
/** Self-serve billing UI — hide configure-limit prompt for managed customers. */
1719
showSelfServe?: boolean;
1820
}): OrgBannerKind {
19-
const { billingLimit, hasExceededFreeTier, showEnvironmentWarning, showSelfServe = true } = input;
21+
const {
22+
hasProjectRuntimeUpdate,
23+
billingLimit,
24+
hasExceededFreeTier,
25+
showEnvironmentWarning,
26+
showSelfServe = true,
27+
} = input;
28+
29+
if (hasProjectRuntimeUpdate) {
30+
return OrgBannerKind.RuntimeUpdate;
31+
}
2032

2133
if (billingLimit?.isConfigured) {
2234
const status = billingLimit.limitState.status;

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { UserGroupIcon } from "~/assets/icons/UserGroupIcon";
1212
import { VercelLogo } from "~/components/integrations/VercelLogo";
1313
import { useFeatureFlags } from "~/hooks/useFeatureFlags";
1414
import { useFeatures } from "~/hooks/useFeatures";
15-
import { type MatchedOrganization } from "~/hooks/useOrganizations";
15+
import { type MatchedOrganization, useHasProjectRuntimeUpdate } from "~/hooks/useOrganizations";
1616
import { cn } from "~/utils/cn";
1717
import {
1818
organizationPath,
@@ -51,18 +51,17 @@ export function OrganizationSettingsSideMenu({
5151
buildInfo,
5252
isUsingPlugin,
5353
isSsoUsingPlugin,
54-
hasProjectRuntimeUpdate,
5554
}: {
5655
organization: MatchedOrganization;
5756
buildInfo: BuildInfo;
5857
isUsingPlugin: boolean;
5958
isSsoUsingPlugin: boolean;
60-
hasProjectRuntimeUpdate: boolean;
6159
}) {
6260
const { isManagedCloud } = useFeatures();
6361
const featureFlags = useFeatureFlags();
6462
const currentPlan = useCurrentPlan();
6563
const showSelfServe = useShowSelfServe();
64+
const hasProjectRuntimeUpdate = useHasProjectRuntimeUpdate();
6665
const isAdmin = useHasAdminAccess();
6766
const showBuildInfo = isAdmin || !isManagedCloud;
6867

‎apps/webapp/app/hooks/useOrganizations.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,11 @@ export function useCanManageBillingLimits(matches?: UIMatch[]) {
9494
});
9595
return data?.canManageBillingLimits === true;
9696
}
97+
98+
export function useHasProjectRuntimeUpdate(matches?: UIMatch[]) {
99+
const data = useTypedMatchesData<typeof orgLoader>({
100+
id: "routes/_app.orgs.$organizationSlug",
101+
matches,
102+
});
103+
return data?.hasProjectRuntimeUpdate === true;
104+
}

‎apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx‎

Lines changed: 3 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { Outlet, useRouteLoaderData } from "@remix-run/react";
2-
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
32
import { VERSION as coreVersion } from "@trigger.dev/core";
43
import { type ReactNode } from "react";
54
import { typedjson, useTypedLoaderData } from "remix-typedjson";
@@ -10,51 +9,15 @@ import {
109
OrganizationSettingsSideMenu,
1110
} from "~/components/navigation/OrganizationSettingsSideMenu";
1211
import { useOrganization } from "~/hooks/useOrganizations";
13-
import { resolveOrgIdFromSlugForUser } from "~/models/organization.server";
14-
import { organizationHasProjectRuntimeUpdate } from "~/services/projectRuntimeUpdates.server";
1512
import { rbac } from "~/services/rbac.server";
16-
import { requireUserId } from "~/services/session.server";
1713
import { ssoController } from "~/services/sso.server";
1814

1915
const SETTINGS_ROUTE_ID = "routes/_app.orgs.$organizationSlug.settings";
2016

21-
// The side-menu dot links to the Projects settings page, which requires `read` on
22-
// `deployments`, so gate the dot on the same ability the page checks.
23-
async function canReadDeployments({
24-
request,
25-
userId,
26-
organizationSlug,
27-
}: {
28-
request: Request;
29-
userId: string;
30-
organizationSlug: string;
31-
}) {
32-
// Membership-scoped so the dot is never computed against an org the user is not in.
33-
const organizationId = await resolveOrgIdFromSlugForUser(organizationSlug, userId);
34-
if (!organizationId) {
35-
return false;
36-
}
37-
38-
const auth = await rbac.authenticateAuthorizeSession(
39-
request,
40-
{ userId, organizationId },
41-
{ action: "read", resource: { type: "deployments" } }
42-
);
43-
return auth.ok;
44-
}
45-
46-
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
47-
const userId = await requireUserId(request);
48-
const organizationSlug = params.organizationSlug;
49-
50-
const [isUsingPlugin, isSsoUsingPlugin, hasProjectRuntimeUpdate] = await Promise.all([
17+
export const loader = async () => {
18+
const [isUsingPlugin, isSsoUsingPlugin] = await Promise.all([
5119
rbac.isUsingPlugin(),
5220
ssoController.isUsingPlugin(),
53-
organizationSlug
54-
? canReadDeployments({ request, userId, organizationSlug }).then((canRead) =>
55-
canRead ? organizationHasProjectRuntimeUpdate({ organizationSlug, userId }) : false
56-
)
57-
: Promise.resolve(false),
5821
]);
5922
return typedjson({
6023
buildInfo: {
@@ -66,21 +29,18 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
6629
} satisfies BuildInfo,
6730
isUsingPlugin,
6831
isSsoUsingPlugin,
69-
hasProjectRuntimeUpdate,
7032
});
7133
};
7234

7335
function SettingsChrome({
7436
buildInfo,
7537
isUsingPlugin,
7638
isSsoUsingPlugin,
77-
hasProjectRuntimeUpdate,
7839
children,
7940
}: {
8041
buildInfo: BuildInfo;
8142
isUsingPlugin: boolean;
8243
isSsoUsingPlugin: boolean;
83-
hasProjectRuntimeUpdate: boolean;
8444
children: ReactNode;
8545
}) {
8646
const organization = useOrganization();
@@ -93,7 +53,6 @@ function SettingsChrome({
9353
buildInfo={buildInfo}
9454
isUsingPlugin={isUsingPlugin}
9555
isSsoUsingPlugin={isSsoUsingPlugin}
96-
hasProjectRuntimeUpdate={hasProjectRuntimeUpdate}
9756
/>
9857
<MainBody>{children}</MainBody>
9958
</div>
@@ -102,15 +61,13 @@ function SettingsChrome({
10261
}
10362

10463
export default function Page() {
105-
const { buildInfo, isUsingPlugin, isSsoUsingPlugin, hasProjectRuntimeUpdate } =
106-
useTypedLoaderData<typeof loader>();
64+
const { buildInfo, isUsingPlugin, isSsoUsingPlugin } = useTypedLoaderData<typeof loader>();
10765

10866
return (
10967
<SettingsChrome
11068
buildInfo={buildInfo}
11169
isUsingPlugin={isUsingPlugin}
11270
isSsoUsingPlugin={isSsoUsingPlugin}
113-
hasProjectRuntimeUpdate={hasProjectRuntimeUpdate}
11471
>
11572
<Outlet />
11673
</SettingsChrome>
@@ -127,7 +84,6 @@ export function ErrorBoundary() {
12784
buildInfo: BuildInfo;
12885
isUsingPlugin: boolean;
12986
isSsoUsingPlugin: boolean;
130-
hasProjectRuntimeUpdate: boolean;
13187
}
13288
| undefined;
13389

@@ -140,7 +96,6 @@ export function ErrorBoundary() {
14096
buildInfo={data.buildInfo}
14197
isUsingPlugin={data.isUsingPlugin}
14298
isSsoUsingPlugin={data.isSsoUsingPlugin}
143-
hasProjectRuntimeUpdate={data.hasProjectRuntimeUpdate}
14499
>
145100
<RouteErrorDisplay />
146101
</SettingsChrome>

‎apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { getImpersonationId } from "~/services/impersonation.server";
1212
import { getCachedUsage, getBillingLimit, getCurrentPlan } from "~/services/platform.v3.server";
1313
import { rbac } from "~/services/rbac.server";
1414
import { ssoController } from "~/services/sso.server";
15+
import { organizationHasProjectRuntimeUpdate } from "~/services/projectRuntimeUpdates.server";
1516
import { canManageBillingLimits } from "~/services/routeBuilders/permissions.server";
1617
import { requireUser } from "~/services/session.server";
1718
import { telemetry } from "~/services/telemetry.server";
@@ -128,6 +129,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
128129
regions,
129130
isUsingRbacPlugin,
130131
isUsingSsoPlugin,
132+
organizationHasRuntimeUpdate,
131133
] = await Promise.all([
132134
rbac
133135
.authenticateSession(request, {
@@ -157,10 +159,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
157159
// items. Both calls are cheap and cached.
158160
rbac.isUsingPlugin().catch(() => false),
159161
ssoController.isUsingPlugin().catch(() => false),
162+
organizationHasProjectRuntimeUpdate({ organizationId: organization.id }),
160163
]);
161164
const userCanManageBillingLimits = sessionAuth.ok
162165
? canManageBillingLimits(sessionAuth.ability)
163166
: false;
167+
const hasProjectRuntimeUpdate =
168+
sessionAuth.ok &&
169+
sessionAuth.ability.can("read", { type: "deployments" }) &&
170+
organizationHasRuntimeUpdate;
164171

165172
let hasExceededFreeTier = false;
166173
let usagePercentage = 0;
@@ -218,6 +225,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
218225
},
219226
widgetLimitPerDashboard,
220227
canManageBillingLimits: userCanManageBillingLimits,
228+
hasProjectRuntimeUpdate,
221229
isUsingRbacPlugin,
222230
isUsingSsoPlugin,
223231
});

‎apps/webapp/app/services/projectRuntimeUpdates.server.ts‎

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -86,19 +86,13 @@ export async function listCurrentProductionProjectRuntimes(scope: Scope) {
8686
}
8787

8888
export async function organizationHasProjectRuntimeUpdate({
89-
organizationSlug,
90-
userId,
89+
organizationId,
9190
}: {
92-
organizationSlug: string;
93-
userId: string;
91+
organizationId: string;
9492
}): Promise<boolean> {
9593
const project = await prisma.project.findFirst({
9694
where: {
97-
organization: {
98-
slug: organizationSlug,
99-
deletedAt: null,
100-
members: { some: { userId } },
101-
},
95+
organizationId,
10296
version: "V3",
10397
deletedAt: null,
10498
environments: {

‎apps/webapp/test/orgBanner.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,24 @@ import { describe, expect, it } from "vitest";
22
import { OrgBannerKind, selectOrgBanner } from "~/components/billing/selectOrgBanner";
33

44
describe("selectOrgBanner", () => {
5+
it("prioritizes runtime updates over all other banners", () => {
6+
expect(
7+
selectOrgBanner({
8+
hasProjectRuntimeUpdate: true,
9+
billingLimit: {
10+
isConfigured: true,
11+
mode: "plan",
12+
cancelInProgressRuns: false,
13+
limitState: { status: "rejected", hitAt: "t", graceEndsAt: "t" },
14+
effectiveAmountCents: 1000,
15+
gracePeriodMs: 86_400_000,
16+
},
17+
hasExceededFreeTier: true,
18+
showEnvironmentWarning: true,
19+
})
20+
).toBe(OrgBannerKind.RuntimeUpdate);
21+
});
22+
523
it("prioritizes limit-rejected over grace and no-limit", () => {
624
expect(
725
selectOrgBanner({

0 commit comments

Comments
 (0)