Add links to and from deployments (#1921)

* link from deployments tasks to filtered runs view

* jump to deployment

* don't add version links for dev (yet)
This commit is contained in:
nicktrn
2025-04-14 14:43:52 +01:00
committed by GitHub
parent faf5d01b2c
commit 5f4c6071d8
6 changed files with 129 additions and 11 deletions
@@ -168,4 +168,52 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
}),
};
}
public async findPageForVersion({
userId,
projectSlug,
organizationSlug,
environmentSlug,
version,
}: {
userId: User["id"];
projectSlug: Project["slug"];
organizationSlug: Organization["slug"];
environmentSlug: string;
version: string;
}) {
const project = await this.#prismaClient.project.findFirstOrThrow({
select: {
id: true,
},
where: {
slug: projectSlug,
organization: {
slug: organizationSlug,
members: {
some: {
userId,
},
},
},
},
});
const environment = await findEnvironmentBySlug(project.id, environmentSlug, userId);
if (!environment) {
throw new Error(`Environment not found`);
}
// Find how many deployments have been made since this version
const deploymentsSinceVersion = await this.#prismaClient.$queryRaw<{ count: BigInt }[]>`
SELECT COUNT(*) as count
FROM ${sqlDatabaseSchema}."WorkerDeployment"
WHERE "projectId" = ${project.id}
AND "environmentId" = ${environment.id}
AND string_to_array(version, '.')::int[] > string_to_array(${version}, '.')::int[]
`;
const count = Number(deploymentsSinceVersion[0].count);
return Math.floor(count / pageSize) + 1;
}
}
@@ -28,7 +28,7 @@ import { useUser } from "~/hooks/useUser";
import { DeploymentPresenter } from "~/presenters/v3/DeploymentPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { v3DeploymentParams, v3DeploymentsPath } from "~/utils/pathBuilder";
import { v3DeploymentParams, v3DeploymentsPath, v3RunsPath } from "~/utils/pathBuilder";
import { capitalizeWord } from "~/utils/string";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
@@ -231,16 +231,19 @@ export default function Page() {
</TableHeader>
<TableBody>
{deployment.tasks.map((t) => {
const path = v3RunsPath(organization, project, environment, {
tasks: [t.slug],
});
return (
<TableRow key={t.slug}>
<TableCell>
<TableCell to={path}>
<div className="inline-flex flex-col gap-0.5">
<Paragraph variant="extra-small" className="text-text-dimmed">
{t.slug}
</Paragraph>
</div>
</TableCell>
<TableCell>{t.filePath}</TableCell>
<TableCell to={path}>{t.filePath}</TableCell>
</TableRow>
);
})}
@@ -1,12 +1,11 @@
import { ArrowPathIcon, ArrowUturnLeftIcon, BookOpenIcon } from "@heroicons/react/20/solid";
import { type MetaFunction, Outlet, useLocation, useParams } from "@remix-run/react";
import { type MetaFunction, Outlet, useLocation, useParams, useNavigate } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { PromoteIcon } from "~/assets/icons/PromoteIcon";
import { DeploymentsNone, DeploymentsNoneDev } from "~/components/BlankStatePanels";
import { UserAvatar } from "~/components/UserProfilePhoto";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Badge } from "~/components/primitives/Badge";
import { Button, LinkButton } from "~/components/primitives/Buttons";
@@ -53,6 +52,7 @@ import { EnvironmentParamSchema, docsPath, v3DeploymentPath } from "~/utils/path
import { createSearchParams } from "~/utils/searchParams";
import { deploymentIndexingIsRetryable } from "~/v3/deploymentStatus";
import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions";
import { useEffect } from "react";
export const meta: MetaFunction = () => {
return [
@@ -64,6 +64,7 @@ export const meta: MetaFunction = () => {
const SearchParams = z.object({
page: z.coerce.number().optional(),
version: z.string().optional(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
@@ -71,10 +72,29 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const searchParams = createSearchParams(request.url, SearchParams);
const page = searchParams.success ? searchParams.params.get("page") ?? 1 : 1;
let page = searchParams.success ? Number(searchParams.params.get("page") ?? 1) : 1;
const version = searchParams.success ? searchParams.params.get("version")?.toString() : undefined;
const presenter = new DeploymentListPresenter();
// If we have a version, find its page
if (version) {
try {
page = await presenter.findPageForVersion({
userId,
organizationSlug,
projectSlug: projectParam,
environmentSlug: envParam,
version,
});
} catch (error) {
console.error("Error finding page for version", error);
// Carry on, we'll just show the selected page
}
}
try {
const presenter = new DeploymentListPresenter();
const result = await presenter.call({
userId,
organizationSlug,
@@ -83,7 +103,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
page,
});
return typedjson(result);
// If we have a version, find the deployment
const selectedDeployment = version
? result.deployments.find((d) => d.version === version)
: undefined;
return typedjson({ ...result, selectedDeployment });
} catch (error) {
console.error(error);
throw new Response(undefined, {
@@ -97,10 +122,23 @@ export default function Page() {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { deployments, currentPage, totalPages } = useTypedLoaderData<typeof loader>();
const { deployments, currentPage, totalPages, selectedDeployment } =
useTypedLoaderData<typeof loader>();
const hasDeployments = totalPages > 0;
const { deploymentParam } = useParams();
const location = useLocation();
const navigate = useNavigate();
// If we have a selected deployment from the version param, show it
useEffect(() => {
if (selectedDeployment && !deploymentParam) {
const searchParams = new URLSearchParams(location.search);
searchParams.delete("version");
searchParams.set("page", currentPage.toString());
navigate(`${location.pathname}/${selectedDeployment.shortCode}?${searchParams.toString()}`);
}
}, [selectedDeployment, deploymentParam, location.search]);
const currentDeployment = deployments.find((d) => d.isCurrent);
@@ -33,7 +33,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
return new Response("Not found", { status: 404 });
}
// Redirect to the project's runs page
// Redirect to the project's deployments page
return redirect(
`/orgs/${project.organization.slug}/projects/${project.slug}/deployments/${validatedParams.deploymentParam}`
);
@@ -59,6 +59,7 @@ import { formatCurrencyAccurate } from "~/utils/numberFormatter";
import {
docsPath,
v3BatchPath,
v3DeploymentVersionPath,
v3RunDownloadLogsPath,
v3RunPath,
v3RunSpanPath,
@@ -527,7 +528,26 @@ function RunBody({
<Property.Label>Version</Property.Label>
<Property.Value>
{run.version ? (
run.version
environment.type === "DEVELOPMENT" ? (
run.version
) : (
<SimpleTooltip
button={
<TextLink
to={v3DeploymentVersionPath(
organization,
project,
environment,
run.version
)}
className="group flex flex-wrap items-center gap-x-1 gap-y-0"
>
{run.version}
</TextLink>
}
content={"Jump to deployment"}
/>
)
) : (
<span className="flex items-center gap-1">
<span>Never started</span>
+9
View File
@@ -395,6 +395,15 @@ export function v3DeploymentPath(
return `${v3DeploymentsPath(organization, project, environment)}/${deployment.shortCode}${query}`;
}
export function v3DeploymentVersionPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
version: string
) {
return `${v3DeploymentsPath(organization, project, environment)}?version=${version}`;
}
export function v3BillingPath(organization: OrgForPath, message?: string) {
return `${organizationPath(organization)}/settings/billing${
message ? `?message=${encodeURIComponent(message)}` : ""