feat(webapp,cli,core): list production project runtime updates (#4659)
This commit is contained in:
@@ -2,6 +2,7 @@ import { ArrowLeftIcon } from "@heroicons/react/24/solid";
|
||||
import { BellIcon } from "~/assets/icons/BellIcon";
|
||||
import { ChainLinkIcon } from "~/assets/icons/ChainLinkIcon";
|
||||
import { CreditCardIcon } from "~/assets/icons/CreditCardIcon";
|
||||
import { FolderOpenIcon } from "~/assets/icons/FolderOpenIcon";
|
||||
import { PadlockIcon } from "~/assets/icons/PadlockIcon";
|
||||
import { UsageIcon } from "~/assets/icons/UsageIcon";
|
||||
import { RolesIcon } from "~/assets/icons/RolesIcon";
|
||||
@@ -16,6 +17,7 @@ import { cn } from "~/utils/cn";
|
||||
import {
|
||||
organizationPath,
|
||||
organizationRolesPath,
|
||||
organizationRuntimeUpdatesPath,
|
||||
organizationSettingsPath,
|
||||
organizationSlackIntegrationPath,
|
||||
organizationSsoPath,
|
||||
@@ -49,11 +51,13 @@ export function OrganizationSettingsSideMenu({
|
||||
buildInfo,
|
||||
isUsingPlugin,
|
||||
isSsoUsingPlugin,
|
||||
hasProjectRuntimeUpdate,
|
||||
}: {
|
||||
organization: MatchedOrganization;
|
||||
buildInfo: BuildInfo;
|
||||
isUsingPlugin: boolean;
|
||||
isSsoUsingPlugin: boolean;
|
||||
hasProjectRuntimeUpdate: boolean;
|
||||
}) {
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const featureFlags = useFeatureFlags();
|
||||
@@ -127,6 +131,22 @@ export function OrganizationSettingsSideMenu({
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Projects"
|
||||
icon={FolderOpenIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={organizationRuntimeUpdatesPath(organization)}
|
||||
data-action="runtime-updates"
|
||||
badge={
|
||||
hasProjectRuntimeUpdate ? (
|
||||
<>
|
||||
<span aria-hidden className="size-2 shrink-0 rounded-full bg-warning" />
|
||||
<span className="sr-only">Runtime update available.</span>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Team"
|
||||
icon={UserGroupIcon}
|
||||
|
||||
@@ -142,7 +142,8 @@ export function ClipboardField({
|
||||
readOnly={true}
|
||||
className={cn(
|
||||
"shrink grow select-all overflow-x-auto",
|
||||
fullWidth ? "w-full" : "max-w-fit",
|
||||
// min-w-0 stops the input's intrinsic min-width pushing the copy button out of the row.
|
||||
fullWidth ? "w-full min-w-0" : "max-w-fit",
|
||||
input
|
||||
)}
|
||||
onFocus={(e) => {
|
||||
|
||||
@@ -50,7 +50,7 @@ export function CopyButton({
|
||||
onClick={copy}
|
||||
className={cn(
|
||||
buttonSize,
|
||||
"flex items-center justify-center rounded border border-border-bright bg-background-hover",
|
||||
"flex shrink-0 items-center justify-center rounded border border-border-bright bg-background-hover",
|
||||
copied
|
||||
? "text-green-500"
|
||||
: "text-text-dimmed hover:border-border-bright hover:bg-background-raised hover:text-text-bright",
|
||||
|
||||
@@ -2,7 +2,8 @@ import * as React from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { InfoIconTooltip } from "./Tooltip";
|
||||
|
||||
const variants = {
|
||||
// Non-form labels (e.g. settings row titles) share this typography, so it is exported.
|
||||
export const labelVariants = {
|
||||
small: {
|
||||
text: "font-sans text-[0.8125rem] font-normal text-text-bright leading-tight flex items-center gap-1",
|
||||
},
|
||||
@@ -17,7 +18,7 @@ const variants = {
|
||||
type LabelProps = React.AllHTMLAttributes<HTMLLabelElement> & {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
variant?: keyof typeof variants;
|
||||
variant?: keyof typeof labelVariants;
|
||||
required?: boolean;
|
||||
tooltip?: React.ReactNode;
|
||||
};
|
||||
@@ -30,7 +31,7 @@ export function Label({
|
||||
tooltip,
|
||||
...props
|
||||
}: LabelProps) {
|
||||
const variation = variants[variant];
|
||||
const variation = labelVariants[variant];
|
||||
return (
|
||||
<label className={cn(variation.text, className)} {...props}>
|
||||
{children}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type ReactNode } from "react";
|
||||
import { MainHorizontallyCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Header2, Header3 } from "./Headers";
|
||||
import { labelVariants } from "./Label";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
|
||||
// A composable layout system for settings pages: a centered container holds
|
||||
@@ -82,7 +83,7 @@ export function SettingsHeader({
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-0.5">
|
||||
<Heading>{title}</Heading>
|
||||
{description ? <Paragraph variant="small">{description}</Paragraph> : null}
|
||||
</div>
|
||||
@@ -101,10 +102,7 @@ export function SettingsRowTitle({
|
||||
htmlFor?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const classes = cn(
|
||||
"block font-sans text-sm font-semibold leading-tight text-text-bright",
|
||||
className
|
||||
);
|
||||
const classes = cn(labelVariants.medium.text, className);
|
||||
return htmlFor ? (
|
||||
<label htmlFor={htmlFor} className={classes}>
|
||||
{children}
|
||||
@@ -123,7 +121,7 @@ export function SettingsRowDescription({
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Paragraph variant="small" className={className}>
|
||||
<Paragraph variant="extra-small" className={className}>
|
||||
{children}
|
||||
</Paragraph>
|
||||
);
|
||||
@@ -170,7 +168,7 @@ export function SettingsRow({
|
||||
)}
|
||||
>
|
||||
{children ?? (
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex-1 space-y-0.5">
|
||||
{title ? (
|
||||
<SettingsRowTitle htmlFor={htmlFor} className={titleClassName}>
|
||||
{title}
|
||||
@@ -229,7 +227,7 @@ export function SettingsAlertRow({
|
||||
|
||||
return (
|
||||
<SettingsRow action={action}>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex-1 space-y-0.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Icon className={cn("size-4 shrink-0", color)} />
|
||||
<SettingsRowTitle className={color}>{title}</SettingsRowTitle>
|
||||
|
||||
@@ -3,11 +3,16 @@ import { prisma } from "~/db.server";
|
||||
import { getUsersInvites } from "~/models/member.server";
|
||||
import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { deeplinkSuffix, resolveDeeplinkPage } from "~/utils/deeplinkPages";
|
||||
import {
|
||||
deeplinkSuffix,
|
||||
resolveDeeplinkPage,
|
||||
resolveOrganizationDeeplinkPage,
|
||||
} from "~/utils/deeplinkPages";
|
||||
import {
|
||||
invitesPath,
|
||||
newOrganizationPath,
|
||||
newProjectPath,
|
||||
organizationRuntimeUpdatesPath,
|
||||
v3EnvironmentPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
@@ -16,7 +21,9 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
|
||||
const { pathname, search } = new URL(request.url);
|
||||
const page = resolveDeeplinkPage(deeplinkSuffix(pathname));
|
||||
const suffix = deeplinkSuffix(pathname);
|
||||
const page = resolveDeeplinkPage(suffix);
|
||||
const organizationPage = resolveOrganizationDeeplinkPage(suffix);
|
||||
|
||||
const invites = await getUsersInvites({ email: user.email });
|
||||
if (invites.length > 0) {
|
||||
@@ -26,11 +33,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const presenter = new SelectBestEnvironmentPresenter();
|
||||
try {
|
||||
const { project, organization, environment } = await presenter.call({ user });
|
||||
if (organizationPage === "runtime-updates") {
|
||||
return redirect(`${organizationRuntimeUpdatesPath(organization)}${search}`);
|
||||
}
|
||||
|
||||
const environmentPath = v3EnvironmentPath(organization, project, environment);
|
||||
const pageSuffix = page ? `/${page}` : "";
|
||||
|
||||
const suffix = page ? `/${page}` : "";
|
||||
|
||||
return redirect(`${environmentPath}${suffix}${search}`);
|
||||
return redirect(`${environmentPath}${pageSuffix}${search}`);
|
||||
} catch (_e) {
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: {
|
||||
@@ -47,6 +57,10 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
});
|
||||
|
||||
if (organization) {
|
||||
if (organizationPage === "runtime-updates") {
|
||||
return redirect(`${organizationRuntimeUpdatesPath(organization)}${search}`);
|
||||
}
|
||||
|
||||
return redirect(newProjectPath(organization));
|
||||
}
|
||||
|
||||
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
import { NODE_RUNTIME_UPDATE_MAJOR } from "@trigger.dev/core/v3";
|
||||
import { RuntimeIcon } from "~/components/RuntimeIcon";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { ClipboardField } from "~/components/primitives/ClipboardField";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
SettingsBlock,
|
||||
SettingsContainer,
|
||||
SettingsHeader,
|
||||
SettingsRow,
|
||||
SettingsRowTitle,
|
||||
SettingsSection,
|
||||
} from "~/components/primitives/SettingsLayout";
|
||||
import { v3DeploymentPath, v3ProjectPath } from "~/utils/pathBuilder";
|
||||
|
||||
// The source major has a constant in packages/core; the target it moves projects to does not.
|
||||
const NODE_RUNTIME_TARGET_MAJOR = 24;
|
||||
|
||||
const CONFIG_SNIPPET = `export default defineConfig({
|
||||
project: "<your-project-ref>",
|
||||
runtime: "node-${NODE_RUNTIME_TARGET_MAJOR}",
|
||||
});`;
|
||||
|
||||
const CLI_COMMAND = "npx trigger.dev@latest projects list --needs-update";
|
||||
|
||||
/** A project and its current Production deployment, or `null` when it has never been deployed. */
|
||||
export type ProjectRuntimeRow = {
|
||||
name: string;
|
||||
ref: string;
|
||||
slug: string;
|
||||
environmentSlug: string;
|
||||
deployment: {
|
||||
runtime: string | null;
|
||||
runtimeVersion: string | null;
|
||||
deployedAt: Date | null;
|
||||
shortCode: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export function RuntimeUpdatesPage({
|
||||
organizationSlug,
|
||||
needsUpdate,
|
||||
otherProjects,
|
||||
}: {
|
||||
organizationSlug: string;
|
||||
needsUpdate: ProjectRuntimeRow[];
|
||||
otherProjects: ProjectRuntimeRow[];
|
||||
}) {
|
||||
const count = needsUpdate.length;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Projects" />
|
||||
</NavBar>
|
||||
<PageBody scrollable>
|
||||
<SettingsContainer>
|
||||
{count > 0 ? (
|
||||
<>
|
||||
<SettingsSection>
|
||||
<SettingsHeader
|
||||
title="Runtime update available"
|
||||
description={`${count} ${
|
||||
count === 1 ? "project is" : "projects are"
|
||||
} still running Node.js ${NODE_RUNTIME_UPDATE_MAJOR} in Production. Update each one to Node.js ${NODE_RUNTIME_TARGET_MAJOR} and deploy a new version.`}
|
||||
/>
|
||||
|
||||
<SettingsRow
|
||||
align="start"
|
||||
title="Update your config"
|
||||
description={
|
||||
<>
|
||||
Set the runtime in{" "}
|
||||
<InlineCode variant="extra-small" className="whitespace-nowrap">
|
||||
trigger.config.ts
|
||||
</InlineCode>
|
||||
, then deploy. <InlineCode variant="extra-small">node-22</InlineCode> and{" "}
|
||||
<InlineCode variant="extra-small">node-26</InlineCode> are also supported.
|
||||
</>
|
||||
}
|
||||
action={
|
||||
<CodeBlock
|
||||
code={CONFIG_SNIPPET}
|
||||
showLineNumbers={false}
|
||||
showOpenInModal={false}
|
||||
className="w-fit"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingsRow
|
||||
title="Or check from the CLI"
|
||||
description="Find every project that needs an update."
|
||||
action={
|
||||
<ClipboardField
|
||||
value={CLI_COMMAND}
|
||||
variant="secondary/small"
|
||||
iconButton
|
||||
className="w-64"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsHeader title="Projects to update" />
|
||||
{needsUpdate.map((project) => (
|
||||
<ProjectRow
|
||||
key={project.ref}
|
||||
organizationSlug={organizationSlug}
|
||||
project={project}
|
||||
/>
|
||||
))}
|
||||
</SettingsSection>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsHeader title="Projects" />
|
||||
{otherProjects.length === 0 ? (
|
||||
<SettingsBlock>
|
||||
<Paragraph variant="small">
|
||||
{count === 0 ? "This organization has no projects yet." : "No other projects."}
|
||||
</Paragraph>
|
||||
</SettingsBlock>
|
||||
) : (
|
||||
otherProjects.map((project) => (
|
||||
<ProjectRow
|
||||
key={project.ref}
|
||||
organizationSlug={organizationSlug}
|
||||
project={project}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectRow({
|
||||
organizationSlug,
|
||||
project,
|
||||
}: {
|
||||
organizationSlug: string;
|
||||
project: ProjectRuntimeRow;
|
||||
}) {
|
||||
const { deployment } = project;
|
||||
|
||||
return (
|
||||
<SettingsRow
|
||||
action={
|
||||
deployment ? (
|
||||
<LinkButton
|
||||
to={v3DeploymentPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: project.slug },
|
||||
{ slug: project.environmentSlug },
|
||||
{ shortCode: deployment.shortCode },
|
||||
0
|
||||
)}
|
||||
variant="secondary/small"
|
||||
aria-label={`View deployment for ${project.name}`}
|
||||
>
|
||||
View deployment
|
||||
</LinkButton>
|
||||
) : (
|
||||
<LinkButton
|
||||
to={v3ProjectPath({ slug: organizationSlug }, { slug: project.slug })}
|
||||
variant="secondary/small"
|
||||
aria-label={`View project ${project.name}`}
|
||||
>
|
||||
View project
|
||||
</LinkButton>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex-1 space-y-0.5">
|
||||
<SettingsRowTitle>{project.name}</SettingsRowTitle>
|
||||
<p className="font-mono text-sm text-text-dimmed">{project.ref}</p>
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-text-dimmed">
|
||||
{deployment ? (
|
||||
<>
|
||||
<RuntimeIcon
|
||||
runtime={deployment.runtime}
|
||||
runtimeVersion={deployment.runtimeVersion}
|
||||
className="size-3.5"
|
||||
withLabel
|
||||
/>
|
||||
{deployment.deployedAt ? (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span>
|
||||
Production deployed <DateTime date={deployment.deployedAt} includeSeconds />
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<span>No Production deployment</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { NODE_RUNTIME_UPDATE_MAJOR } from "@trigger.dev/core/v3";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { resolveOrgIdFromSlugForUser } from "~/models/organization.server";
|
||||
import { listCurrentProductionProjectRuntimes } from "~/services/projectRuntimeUpdates.server";
|
||||
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
|
||||
import { getUserId } from "~/services/session.server";
|
||||
import { pageMeta } from "~/utils/pageTitle";
|
||||
import { OrganizationParamsSchema } from "~/utils/pathBuilder";
|
||||
import { type ProjectRuntimeRow, RuntimeUpdatesPage } from "./RuntimeUpdatesPage";
|
||||
|
||||
export const meta = pageMeta("Projects");
|
||||
|
||||
export const loader = dashboardLoader(
|
||||
{
|
||||
params: OrganizationParamsSchema,
|
||||
// Membership-scoped resolve, like the Team settings loader: the RBAC gate below enforces the
|
||||
// role, this is the tenant floor. An unresolved org yields no scope, which the loader rejects.
|
||||
context: async (params, request) => {
|
||||
const userId = await getUserId(request);
|
||||
if (!userId) return {};
|
||||
const organizationId = await resolveOrgIdFromSlugForUser(params.organizationSlug, userId);
|
||||
return organizationId ? { organizationId } : {};
|
||||
},
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: { type: "deployments" },
|
||||
message: "With your current role, you can't view project deployments.",
|
||||
},
|
||||
},
|
||||
async ({ context, params }) => {
|
||||
const organizationId = context.organizationId;
|
||||
if (!organizationId) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const runtimes = await listCurrentProductionProjectRuntimes({ organizationId });
|
||||
|
||||
const needsUpdate: ProjectRuntimeRow[] = [];
|
||||
const otherProjects: ProjectRuntimeRow[] = [];
|
||||
|
||||
for (const { project, environment, deployment } of runtimes) {
|
||||
const row: ProjectRuntimeRow = {
|
||||
name: project.name,
|
||||
ref: project.externalRef,
|
||||
slug: project.slug,
|
||||
environmentSlug: environment.slug,
|
||||
deployment: deployment
|
||||
? {
|
||||
runtime: deployment.runtime,
|
||||
runtimeVersion: deployment.runtimeVersion,
|
||||
deployedAt: deployment.deployedAt,
|
||||
shortCode: deployment.shortCode,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
if (deployment?.nodeMajor === NODE_RUNTIME_UPDATE_MAJOR) {
|
||||
needsUpdate.push(row);
|
||||
} else {
|
||||
otherProjects.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
return typedjson({
|
||||
organizationSlug: params.organizationSlug,
|
||||
needsUpdate,
|
||||
otherProjects,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default function Page() {
|
||||
const { organizationSlug, needsUpdate, otherProjects } = useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<RuntimeUpdatesPage
|
||||
organizationSlug={organizationSlug}
|
||||
needsUpdate={needsUpdate}
|
||||
otherProjects={otherProjects}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -10,15 +10,51 @@ 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 [isUsingPlugin, isSsoUsingPlugin] = await Promise.all([
|
||||
const userId = await requireUserId(request);
|
||||
const organizationSlug = params.organizationSlug;
|
||||
|
||||
const [isUsingPlugin, isSsoUsingPlugin, hasProjectRuntimeUpdate] = 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: {
|
||||
@@ -30,6 +66,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
} satisfies BuildInfo,
|
||||
isUsingPlugin,
|
||||
isSsoUsingPlugin,
|
||||
hasProjectRuntimeUpdate,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -37,11 +74,13 @@ function SettingsChrome({
|
||||
buildInfo,
|
||||
isUsingPlugin,
|
||||
isSsoUsingPlugin,
|
||||
hasProjectRuntimeUpdate,
|
||||
children,
|
||||
}: {
|
||||
buildInfo: BuildInfo;
|
||||
isUsingPlugin: boolean;
|
||||
isSsoUsingPlugin: boolean;
|
||||
hasProjectRuntimeUpdate: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const organization = useOrganization();
|
||||
@@ -54,6 +93,7 @@ function SettingsChrome({
|
||||
buildInfo={buildInfo}
|
||||
isUsingPlugin={isUsingPlugin}
|
||||
isSsoUsingPlugin={isSsoUsingPlugin}
|
||||
hasProjectRuntimeUpdate={hasProjectRuntimeUpdate}
|
||||
/>
|
||||
<MainBody>{children}</MainBody>
|
||||
</div>
|
||||
@@ -62,13 +102,15 @@ function SettingsChrome({
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { buildInfo, isUsingPlugin, isSsoUsingPlugin } = useTypedLoaderData<typeof loader>();
|
||||
const { buildInfo, isUsingPlugin, isSsoUsingPlugin, hasProjectRuntimeUpdate } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<SettingsChrome
|
||||
buildInfo={buildInfo}
|
||||
isUsingPlugin={isUsingPlugin}
|
||||
isSsoUsingPlugin={isSsoUsingPlugin}
|
||||
hasProjectRuntimeUpdate={hasProjectRuntimeUpdate}
|
||||
>
|
||||
<Outlet />
|
||||
</SettingsChrome>
|
||||
@@ -81,7 +123,12 @@ export default function Page() {
|
||||
// available via useRouteLoaderData.
|
||||
export function ErrorBoundary() {
|
||||
const data = useRouteLoaderData(SETTINGS_ROUTE_ID) as
|
||||
| { buildInfo: BuildInfo; isUsingPlugin: boolean; isSsoUsingPlugin: boolean }
|
||||
| {
|
||||
buildInfo: BuildInfo;
|
||||
isUsingPlugin: boolean;
|
||||
isSsoUsingPlugin: boolean;
|
||||
hasProjectRuntimeUpdate: boolean;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!data) {
|
||||
@@ -93,6 +140,7 @@ export function ErrorBoundary() {
|
||||
buildInfo={data.buildInfo}
|
||||
isUsingPlugin={data.isUsingPlugin}
|
||||
isSsoUsingPlugin={data.isSsoUsingPlugin}
|
||||
hasProjectRuntimeUpdate={data.hasProjectRuntimeUpdate}
|
||||
>
|
||||
<RouteErrorDisplay />
|
||||
</SettingsChrome>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import type { GetProjectRuntimesResponseBody } from "@trigger.dev/core/v3";
|
||||
import { createLoaderPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { listCurrentProductionProjectRuntimes } from "~/services/projectRuntimeUpdates.server";
|
||||
|
||||
// Identity-only: like /api/v1/projects, this returns resources across every organization the PAT
|
||||
// owner belongs to. Runtime details are limited to each project's current Production deployment.
|
||||
export const loader = createLoaderPATApiRoute(
|
||||
{ identityOnly: true },
|
||||
async ({ authentication }) => {
|
||||
const runtimes: GetProjectRuntimesResponseBody = await listCurrentProductionProjectRuntimes({
|
||||
userId: authentication.userId,
|
||||
});
|
||||
|
||||
return json(runtimes);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,130 @@
|
||||
import { NODE_RUNTIME_UPDATE_MAJOR, nodeMajor } from "@trigger.dev/core/v3";
|
||||
import { CURRENT_DEPLOYMENT_LABEL } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { prisma } from "~/db.server";
|
||||
|
||||
/**
|
||||
* The scope is required and exactly one of the two applies: without it the `where` below would
|
||||
* collapse to every V3 project on the instance, so a scopeless call must not typecheck.
|
||||
*/
|
||||
type Scope =
|
||||
| { organizationId: string; userId?: never }
|
||||
| { userId: string; organizationId?: never };
|
||||
|
||||
export async function listCurrentProductionProjectRuntimes(scope: Scope) {
|
||||
const projects = await prisma.project.findMany({
|
||||
where: {
|
||||
...(scope.organizationId !== undefined
|
||||
? { organizationId: scope.organizationId }
|
||||
: {
|
||||
organization: {
|
||||
deletedAt: null,
|
||||
members: { some: { userId: scope.userId } },
|
||||
},
|
||||
}),
|
||||
version: "V3",
|
||||
deletedAt: null,
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
slug: true,
|
||||
externalRef: true,
|
||||
organization: {
|
||||
select: {
|
||||
title: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
environments: {
|
||||
where: { type: "PRODUCTION" },
|
||||
select: {
|
||||
slug: true,
|
||||
workerDeploymentPromotions: {
|
||||
where: { label: CURRENT_DEPLOYMENT_LABEL },
|
||||
select: {
|
||||
deployment: {
|
||||
select: {
|
||||
runtime: true,
|
||||
runtimeVersion: true,
|
||||
deployedAt: true,
|
||||
shortCode: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ organization: { title: "asc" } }, { name: "asc" }],
|
||||
});
|
||||
|
||||
return projects.flatMap((project) =>
|
||||
project.environments.map((environment) => {
|
||||
const deployment = environment.workerDeploymentPromotions[0]?.deployment;
|
||||
|
||||
return {
|
||||
organization: project.organization,
|
||||
project: {
|
||||
name: project.name,
|
||||
slug: project.slug,
|
||||
externalRef: project.externalRef,
|
||||
},
|
||||
environment: {
|
||||
slug: environment.slug,
|
||||
},
|
||||
deployment: deployment
|
||||
? {
|
||||
runtime: deployment.runtime,
|
||||
runtimeVersion: deployment.runtimeVersion,
|
||||
nodeMajor: nodeMajor(deployment.runtime, deployment.runtimeVersion) ?? null,
|
||||
deployedAt: deployment.deployedAt,
|
||||
shortCode: deployment.shortCode,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any project in the organization runs the reported Node.js major in Production.
|
||||
*
|
||||
* The SQL filter mirrors `nodeMajor(runtime, runtimeVersion) === NODE_RUNTIME_UPDATE_MAJOR`, which
|
||||
* the page applies in JS: keep the two in step. Scoped to the caller's membership so the side menu
|
||||
* cannot report on an organization the user does not belong to.
|
||||
*/
|
||||
export async function organizationHasProjectRuntimeUpdate({
|
||||
organizationSlug,
|
||||
userId,
|
||||
}: {
|
||||
organizationSlug: string;
|
||||
userId: string;
|
||||
}): Promise<boolean> {
|
||||
const project = await prisma.project.findFirst({
|
||||
where: {
|
||||
organization: {
|
||||
slug: organizationSlug,
|
||||
deletedAt: null,
|
||||
members: { some: { userId } },
|
||||
},
|
||||
version: "V3",
|
||||
deletedAt: null,
|
||||
environments: {
|
||||
some: {
|
||||
type: "PRODUCTION",
|
||||
workerDeploymentPromotions: {
|
||||
some: {
|
||||
label: CURRENT_DEPLOYMENT_LABEL,
|
||||
deployment: {
|
||||
runtime: { startsWith: "node" },
|
||||
runtimeVersion: { startsWith: `${NODE_RUNTIME_UPDATE_MAJOR}.` },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return project !== null;
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
DEEPLINK_PATH_PREFIX,
|
||||
deeplinkSuffix,
|
||||
ENV_PAGE_TARGETS,
|
||||
ORG_PAGE_TARGETS,
|
||||
resolveDeeplinkPage,
|
||||
resolveOrganizationDeeplinkPage,
|
||||
} from "./deeplinkPages";
|
||||
|
||||
const APP_DIR = join(__dirname, "..");
|
||||
@@ -161,6 +163,15 @@ describe("resolveDeeplinkPage", () => {
|
||||
expect(resolveDeeplinkPage("tasks")).toBe("");
|
||||
});
|
||||
|
||||
it("resolves organization-level pages separately from environment pages", () => {
|
||||
expect(ORG_PAGE_TARGETS.get("runtime-updates")).toEqual({
|
||||
landing: "runtime-updates",
|
||||
prefix: "runtime-updates",
|
||||
});
|
||||
expect(resolveOrganizationDeeplinkPage("runtime-updates")).toBe("runtime-updates");
|
||||
expect(resolveDeeplinkPage("runtime-updates")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("grafts deeper segments onto the prefix", () => {
|
||||
expect(resolveDeeplinkPage("runs/run_123")).toBe("runs/run_123");
|
||||
expect(resolveDeeplinkPage("tasks/standard/my-task")).toBe("tasks/standard/my-task");
|
||||
|
||||
@@ -38,6 +38,10 @@ export const ENV_PAGE_TARGETS: ReadonlyMap<string, DeeplinkTarget> = new Map([
|
||||
["webhooks", page("webhooks")],
|
||||
]);
|
||||
|
||||
export const ORG_PAGE_TARGETS: ReadonlyMap<string, DeeplinkTarget> = new Map([
|
||||
["runtime-updates", page("runtime-updates")],
|
||||
]);
|
||||
|
||||
export const DEEPLINK_PATH_PREFIX = "/_";
|
||||
|
||||
export function deeplinkSuffix(pathname: string): string {
|
||||
@@ -61,11 +65,14 @@ function isSafeSegment(segment: string): boolean {
|
||||
return decoded !== "." && decoded !== "..";
|
||||
}
|
||||
|
||||
export function resolveDeeplinkPage(suffix: string): string | undefined {
|
||||
function resolveDeeplinkTarget(
|
||||
targets: ReadonlyMap<string, DeeplinkTarget>,
|
||||
suffix: string
|
||||
): string | undefined {
|
||||
const segments = suffix.split("/").filter(isSafeSegment);
|
||||
const [first = "", ...rest] = segments;
|
||||
|
||||
const target = ENV_PAGE_TARGETS.get(first.toLowerCase());
|
||||
const target = targets.get(first.toLowerCase());
|
||||
if (target === undefined) return undefined;
|
||||
|
||||
if (rest.length === 0) return target.landing;
|
||||
@@ -76,3 +83,11 @@ export function resolveDeeplinkPage(suffix: string): string | undefined {
|
||||
|
||||
return [target.prefix, ...beyondPrefix].join("/");
|
||||
}
|
||||
|
||||
export function resolveDeeplinkPage(suffix: string): string | undefined {
|
||||
return resolveDeeplinkTarget(ENV_PAGE_TARGETS, suffix);
|
||||
}
|
||||
|
||||
export function resolveOrganizationDeeplinkPage(suffix: string): string | undefined {
|
||||
return resolveDeeplinkTarget(ORG_PAGE_TARGETS, suffix);
|
||||
}
|
||||
|
||||
@@ -170,6 +170,10 @@ export function organizationSettingsPath(organization: OrgForPath) {
|
||||
return `${organizationPath(organization)}/settings`;
|
||||
}
|
||||
|
||||
export function organizationRuntimeUpdatesPath(organization: OrgForPath) {
|
||||
return `${organizationSettingsPath(organization)}/runtime-updates`;
|
||||
}
|
||||
|
||||
function organizationIntegrationsPath(organization: OrgForPath) {
|
||||
return `${organizationPath(organization)}/settings/integrations`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user