Skip to content
Closed
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 .server-changes/node-runtime-update-banner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---

Warn when Production projects still use Node.js 21 and link directly to update instructions
32 changes: 31 additions & 1 deletion apps/webapp/app/components/billing/OrgBanner.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 {
Expand All @@ -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();
Expand All @@ -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,
Expand All @@ -58,6 +67,8 @@ export function OrgBanner() {
const hideBillingLimitBanner = location.pathname.endsWith("/settings/billing-limits");

switch (bannerKind) {
case OrgBannerKind.RuntimeUpdate:
return <RuntimeUpdateBanner />;
case OrgBannerKind.LimitRejected:
return hideBillingLimitBanner ? null : <LimitRejectedBanner />;
case OrgBannerKind.LimitGrace:
Expand All @@ -77,6 +88,25 @@ export function OrgBanner() {
}
}

function RuntimeUpdateBanner() {
const organization = useOrganization();

return (
<AnimatedOrgBannerBar
show
variant="warning"
action={
<LinkButton variant="tertiary/small" to={organizationProjectsPath(organization)}>
Review projects
</LinkButton>
}
>
Some Production projects are still running Node.js {NODE_RUNTIME_UPDATE_MAJOR}. Update them
and deploy a new version.
</AnimatedOrgBannerBar>
);
}

function LimitRejectedBanner() {
const organization = useOrganization();
const showSelfServe = useShowSelfServe();
Expand Down
14 changes: 13 additions & 1 deletion apps/webapp/app/components/billing/selectOrgBanner.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;

Expand Down
8 changes: 8 additions & 0 deletions apps/webapp/app/hooks/useOrganizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,11 @@ export function useCanManageBillingLimits(matches?: UIMatch[]) {
});
return data?.canManageBillingLimits === true;
}

export function useHasProjectRuntimeUpdate(matches?: UIMatch[]) {
const data = useTypedMatchesData<typeof orgLoader>({
id: "routes/_app.orgs.$organizationSlug",
matches,
});
return data?.hasProjectRuntimeUpdate === true;
}
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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: {
Expand All @@ -66,21 +29,18 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
} satisfies BuildInfo,
isUsingPlugin,
isSsoUsingPlugin,
hasProjectRuntimeUpdate,
});
};

function SettingsChrome({
buildInfo,
isUsingPlugin,
isSsoUsingPlugin,
hasProjectRuntimeUpdate,
children,
}: {
buildInfo: BuildInfo;
isUsingPlugin: boolean;
isSsoUsingPlugin: boolean;
hasProjectRuntimeUpdate: boolean;
children: ReactNode;
}) {
const organization = useOrganization();
Expand All @@ -93,7 +53,6 @@ function SettingsChrome({
buildInfo={buildInfo}
isUsingPlugin={isUsingPlugin}
isSsoUsingPlugin={isSsoUsingPlugin}
hasProjectRuntimeUpdate={hasProjectRuntimeUpdate}
/>
<MainBody>{children}</MainBody>
</div>
Expand All @@ -102,15 +61,13 @@ function SettingsChrome({
}

export default function Page() {
const { buildInfo, isUsingPlugin, isSsoUsingPlugin, hasProjectRuntimeUpdate } =
useTypedLoaderData<typeof loader>();
const { buildInfo, isUsingPlugin, isSsoUsingPlugin } = useTypedLoaderData<typeof loader>();

return (
<SettingsChrome
buildInfo={buildInfo}
isUsingPlugin={isUsingPlugin}
isSsoUsingPlugin={isSsoUsingPlugin}
hasProjectRuntimeUpdate={hasProjectRuntimeUpdate}
>
<Outlet />
</SettingsChrome>
Expand All @@ -127,7 +84,6 @@ export function ErrorBoundary() {
buildInfo: BuildInfo;
isUsingPlugin: boolean;
isSsoUsingPlugin: boolean;
hasProjectRuntimeUpdate: boolean;
}
| undefined;

Expand All @@ -140,7 +96,6 @@ export function ErrorBoundary() {
buildInfo={data.buildInfo}
isUsingPlugin={data.isUsingPlugin}
isSsoUsingPlugin={data.isSsoUsingPlugin}
hasProjectRuntimeUpdate={data.hasProjectRuntimeUpdate}
>
<RouteErrorDisplay />
</SettingsChrome>
Expand Down
8 changes: 8 additions & 0 deletions apps/webapp/app/routes/_app.orgs.$organizationSlug/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -128,6 +129,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
regions,
isUsingRbacPlugin,
isUsingSsoPlugin,
organizationHasRuntimeUpdate,
] = await Promise.all([
rbac
.authenticateSession(request, {
Expand Down Expand Up @@ -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 }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Revalidate after promotion or rollback changes the current deployment.

The deployment forms submit location.pathname + location.search as redirectUrl. Both actions call ChangeCurrentDeploymentService and redirect to that same path. For this same-path submission, neither existing form helper matches, so shouldRevalidate returns false. The loader therefore does not rerun organizationHasProjectRuntimeUpdate, and hasProjectRuntimeUpdate can remain stale.

Add a shouldRevalidate condition for the promotion and rollback resource actions, based on params.formAction.

]);
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;
Expand Down Expand Up @@ -218,6 +225,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
},
widgetLimitPerDashboard,
canManageBillingLimits: userCanManageBillingLimits,
hasProjectRuntimeUpdate,
isUsingRbacPlugin,
isUsingSsoPlugin,
});
Expand Down
12 changes: 3 additions & 9 deletions apps/webapp/app/services/projectRuntimeUpdates.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,19 +86,13 @@ export async function listCurrentProductionProjectRuntimes(scope: Scope) {
}

export async function organizationHasProjectRuntimeUpdate({
organizationSlug,
userId,
organizationId,
}: {
organizationSlug: string;
userId: string;
organizationId: string;
}): Promise<boolean> {
const project = await prisma.project.findFirst({
where: {
organization: {
slug: organizationSlug,
deletedAt: null,
members: { some: { userId } },
},
organizationId,
version: "V3",
deletedAt: null,
environments: {
Expand Down
18 changes: 18 additions & 0 deletions apps/webapp/test/orgBanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down