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(); 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 ( Deployments docs {hasDeployments ? (
Deploy Version {deploymentStatuses.map((status) => (
{deploymentStatusDescription(status)}
))} } > Status
Runtime Tasks Deployed at Deployed by Git {hasVercelIntegration && Linked} Go to page
{deployments.length > 0 ? ( deployments.map((deployment) => { const path = v3DeploymentPath( organization, project, environment, deployment, currentPage ); const isSelected = deploymentParam === deployment.shortCode; return (
{deployment.shortCode} {deployment.label && ( {titleCase(deployment.label)} )}
{deployment.version} {deployment.tasksCount !== null ? deployment.tasksCount : "–"} {deployment.deployedAt ? ( ) : ( "–" )} {deployment.git?.source === "trigger_github_app" ? ( ) : deployment.deployedBy ? ( ) : ( "–" )}
{hasVercelIntegration && ( {deployment.vercelDeploymentUrl ? (
e.stopPropagation()} >
) : ( "–" )}
)}
); }) ) : ( No deploys match your filters )}
{connectedGithubRepository && environmentGitHubBranch && (
Automatically triggered by pushes to{" "}
{environmentGitHubBranch}
{" "} in {connectedGithubRepository.repository.fullName}
)}
) : environment.type === "DEVELOPMENT" ? ( ) : ( )}
{}} collapsedSize="0px" collapseAnimation={RESIZABLE_PANEL_ANIMATION} >
); } export function UserTag({ name, avatarUrl }: { name: string; avatarUrl?: string }) { return (
{name}
); } 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 ( {""} ); } return ( {canBeRolledBack && (canWriteDeployments ? ( ) : ( ))} {canBePromoted && (canWriteDeployments ? ( ) : ( ))} {canBeCanceled && (canWriteDeployments ? ( ) : ( ))} } /> ); } 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 ( Rollback to this deployment? 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.
); } function PromoteDeploymentDialog({ projectId, deploymentShortCode, redirectPath, }: RollbackDeploymentDialogProps) { const navigation = useNavigation(); const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/promote`; const isLoading = navigation.formAction === formAction; return ( Promote this deployment? This deployment will become the default for all future runs not explicitly tied to a specific deployment.
); } function CancelDeploymentDialog({ projectId, deploymentShortCode, redirectPath, }: RollbackDeploymentDialogProps) { const navigation = useNavigation(); const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/cancel`; const isLoading = navigation.formAction === formAction; return ( Cancel this deployment? Canceling a deployment cannot be undone. Are you sure?
); }