From 2107b538979c44279cba649aa9945d07f5296695 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Mon, 14 Sep 2026 15:49:27 +0100 Subject: [PATCH] feat(webapp): warn about Node.js 21 production deployments --- .server-changes/node-runtime-update-banner.md | 6 +++ .../app/components/billing/OrgBanner.tsx | 32 +++++++++++- .../app/components/billing/selectOrgBanner.ts | 14 ++++- .../OrganizationSettingsSideMenu.tsx | 5 +- apps/webapp/app/hooks/useOrganizations.ts | 8 +++ .../route.tsx | 51 ++----------------- .../_app.orgs.$organizationSlug/route.tsx | 8 +++ .../services/projectRuntimeUpdates.server.ts | 12 ++--- apps/webapp/test/orgBanner.test.ts | 18 +++++++ 9 files changed, 92 insertions(+), 62 deletions(-) create mode 100644 .server-changes/node-runtime-update-banner.md diff --git a/.server-changes/node-runtime-update-banner.md b/.server-changes/node-runtime-update-banner.md new file mode 100644 index 00000000000..2b4594bc3ef --- /dev/null +++ b/.server-changes/node-runtime-update-banner.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Warn when Production projects still use Node.js 21 and link directly to update instructions diff --git a/apps/webapp/app/components/billing/OrgBanner.tsx b/apps/webapp/app/components/billing/OrgBanner.tsx index acf10f2469d..d88d243a3a9 100644 --- a/apps/webapp/app/components/billing/OrgBanner.tsx +++ b/apps/webapp/app/components/billing/OrgBanner.tsx @@ -1,4 +1,5 @@ import { useLocation } from "@remix-run/react"; +import { NODE_RUNTIME_UPDATE_MAJOR } from "@trigger.dev/core/v3"; import { DateTime } from "~/components/primitives/DateTime"; import { environmentFullTitle } from "~/components/environments/EnvironmentLabel"; import { AnimatedOrgBannerBar } from "~/components/billing/AnimatedOrgBannerBar"; @@ -10,11 +11,17 @@ import { useOrganization, useBillingLimit, useCanManageBillingLimits, + useHasProjectRuntimeUpdate, } from "~/hooks/useOrganizations"; import { useOptionalProject, useProject } from "~/hooks/useProject"; import { useShowSelfServe } from "~/hooks/useShowSelfServe"; import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route"; -import { v3BillingLimitsPath, v3BillingPath, v3QueuesPath } from "~/utils/pathBuilder"; +import { + organizationProjectsPath, + v3BillingLimitsPath, + v3BillingPath, + v3QueuesPath, +} from "~/utils/pathBuilder"; import { ENVIRONMENT_PAUSE_SOURCE_BILLING_LIMIT } from "~/utils/environmentPauseSource"; function getUpgradeResetDate(): Date { @@ -30,6 +37,7 @@ export function OrgBanner() { const project = useOptionalProject(); const environment = useOptionalEnvironment(); const billingLimit = useBillingLimit(); + const hasProjectRuntimeUpdate = useHasProjectRuntimeUpdate(); const currentPlan = useCurrentPlan(); const showSelfServe = useShowSelfServe(); const location = useLocation(); @@ -48,6 +56,7 @@ export function OrgBanner() { const isArchived = !!(organization && project && environment && environment.archivedAt); const bannerKind = selectOrgBanner({ + hasProjectRuntimeUpdate, billingLimit, hasExceededFreeTier: currentPlan?.v3Usage.hasExceededFreeTier === true, showEnvironmentWarning: isPaused || isArchived, @@ -58,6 +67,8 @@ export function OrgBanner() { const hideBillingLimitBanner = location.pathname.endsWith("/settings/billing-limits"); switch (bannerKind) { + case OrgBannerKind.RuntimeUpdate: + return ; case OrgBannerKind.LimitRejected: return hideBillingLimitBanner ? null : ; case OrgBannerKind.LimitGrace: @@ -77,6 +88,25 @@ export function OrgBanner() { } } +function RuntimeUpdateBanner() { + const organization = useOrganization(); + + return ( + + Review projects + + } + > + Some Production projects are still running Node.js {NODE_RUNTIME_UPDATE_MAJOR}. Update them + and deploy a new version. + + ); +} + function LimitRejectedBanner() { const organization = useOrganization(); const showSelfServe = useShowSelfServe(); diff --git a/apps/webapp/app/components/billing/selectOrgBanner.ts b/apps/webapp/app/components/billing/selectOrgBanner.ts index c31c6de7795..6c465c541f7 100644 --- a/apps/webapp/app/components/billing/selectOrgBanner.ts +++ b/apps/webapp/app/components/billing/selectOrgBanner.ts @@ -1,6 +1,7 @@ import type { BillingLimitResult } from "~/services/billingLimit.schemas"; export enum OrgBannerKind { + RuntimeUpdate = "runtime-update", LimitRejected = "limit-rejected", LimitGrace = "limit-grace", NoLimitConfigured = "no-limit", @@ -10,13 +11,24 @@ export enum OrgBannerKind { } export function selectOrgBanner(input: { + hasProjectRuntimeUpdate?: boolean; billingLimit?: BillingLimitResult; hasExceededFreeTier?: boolean; showEnvironmentWarning?: boolean; /** Self-serve billing UI — hide configure-limit prompt for managed customers. */ showSelfServe?: boolean; }): OrgBannerKind { - const { billingLimit, hasExceededFreeTier, showEnvironmentWarning, showSelfServe = true } = input; + const { + hasProjectRuntimeUpdate, + billingLimit, + hasExceededFreeTier, + showEnvironmentWarning, + showSelfServe = true, + } = input; + + if (hasProjectRuntimeUpdate) { + return OrgBannerKind.RuntimeUpdate; + } if (billingLimit?.isConfigured) { const status = billingLimit.limitState.status; diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index f628d612dd4..ac968110892 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -12,7 +12,7 @@ import { UserGroupIcon } from "~/assets/icons/UserGroupIcon"; import { VercelLogo } from "~/components/integrations/VercelLogo"; import { useFeatureFlags } from "~/hooks/useFeatureFlags"; import { useFeatures } from "~/hooks/useFeatures"; -import { type MatchedOrganization } from "~/hooks/useOrganizations"; +import { type MatchedOrganization, useHasProjectRuntimeUpdate } from "~/hooks/useOrganizations"; import { cn } from "~/utils/cn"; import { organizationPath, @@ -51,18 +51,17 @@ export function OrganizationSettingsSideMenu({ buildInfo, isUsingPlugin, isSsoUsingPlugin, - hasProjectRuntimeUpdate, }: { organization: MatchedOrganization; buildInfo: BuildInfo; isUsingPlugin: boolean; isSsoUsingPlugin: boolean; - hasProjectRuntimeUpdate: boolean; }) { const { isManagedCloud } = useFeatures(); const featureFlags = useFeatureFlags(); const currentPlan = useCurrentPlan(); const showSelfServe = useShowSelfServe(); + const hasProjectRuntimeUpdate = useHasProjectRuntimeUpdate(); const isAdmin = useHasAdminAccess(); const showBuildInfo = isAdmin || !isManagedCloud; diff --git a/apps/webapp/app/hooks/useOrganizations.ts b/apps/webapp/app/hooks/useOrganizations.ts index 4cd603b29e8..f8cc9cb062c 100644 --- a/apps/webapp/app/hooks/useOrganizations.ts +++ b/apps/webapp/app/hooks/useOrganizations.ts @@ -94,3 +94,11 @@ export function useCanManageBillingLimits(matches?: UIMatch[]) { }); return data?.canManageBillingLimits === true; } + +export function useHasProjectRuntimeUpdate(matches?: UIMatch[]) { + const data = useTypedMatchesData({ + id: "routes/_app.orgs.$organizationSlug", + matches, + }); + return data?.hasProjectRuntimeUpdate === true; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx index ff0437d6cbd..969a67b2b03 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings/route.tsx @@ -1,5 +1,4 @@ import { Outlet, useRouteLoaderData } from "@remix-run/react"; -import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { VERSION as coreVersion } from "@trigger.dev/core"; import { type ReactNode } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; @@ -10,51 +9,15 @@ import { OrganizationSettingsSideMenu, } from "~/components/navigation/OrganizationSettingsSideMenu"; import { useOrganization } from "~/hooks/useOrganizations"; -import { resolveOrgIdFromSlugForUser } from "~/models/organization.server"; -import { organizationHasProjectRuntimeUpdate } from "~/services/projectRuntimeUpdates.server"; import { rbac } from "~/services/rbac.server"; -import { requireUserId } from "~/services/session.server"; import { ssoController } from "~/services/sso.server"; const SETTINGS_ROUTE_ID = "routes/_app.orgs.$organizationSlug.settings"; -// The side-menu dot links to the Projects settings page, which requires `read` on -// `deployments`, so gate the dot on the same ability the page checks. -async function canReadDeployments({ - request, - userId, - organizationSlug, -}: { - request: Request; - userId: string; - organizationSlug: string; -}) { - // Membership-scoped so the dot is never computed against an org the user is not in. - const organizationId = await resolveOrgIdFromSlugForUser(organizationSlug, userId); - if (!organizationId) { - return false; - } - - const auth = await rbac.authenticateAuthorizeSession( - request, - { userId, organizationId }, - { action: "read", resource: { type: "deployments" } } - ); - return auth.ok; -} - -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const organizationSlug = params.organizationSlug; - - const [isUsingPlugin, isSsoUsingPlugin, hasProjectRuntimeUpdate] = await Promise.all([ +export const loader = async () => { + const [isUsingPlugin, isSsoUsingPlugin] = await Promise.all([ rbac.isUsingPlugin(), ssoController.isUsingPlugin(), - organizationSlug - ? canReadDeployments({ request, userId, organizationSlug }).then((canRead) => - canRead ? organizationHasProjectRuntimeUpdate({ organizationSlug, userId }) : false - ) - : Promise.resolve(false), ]); return typedjson({ buildInfo: { @@ -66,7 +29,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { } satisfies BuildInfo, isUsingPlugin, isSsoUsingPlugin, - hasProjectRuntimeUpdate, }); }; @@ -74,13 +36,11 @@ function SettingsChrome({ buildInfo, isUsingPlugin, isSsoUsingPlugin, - hasProjectRuntimeUpdate, children, }: { buildInfo: BuildInfo; isUsingPlugin: boolean; isSsoUsingPlugin: boolean; - hasProjectRuntimeUpdate: boolean; children: ReactNode; }) { const organization = useOrganization(); @@ -93,7 +53,6 @@ function SettingsChrome({ buildInfo={buildInfo} isUsingPlugin={isUsingPlugin} isSsoUsingPlugin={isSsoUsingPlugin} - hasProjectRuntimeUpdate={hasProjectRuntimeUpdate} /> {children} @@ -102,15 +61,13 @@ function SettingsChrome({ } export default function Page() { - const { buildInfo, isUsingPlugin, isSsoUsingPlugin, hasProjectRuntimeUpdate } = - useTypedLoaderData(); + const { buildInfo, isUsingPlugin, isSsoUsingPlugin } = useTypedLoaderData(); return ( @@ -127,7 +84,6 @@ export function ErrorBoundary() { buildInfo: BuildInfo; isUsingPlugin: boolean; isSsoUsingPlugin: boolean; - hasProjectRuntimeUpdate: boolean; } | undefined; @@ -140,7 +96,6 @@ export function ErrorBoundary() { buildInfo={data.buildInfo} isUsingPlugin={data.isUsingPlugin} isSsoUsingPlugin={data.isSsoUsingPlugin} - hasProjectRuntimeUpdate={data.hasProjectRuntimeUpdate} > diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx index 26133675e0d..152ffd26102 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx @@ -12,6 +12,7 @@ import { getImpersonationId } from "~/services/impersonation.server"; import { getCachedUsage, getBillingLimit, getCurrentPlan } from "~/services/platform.v3.server"; import { rbac } from "~/services/rbac.server"; import { ssoController } from "~/services/sso.server"; +import { organizationHasProjectRuntimeUpdate } from "~/services/projectRuntimeUpdates.server"; import { canManageBillingLimits } from "~/services/routeBuilders/permissions.server"; import { requireUser } from "~/services/session.server"; import { telemetry } from "~/services/telemetry.server"; @@ -128,6 +129,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { regions, isUsingRbacPlugin, isUsingSsoPlugin, + organizationHasRuntimeUpdate, ] = await Promise.all([ rbac .authenticateSession(request, { @@ -157,10 +159,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { // items. Both calls are cheap and cached. rbac.isUsingPlugin().catch(() => false), ssoController.isUsingPlugin().catch(() => false), + organizationHasProjectRuntimeUpdate({ organizationId: organization.id }), ]); const userCanManageBillingLimits = sessionAuth.ok ? canManageBillingLimits(sessionAuth.ability) : false; + const hasProjectRuntimeUpdate = + sessionAuth.ok && + sessionAuth.ability.can("read", { type: "deployments" }) && + organizationHasRuntimeUpdate; let hasExceededFreeTier = false; let usagePercentage = 0; @@ -218,6 +225,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }, widgetLimitPerDashboard, canManageBillingLimits: userCanManageBillingLimits, + hasProjectRuntimeUpdate, isUsingRbacPlugin, isUsingSsoPlugin, }); diff --git a/apps/webapp/app/services/projectRuntimeUpdates.server.ts b/apps/webapp/app/services/projectRuntimeUpdates.server.ts index 4393fe25344..91823caea9a 100644 --- a/apps/webapp/app/services/projectRuntimeUpdates.server.ts +++ b/apps/webapp/app/services/projectRuntimeUpdates.server.ts @@ -86,19 +86,13 @@ export async function listCurrentProductionProjectRuntimes(scope: Scope) { } export async function organizationHasProjectRuntimeUpdate({ - organizationSlug, - userId, + organizationId, }: { - organizationSlug: string; - userId: string; + organizationId: string; }): Promise { const project = await prisma.project.findFirst({ where: { - organization: { - slug: organizationSlug, - deletedAt: null, - members: { some: { userId } }, - }, + organizationId, version: "V3", deletedAt: null, environments: { diff --git a/apps/webapp/test/orgBanner.test.ts b/apps/webapp/test/orgBanner.test.ts index 73906090829..94dd029fb28 100644 --- a/apps/webapp/test/orgBanner.test.ts +++ b/apps/webapp/test/orgBanner.test.ts @@ -2,6 +2,24 @@ import { describe, expect, it } from "vitest"; import { OrgBannerKind, selectOrgBanner } from "~/components/billing/selectOrgBanner"; describe("selectOrgBanner", () => { + it("prioritizes runtime updates over all other banners", () => { + expect( + selectOrgBanner({ + hasProjectRuntimeUpdate: true, + billingLimit: { + isConfigured: true, + mode: "plan", + cancelInProgressRuns: false, + limitState: { status: "rejected", hitAt: "t", graceEndsAt: "t" }, + effectiveAmountCents: 1000, + gracePeriodMs: 86_400_000, + }, + hasExceededFreeTier: true, + showEnvironmentWarning: true, + }) + ).toBe(OrgBannerKind.RuntimeUpdate); + }); + it("prioritizes limit-rejected over grace and no-limit", () => { expect( selectOrgBanner({