733 lines
27 KiB
TypeScript
733 lines
27 KiB
TypeScript
import {
|
||
ArrowPathIcon,
|
||
ArrowUturnLeftIcon,
|
||
BookOpenIcon,
|
||
NoSymbolIcon,
|
||
} from "@heroicons/react/20/solid";
|
||
import {
|
||
Form,
|
||
type MetaFunction,
|
||
Outlet,
|
||
useLocation,
|
||
useNavigate,
|
||
useNavigation,
|
||
useParams,
|
||
} from "@remix-run/react";
|
||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||
import { CogIcon, GitBranchIcon } from "lucide-react";
|
||
import { useEffect } from "react";
|
||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||
import { z } from "zod";
|
||
import { PromoteIcon } from "~/assets/icons/PromoteIcon";
|
||
import { VercelLink } from "~/components/integrations/VercelLink";
|
||
import { DeploymentsNone, DeploymentsNoneDev } from "~/components/BlankStatePanels";
|
||
import { OctoKitty } from "~/components/GitHubLoginButton";
|
||
import { GitMetadata } from "~/components/GitMetadata";
|
||
import { RuntimeIcon } from "~/components/RuntimeIcon";
|
||
import { UserAvatar } from "~/components/UserProfilePhoto";
|
||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||
import { Badge } from "~/components/primitives/Badge";
|
||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||
import { DateTime } from "~/components/primitives/DateTime";
|
||
import { SpinnerWhite } from "~/components/primitives/Spinner";
|
||
import {
|
||
Dialog,
|
||
DialogDescription,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTrigger,
|
||
DialogFooter,
|
||
} from "~/components/primitives/Dialog";
|
||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||
import { PaginationControls } from "~/components/primitives/Pagination";
|
||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||
import {
|
||
RESIZABLE_PANEL_ANIMATION,
|
||
ResizableHandle,
|
||
ResizablePanel,
|
||
ResizablePanelGroup,
|
||
collapsibleHandleClassName,
|
||
} from "~/components/primitives/Resizable";
|
||
import {
|
||
Table,
|
||
TableBlankRow,
|
||
TableBody,
|
||
TableCell,
|
||
TableCellMenu,
|
||
TableHeader,
|
||
TableHeaderCell,
|
||
TableRow,
|
||
} from "~/components/primitives/Table";
|
||
import {
|
||
DeploymentStatus,
|
||
deploymentStatusDescription,
|
||
deploymentStatuses,
|
||
} from "~/components/runs/v3/DeploymentStatus";
|
||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||
import { useOrganization } from "~/hooks/useOrganizations";
|
||
import { useProject } from "~/hooks/useProject";
|
||
import { resolveOrgIdFromSlug } from "~/models/organization.server";
|
||
import {
|
||
type DeploymentListItem,
|
||
DeploymentListPresenter,
|
||
} from "~/presenters/v3/DeploymentListPresenter.server";
|
||
import { requireUserId } from "~/services/session.server";
|
||
import { rbac } from "~/services/rbac.server";
|
||
import { checkPermissions } from "~/services/routeBuilders/permissions.server";
|
||
import { titleCase } from "~/utils";
|
||
import { cn } from "~/utils/cn";
|
||
import {
|
||
EnvironmentParamSchema,
|
||
docsPath,
|
||
v3DeploymentPath,
|
||
v3ProjectSettingsIntegrationsPath,
|
||
} from "~/utils/pathBuilder";
|
||
import { createSearchParams } from "~/utils/searchParams";
|
||
import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions";
|
||
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
|
||
import { env } from "~/env.server";
|
||
import { DialogClose } from "@radix-ui/react-dialog";
|
||
|
||
export const meta: MetaFunction = () => {
|
||
return [
|
||
{
|
||
title: `Deployments | Trigger.dev`,
|
||
},
|
||
];
|
||
};
|
||
|
||
const SearchParams = z.object({
|
||
page: z.coerce.number().optional(),
|
||
version: z.string().optional(),
|
||
});
|
||
|
||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||
const userId = await requireUserId(request);
|
||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||
|
||
const searchParams = createSearchParams(request.url, SearchParams);
|
||
|
||
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 result = await presenter.call({
|
||
userId,
|
||
organizationSlug,
|
||
projectSlug: projectParam,
|
||
environmentSlug: envParam,
|
||
page,
|
||
});
|
||
|
||
// If we have a version, find the deployment
|
||
const selectedDeployment = version
|
||
? result.deployments.find((d) => d.version === version)
|
||
: undefined;
|
||
|
||
const autoReloadPollIntervalMs = env.DEPLOYMENTS_AUTORELOAD_POLL_INTERVAL_MS;
|
||
|
||
// Display flag for the rollback/promote/cancel controls — the action
|
||
// routes enforce write:deployments independently. Permissive in OSS.
|
||
const orgId = await resolveOrgIdFromSlug(organizationSlug);
|
||
const deploymentAuth = orgId
|
||
? await rbac.authenticateSession(request, { userId, organizationId: orgId })
|
||
: null;
|
||
const canWriteDeployments =
|
||
deploymentAuth && deploymentAuth.ok
|
||
? checkPermissions(deploymentAuth.ability, {
|
||
canWriteDeployments: { action: "write", resource: { type: "deployments" } },
|
||
}).canWriteDeployments
|
||
: true;
|
||
|
||
return typedjson({
|
||
...result,
|
||
selectedDeployment,
|
||
autoReloadPollIntervalMs,
|
||
canWriteDeployments,
|
||
});
|
||
} 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 environment = useEnvironment();
|
||
const {
|
||
deployments,
|
||
currentPage,
|
||
totalPages,
|
||
selectedDeployment,
|
||
connectedGithubRepository,
|
||
environmentGitHubBranch,
|
||
autoReloadPollIntervalMs,
|
||
hasVercelIntegration,
|
||
canWriteDeployments,
|
||
} = useTypedLoaderData<typeof loader>();
|
||
const hasDeployments = totalPages > 0;
|
||
|
||
const { deploymentParam } = useParams();
|
||
const location = useLocation();
|
||
const navigate = useNavigate();
|
||
|
||
useAutoRevalidate({ interval: autoReloadPollIntervalMs, onFocus: true });
|
||
|
||
// 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);
|
||
|
||
return (
|
||
<PageContainer>
|
||
<NavBar>
|
||
<PageTitle title="Deployments" />
|
||
<PageAccessories>
|
||
<LinkButton
|
||
variant={"docs/small"}
|
||
LeadingIcon={BookOpenIcon}
|
||
to={docsPath("/cli-deploy")}
|
||
>
|
||
Deployments docs
|
||
</LinkButton>
|
||
</PageAccessories>
|
||
</NavBar>
|
||
<PageBody scrollable={false}>
|
||
<ResizablePanelGroup orientation="horizontal" className="h-full max-h-full">
|
||
<ResizablePanel id="deployments-main" min="100px" className="max-h-full">
|
||
{hasDeployments ? (
|
||
<div className="flex h-full max-h-full flex-col">
|
||
<Table containerClassName="border-t-0 grow">
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHeaderCell>Deploy</TableHeaderCell>
|
||
<TableHeaderCell>Version</TableHeaderCell>
|
||
<TableHeaderCell
|
||
tooltip={
|
||
<div className="flex flex-col divide-y divide-grid-dimmed">
|
||
{deploymentStatuses.map((status) => (
|
||
<div
|
||
key={status}
|
||
className="grid grid-cols-[8rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1"
|
||
>
|
||
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
|
||
<DeploymentStatus status={status} isBuilt={false} />
|
||
</div>
|
||
<Paragraph
|
||
variant="extra-small"
|
||
className="text-wrap! text-text-dimmed"
|
||
>
|
||
{deploymentStatusDescription(status)}
|
||
</Paragraph>
|
||
</div>
|
||
))}
|
||
</div>
|
||
}
|
||
>
|
||
Status
|
||
</TableHeaderCell>
|
||
<TableHeaderCell>Runtime</TableHeaderCell>
|
||
<TableHeaderCell>Tasks</TableHeaderCell>
|
||
<TableHeaderCell>Deployed at</TableHeaderCell>
|
||
<TableHeaderCell>Deployed by</TableHeaderCell>
|
||
<TableHeaderCell>Git</TableHeaderCell>
|
||
{hasVercelIntegration && <TableHeaderCell>Linked</TableHeaderCell>}
|
||
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{deployments.length > 0 ? (
|
||
deployments.map((deployment) => {
|
||
const path = v3DeploymentPath(
|
||
organization,
|
||
project,
|
||
environment,
|
||
deployment,
|
||
currentPage
|
||
);
|
||
const isSelected = deploymentParam === deployment.shortCode;
|
||
return (
|
||
<TableRow key={deployment.id} className="group" isSelected={isSelected}>
|
||
<TableCell to={path} isTabbableCell isSelected={isSelected}>
|
||
<div className="flex items-center gap-2">
|
||
<Paragraph
|
||
variant="extra-small"
|
||
className="group-hover/table-row:text-text-bright"
|
||
>
|
||
{deployment.shortCode}
|
||
</Paragraph>
|
||
{deployment.label && (
|
||
<Badge variant="extra-small">{titleCase(deployment.label)}</Badge>
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell to={path} isSelected={isSelected}>
|
||
{deployment.version}
|
||
</TableCell>
|
||
<TableCell to={path} isSelected={isSelected}>
|
||
<DeploymentStatus
|
||
status={deployment.status}
|
||
isBuilt={deployment.isBuilt}
|
||
/>
|
||
</TableCell>
|
||
<TableCell to={path} isSelected={isSelected}>
|
||
<RuntimeIcon
|
||
runtime={deployment.runtime}
|
||
runtimeVersion={deployment.runtimeVersion}
|
||
/>
|
||
</TableCell>
|
||
<TableCell to={path} isSelected={isSelected}>
|
||
{deployment.tasksCount !== null ? deployment.tasksCount : "–"}
|
||
</TableCell>
|
||
<TableCell to={path} isSelected={isSelected}>
|
||
{deployment.deployedAt ? (
|
||
<DateTime date={deployment.deployedAt} />
|
||
) : (
|
||
"–"
|
||
)}
|
||
</TableCell>
|
||
<TableCell to={path} isSelected={isSelected}>
|
||
{deployment.git?.source === "trigger_github_app" ? (
|
||
<UserTag
|
||
name={deployment.git.ghUsername ?? "GitHub Integration"}
|
||
avatarUrl={deployment.git.ghUserAvatarUrl}
|
||
/>
|
||
) : deployment.deployedBy ? (
|
||
<UserTag
|
||
name={
|
||
deployment.deployedBy.name ??
|
||
deployment.deployedBy.displayName ??
|
||
""
|
||
}
|
||
avatarUrl={deployment.deployedBy.avatarUrl ?? undefined}
|
||
/>
|
||
) : (
|
||
"–"
|
||
)}
|
||
</TableCell>
|
||
<TableCell isSelected={isSelected}>
|
||
<div className="-ml-1 flex items-center">
|
||
<GitMetadata git={deployment.git} />
|
||
</div>
|
||
</TableCell>
|
||
{hasVercelIntegration && (
|
||
<TableCell isSelected={isSelected}>
|
||
{deployment.vercelDeploymentUrl ? (
|
||
<div
|
||
className="-ml-1 flex items-center"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<VercelLink
|
||
vercelDeploymentUrl={deployment.vercelDeploymentUrl}
|
||
/>
|
||
</div>
|
||
) : (
|
||
"–"
|
||
)}
|
||
</TableCell>
|
||
)}
|
||
<DeploymentActionsCell
|
||
deployment={deployment}
|
||
path={path}
|
||
isSelected={isSelected}
|
||
currentDeployment={currentDeployment}
|
||
canWriteDeployments={canWriteDeployments}
|
||
/>
|
||
</TableRow>
|
||
);
|
||
})
|
||
) : (
|
||
<TableBlankRow colSpan={hasVercelIntegration ? 9 : 8}>
|
||
<Paragraph className="flex items-center justify-center">
|
||
No deploys match your filters
|
||
</Paragraph>
|
||
</TableBlankRow>
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
<div
|
||
className={cn(
|
||
"-mt-px flex flex-wrap justify-end gap-2 border-t border-grid-dimmed px-3 pb-[7px] pt-[6px]",
|
||
connectedGithubRepository && environmentGitHubBranch && "justify-between"
|
||
)}
|
||
>
|
||
{connectedGithubRepository && environmentGitHubBranch && (
|
||
<div className="flex flex-nowrap items-center gap-2 whitespace-nowrap text-sm">
|
||
<OctoKitty className="size-4" />
|
||
Automatically triggered by pushes to{" "}
|
||
<div className="flex max-w-32 items-center gap-1 truncate rounded bg-grid-dimmed px-1 font-mono">
|
||
<GitBranchIcon className="size-3 shrink-0" />
|
||
<span className="max-w-28 truncate">{environmentGitHubBranch}</span>
|
||
</div>{" "}
|
||
in
|
||
<a
|
||
href={connectedGithubRepository.repository.htmlUrl}
|
||
target="_blank"
|
||
rel="noreferrer noopener"
|
||
className="max-w-52 truncate text-sm text-text-dimmed underline transition-colors hover:text-text-bright"
|
||
>
|
||
{connectedGithubRepository.repository.fullName}
|
||
</a>
|
||
<LinkButton
|
||
variant="minimal/small"
|
||
LeadingIcon={CogIcon}
|
||
to={v3ProjectSettingsIntegrationsPath(organization, project, environment)}
|
||
/>
|
||
</div>
|
||
)}
|
||
<PaginationControls currentPage={currentPage} totalPages={totalPages} />
|
||
</div>
|
||
</div>
|
||
) : environment.type === "DEVELOPMENT" ? (
|
||
<MainCenteredContainer className="max-w-prose">
|
||
<DeploymentsNoneDev />
|
||
</MainCenteredContainer>
|
||
) : (
|
||
<MainCenteredContainer className="max-w-prose">
|
||
<DeploymentsNone />
|
||
</MainCenteredContainer>
|
||
)}
|
||
</ResizablePanel>
|
||
|
||
<ResizableHandle
|
||
id="deployments-handle"
|
||
className={collapsibleHandleClassName(!!deploymentParam)}
|
||
/>
|
||
<ResizablePanel
|
||
id="deployments-inspector"
|
||
default="400px"
|
||
min="400px"
|
||
max="800px"
|
||
className="overflow-hidden"
|
||
collapsible
|
||
collapsed={!deploymentParam}
|
||
onCollapseChange={() => {}}
|
||
collapsedSize="0px"
|
||
collapseAnimation={RESIZABLE_PANEL_ANIMATION}
|
||
>
|
||
<div className="h-full" style={{ minWidth: 400 }}>
|
||
<Outlet />
|
||
</div>
|
||
</ResizablePanel>
|
||
</ResizablePanelGroup>
|
||
</PageBody>
|
||
</PageContainer>
|
||
);
|
||
}
|
||
|
||
export function UserTag({ name, avatarUrl }: { name: string; avatarUrl?: string }) {
|
||
return (
|
||
<div className="flex items-center gap-1">
|
||
<UserAvatar
|
||
avatarUrl={avatarUrl}
|
||
name={name}
|
||
className="h-4 w-4 group-hover/table-row:text-text-bright"
|
||
/>
|
||
<Paragraph variant="extra-small" className="group-hover/table-row:text-text-bright">
|
||
{name}
|
||
</Paragraph>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DeploymentActionsCell({
|
||
deployment,
|
||
path,
|
||
isSelected,
|
||
currentDeployment,
|
||
canWriteDeployments,
|
||
}: {
|
||
deployment: DeploymentListItem;
|
||
path: string;
|
||
isSelected: boolean;
|
||
currentDeployment?: DeploymentListItem;
|
||
canWriteDeployments: boolean;
|
||
}) {
|
||
const location = useLocation();
|
||
const project = useProject();
|
||
|
||
const canBeMadeCurrent = !deployment.isCurrent && deployment.isDeployed;
|
||
const canBeRolledBack =
|
||
canBeMadeCurrent &&
|
||
currentDeployment?.version &&
|
||
compareDeploymentVersions(deployment.version, currentDeployment.version) === -1;
|
||
const canBePromoted = canBeMadeCurrent && !canBeRolledBack;
|
||
|
||
const finalStatuses = ["CANCELED", "DEPLOYED", "FAILED", "TIMED_OUT"];
|
||
const canBeCanceled = !finalStatuses.includes(deployment.status);
|
||
|
||
if (!canBeRolledBack && !canBePromoted && !canBeCanceled) {
|
||
return (
|
||
<TableCell to={path} isSelected={isSelected}>
|
||
{""}
|
||
</TableCell>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<TableCellMenu
|
||
isSticky
|
||
isSelected={isSelected}
|
||
popoverContent={
|
||
<>
|
||
{canBeRolledBack &&
|
||
(canWriteDeployments ? (
|
||
<Dialog>
|
||
<DialogTrigger asChild>
|
||
<Button
|
||
variant="small-menu-item"
|
||
LeadingIcon={ArrowUturnLeftIcon}
|
||
leadingIconClassName="text-blue-500"
|
||
fullWidth
|
||
textAlignLeft
|
||
>
|
||
Rollback
|
||
</Button>
|
||
</DialogTrigger>
|
||
<RollbackDeploymentDialog
|
||
projectId={project.id}
|
||
deploymentShortCode={deployment.shortCode}
|
||
redirectPath={`${location.pathname}${location.search}`}
|
||
/>
|
||
</Dialog>
|
||
) : (
|
||
<Button
|
||
variant="small-menu-item"
|
||
LeadingIcon={ArrowUturnLeftIcon}
|
||
leadingIconClassName="text-blue-500"
|
||
fullWidth
|
||
textAlignLeft
|
||
disabled
|
||
tooltip="You don't have permission to roll back deployments"
|
||
>
|
||
Rollback
|
||
</Button>
|
||
))}
|
||
{canBePromoted &&
|
||
(canWriteDeployments ? (
|
||
<Dialog>
|
||
<DialogTrigger asChild>
|
||
<Button
|
||
variant="small-menu-item"
|
||
LeadingIcon={PromoteIcon}
|
||
leadingIconClassName="text-blue-500"
|
||
fullWidth
|
||
textAlignLeft
|
||
>
|
||
Promote
|
||
</Button>
|
||
</DialogTrigger>
|
||
<PromoteDeploymentDialog
|
||
projectId={project.id}
|
||
deploymentShortCode={deployment.shortCode}
|
||
redirectPath={`${location.pathname}${location.search}`}
|
||
/>
|
||
</Dialog>
|
||
) : (
|
||
<Button
|
||
variant="small-menu-item"
|
||
LeadingIcon={PromoteIcon}
|
||
leadingIconClassName="text-blue-500"
|
||
fullWidth
|
||
textAlignLeft
|
||
disabled
|
||
tooltip="You don't have permission to promote deployments"
|
||
>
|
||
Promote
|
||
</Button>
|
||
))}
|
||
{canBeCanceled &&
|
||
(canWriteDeployments ? (
|
||
<Dialog>
|
||
<DialogTrigger asChild>
|
||
<Button
|
||
variant="small-menu-item"
|
||
LeadingIcon={NoSymbolIcon}
|
||
leadingIconClassName="text-error"
|
||
fullWidth
|
||
textAlignLeft
|
||
>
|
||
Cancel
|
||
</Button>
|
||
</DialogTrigger>
|
||
<CancelDeploymentDialog
|
||
projectId={project.id}
|
||
deploymentShortCode={deployment.shortCode}
|
||
redirectPath={`${location.pathname}${location.search}`}
|
||
/>
|
||
</Dialog>
|
||
) : (
|
||
<Button
|
||
variant="small-menu-item"
|
||
LeadingIcon={NoSymbolIcon}
|
||
leadingIconClassName="text-error"
|
||
fullWidth
|
||
textAlignLeft
|
||
disabled
|
||
tooltip="You don't have permission to cancel deployments"
|
||
>
|
||
Cancel
|
||
</Button>
|
||
))}
|
||
</>
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
|
||
type RollbackDeploymentDialogProps = {
|
||
projectId: string;
|
||
deploymentShortCode: string;
|
||
redirectPath: string;
|
||
};
|
||
|
||
function RollbackDeploymentDialog({
|
||
projectId,
|
||
deploymentShortCode,
|
||
redirectPath,
|
||
}: RollbackDeploymentDialogProps) {
|
||
const navigation = useNavigation();
|
||
|
||
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/rollback`;
|
||
const isLoading = navigation.formAction === formAction;
|
||
|
||
return (
|
||
<DialogContent key="rollback">
|
||
<DialogHeader>Rollback to this deployment?</DialogHeader>
|
||
<DialogDescription>
|
||
This deployment will become the default for all future runs. Tasks triggered but not
|
||
included in this deploy will remain queued until you roll back to or create a new deployment
|
||
with these tasks included.
|
||
</DialogDescription>
|
||
<DialogFooter>
|
||
<DialogClose asChild>
|
||
<Button variant="tertiary/medium">Cancel</Button>
|
||
</DialogClose>
|
||
<Form
|
||
action={`/resources/${projectId}/deployments/${deploymentShortCode}/rollback`}
|
||
method="post"
|
||
>
|
||
<Button
|
||
type="submit"
|
||
name="redirectUrl"
|
||
value={redirectPath}
|
||
variant="primary/medium"
|
||
LeadingIcon={isLoading ? SpinnerWhite : ArrowPathIcon}
|
||
disabled={isLoading}
|
||
shortcut={{ modifiers: ["mod"], key: "enter" }}
|
||
>
|
||
{isLoading ? "Rolling back..." : "Rollback deployment"}
|
||
</Button>
|
||
</Form>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
);
|
||
}
|
||
|
||
function PromoteDeploymentDialog({
|
||
projectId,
|
||
deploymentShortCode,
|
||
redirectPath,
|
||
}: RollbackDeploymentDialogProps) {
|
||
const navigation = useNavigation();
|
||
|
||
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/promote`;
|
||
const isLoading = navigation.formAction === formAction;
|
||
|
||
return (
|
||
<DialogContent key="promote">
|
||
<DialogHeader>Promote this deployment?</DialogHeader>
|
||
<DialogDescription>
|
||
This deployment will become the default for all future runs not explicitly tied to a
|
||
specific deployment.
|
||
</DialogDescription>
|
||
<DialogFooter>
|
||
<DialogClose asChild>
|
||
<Button variant="tertiary/medium">Cancel</Button>
|
||
</DialogClose>
|
||
<Form
|
||
action={`/resources/${projectId}/deployments/${deploymentShortCode}/promote`}
|
||
method="post"
|
||
>
|
||
<Button
|
||
type="submit"
|
||
name="redirectUrl"
|
||
value={redirectPath}
|
||
variant="primary/medium"
|
||
LeadingIcon={isLoading ? SpinnerWhite : ArrowPathIcon}
|
||
disabled={isLoading}
|
||
shortcut={{ modifiers: ["mod"], key: "enter" }}
|
||
>
|
||
{isLoading ? "Promoting..." : "Promote deployment"}
|
||
</Button>
|
||
</Form>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
);
|
||
}
|
||
|
||
function CancelDeploymentDialog({
|
||
projectId,
|
||
deploymentShortCode,
|
||
redirectPath,
|
||
}: RollbackDeploymentDialogProps) {
|
||
const navigation = useNavigation();
|
||
|
||
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/cancel`;
|
||
const isLoading = navigation.formAction === formAction;
|
||
|
||
return (
|
||
<DialogContent key="cancel">
|
||
<DialogHeader>Cancel this deployment?</DialogHeader>
|
||
<DialogDescription>Canceling a deployment cannot be undone. Are you sure?</DialogDescription>
|
||
<DialogFooter>
|
||
<DialogClose asChild>
|
||
<Button variant="tertiary/medium">Back</Button>
|
||
</DialogClose>
|
||
<Form action={formAction} method="post">
|
||
<Button
|
||
type="submit"
|
||
name="redirectUrl"
|
||
value={redirectPath}
|
||
variant="danger/medium"
|
||
LeadingIcon={isLoading ? SpinnerWhite : NoSymbolIcon}
|
||
disabled={isLoading}
|
||
shortcut={{ modifiers: ["mod"], key: "enter" }}
|
||
>
|
||
{isLoading ? "Canceling..." : "Cancel deployment"}
|
||
</Button>
|
||
</Form>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
);
|
||
}
|