v3 deployments page (#945)

* Set the “current” deployment in WorkerDeploymentPromotion when a new deployment is completed

* Deployments page added to sidenav

* Basic deployments page working

* Pagination for deployments

* Right-align the pagination controls

* Improved the blank state

* Selected deployment panel

* Improved the side panel
This commit is contained in:
Matt Aitken
2024-03-15 16:04:01 +00:00
committed by GitHub
parent 1c98eb2b61
commit 5b9c67aeef
16 changed files with 926 additions and 121 deletions
@@ -7,6 +7,7 @@ import {
CursorArrowRaysIcon,
IdentificationIcon,
KeyIcon,
ServerStackIcon,
ShieldCheckIcon,
} from "@heroicons/react/20/solid";
import { UserGroupIcon, UserPlusIcon } from "@heroicons/react/24/solid";
@@ -40,6 +41,7 @@ import {
projectSetupPath,
projectTriggersPath,
v3ApiKeysPath,
v3DeploymentsPath,
v3EnvironmentVariablesPath,
v3ProjectPath,
v3ProjectSettingsPath,
@@ -551,6 +553,13 @@ function V3ProjectSideMenu({
to={v3EnvironmentVariablesPath(organization, project)}
data-action="environment variables"
/>
<SideMenuItem
name="Deployments"
icon={ServerStackIcon}
iconColor="text-blue-500"
to={v3DeploymentsPath(organization, project)}
data-action="deployments"
/>
<SideMenuItem
name="Project settings"
icon="settings"
@@ -10,7 +10,7 @@ const variants = {
"grid place-items-center rounded-sm px-1.5 h-5 tracking-wider text-xxs border border-dimmed text-text-dimmed uppercase whitespace-nowrap",
v3: "grid place-items-center rounded-full px-[0.4rem] h-5 tracking-wider text-xxs bg-charcoal-750 text-primary whitespace-nowrap",
"outline-rounded":
"grid place-items-center rounded-full px-1.5 h-5 tracking-wider text-xxs border border-blue-500 text-blue-500 uppercase whitespace-nowrap",
"grid place-items-center rounded-full px-1 h-4 tracking-wider text-xxs border border-blue-500 text-blue-500 uppercase whitespace-nowrap",
};
type BadgeProps = React.HTMLAttributes<HTMLDivElement> & {
@@ -3,17 +3,14 @@ import { ChevronLeftIcon } from "@heroicons/react/24/solid";
import { Link, useLocation } from "@remix-run/react";
import { LinkDisabled } from "./LinkWithDisabled";
import { cn } from "~/utils/cn";
import { ButtonContent, LinkButton } from "./Buttons";
export function PaginationControls({
currentPage,
totalPages,
pageSize,
totalResults,
}: {
currentPage: number;
totalPages: number;
pageSize: number;
totalResults: number;
}) {
const location = useLocation();
if (totalPages <= 1) {
@@ -21,65 +18,33 @@ export function PaginationControls({
}
return (
<div className="flex items-center justify-between rounded-b-lg border-t border-charcoal-850 bg-charcoal-700/20 py-3 pl-4 pr-3 text-charcoal-400">
<div className="flex flex-1 justify-between sm:hidden">
{currentPage > 1 && (
<Link
to={pageUrl(location, currentPage - 1)}
className="hover:bg-charcoal-50 relative inline-flex items-center rounded-md border border-charcoal-300 bg-charcoal-700/20 px-4 py-2 text-xs"
>
Previous
</Link>
)}
{currentPage < totalPages && (
<Link
to={pageUrl(location, currentPage + 1)}
className="hover:bg-charcoal-50 relative ml-3 inline-flex items-center rounded-md border border-charcoal-300 bg-charcoal-700/20 px-4 py-2 text-xs"
>
Next
</Link>
)}
</div>
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
<p className="text-sm text-charcoal-400">
Showing <span className="font-medium">{(currentPage - 1) * pageSize + 1}</span> to{" "}
<span className="font-medium">{currentPage * pageSize}</span> of{" "}
<span className="font-medium">{totalResults}</span> results
</p>
</div>
<div>
<nav
className="isolate inline-flex -space-x-px rounded-md shadow-sm"
aria-label="Pagination"
>
<LinkDisabled
to={pageUrl(location, currentPage - 1)}
disabled={currentPage === 1}
className="relative inline-flex items-center rounded-l border border-charcoal-500 bg-charcoal-700/20 px-2 text-xs font-medium text-charcoal-400 transition hover:border-charcoal-400 hover:bg-charcoal-400 hover:text-charcoal-800 focus:z-20"
disabledClassName="opacity-30 cursor-default hover:bg-charcoal-700/20 hover:border-charcoal-500 hover:!text-charcoal-400"
>
<span className="sr-only">Previous</span>
<ChevronLeftIcon className="h-4 w-4" aria-hidden="true" />
</LinkDisabled>
<nav className="flex items-center gap-1" aria-label="Pagination">
<LinkDisabled
to={pageUrl(location, currentPage - 1)}
className={currentPage > 1 ? "group" : ""}
disabled={currentPage === 1}
disabledClassName="opacity-30 cursor-default"
>
<ButtonContent variant="minimal/medium" LeadingIcon={ChevronLeftIcon}>
Previous
</ButtonContent>
</LinkDisabled>
{calculatePageLinks(currentPage, totalPages).map((page, i) => (
<PageLinkComponent page={page} key={i} location={location} />
))}
{calculatePageLinks(currentPage, totalPages).map((page, i) => (
<PageLinkComponent page={page} key={i} location={location} />
))}
<LinkDisabled
to={pageUrl(location, currentPage + 1)}
disabled={currentPage === totalPages}
className="relative inline-flex items-center rounded-r border border-charcoal-500 bg-charcoal-700/20 px-2 text-xs font-medium text-charcoal-400 transition hover:border-charcoal-400 hover:bg-charcoal-400 hover:text-charcoal-800 focus:z-20"
disabledClassName="opacity-30 cursor-default hover:bg-charcoal-700/20 hover:border-charcoal-500 hover:!text-charcoal-400"
>
<span className="sr-only">Next</span>
<ChevronRightIcon className="h-4 w-4" aria-hidden="true" />
</LinkDisabled>
</nav>
</div>
</div>
</div>
<LinkDisabled
to={pageUrl(location, currentPage + 1)}
className={currentPage !== totalPages ? "group" : ""}
disabled={currentPage === totalPages}
disabledClassName="opacity-30 cursor-default"
>
<ButtonContent variant="minimal/medium" TrailingIcon={ChevronRightIcon}>
Next
</ButtonContent>
</LinkDisabled>
</nav>
);
}
@@ -92,11 +57,9 @@ function pageUrl(location: ReturnType<typeof useLocation>, page: number): string
}
const baseClass =
"relative inline-flex items-center border px-3.5 py-2 text-xs font-medium focus:z-20 transition";
const unselectedClass =
"bg-charcoal-700/20 border-charcoal-500 text-charcoal-400 hover:bg-charcoal-400 hover:text-charcoal-900";
const selectedClass =
"z-10 bg-charcoal-500 border-charcoal-500 hover:bg-charcoal-400 text-charcoal-900";
"flex items-center justify-center border border-transparent h-8 w-8 text-xs font-medium transition text-text-dimmed rounded-sm";
const unselectedClass = "hover:bg-tertiary hover:text-text-bright";
const selectedClass = "border-text-dimmed text-text-bright hover:bg-tertiary";
function PageLinkComponent({
page,
@@ -106,25 +69,16 @@ function PageLinkComponent({
location: ReturnType<typeof useLocation>;
}) {
if (page.type === "specific") {
if (page.isCurrent) {
return (
<Link to={pageUrl(location, page.page)} className={cn(baseClass, selectedClass)}>
{page.page}
</Link>
);
} else {
return (
<Link to={pageUrl(location, page.page)} className={cn(baseClass, unselectedClass)}>
{page.page}
</Link>
);
}
} else {
return (
<span className="inline-flex items-center border-t-2 border-transparent px-4 pt-4 text-xs font-medium text-charcoal-500">
...
</span>
<Link
to={pageUrl(location, page.page)}
className={cn(baseClass, page.isCurrent ? selectedClass : unselectedClass)}
>
{page.page}
</Link>
);
} else {
return <span className={baseClass}>...</span>;
}
}
@@ -0,0 +1,40 @@
import { ReactNode } from "react";
import { Paragraph } from "./Paragraph";
import { cn } from "~/utils/cn";
export function PropertyTable({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div className={cn("grid grid-cols-[auto,1fr] items-center gap-x-4 gap-y-2", className)}>
{children}
</div>
);
}
export type PropertyProps = {
label: ReactNode;
labelClassName?: string;
children: ReactNode;
};
export function Property({ label, labelClassName, children }: PropertyProps) {
return (
<>
<div className={labelClassName}>
{typeof label === "string" ? <Paragraph variant="small">{label}</Paragraph> : label}
</div>
<div>
{typeof children === "string" ? (
<Paragraph variant="small/bright">{children}</Paragraph>
) : (
children
)}
</div>
</>
);
}
@@ -0,0 +1,101 @@
import {
ArrowPathIcon,
BoltSlashIcon,
BugAntIcon,
CheckCircleIcon,
ClockIcon,
NoSymbolIcon,
PauseCircleIcon,
RectangleStackIcon,
ServerStackIcon,
XCircleIcon,
} from "@heroicons/react/20/solid";
import { TaskRunStatus, WorkerDeploymentStatus } from "@trigger.dev/database";
import { Spinner } from "~/components/primitives/Spinner";
import { cn } from "~/utils/cn";
export function DeploymentStatus({
status,
className,
}: {
status: WorkerDeploymentStatus;
className?: string;
}) {
return (
<span className={cn("flex items-center gap-1", className)}>
<DeploymentStatusIcon status={status} className="h-4 w-4" />
<DeploymentStatusLabel status={status} />
</span>
);
}
export function DeploymentStatusLabel({ status }: { status: WorkerDeploymentStatus }) {
return (
<span className={deploymentStatusClassNameColor(status)}>{deploymentStatusTitle(status)}</span>
);
}
export function DeploymentStatusIcon({
status,
className,
}: {
status: WorkerDeploymentStatus;
className: string;
}) {
switch (status) {
case "PENDING":
case "BUILDING":
case "DEPLOYING":
return <Spinner className={cn(deploymentStatusClassNameColor(status), className)} />;
case "DEPLOYED":
return <CheckCircleIcon className={cn(deploymentStatusClassNameColor(status), className)} />;
case "CANCELED":
return <NoSymbolIcon className={cn(deploymentStatusClassNameColor(status), className)} />;
case "FAILED":
return <XCircleIcon className={cn(deploymentStatusClassNameColor(status), className)} />;
default: {
const _exhaustiveCheck: never = status;
throw new Error(`Non-exhaustive match for value: ${status}`);
}
}
}
export function deploymentStatusClassNameColor(status: WorkerDeploymentStatus): string {
switch (status) {
case "PENDING":
case "BUILDING":
case "DEPLOYING":
return "text-pending";
case "CANCELED":
return "text-charcoal-500";
case "DEPLOYED":
return "text-success";
case "FAILED":
return "text-error";
default: {
const _exhaustiveCheck: never = status;
throw new Error(`Non-exhaustive match for value: ${status}`);
}
}
}
export function deploymentStatusTitle(status: WorkerDeploymentStatus): string {
switch (status) {
case "PENDING":
return "Pending…";
case "BUILDING":
return "Building…";
case "DEPLOYING":
return "Deploying…";
case "DEPLOYED":
return "Deployed";
case "CANCELED":
return "Canceled";
case "FAILED":
return "Failed";
default: {
const _exhaustiveCheck: never = status;
throw new Error(`Non-exhaustive match for value: ${status}`);
}
}
}
@@ -28,7 +28,7 @@ type TaskFunctionNameProps = {
export function TaskFunctionName({ variant, functionName, className }: TaskFunctionNameProps) {
return (
<InlineCode variant={variant} className={cn("text-sun-100", className)}>
{functionName}()
{`${functionName}()`}
</InlineCode>
);
}
+1
View File
@@ -1,5 +1,6 @@
export const LIVE_ENVIRONMENT = "live";
export const DEV_ENVIRONMENT = "development";
export const CURRENT_DEPLOYMENT_LABEL = "current";
export const MAX_LIVE_PROJECTS = 1;
export const DEFAULT_MAX_CONCURRENT_RUNS = 10;
export const MAX_CONCURRENT_RUNS_LIMIT = 20;
@@ -0,0 +1,158 @@
import { WorkerDeploymentStatus } from "@trigger.dev/database";
import { PrismaClient, prisma } from "~/db.server";
import { Organization } from "~/models/organization.server";
import { Project } from "~/models/project.server";
import { User } from "~/models/user.server";
import { getUsername } from "~/utils/username";
const pageSize = 20;
export class DeploymentListPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
userId,
projectSlug,
organizationSlug,
page = 1,
}: {
userId: User["id"];
projectSlug: Project["slug"];
organizationSlug: Organization["slug"];
page?: number;
}) {
const project = await this.#prismaClient.project.findFirstOrThrow({
select: {
id: true,
environments: {
select: {
id: true,
type: true,
slug: true,
orgMember: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
},
},
},
where: {
slug: projectSlug,
organization: {
slug: organizationSlug,
members: {
some: {
userId,
},
},
},
},
});
const totalCount = await this.#prismaClient.workerDeployment.count({
where: {
projectId: project.id,
},
});
const labeledDeployments = await this.#prismaClient.workerDeploymentPromotion.findMany({
where: {
environmentId: {
in: project.environments.map((env) => env.id),
},
},
select: {
deploymentId: true,
label: true,
},
});
const deployments = await this.#prismaClient.$queryRaw<
{
id: string;
shortCode: string;
version: string;
status: WorkerDeploymentStatus;
environmentId: string;
deployedAt: Date | null;
tasksCount: BigInt | null;
userId: string | null;
userName: string | null;
userDisplayName: string | null;
userAvatarUrl: string | null;
}[]
>`
SELECT
wd."id",
wd."shortCode",
wd."version",
(SELECT COUNT(*) FROM "BackgroundWorkerTask" WHERE "BackgroundWorkerTask"."workerId" = wd."workerId") AS "tasksCount",
wd."environmentId",
wd."status",
u."id" AS "userId",
u."name" AS "userName",
u."displayName" AS "userDisplayName",
u."avatarUrl" AS "userAvatarUrl",
wd."deployedAt"
FROM
"WorkerDeployment" as wd
INNER JOIN
"User" as u ON wd."triggeredById" = u."id"
WHERE
wd."projectId" = ${project.id}
ORDER BY
wd."version" DESC
LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
return {
currentPage: page,
totalPages: Math.ceil(totalCount / pageSize),
deployments: deployments.map((deployment) => {
const environment = project.environments.find((env) => env.id === deployment.environmentId);
if (!environment) {
throw new Error(`Environment not found for deployment ${deployment.id}`);
}
const label = labeledDeployments.find(
(labeledDeployment) => labeledDeployment.deploymentId === deployment.id
);
return {
id: deployment.id,
shortCode: deployment.shortCode,
version: deployment.version,
status: deployment.status,
deployedAt: deployment.deployedAt,
tasksCount: deployment.tasksCount ? Number(deployment.tasksCount) : null,
label: label?.label,
environment: {
id: environment.id,
type: environment.type,
slug: environment.slug,
userId: environment.orgMember?.user.id,
userName: getUsername(environment.orgMember?.user),
},
deployedBy: deployment.userId
? {
id: deployment.userId,
name: deployment.userName,
displayName: deployment.userDisplayName,
avatarUrl: deployment.userAvatarUrl,
}
: undefined,
};
}),
};
}
}
@@ -0,0 +1,124 @@
import { WorkerDeployment, WorkerDeploymentStatus } from "@trigger.dev/database";
import { PrismaClient, prisma } from "~/db.server";
import { Organization } from "~/models/organization.server";
import { Project } from "~/models/project.server";
import { User } from "~/models/user.server";
import { getUsername } from "~/utils/username";
export class DeploymentPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call({
userId,
projectSlug,
organizationSlug,
deploymentShortCode,
}: {
userId: User["id"];
projectSlug: Project["slug"];
organizationSlug: Organization["slug"];
deploymentShortCode: WorkerDeployment["shortCode"];
}) {
const project = await this.#prismaClient.project.findFirstOrThrow({
select: {
id: true,
},
where: {
slug: projectSlug,
organization: {
slug: organizationSlug,
members: {
some: {
userId,
},
},
},
},
});
const deployment = await this.#prismaClient.workerDeployment.findUniqueOrThrow({
where: {
projectId_shortCode: {
projectId: project.id,
shortCode: deploymentShortCode,
},
},
select: {
id: true,
shortCode: true,
version: true,
environment: {
select: {
id: true,
type: true,
slug: true,
orgMember: {
select: {
user: {
select: {
id: true,
name: true,
displayName: true,
},
},
},
},
},
},
status: true,
deployedAt: true,
promotions: {
select: {
label: true,
},
},
worker: {
select: {
tasks: {
select: {
slug: true,
exportName: true,
filePath: true,
},
orderBy: {
exportName: "asc",
},
},
},
},
triggeredBy: {
select: {
id: true,
name: true,
displayName: true,
avatarUrl: true,
},
},
},
});
return {
deployment: {
id: deployment.id,
shortCode: deployment.shortCode,
version: deployment.version,
status: deployment.status,
deployedAt: deployment.deployedAt,
tasks: deployment.worker?.tasks,
label: deployment.promotions?.[0]?.label,
environment: {
id: deployment.environment.id,
type: deployment.environment.type,
slug: deployment.environment.slug,
userId: deployment.environment.orgMember?.user.id,
userName: getUsername(deployment.environment.orgMember?.user),
},
deployedBy: deployment.triggeredBy,
},
};
}
}
@@ -0,0 +1,180 @@
import { CommandLineIcon, ServerIcon } from "@heroicons/react/20/solid";
import { useLocation } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { TerminalIcon, TerminalSquareIcon } from "lucide-react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { BlankstateInstructions } from "~/components/BlankstateInstructions";
import { UserAvatar } from "~/components/UserProfilePhoto";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Badge } from "~/components/primitives/Badge";
import { LinkButton } from "~/components/primitives/Buttons";
import { DateTime, DateTimeAccurate } from "~/components/primitives/DateTime";
import { Header2 } from "~/components/primitives/Headers";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
import { ResizablePanel, ResizablePanelGroup } from "~/components/primitives/Resizable";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableCellChevron,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { TextLink } from "~/components/primitives/TextLink";
import { DeploymentStatus } from "~/components/runs/v3/DeploymentStatus";
import { TaskFunctionName } from "~/components/runs/v3/TaskPath";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useUser } from "~/hooks/useUser";
import { DeploymentListPresenter } from "~/presenters/v3/DeploymentListPresenter.server";
import { DeploymentPresenter } from "~/presenters/v3/DeploymentPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import {
ProjectParamSchema,
docsPath,
runParam,
v3DeploymentParams,
v3DeploymentPath,
v3DeploymentsPath,
v3RunPath,
} from "~/utils/pathBuilder";
import { createSearchParams } from "~/utils/searchParams";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, deploymentParam } = v3DeploymentParams.parse(params);
try {
const presenter = new DeploymentPresenter();
const { deployment } = await presenter.call({
userId,
organizationSlug,
projectSlug: projectParam,
deploymentShortCode: deploymentParam,
});
return typedjson({ deployment });
} catch (error) {
console.error(error);
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, if this problem persists please contact support.",
});
}
};
export default function Page() {
const organization = useOrganization();
const project = useProject();
const location = useLocation();
const user = useUser();
const { deployment } = useTypedLoaderData<typeof loader>();
const page = new URLSearchParams(location.search).get("page");
const usernameForEnv =
user.id !== deployment.environment.userId ? deployment.environment.userName : undefined;
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden bg-background-bright">
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
<Header2 className={cn("whitespace-nowrap")}>Deploy: {deployment.shortCode}</Header2>
<LinkButton
to={`${v3DeploymentsPath(organization, project)}${page ? `?page=${page}` : ""}`}
variant="minimal/medium"
LeadingIcon={ExitIcon}
shortcut={{ key: "esc" }}
/>
</div>
<div className="overflow-y-auto px-3 pt-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className="flex flex-col gap-4">
<PropertyTable>
<Property label="Deploy">
<div className="flex items-center gap-2">
<Paragraph variant="small/bright">{deployment.shortCode}</Paragraph>
{deployment.label && <Badge variant="outline-rounded">{deployment.label}</Badge>}
</div>
</Property>
<Property label="Environment">
<EnvironmentLabel environment={deployment.environment} userName={usernameForEnv} />
</Property>
<Property label="Version">{deployment.version}</Property>
<Property label="Status">
<DeploymentStatus status={deployment.status} className="text-sm" />
</Property>
<Property label="Tasks">{deployment.tasks ? deployment.tasks.length : ""}</Property>
<Property label="Deployed at">
<Paragraph variant="small/bright">
{deployment.deployedAt ? (
<>
<DateTimeAccurate date={deployment.deployedAt} /> UTC
</>
) : (
""
)}
</Paragraph>
</Property>
<Property label="Deployed by">
{deployment.deployedBy ? (
<div className="flex items-center gap-1">
<UserAvatar
avatarUrl={deployment.deployedBy.avatarUrl}
name={deployment.deployedBy.name ?? deployment.deployedBy.displayName}
className="h-4 w-4"
/>
<Paragraph variant="small">
{deployment.deployedBy.name ?? deployment.deployedBy.displayName}
</Paragraph>
</div>
) : (
""
)}
</Property>
</PropertyTable>
{deployment.tasks ? (
<div className="divide-y divide-charcoal-800 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell className="px-2">Task</TableHeaderCell>
<TableHeaderCell className="px-2">File path</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{deployment.tasks.map((t) => {
return (
<TableRow key={t.slug}>
<TableCell>
<div className="inline-flex flex-col gap-0.5">
<TaskFunctionName
variant="extra-small"
functionName={t.exportName}
className="-ml-1 inline-flex"
/>
<Paragraph variant="extra-small" className="text-text-dimmed">
{t.slug}
</Paragraph>
</div>
</TableCell>
<TableCell>{t.filePath}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
) : null}
</div>
</div>
</div>
);
}
@@ -0,0 +1,233 @@
import { CommandLineIcon, ServerIcon } from "@heroicons/react/20/solid";
import { Outlet, useParams } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { TerminalIcon, TerminalSquareIcon } from "lucide-react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { BlankstateInstructions } from "~/components/BlankstateInstructions";
import { UserAvatar } from "~/components/UserProfilePhoto";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Badge } from "~/components/primitives/Badge";
import { LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableCellChevron,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { TextLink } from "~/components/primitives/TextLink";
import { DeploymentStatus } from "~/components/runs/v3/DeploymentStatus";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useUser } from "~/hooks/useUser";
import { DeploymentListPresenter } from "~/presenters/v3/DeploymentListPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { ProjectParamSchema, docsPath, v3DeploymentPath } from "~/utils/pathBuilder";
import { createSearchParams } from "~/utils/searchParams";
const SearchParams = z.object({
page: z.coerce.number().optional(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
const searchParams = createSearchParams(request.url, SearchParams);
const page = searchParams.success ? searchParams.params.get("page") ?? 1 : 1;
try {
const presenter = new DeploymentListPresenter();
const result = await presenter.call({
userId,
organizationSlug,
projectSlug: projectParam,
page,
});
return typedjson(result);
} catch (error) {
console.error(error);
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, if this problem persists please contact support.",
});
}
};
export default function Page() {
const organization = useOrganization();
const project = useProject();
const user = useUser();
const { deployments, currentPage, totalPages } = useTypedLoaderData<typeof loader>();
const hasDeployments = totalPages > 0;
const { deploymentParam } = useParams();
return (
<PageContainer>
<NavBar>
<PageTitle title="Deployments" />
</NavBar>
<PageBody scrollable={false}>
<ResizablePanelGroup direction="horizontal" className="h-full max-h-full">
<ResizablePanel order={1} minSize={20} defaultSize={60}>
{hasDeployments ? (
<div className="flex flex-col gap-4 p-3">
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Deploy</TableHeaderCell>
<TableHeaderCell>Env</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Tasks</TableHeaderCell>
<TableHeaderCell>Deployed at</TableHeaderCell>
<TableHeaderCell>Deployed by</TableHeaderCell>
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{deployments.length > 0 ? (
deployments.map((deployment) => {
const usernameForEnv =
user.id !== deployment.environment.userId
? deployment.environment.userName
: undefined;
const path = v3DeploymentPath(organization, project, deployment);
return (
<TableRow key={deployment.id} className="group">
<TableCell to={path}>
<div className="flex items-center gap-2">
<Paragraph variant="extra-small">{deployment.shortCode}</Paragraph>
{deployment.label && (
<Badge variant="outline-rounded">{deployment.label}</Badge>
)}
</div>
</TableCell>
<TableCell to={path}>
<EnvironmentLabel
environment={deployment.environment}
userName={usernameForEnv}
/>
</TableCell>
<TableCell to={path}>{deployment.version}</TableCell>
<TableCell to={path}>
<DeploymentStatus status={deployment.status} />
</TableCell>
<TableCell to={path}>
{deployment.tasksCount !== null ? deployment.tasksCount : ""}
</TableCell>
<TableCell to={path}>
{deployment.deployedAt ? (
<DateTime date={deployment.deployedAt} />
) : (
""
)}
</TableCell>
<TableCell to={path}>
{deployment.deployedBy ? (
<div className="flex items-center gap-1">
<UserAvatar
avatarUrl={deployment.deployedBy.avatarUrl}
name={
deployment.deployedBy.name ??
deployment.deployedBy.displayName
}
className="h-4 w-4"
/>
<Paragraph variant="extra-small">
{deployment.deployedBy.name ??
deployment.deployedBy.displayName}
</Paragraph>
</div>
) : (
""
)}
</TableCell>
<TableCellChevron to={path} />
</TableRow>
);
})
) : (
<TableBlankRow colSpan={6}>
<Paragraph variant="small" className="flex items-center justify-center">
No deploys match your filters
</Paragraph>
</TableBlankRow>
)}
</TableBody>
</Table>
<div className="flex justify-end">
<PaginationControls currentPage={currentPage} totalPages={totalPages} />
</div>
</div>
) : (
<CreateDeploymentInstructions />
)}
</ResizablePanel>
{deploymentParam && (
<>
<ResizableHandle withHandle />
<ResizablePanel order={2} minSize={20} defaultSize={40}>
<Outlet />
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</PageBody>
</PageContainer>
);
}
function CreateDeploymentInstructions() {
return (
<MainCenteredContainer className="max-w-prose">
<BlankstateInstructions title="Deploy for the first time">
<Paragraph spacing>
There are several ways to deploy your tasks. You can use the CLI, Continuous Integration
(like GitHub Actions), or an integration with a service like Netlify or Vercel. Make sure
you{" "}
<TextLink href={docsPath("v3/deploy-environment-variables")}>
set your environment variables
</TextLink>{" "}
first.
</Paragraph>
<div className="flex gap-3">
<LinkButton
to={docsPath("v3/cli-deploy")}
variant="tertiary/medium"
LeadingIcon={CommandLineIcon}
className="inline-flex"
>
Deploy with the CLI
</LinkButton>
<LinkButton
to={docsPath("v3/github-actions")}
variant="tertiary/medium"
LeadingIcon={ServerIcon}
className="inline-flex"
>
Deploy with GitHub actions
</LinkButton>
</div>
</BlankstateInstructions>
</MainCenteredContainer>
);
}
@@ -10,6 +10,7 @@ import { LinkButton } from "~/components/primitives/Buttons";
import { DateTimeAccurate } from "~/components/primitives/DateTime";
import { Header2 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
import { LiveTimer } from "~/components/runs/v3/LiveTimer";
import { RunIcon } from "~/components/runs/v3/RunIcon";
import { SpanEvents } from "~/components/runs/v3/SpanEvents";
@@ -138,33 +139,6 @@ export default function Page() {
);
}
function PropertyTable({ children, className }: { children: ReactNode; className?: string }) {
return <div className="grid grid-cols-[auto,1fr] items-baseline gap-x-4 gap-y-2">{children}</div>;
}
type PropertyProps = {
label: ReactNode;
labelClassName?: string;
children: ReactNode;
};
function Property({ label, labelClassName, children }: PropertyProps) {
return (
<>
<div className={labelClassName}>
{typeof label === "string" ? <Paragraph variant="small">{label}</Paragraph> : label}
</div>
<div>
{typeof children === "string" ? (
<Paragraph variant="small/bright">{children}</Paragraph>
) : (
children
)}
</div>
</>
);
}
type TimelineProps = {
startTime: Date;
duration: number;
@@ -1,17 +1,13 @@
import { Link, Outlet, useLocation, useNavigation, useParams } from "@remix-run/react";
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Fragment } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { BlankstateInstructions } from "~/components/BlankstateInstructions";
import {
environmentBorderClassName,
environmentTextClassName,
environmentTitle,
} from "~/components/environments/EnvironmentLabel";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { LinkButton } from "~/components/primitives/Buttons";
import { Header2 } from "~/components/primitives/Headers";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
@@ -30,7 +26,6 @@ import {
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { TextLink } from "~/components/primitives/TextLink";
import { TaskFunctionName } from "~/components/runs/v3/TaskPath";
import { useLinkStatus } from "~/hooks/useLinkStatus";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
@@ -43,8 +38,7 @@ import {
} from "~/presenters/v3/TestPresenter.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { ProjectParamSchema, v3ProjectPath, v3TestPath, v3TestTaskPath } from "~/utils/pathBuilder";
import { createSearchParams } from "~/utils/searchParams";
import { ProjectParamSchema, v3TestPath, v3TestTaskPath } from "~/utils/pathBuilder";
export const TestSearchParams = z.object({
environment: z.string().optional(),
+18
View File
@@ -6,6 +6,7 @@ import type {
TriggerHttpEndpoint,
TriggerSource,
Webhook,
WorkerDeployment,
} from "@trigger.dev/database";
import { z } from "zod";
import { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
@@ -26,6 +27,7 @@ export type HttpEndpointForPath = Pick<TriggerHttpEndpoint, "key">;
export type TaskForPath = Pick<BackgroundWorkerTask, "friendlyId">;
export type v3RunForPath = Pick<TaskRun, "friendlyId">;
export type v3SpanForPath = Pick<TaskRun, "spanId">;
export type DeploymentForPath = Pick<WorkerDeployment, "shortCode">;
export const OrganizationParamsSchema = z.object({
organizationSlug: z.string(),
@@ -84,6 +86,10 @@ export const v3SpanParamsSchema = ProjectParamSchema.extend({
spanParam: z.string(),
});
export const v3DeploymentParams = ProjectParamSchema.extend({
deploymentParam: z.string(),
});
export function trimTrailingSlash(path: string) {
return path.replace(/\/$/, "");
}
@@ -363,6 +369,18 @@ export function v3ProjectSettingsPath(organization: OrgForPath, project: Project
return `${v3ProjectPath(organization, project)}/settings`;
}
export function v3DeploymentsPath(organization: OrgForPath, project: ProjectForPath) {
return `${v3ProjectPath(organization, project)}/deployments`;
}
export function v3DeploymentPath(
organization: OrgForPath,
project: ProjectForPath,
deployment: DeploymentForPath
) {
return `${v3DeploymentsPath(organization, project)}/${deployment.shortCode}`;
}
// Integration
export function integrationClientPath(organization: OrgForPath, client: IntegrationForPath) {
return `${organizationIntegrationsPath(organization)}/${clientParam(client)}`;
+1 -1
View File
@@ -51,7 +51,7 @@ type SearchParamsResult<TParams extends ParamType> =
| { success: true; params: SearchParams<TParams> }
| { success: false; error: string };
type ParamType = Record<string, string>;
type ParamType = Record<string, any>;
export function createSearchParams<TParams extends ParamType>(
url: string,
@@ -4,6 +4,7 @@ import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { generateFriendlyId } from "../friendlyIdentifiers";
import { BaseService } from "./baseService.server";
import { createBackgroundTasks } from "./createBackgroundWorker.server";
import { CURRENT_DEPLOYMENT_LABEL } from "~/consts";
export class CreateDeployedBackgroundWorkerService extends BaseService {
public async call(
@@ -46,12 +47,30 @@ export class CreateDeployedBackgroundWorkerService extends BaseService {
id: deployment.id,
},
data: {
workerId: backgroundWorker.id,
status: "DEPLOYED",
workerId: backgroundWorker.id,
deployedAt: new Date(),
},
});
//set this deployment as the current deployment for this environment
await this._prisma.workerDeploymentPromotion.upsert({
where: {
environmentId_label: {
environmentId: environment.id,
label: CURRENT_DEPLOYMENT_LABEL,
},
},
create: {
deploymentId: deployment.id,
environmentId: environment.id,
label: CURRENT_DEPLOYMENT_LABEL,
},
update: {
deploymentId: deployment.id,
},
});
return backgroundWorker;
});
}