feat(webapp,cli,core): list production project runtime updates (#4659)

This commit is contained in:
Chris Arderne
2026-08-19 13:44:55 +01:00
committed by GitHub
parent 49aff3cb39
commit 4dabfca1d5
21 changed files with 770 additions and 24 deletions
@@ -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>
+19 -5
View File
@@ -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));
}
@@ -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");
+17 -2
View File
@@ -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);
}
+4
View File
@@ -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`;
}